@jeffober/thatch 0.1.34 → 0.1.36

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/src/tool-defs.ts CHANGED
@@ -2,6 +2,8 @@ import { z } from "zod";
2
2
  import type { ThatchDB, DedupCandidate, MemoryRow } from "./db";
3
3
  import type { EmbeddingModel } from "./embeddings";
4
4
  import { predictionVerb } from "./prompts";
5
+ import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToFullJson, messageToFullJson } from "./session-db";
6
+ import { WATCHER_EVENT_TYPES, type WatcherRegistry, type WatcherEventType } from "./watchers";
5
7
 
6
8
  // Near-duplicate thresholds for matcher/prediction/behavior dedup at
7
9
  // creation time. Matches the thatch_find_duplicates threshold (0.85).
@@ -36,6 +38,21 @@ export interface CoreContext {
36
38
  * childToParent). Called by extraction_done when the sub-agent passes
37
39
  * the parent's session_id. */
38
40
  drainExtractionQueue?: (sessionID: string) => void;
41
+ /** The plugin's in-memory watcher registry. Only wired on the opencode
42
+ * path - MCP hosts have no poller, no event bus, and no way to deliver a
43
+ * proactive prompt, so watch tools are opencode-only. */
44
+ watchers?: WatcherRegistry;
45
+ }
46
+
47
+ /**
48
+ * Per-call host context identifying the session a tool call belongs to.
49
+ * opencode's plugin system supplies it on every tool execution; MCP servers
50
+ * have no session concept and omit it. Only tools that need session identity
51
+ * consume it - the rest ignore the third execute parameter.
52
+ */
53
+ export interface HostToolContext {
54
+ sessionID: string;
55
+ agent: string;
39
56
  }
40
57
 
41
58
  /**
@@ -49,7 +66,13 @@ export interface ToolDef {
49
66
  name: string;
50
67
  description: string;
51
68
  args: Record<string, z.ZodType>;
52
- execute(args: Record<string, unknown>, ctx: CoreContext): Promise<string>;
69
+ execute(args: Record<string, unknown>, ctx: CoreContext, host?: HostToolContext): Promise<string>;
70
+ /**
71
+ * When true, the tool exists only on the opencode plugin path and the MCP
72
+ * server filters it out of tools/list. Used for tools that depend on host
73
+ * capabilities MCP hosts lack (e.g. session identity).
74
+ */
75
+ opencodeOnly?: boolean;
53
76
  }
54
77
 
55
78
  // ---------------------------------------------------------------------------
@@ -795,10 +818,247 @@ const behaviorDeleteDef: ToolDef = {
795
818
  },
796
819
  };
797
820
 
