@jeffober/thatch 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +8 -1
  2. package/artifacts/skills/thatch-clear-writing.md +103 -5
  3. package/artifacts/skills/thatch-coordination.md +69 -0
  4. package/artifacts/skills/thatch-pr-description.md +21 -13
  5. package/artifacts/skills/thatch-review-no-slop.md +15 -0
  6. package/bin/thatch +95 -44
  7. package/docs/assets/deepwiki-badge.svg +1 -0
  8. package/docs/dev/README.md +7 -2
  9. package/docs/dev/features/cli.md +2 -0
  10. package/docs/dev/features/cross-session-chat.md +118 -11
  11. package/docs/dev/features/notifications.md +1 -2
  12. package/docs/dev/features/opencode-plugin.md +18 -3
  13. package/docs/dev/features/prediction-engine.md +44 -5
  14. package/docs/dev/features/session-tabs.md +155 -0
  15. package/docs/dev/features/setup.md +12 -0
  16. package/docs/dev/features/watchers.md +62 -6
  17. package/docs/dev/gotchas.md +68 -0
  18. package/docs/dev/mcp-parity.md +2 -2
  19. package/docs/dev/setup-and-hooks.md +7 -2
  20. package/docs/dev/skills.md +1 -1
  21. package/docs/plans/generalized-session-heartbeat.md +212 -0
  22. package/docs/plans/prediction-consolidation.md +88 -68
  23. package/docs/user/README.md +12 -3
  24. package/docs/user/cli.md +5 -1
  25. package/docs/user/commands.md +11 -4
  26. package/docs/user/cross-session-chat.md +31 -8
  27. package/docs/user/prediction-engine.md +11 -0
  28. package/docs/user/setup.md +29 -0
  29. package/docs/user/skills.md +1 -1
  30. package/docs/user/watchers.md +22 -7
  31. package/package.json +10 -2
  32. package/src/alerts.ts +1 -1
  33. package/src/chat.ts +320 -27
  34. package/src/commands.ts +14 -0
  35. package/src/db.ts +51 -1
  36. package/src/git.ts +3 -1
  37. package/src/hygiene.ts +5 -0
  38. package/src/mcp.ts +95 -7
  39. package/src/opencode/tui-plugin.ts +70 -0
  40. package/src/opencode/v2.ts +197 -4
  41. package/src/prediction.ts +90 -0
  42. package/src/prompts.ts +110 -23
  43. package/src/runtime.ts +187 -9
  44. package/src/session-db.ts +4 -4
  45. package/src/session-tab-shared.ts +180 -0
  46. package/src/setup.ts +59 -3
  47. package/src/skills.ts +1 -0
  48. package/src/tool-defs.ts +412 -55
  49. package/src/tools.ts +2 -0
  50. package/src/watchers.ts +263 -26
  51. package/docs/plans/intuition-drives.md +0 -147
  52. package/docs/plans/llm-alerts.md +0 -287
  53. package/docs/plans/thatch-actions.md +0 -179
  54. package/docs/plans/worktree-deletion-recovery.md +0 -246