821
+ /**
822
+ * Reports the calling session's identity. opencode does not surface its
823
+ * session ID to the model, so the model cannot learn it any other way -
824
+ * yet it needs the ID to call thatch_get_extraction_payload /
825
+ * thatch_extraction_done on the parent's behalf and for session
826
+ * archaeology against opencode.db. MCP hosts have no session concept,
827
+ * hence opencodeOnly.
828
+ */
829
+ const getSessionInfoDef: ToolDef = {
830
+ name: "get_session_info",
831
+ description:
832
+ "Return the current session's identity: the host session ID and the " +
833
+ "agent name running this turn (e.g. build, plan, or a sub-agent type). " +
834
+ "opencode-only: MCP hosts have no session concept. Use the session ID " +
835
+ "when a nudge tells you to call a tool with the parent's session_id, " +
836
+ "or to query past sessions in ~/.local/share/opencode/opencode.db.",
837
+ args: {},
838
+ opencodeOnly: true,
839
+ async execute(_args, _ctx, host) {
840
+ if (!host) {
841
+ return "Session identity is unavailable: this host did not provide a session context.";
842
+ }
843
+ return `sessionID: ${host.sessionID}\nagent: ${host.agent}`;
844
+ },
845
+ };
846
+
847
+ /**
848
+ * Lets the model search its own past opencode conversations. The model only
849
+ * knows the current session id (via get_session_info); everything before
850
+ * compaction or in earlier sessions is invisible to it otherwise. Returns
851
+ * the same JSONL timeline shape as `thatch session search`, with previews
852
+ * truncated so a result set stays context-affordable; session_get fetches
853
+ * full content for interesting hits.
854
+ */
855
+ const sessionSearchDef: ToolDef = {
856
+ name: "session_search",
857
+ description:
858
+ "Search past opencode session conversations by substring (or regex with " +
859
+ "regex: true). Matches decoded message text, tool outputs, and reasoning " +
860
+ "across all sessions (or one session with session_id). Returns JSONL " +
861
+ "entries with ids for follow-up: call session_get with a hit's part_id " +
862
+ "or msg_id to retrieve the full content. opencode-only.",
863
+ args: {
864
+ query: z.string().describe(
865
+ "Substring to find (case-insensitive), or a regular expression when regex is true.",
866
+ ),
867
+ regex: z.boolean().optional().describe(
868
+ "Treat query as a regular expression instead of a plain substring.",
869
+ ),
870
+ session_id: z.string().optional().describe(
871
+ "Narrow the search to one session. Omit to search all sessions.",
872
+ ),
873
+ limit: z.number().optional().describe(
874
+ "Maximum matches to return (default 200, oldest first).",
875
+ ),
876
+ },
877
+ opencodeOnly: true,
878
+ async execute(args) {
879
+ const dbPath = resolveOpencodeDbPath();
880
+ if (!dbPath) {
881
+ return "Session search is unavailable: no opencode database was found (set OPENCODE_DB to point at one).";
882
+ }
883
+ const db = new SessionDB(dbPath);
884
+ try {
885
+ const matches = db.search(args.query as string, {
886
+ regex: args.regex === true,
887
+ sessionID: args.session_id as string | undefined,
888
+ limit: typeof args.limit === "number" ? args.limit : undefined,
889
+ });
890
+ if (matches.length === 0) return `No matches for "${args.query}".`;
891
+ // Session titles orient cross-session results the same way the CLI
892
+ // search does; cached because matches cluster by session.
893
+ const titles = new Map<string, string | null>();
894
+ return matches
895
+ .map((part) => {
896
+ if (!titles.has(part.sessionId)) {
897
+ titles.set(part.sessionId, db.getSession(part.sessionId)?.title ?? null);
898
+ }
899
+ return JSON.stringify({
900
+ session_id: part.sessionId,
901
+ session_title: titles.get(part.sessionId),
902
+ ...partToTimelineEntry(part),
903
+ });
904
+ })
905
+ .join("\n");
906
+ } finally {
907
+ db.close();
908
+ }
909
+ },
910
+ };
911
+
912
+ /**
913
+ * Fetches the full, untruncated content of one part or message found via
914
+ * session_search. Tool parts carry their complete args and output - the
915
+ * detail the timeline preview truncates.
916
+ */
917
+ const sessionGetDef: ToolDef = {
918
+ name: "session_get",
919
+ description:
920
+ "Retrieve the full content of one conversation item by id. Pass a " +
921
+ "part_id (from session_search or session list) for one content piece, " +
922
+ "or a msg_id for an entire message with all of its parts. Tool parts " +
923
+ "include their complete input arguments and output. opencode-only.",
924
+ args: {
925
+ id: z.string().describe(
926
+ "A part id (prt_...) or message id (msg_...) from session_search results.",
927
+ ),
928
+ },
929
+ opencodeOnly: true,
930
+ async execute(args) {
931
+ const dbPath = resolveOpencodeDbPath();
932
+ if (!dbPath) {
933
+ return "Session retrieval is unavailable: no opencode database was found (set OPENCODE_DB to point at one).";
934
+ }
935
+ const db = new SessionDB(dbPath);
936
+ try {
937
+ const id = args.id as string;
938
+ if (id.startsWith("msg_")) {
939
+ const message = db.getMessage(id);
940
+ if (!message) return `No message "${id}" found.`;
941
+ return JSON.stringify(messageToFullJson(message), null, 2);
942
+ }
943
+ const part = db.getPart(id);
944
+ if (!part) return `No part "${id}" found.`;
945
+ return JSON.stringify(partToFullJson(part), null, 2);
946
+ } finally {
947
+ db.close();
948
+ }
949
+ },
950
+ };
951
+
952
+ /**
953
+ * Registers a background watcher on a GitHub PR. The plugin process polls
954
+ * the PR (via the gh CLI, using the user's existing gh auth) and prompts the
955
+ * session with a synthetic notification when a watched event happens.
956
+ * opencode-only: MCP hosts have no poller and no proactive-prompt channel.
957
+ */
958
+ const watchCreateDef: ToolDef = {
959
+ name: "watch_create",
960
+ description:
961
+ "Watch a GitHub PR for events and get notified in this session when they " +
962
+ "happen. Events: new top-level comments, new inline review comments, " +
963
+ "replies to review comments, new commits (head SHA change), PR status " +
964
+ "changes, title/description edits, and completed CI check runs. The " +
965
+ "watcher polls in the background and injects a notification prompt; " +
966
+ "notifications carry pointers only (author, URL) - fetch details yourself " +
967
+ "with the gh CLI when you decide to act. Watches live until cancelled, " +
968
+ "the session ends, or opencode restarts. Requires the gh CLI.",
969
+ args: {
970
+ pr: z.number().int().positive().describe(
971
+ "The PR number to watch.",
972
+ ),
973
+ repo: z.string().optional().describe(
974
+ "GitHub repo as owner/repo. Defaults to this project's repo from the git remote.",
975
+ ),
976
+ events: z.array(z.enum(WATCHER_EVENT_TYPES as [WatcherEventType, ...WatcherEventType[]])).optional().describe(
977
+ "Which event types to watch. Omit for all event types.",
978
+ ),
979
+ },
980
+ opencodeOnly: true,
981
+ async execute(args, ctx, host) {
982
+ if (!host) {
983
+ return "Watching is unavailable: this host did not provide a session context.";
984
+ }
985
+ if (!ctx.watchers) {
986
+ return "Watching is unavailable: no watcher registry was wired by this host.";
987
+ }
988
+ const repo = (args.repo as string | undefined) ?? ctx.defaultStore;
989
+ const events = (args.events as WatcherEventType[] | undefined) ?? [...WATCHER_EVENT_TYPES];
990
+ const result = await ctx.watchers.create(host.sessionID, repo, args.pr as number, events);
991
+ if (!result.ok) return `Watcher not created: ${result.error}`;
992
+ const w = result.watcher;
993
+ return (
994
+ `[watching] ${w.repo}#${w.pr}\n` +
995
+ `id: ${w.id}\n` +
996
+ `events: ${w.events.join(", ")}\n` +
997
+ `head: ${w.state.headSha.slice(0, 7)} (${w.state.state}${w.state.merged ? ", merged" : ""})\n\n` +
998
+ `The baseline is captured now - only changes from this point notify. ` +
999
+ `You will receive a system notification in this session when a watched event happens. ` +
1000
+ `State any handling policy for those notifications now (e.g. what to act on, what just to report), ` +
1001
+ `since the notification itself carries only pointers.`
1002
+ );
1003
+ },
1004
+ };
1005
+
1006
+ /**
1007
+ * Lists the calling session's active watchers. opencode-only, session-scoped:
1008
+ * a session can only ever see its own watches.
1009
+ */
1010
+ const watchListDef: ToolDef = {
1011
+ name: "watch_list",
1012
+ description:
1013
+ "List this session's active watchers with their ids, targets, watched " +
1014
+ "event types, and remaining time. opencode-only.",
1015
+ args: {},
1016
+ opencodeOnly: true,
1017
+ async execute(_args, ctx, host) {
1018
+ if (!host) {
1019
+ return "Watching is unavailable: this host did not provide a session context.";
1020
+ }
1021
+ if (!ctx.watchers) {
1022
+ return "Watching is unavailable: no watcher registry was wired by this host.";
1023
+ }
1024
+ const watchers = ctx.watchers.listForSession(host.sessionID);
1025
+ if (watchers.length === 0) return "No active watchers.";
1026
+ const minutesLeft = (w: { expiresAt: number }) => Math.max(0, Math.round((w.expiresAt - Date.now()) / 60_000));
1027
+ return watchers
1028
+ .map((w) => `${w.id}: ${w.repo}#${w.pr} events=[${w.events.join(",")}] expires in ${minutesLeft(w)}m head=${w.state.headSha.slice(0, 7)}`)
1029
+ .join("\n");
1030
+ },
1031
+ };
1032
+
1033
+ /**
1034
+ * Cancels one watcher. opencode-only, session-scoped: cancelling by id only
1035
+ * works for the session that created it.
1036
+ */
1037
+ const watchCancelDef: ToolDef = {
1038
+ name: "watch_cancel",
1039
+ description: "Cancel one of this session's watchers by id. opencode-only.",
1040
+ args: {
1041
+ id: z.string().describe("The watcher id from watch_create or watch_list."),
1042
+ },
1043
+ opencodeOnly: true,
1044
+ async execute(args, ctx, host) {
1045
+ if (!host) {
1046
+ return "Watching is unavailable: this host did not provide a session context.";
1047
+ }
1048
+ if (!ctx.watchers) {
1049
+ return "Watching is unavailable: no watcher registry was wired by this host.";
1050
+ }
1051
+ const cancelled = ctx.watchers.cancel(host.sessionID, args.id as string);
1052
+ if (!cancelled) return `No watcher "${args.id}" in this session.`;
1053
+ return `[cancelled] ${args.id}`;
1054
+ },
1055
+ };
1056
+
798
1057
  /**
799
1058
  * All tool definitions, in the order they should be presented to the agent.
800
- * The opencode plugin wraps each in `tool()`; the MCP server exposes them
801
- * via `tools/list` and dispatches `tools/call` to their execute functions.
1059
+ * The opencode plugin wraps each in `tool()`; the MCP server exposes the
1060
+ * non-opencodeOnly ones via `tools/list` and dispatches `tools/call` to
1061
+ * their execute functions.
802
1062
  */