package/src/mcp.ts CHANGED
@@ -70,7 +70,9 @@ export function compileTools(): Map<string, CompiledTool> {
70
70
  for (const def of TOOL_DEFS) {
71
71
  // opencode-only tools depend on host capabilities MCP servers don't
72
72
  // have (e.g. session identity via HostToolContext) - never expose them.
73
- if (def.opencodeOnly) continue;
73
+ // v2-only tools ride v2 plugin surfaces (session move, rpc emit) with
74
+ // no MCP equivalent - same rule.
75
+ if (def.opencodeOnly || def.v2Only) continue;
74
76
  const schema = z.object(def.args);
75
77
  map.set(def.name, {
76
78
  def,
@@ -120,7 +122,12 @@ export function compilePrompts(): Map<string, ActionDef> {
120
122
  * prompt and search for matches without loading the model themselves.
121
123
  */
122
124
  export async function runMcpServer(): Promise<void> {
123
- const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
125
+ // Deliberate superset of detectRepo's own chain (src/git.ts): the pinned
126
+ // THATCH_PROJECT_DIR (written into project-local MCP configs by setup)
127
+ // beats ambient host vars, which beat the spawn cwd - the server's spawn
128
+ // cwd is the one input no in-repo test can pin.
129
+ const projectDir =
130
+ process.env.THATCH_PROJECT_DIR ?? process.env.CURSOR_PROJECT_DIR ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
124
131
 
125
132
  const home = process.env.HOME ?? "/tmp";
126
133
  const configHome = process.env.XDG_CONFIG_HOME ?? `${home}/.config`;
@@ -130,7 +137,13 @@ export async function runMcpServer(): Promise<void> {
130
137
  // The DB must exist before identity detection: detectRepo consults the
131
138
  // repo_paths cache when the project directory is gone (deleted worktree).
132
139
  const db = new ThatchDB(dbPath);
133
- const repo = await detectRepo(projectDir, repoPathCache(db));
140
+ const detected = await detectRepo(projectDir, repoPathCache(db));
141
+ // unknown-to-global, matching the hook's mapping (bin/thatch): chat
142
+ // registration must follow the same store the tools would read, and
143
+ // resolveStore degrades both spellings to the global store anyway. The
144
+ // alignment keeps hook-registered and server-registered rows comparable
145
+ // by project - which the `as` identity binding depends on.
146
+ const repo = detected === "unknown" ? "global" : detected;
134
147
  const model = new BgeEmbeddingModel(modelName);
135
148
 
136
149
  await seedDefaultBehaviors(db, model);
@@ -251,6 +264,27 @@ export async function runMcpServer(): Promise<void> {
251
264
  // requests. Never blocks tool calls.
252
265
  startVersionChecker(dbPath);
253
266
 
267
+ // Idempotent teardown, shared by the stdin-end path (the host closed the
268
+ // pipe - the normal shutdown) and the signal handlers (SIGINT/SIGTERM/
269
+ // SIGHUP kill the process mid-loop, and without handlers they skipped
270
+ // every teardown step). Order matters: the sideband goes first (nothing
271
+ // may reach the model after disposal), the model's ONNX sessions release
272
+ // before the db closes, and the db close runs in a finally so a disposal
273
+ // failure cannot leak the connection.
274
+ const teardown = createMcpTeardown({
275
+ sideband,
276
+ model,
277
+ db,
278
+ dbPath,
279
+ stopVersionChecker,
280
+ removeVersionFile,
281
+ });
282
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
283
+ process.on(signal, () => {
284
+ void teardown(signal).then(() => process.exit(0));
285
+ });
286
+ }
287
+
254
288
  // Read stdin line by line. Each line is a complete JSON-RPC message.
255
289
  const decoder = new TextDecoder();
256
290
  let buf = "";
@@ -303,10 +337,64 @@ export async function runMcpServer(): Promise<void> {
303
337
  }
304
338
  }
305
339
 
306
- sideband.stop();
307
- stopVersionChecker();
308
- removeVersionFile(dbPath);
309
- db.close();
340
+ await teardown("stdin-end");
341
+ }
342
+
343
+ /**
344
+ * Builds the idempotent MCP-server teardown. Injected deps so tests can
345
+ * spy on the close order without spawning anything: sideband first
346
+ * (nothing may reach the model after disposal), version checker + file
347
+ * next, the model's native ONNX sessions in a catch (a disposal failure
348
+ * must not skip the db close), and the db in a finally.
349
+ */
350
+ export function createMcpTeardown(deps: {
351
+ sideband: { stop(): void };
352
+ model: { dispose(): Promise<void> };
353
+ db: { close(): void };
354
+ dbPath: string;
355
+ stopVersionChecker: () => void;
356
+ removeVersionFile: (dbPath: string) => void;
357
+ }): (reason: string) => Promise<void> {
358
+ let inFlight: Promise<void> | undefined;
359
+ return (reason: string): Promise<void> => {
360
+ // Idempotent ACROSS awaits: a second call while the first teardown is
361
+ // still running returns the IN-FLIGHT promise, so an early
362
+ // process.exit(0) waits out the model disposal and the db close
363
+ // instead of cutting them (a fresh resolve() skipped them). `torn`
364
+ // tracking is subsumed: inFlight is set synchronously before any await.
365
+ if (inFlight) return inFlight;
366
+ void reason;
367
+ // Every step is isolated: a teardown step's own failure must not
368
+ // block the remaining ones (the sideband throwing on an
369
+ // already-unlinked socket used to skip the model disposal and the
370
+ // db close entirely).
371
+ try {
372
+ deps.sideband.stop();
373
+ } catch (err) {
374
+ console.error(`[thatch] MCP sideband stop failed: ${err}`);
375
+ }
376
+ try {
377
+ deps.stopVersionChecker();
378
+ } catch (err) {
379
+ console.error(`[thatch] MCP version-checker stop failed: ${err}`);
380
+ }
381
+ try {
382
+ deps.removeVersionFile(deps.dbPath);
383
+ } catch (err) {
384
+ console.error(`[thatch] MCP version-file removal failed: ${err}`);
385
+ }
386
+ inFlight = deps.model
387
+ .dispose()
388
+ .catch((err: unknown) => console.error(`[thatch] MCP model disposal failed: ${err}`))
389
+ .finally(() => {
390
+ try {
391
+ deps.db.close();
392
+ } catch (err) {
393
+ console.error(`[thatch] MCP db close failed: ${err}`);
394
+ }
395
+ });
396
+ return inFlight;
397
+ };
310
398
  }
311
399
 
312
400
  /**
@@ -0,0 +1,70 @@
1
+ // The session-tab TUI CLI plugin: the TUI-side half of the session-tab
2
+ // feature (docs/dev/features/session-tabs.md). Loaded by the opencode v2 TUI
3
+ // from the package's ./tui entrypoint; runs once per TUI window, next to the
4
+ // tab primitives it drives.
5
+ //
6
+ // The server-side adapter cannot reach the tab strip - the v2 plugin event
7
+ // domain is subscribe-only and the HTTP surface has no publish route - so
8
+ // the flow crosses processes over an rpc event: the adapter emits
9
+ // rpc.thatch-tabs.tab-opened (sessionID + the coordinator instance's
10
+ // directory), the SSE feed fans it to every window, and THIS plugin acts on
11
+ // it with ui.tabs.open. The guard mirrors the built-in tui.* handlers
12
+ // (packages/tui/src/app.tsx): an event only acts on the window whose
13
+ // directory matches the event's location. ui.tabs.open is idempotent and
14
+ // never steals focus, so no dedupe state is kept - a hot reload re-runs
15
+ // setup from fresh state and double events are no-ops.
16
+ //
17
+ // ISOLATION: this module must only be imported by the TUI process - tests
18
+ // import the pure filter/guard logic from src/session-tab-shared.ts, never
19
+ // this file (the tui entrypoint imports solid-js, an uninstalled optional
20
+ // peer).
21
+
22
+ import { Plugin } from "@opencode/plugin/tui";
23
+ import { isTabClosedEvent, isTabOpenedEvent, passesDirectoryGuard } from "../session-tab-shared";
24
+
25
+ export default Plugin.define({
26
+ id: "jeffober-thatch-tui",
27
+ setup(context) {
28
+ const off = context.data.listen(({ details }) => {
29
+ const windowDirectory = context.location?.directory ?? context.data.location.default().directory;
30
+ if (!passesDirectoryGuard(details.location?.directory, windowDirectory)) return;
31
+ if (isTabClosedEvent(details.type)) {
32
+ // A tool-initiated close request: close the tab if THIS window has
33
+ // one for the session. Windows that never opened it (and windows at
34
+ // other directories, filtered above) return false - that is the
35
+ // correct no-op, not an error. The close is reopenable by the user
36
+ // (the strip's reopen stack), so this is tab-level, not session
37
+ // deletion.
38
+ const closeID = (details.data as { sessionID?: string }).sessionID;
39
+ if (!closeID) {
40
+ console.error("[thatch] tab-closed event without a sessionID");
41
+ return;
42
+ }
43
+ if (!context.ui.tabs.close(closeID)) {
44
+ console.error("[thatch] tab-close requested but no tab matched (or tabs are disabled)");
45
+ }
46
+ return;
47
+ }
48
+ if (!isTabOpenedEvent(details.type)) return;
49
+ const sessionID = (details.data as { sessionID?: string }).sessionID;
50
+ if (!sessionID) {
51
+ console.error("[thatch] tab-opened event without a sessionID");
52
+ return;
53
+ }
54
+ // Sync the session into the TUI's cache before opening: the strip
55
+ // renders the session's real title instead of the new-session
56
+ // fallback.
57
+ void context.data.session
58
+ .sync(sessionID)
59
+ .catch(() => {})
60
+ .then(() => {
61
+ if (!context.ui.tabs.open(sessionID)) {
62
+ console.error("[thatch] tab-open requested but session tabs are disabled in this window");
63
+ }
64
+ });
65
+ });
66
+ // The unsubscribe IS the cleanup: without it every plugin hot reload
67
+ // leaks another listener.
68
+ return off;
69
+ },
70
+ });
@@ -2,11 +2,15 @@ import type { Plugin } from "@opencode/plugin";
2
2
  import { z } from "zod";
3
3
  import type { Tool } from "@opencode/schema/tool";
4
4
  import type { ToolContext as V2ToolContext } from "@opencode/plugin/promise/tool";
5
+ import type { ThatchDB } from "../db";
5
6
  import type { HostCapabilities, PromptPart, ToastInput } from "../capabilities";
6
7
  import { createRuntime } from "../runtime";
7
8
  import { wrapUpCommandContent } from "../commands";
8
9
  import { deriveTitle } from "../extraction";
9
- import { TOOL_DEFS, trimHostContext, type HostToolContext } from "../tool-defs";
10
+ import { detectRepo, detectWorktreeKind } from "../git";
11
+ import { chatEnabled, loadConfig } from "../config";
12
+ import { SESSION_TAB_RPC, TAB_CLOSED_EVENT, TAB_OPENED_EVENT, buildSubordinatePrompt, tabClosedEventType, tabOpenedEventType } from "../session-tab-shared";
13
+ import { TOOL_DEFS, trimHostContext, type HostToolContext, type SessionTabCloseInput, type SessionTabHost, type SessionTabSpawnInput, type SessionTabSpawnResult } from "../tool-defs";
10
14
 
11
15
  // The opencode v2 adapter (opencode 2.x, plugin API @opencode/plugin 2.x).
12
16
  // Loaded only by v2 hosts - the dual entry (src/index.ts) lazy-imports this
@@ -39,6 +43,10 @@ import { TOOL_DEFS, trimHostContext, type HostToolContext } from "../tool-defs";
39
43
  // {type, data, location?}. Filtered client-side the way the v1 host
40
44
  // filters server-side: events resolve to a directory (their own, the
41
45
  // session cache, or session.get) and drop when it is not ours.
46
+ // - TUI reach: the ONLY server-to-TUI channel is the rpc event bridge - the
47
+ // session-tab rpc definition is registered at setup and its events reach
48
+ // the TUI CLI plugin (the package's ./tui entrypoint), which drives the
49
+ // tab strip (ui.tabs). Toast/command/exit pushes still have no surface.
42
50
  // - toasts, tui commands, session delete/list endpoints:
43
51
  // no v2 surface reachable from a plugin - degrades as no-op/null.
44
52
  // fetchStatuses returns {} because the wake gate treats an unknown session
@@ -77,7 +85,7 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
77
85
  let inflightTools = 0;
78
86
 
79
87
  const capabilities = buildCapabilities(context, worktree, childSessions);
80
- const runtime = await createRuntime({ capabilities, directory, worktree });
88
+ const runtime = await createRuntime({ capabilities, directory, worktree, v2Tools: true });
81
89
  // Seed the forwarding set from rehydrated child bookkeeping: after a
82
90
  // reload, an in-flight extraction child's maps come back from the journal,
83
91
  // but this NEW adapter instance's childSessions set starts empty - without
@@ -85,6 +93,37 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
85
93
  // (the below-root launch case).
86
94
  for (const id of runtime.childSessionIds()) childSessions.add(id);
87
95
 
96
+ // Session-tab surface: register the rpc definition once per activation and
97
+ // wire the host flow into the shared CoreContext (post-construction - the
98
+ // seam closes over both the runtime's db and the plugin context; see
99
+ // CoreContext.sessionTabHost in tool-defs.ts). The events are the ONLY
100
+ // server-to-TUI channel on v2: the TUI CLI plugin (the package's ./tui
101
+ // entrypoint) consumes them and calls ui.tabs.open. A TUI that is absent
102
+ // (headless run, still connecting) simply never acts on them.
103
+ //
104
+ // SDK-floor guard: the rpc surface is newer than some @opencode/plugin
105
+ // builds. An absent rpc domain must not crash setup - degrade by leaving
106
+ // the seam unwired; the session_tab tools refuse cleanly through it
107
+ // ("did not wire the session-tab surface") like any other unwired host.
108
+ const tabRegistration = typeof context.rpc?.register === "function"
109
+ ? await context.rpc.register(SESSION_TAB_RPC, {})
110
+ : undefined;
111
+ if (tabRegistration) {
112
+ const emitTabOpened = (data: { sessionID: string; directory: string }) =>
113
+ tabRegistration.events.emit(TAB_OPENED_EVENT, data);
114
+ runtime.coreContext.sessionTabHost = buildSessionTabHost({
115
+ context,
116
+ directory,
117
+ db: runtime.coreContext.db,
118
+ emitTabOpened,
119
+ emitTabClosed: (data: { sessionID: string; chatName: string | null }) =>
120
+ tabRegistration.events.emit(TAB_CLOSED_EVENT, data),
121
+ debug: runtime.debug,
122
+ });
123
+ } else {
124
+ console.error("[thatch] opencode v2 rpc surface unavailable - session_tab tools will report unwired");
125
+ }
126
+
88
127
  // Tool registration: the same CoreContext the v1 adapter feeds to
89
128
  // createTools, registered through the v2 ToolEditor instead.
90
129
  //
@@ -301,11 +340,44 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
301
340
  location?: { directory?: string };
302
341
  };
303
342
  const data = located.data ?? {};
304
- if (data.sessionID && located.location?.directory) sessionDirs.set(data.sessionID, located.location.directory);
305
- if (data.sessionID && data.location?.directory) sessionDirs.set(data.sessionID, data.location.directory);
343
+ // rpc events (the session-tab bridge) stamp the PUBLISHING
344
+ // instance's location, not the session's - caching a tab event's
345
+ // location would pin the subordinate's sessionID to the
346
+ // coordinator's directory, and its later location-less execution
347
+ // events would route to the wrong instance (or be dropped by the
348
+ // right one). The tab-opened payload's directory IS authoritative
349
+ // (the subordinate's final, post-move directory) and is cached
350
+ // explicitly below.
351
+ const fromRpc = located.type.startsWith("rpc.");
352
+ if (!fromRpc && data.sessionID && located.location?.directory) sessionDirs.set(data.sessionID, located.location.directory);
353
+ if (!fromRpc && data.sessionID && data.location?.directory) sessionDirs.set(data.sessionID, data.location.directory);
354
+ if (located.type === tabOpenedEventType) {
355
+ const opened = (data ?? {}) as { sessionID?: string; directory?: string };
356
+ if (opened.sessionID && opened.directory) sessionDirs.set(opened.sessionID, opened.directory);
357
+ }
306
358
  let eventDir = located.location?.directory ?? (data.sessionID ? sessionDirs.get(data.sessionID) : undefined);
307
359
  if (!eventDir && data.sessionID) eventDir = await resolveSessionDir(data.sessionID);
308
360
  runtime.debug("v2:pump", `event ${located.type} dir=${eventDir} self=${directory}`);
361
+ if (located.type === tabClosedEventType) {
362
+ // The session-tab tool's confirmed tab close: translated here
363
+ // rather than in translateEvent because the payload shape is
364
+ // tab-domain, and the death path is runtime-domain. Routed by
365
+ // the CLOSED session's own location, never the event's: the rpc
366
+ // event carries the publishing (coordinator) instance's
367
+ // directory, while the watchers and status bookkeeping live in
368
+ // the instance that owns the session (its post-move location).
369
+ // A later located event repairs the cache; resolve live when
370
+ // nothing is cached.
371
+ const closed = (data ?? {}) as { sessionID?: string; chatName?: string | null };
372
+ if (closed.sessionID) {
373
+ let closedDir = sessionDirs.get(closed.sessionID);
374
+ if (!closedDir) closedDir = await resolveSessionDir(closed.sessionID);
375
+ if (eventMatchesInstance(closedDir, closed.sessionID, directory, childSessions)) {
376
+ await runtime.onEvent({ type: "session.tab_closed", properties: { sessionID: closed.sessionID, chatName: closed.chatName ?? null } });
377
+ }
378
+ }
379
+ continue;
380
+ }
309
381
  if (eventMatchesInstance(eventDir, data.sessionID, directory, childSessions)) {
310
382
  for (const translated of translateEvent(located)) {
311
383
  await runtime.onEvent(translated);
@@ -346,6 +418,7 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
346
418
  registerPrompt.dispose();
347
419
  registerCompaction.dispose();
348
420
  registerCommands.dispose();
421
+ tabRegistration?.dispose();
349
422
  await runtime.dispose();
350
423
  };
351
424
  }
@@ -506,6 +579,126 @@ export function translateEvent(located: { type: string; data?: any }): { type: s
506
579
  }
507
580
  }
508
581
 
582
+ /**
583
+ * The session-tab host flow - everything behind the CoreContext.sessionTabHost
584
+ * seam, host-executed end to end (the model supplies only the args; see the
585
+ * host-owned-handshake rule: no ids cross a prompt boundary). Order is
586
+ * load-bearing (docs/dev/features/session-tabs.md, "How it works"):
587
+ *
588
+ * create -> move -> register subordinate -> emit tab-opened -> prompt
589
+ *
590
+ * (The calling session's chat name is resolved by the tool definition - it
591
+ * has the db handle and the session context.)
592
+ *
593
+ * The emit runs AFTER the move: the event routes by THIS instance's location
594
+ * (the coordinator's directory) regardless of the session's directory, so
595
+ * tab placement is identical either way, and a failed move leaves no
596
+ * stranded open tab. The move targets an idle session, so its default
597
+ * "steer" delivery applies immediately - the subordinate's whole first turn
598
+ * runs in its final directory.
599
+ *
600
+ * The subordinate is chat-registered deliberately - participant semantics,
601
+ * like an explicit chat_register; it is NEVER marked machinery, and its
602
+ * title cannot collide with the machinery title (validateTitle rejects it).
603
+ */
604
+ function buildSessionTabHost(input: {
605
+ context: V2Context;
606
+ /** This instance's directory - the coordinator session's cwd. */
607
+ directory: string;
608
+ db: ThatchDB;
609
+ emitTabOpened: (data: { sessionID: string; directory: string }) => Promise<unknown>;
610
+ emitTabClosed: (data: { sessionID: string; chatName: string | null }) => Promise<unknown>;
611
+ debug(tag: string, message: string): void;
612
+ }): SessionTabHost {
613
+ return {
614
+ directory: input.directory,
615
+ spawnSubordinate: async (spec: SessionTabSpawnInput): Promise<SessionTabSpawnResult> => {
616
+ const worktreeDir = typeof spec.worktree === "string" ? spec.worktree : undefined;
617
+ const isWorktreeFlow = worktreeDir !== undefined;
618
+ const createDirectory = isWorktreeFlow ? input.directory : spec.directory;
619
+ const finalDirectory = worktreeDir ?? spec.directory;
620
+ // Failure narration for the error below: a mid-flow throw (move
621
+ // refused, prompt endpoint down) must leave the calling model the
622
+ // session id and what already happened, or the created session is
623
+ // stranded with no way to name it - there is no session delete on
624
+ // the v2 surface, so rollback is not an option either.
625
+ const steps: string[] = [];
626
+ let sessionID: string | undefined;
627
+ let chatName: string | null = null;
628
+ try {
629
+ const created = await input.context.session.create({
630
+ title: spec.title,
631
+ metadata: {
632
+ thatch: {
633
+ coordinatedBy: spec.coordinatorChatName,
634
+ coordinatorSessionID: spec.coordinatorSessionID,
635
+ ...(worktreeDir !== undefined ? { worktree: worktreeDir } : {}),
636
+ },
637
+ },
638
+ location: { directory: createDirectory },
639
+ });
640
+ sessionID = created.id;
641
+ steps.push(`created session ${sessionID ?? "(no id returned)"}`);
642
+ if (worktreeDir !== undefined) {
643
+ await input.context.session.move({ sessionID, directory: worktreeDir });
644
+ steps.push(`moved to ${worktreeDir}`);
645
+ }
646
+ // Pre-register the subordinate AFTER the move, so the roster row
647
+ // records the session's FINAL directory (right repo slug, right
648
+ // worktree kind) and a failed move leaves no row behind. Deliberate
649
+ // participant semantics - like an explicit chat_register, never marked
650
+ // machinery - so the response carries its chat name and the
651
+ // coordinator's first chat_send resolves; the later auto-register
652
+ // converges (same id, same name). Gated on the chat-enabled config
653
+ // like every other registration path: with chat off, the tool
654
+ // response reports no chat name and advertises no supervision.
655
+ const repo = await detectRepo(finalDirectory);
656
+ const registered = chatEnabled(loadConfig().config)
657
+ ? input.db.registerChatSession(
658
+ sessionID,
659
+ repo,
660
+ null,
661
+ "opencode",
662
+ null,
663
+ detectWorktreeKind(finalDirectory),
664
+ )
665
+ : { ok: false as const, error: "chat disabled in config" };
666
+ if (registered.ok) chatName = registered.name;
667
+ steps.push(registered.ok ? `registered in chat as ${registered.name}` : "chat registration skipped");
668
+ input.debug(
669
+ "session-tab",
670
+ `created ${sessionID} dir=${finalDirectory} repo=${repo} chat=${registered.ok ? registered.name : registered.error}`,
671
+ );
672
+ // The payload's directory is the subordinate's FINAL (post-move)
673
+ // directory, not this instance's: the TUI guard routes by the event
674
+ // envelope's location, while the pump caches this field so the
675
+ // subordinate's later location-less execution events resolve to the
676
+ // instance that owns it.
677
+ await input.emitTabOpened({ sessionID, directory: finalDirectory });
678
+ steps.push("tab-opened emitted");
679
+ await input.context.session.prompt({
680
+ sessionID,
681
+ text: buildSubordinatePrompt(spec.coordinatorChatName, spec.prompt),
682
+ });
683
+ steps.push("task prompt delivered");
684
+ return { sessionID, chatName, directory: finalDirectory };
685
+ } catch (err) {
686
+ const reason = err instanceof Error ? err.message : String(err);
687
+ throw new Error(
688
+ `session_tab spawn failed: ${reason}. Completed steps: ${steps.length > 0 ? steps.join("; ") : "none"}.` +
689
+ (sessionID
690
+ ? ` The session ${sessionID} was created and still exists - name it in your report so it can be recovered (sessions list, or thatch_session_tab_close${chatName ? ` by chat name ${chatName}` : ""}).`
691
+ : " No session was created."),
692
+ );
693
+ }
694
+ },
695
+ closeTab: async (spec: SessionTabCloseInput): Promise<void> => {
696
+ await input.emitTabClosed({ sessionID: spec.sessionID, chatName: spec.chatName });
697
+ input.debug("session-tab", `tab-close emitted for ${spec.sessionID} (${spec.chatName ?? "unnamed"})`);
698
+ },
699
+ };
700
+ }
701
+
509
702
  // The HostCapabilities implementation over the v2 promise context. Every
510
703
  // operation the v2 surface lacks degrades as a no-op or null - the shared
511
704
  // runtime's callers treat those results as best-effort and log, never crash.
package/src/prediction.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { blobToVector, cosineSimilarity } from "./vector-math";
2
3
  import { ScoringEngine, type NudgeItem, PREDICTION_K, PREDICTION_P0, PREDICTION_W_SOFT } from "./scoring-engine";
3
4
 
4
5
  export { PREDICTION_K, PREDICTION_P0, PREDICTION_W_SOFT };
@@ -27,6 +28,17 @@ export interface PredictionRow {
27
28
 
28
29
  export interface PredictionNudgeItem extends NudgeItem {}
29
30
 
31
+ /** A pair of predictions that are cosine-close enough to be wordings of the
32
+ * same preference. Returned by findPredictionDuplicates for agent review. */
33
+ export interface PredictionDedupCandidate {
34
+ store: string;
35
+ predictionA: string;
36
+ statementA: string;
37
+ predictionB: string;
38
+ statementB: string;
39
+ cosine: number;
40
+ }
41
+
30
42
  export interface ScoredPrediction {
31
43
  matcher_id: string;
32
44
  matcher_description: string;
@@ -55,8 +67,13 @@ const config = {
55
67
  */
56
68
  export class PredictionEngine {
57
69
  #engine: ScoringEngine;
70
+ // Kept for the dedup pass, whose tables are prediction-only (the shared
71
+ // ScoringEngine stays unaware of them; the behavior engine has no pair
72
+ // verdicts yet).
73
+ #db: Database;
58
74
 
59
75
  constructor(db: Database) {
76
+ this.#db = db;
60
77
  this.#engine = new ScoringEngine(db, config);
61
78
  }
62
79
 
@@ -124,4 +141,77 @@ export class PredictionEngine {
124
141
  listPredictions(store: string) {
125
142
  return this.#engine.listItems(store);
126
143
  }
144
+
145
+ /**
146
+ * Corpus-level dedup: pairwise cosine over the store's predictions,
147
+ * skipping pairs already adjudicated in prediction_dedup_pairs. The
148
+ * cosine-only counterpart of the memory side's findDuplicates
149
+ * (src/db.ts) - the co-fire refinement in the compounds plan layers
150
+ * behavioral evidence on top of this same method later. Threshold 0.70
151
+ * because [0.85, 1] is already caught at write time
152
+ * (findNearestPrediction) and below 0.70 short-statement embeddings get
153
+ * too noisy to call duplicates.
154
+ */
155
+ findPredictionDuplicates(store: string, threshold = 0.70): PredictionDedupCandidate[] {
156
+ const rows = this.#db
157
+ .query(
158
+ "SELECT id, statement, embedding FROM predictions WHERE store = ? AND embedding IS NOT NULL ORDER BY id",
159
+ )
160
+ .all(store) as any[];
161
+
162
+ if (rows.length < 2) return [];
163
+
164
+ const preds = rows.map((r: any) => ({
165
+ id: r.id as string,
166
+ statement: r.statement as string,
167
+ embedding: blobToVector(r.embedding),
168
+ }));
169
+
170
+ const checked = this.#checkedPairs(store);
171
+ const candidates: PredictionDedupCandidate[] = [];
172
+ for (let i = 0; i < preds.length; i++) {
173
+ for (let j = i + 1; j < preds.length; j++) {
174
+ const key = [preds[i].id, preds[j].id].sort().join("|");
175
+ if (checked.has(key)) continue;
176
+ if (preds[i].embedding.length !== preds[j].embedding.length) continue;
177
+
178
+ const cosine = cosineSimilarity(preds[i].embedding, preds[j].embedding);
179
+ if (cosine >= threshold) {
180
+ candidates.push({
181
+ store,
182
+ predictionA: preds[i].id,
183
+ statementA: preds[i].statement,
184
+ predictionB: preds[j].id,
185
+ statementB: preds[j].statement,
186
+ cosine: Math.round(cosine * 1000) / 1000,
187
+ });
188
+ }
189
+ }
190
+ }
191
+
192
+ candidates.sort((a, b) => b.cosine - a.cosine);
193
+ return candidates;
194
+ }
195
+
196
+ /**
197
+ * Record a pair verdict (duplicate | distinct) so findPredictionDuplicates
198
+ * stops surfacing it. The id pair is stored in canonical sorted order -
199
+ * the same contract as dedup_pairs - so (A,B) and (B,A) are one row.
200
+ */
201
+ markPairChecked(store: string, idA: string, idB: string, status: string): void {
202
+ const [a, b] = [idA, idB].sort();
203
+ this.#db.run(
204
+ `INSERT INTO prediction_dedup_pairs (store, id_a, id_b, status, checked_at)
205
+ VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ','now'))
206
+ ON CONFLICT (store, id_a, id_b) DO UPDATE SET status = excluded.status, checked_at = excluded.checked_at`,
207
+ [store, a, b, status],
208
+ );
209
+ }
210
+
211
+ #checkedPairs(store: string): Set<string> {
212
+ const rows = this.#db
213
+ .query("SELECT id_a, id_b FROM prediction_dedup_pairs WHERE store = ?")
214
+ .all(store) as any[];
215
+ return new Set(rows.map((r: any) => [r.id_a, r.id_b].sort().join("|")));
216
+ }
127
217
  }