803
1063
  export const TOOL_DEFS: ToolDef[] = [
804
1064
  rememberDef,
@@ -819,4 +1079,10 @@ export const TOOL_DEFS: ToolDef[] = [
819
1079
  behaviorFeedbackDef,
820
1080
  behaviorListDef,
821
1081
  behaviorDeleteDef,
1082
+ getSessionInfoDef,
1083
+ sessionSearchDef,
1084
+ sessionGetDef,
1085
+ watchCreateDef,
1086
+ watchListDef,
1087
+ watchCancelDef,
822
1088
  ];
package/src/tools.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
2
  import type { ThatchDB } from "./db";
3
3
  import type { EmbeddingModel } from "./embeddings";
4
- import { TOOL_DEFS, type CoreContext } from "./tool-defs";
4
+ import { TOOL_DEFS, type CoreContext, type HostToolContext } from "./tool-defs";
5
+ import type { WatcherRegistry } from "./watchers";
5
6
 
6
7
  /**
7
8
  * Builds the opencode tool map from shared tool definitions. Each definition
@@ -16,6 +17,7 @@ export function createTools(
16
17
  extensions?: {
17
18
  extractionPayloadProvider?: CoreContext["extractionPayloadProvider"];
18
19
  drainExtractionQueue?: CoreContext["drainExtractionQueue"];
20
+ watcherRegistry?: WatcherRegistry;
19
21
  },
20
22
  ): Record<string, ReturnType<typeof tool>> {
21
23
  const ctx: CoreContext = {
@@ -24,6 +26,7 @@ export function createTools(
24
26
  defaultStore,
25
27
  extractionPayloadProvider: extensions?.extractionPayloadProvider,
26
28
  drainExtractionQueue: extensions?.drainExtractionQueue,
29
+ watchers: extensions?.watcherRegistry,
27
30
  };
28
31
 
29
32
  const tools: Record<string, ReturnType<typeof tool>> = {};
@@ -31,8 +34,13 @@ export function createTools(
31
34
  tools[`thatch_${def.name}`] = tool({
32
35
  description: def.description,
33
36
  args: def.args,
34
- async execute(args) {
35
- return def.execute(args as Record<string, unknown>, ctx);
37
+ async execute(args, hostContext) {
38
+ // Trim opencode's ToolContext to the host-agnostic fields the shared
39
+ // definitions know about. Tests and MCP paths pass no host context.
40
+ const host: HostToolContext | undefined = hostContext
41
+ ? { sessionID: hostContext.sessionID, agent: hostContext.agent }
42
+ : undefined;
43
+ return def.execute(args as Record<string, unknown>, ctx, host);
36
44
  },
37
45
  });
38
46
  }