@jeffober/thatch 0.1.35 → 0.1.37

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 { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, type WatcherRegistry, type PrWatcherEventType, type BranchWatcherEventType } 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,316 @@ 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, review thread resolutions, new commits " +
964
+ "(head SHA change), PR status changes, title/description edits, and " +
965
+ "completed CI check runs. The watcher polls in the background and " +
966
+ "injects a notification prompt; notifications carry pointers only " +
967
+ "(author, URL) - fetch details yourself with the gh CLI when you decide " +
968
+ "to act. Watches live until cancelled, the session ends, or opencode " +
969
+ "restarts. Requires the gh CLI.",
970
+ args: {
971
+ pr: z.number().int().positive().describe(
972
+ "The PR number to watch.",
973
+ ),
974
+ repo: z.string().optional().describe(
975
+ "GitHub repo as owner/repo. Defaults to this project's repo from the git remote.",
976
+ ),
977
+ events: z.array(z.enum(PR_EVENT_TYPES as [PrWatcherEventType, ...PrWatcherEventType[]])).optional().describe(
978
+ "Which event types to watch. Omit for all PR event types.",
979
+ ),
980
+ },
981
+ opencodeOnly: true,
982
+ async execute(args, ctx, host) {
983
+ if (!host) {
984
+ return "Watching is unavailable: this host did not provide a session context.";
985
+ }
986
+ if (!ctx.watchers) {
987
+ return "Watching is unavailable: no watcher registry was wired by this host.";
988
+ }
989
+ const repo = (args.repo as string | undefined) ?? ctx.defaultStore;
990
+ const events = (args.events as PrWatcherEventType[] | undefined) ?? [...PR_EVENT_TYPES];
991
+ const result = await ctx.watchers.createPr(host.sessionID, repo, args.pr as number, events);
992
+ if (!result.ok) return `Watcher not created: ${result.error}`;
993
+ const w = result.watcher;
994
+ return (
995
+ `[watching] ${w.repo}#${w.pr}\n` +
996
+ `id: ${w.id}\n` +
997
+ `events: ${w.events.join(", ")}\n` +
998
+ `head: ${w.state.headSha.slice(0, 7)} (${w.state.state}${w.state.merged ? ", merged" : ""})\n\n` +
999
+ `The baseline is captured now - only changes from this point notify. ` +
1000
+ `You will receive a system notification in this session when a watched event happens. ` +
1001
+ `State any handling policy for those notifications now (e.g. what to act on, what just to report), ` +
1002
+ `since the notification itself carries only pointers.`
1003
+ );
1004
+ },
1005
+ };
1006
+
1007
+ /**
1008
+ * Lists the calling session's active watchers. opencode-only, session-scoped:
1009
+ * a session can only ever see its own watches.
1010
+ */
1011
+ const watchListDef: ToolDef = {
1012
+ name: "watch_list",
1013
+ description:
1014
+ "List this session's active watchers with their ids, targets, watched " +
1015
+ "event types, and remaining time. opencode-only.",
1016
+ args: {},
1017
+ opencodeOnly: true,
1018
+ async execute(_args, ctx, host) {
1019
+ if (!host) {
1020
+ return "Watching is unavailable: this host did not provide a session context.";
1021
+ }
1022
+ if (!ctx.watchers) {
1023
+ return "Watching is unavailable: no watcher registry was wired by this host.";
1024
+ }
1025
+ const watchers = ctx.watchers.listForSession(host.sessionID);
1026
+ if (watchers.length === 0) return "No active watchers.";
1027
+ const minutesLeft = (w: { expiresAt: number }) => Math.max(0, Math.round((w.expiresAt - Date.now()) / 60_000));
1028
+ return watchers
1029
+ .map((w) => {
1030
+ const target = w.source === "pr" ? `${w.repo}#${w.pr}` : `${w.repo}@${w.branch}`;
1031
+ return `${w.id}: ${target} [${w.source}] events=[${w.events.join(",")}] expires in ${minutesLeft(w)}m head=${w.state.headSha.slice(0, 7)}`;
1032
+ })
1033
+ .join("\n");
1034
+ },
1035
+ };
1036
+
1037
+ /**
1038
+ * Registers a background watcher on a GitHub branch (typically main): commit
1039
+ * landings, check-run completions on the head, and workflow runs. This is
1040
+ * how the model watches "CI against main" or waits for a post-merge build.
1041
+ * opencode-only, like the PR watch tools.
1042
+ */
1043
+ const watchBranchCreateDef: ToolDef = {
1044
+ name: "watch_branch_create",
1045
+ description:
1046
+ "Watch a GitHub branch (typically main) and get notified in this session " +
1047
+ "when things happen on it: branch_commit when new commits land (e.g. a " +
1048
+ "PR merged), branch_ci when a check run on the head completes, and " +
1049
+ "branch_workflow when a GitHub Actions workflow run starts or finishes " +
1050
+ "on the branch. Optionally filter to workflow names (substring match). " +
1051
+ "The watcher polls in the background and injects a notification prompt; " +
1052
+ "notifications carry pointers only - fetch details with the gh CLI when " +
1053
+ "you decide to act. Watches live until cancelled, the session ends, or " +
1054
+ "opencode restarts. Requires the gh CLI.",
1055
+ args: {
1056
+ branch: z.string().describe(
1057
+ "The branch name to watch, e.g. main.",
1058
+ ),
1059
+ repo: z.string().optional().describe(
1060
+ "GitHub repo as owner/repo. Defaults to this project's repo from the git remote.",
1061
+ ),
1062
+ events: z.array(z.enum(BRANCH_EVENT_TYPES as [BranchWatcherEventType, ...BranchWatcherEventType[]])).optional().describe(
1063
+ "Which event types to watch. Omit for all branch event types.",
1064
+ ),
1065
+ workflows: z.array(z.string()).optional().describe(
1066
+ "Only notify for workflow runs whose name contains one of these substrings (case-insensitive). Omit for all workflows.",
1067
+ ),
1068
+ },
1069
+ opencodeOnly: true,
1070
+ async execute(args, ctx, host) {
1071
+ if (!host) {
1072
+ return "Watching is unavailable: this host did not provide a session context.";
1073
+ }
1074
+ if (!ctx.watchers) {
1075
+ return "Watching is unavailable: no watcher registry was wired by this host.";
1076
+ }
1077
+ const repo = (args.repo as string | undefined) ?? ctx.defaultStore;
1078
+ const events = (args.events as BranchWatcherEventType[] | undefined) ?? [...BRANCH_EVENT_TYPES];
1079
+ const result = await ctx.watchers.createBranch(
1080
+ host.sessionID,
1081
+ repo,
1082
+ args.branch as string,
1083
+ events,
1084
+ (args.workflows as string[] | undefined) ?? [],
1085
+ );
1086
+ if (!result.ok) return `Watcher not created: ${result.error}`;
1087
+ const w = result.watcher;
1088
+ return (
1089
+ `[watching] ${w.repo}@${w.branch}\n` +
1090
+ `id: ${w.id}\n` +
1091
+ `events: ${w.events.join(", ")}\n` +
1092
+ (w.workflows.length > 0 ? `workflow filter: ${w.workflows.join(", ")}\n` : "") +
1093
+ `head: ${w.state.headSha.slice(0, 7)}\n\n` +
1094
+ `The baseline is captured now - only changes from this point notify. ` +
1095
+ `You will receive a system notification in this session when a watched event happens. ` +
1096
+ `State any handling policy for those notifications now (e.g. what to act on, what just to report), ` +
1097
+ `since the notification itself carries only pointers.`
1098
+ );
1099
+ },
1100
+ };
1101
+
1102
+ /**
1103
+ * Cancels one watcher. opencode-only, session-scoped: cancelling by id only
1104
+ * works for the session that created it.
1105
+ */
1106
+ const watchCancelDef: ToolDef = {
1107
+ name: "watch_cancel",
1108
+ description: "Cancel one of this session's watchers by id. opencode-only.",
1109
+ args: {
1110
+ id: z.string().describe("The watcher id from watch_create, watch_branch_create, or watch_list."),
1111
+ },
1112
+ opencodeOnly: true,
1113
+ async execute(args, ctx, host) {
1114
+ if (!host) {
1115
+ return "Watching is unavailable: this host did not provide a session context.";
1116
+ }
1117
+ if (!ctx.watchers) {
1118
+ return "Watching is unavailable: no watcher registry was wired by this host.";
1119
+ }
1120
+ const cancelled = ctx.watchers.cancel(host.sessionID, args.id as string);
1121
+ if (!cancelled) return `No watcher "${args.id}" in this session.`;
1122
+ return `[cancelled] ${args.id}`;
1123
+ },
1124
+ };
1125
+
798
1126
  /**
799
1127
  * 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.
1128
+ * The opencode plugin wraps each in `tool()`; the MCP server exposes the
1129
+ * non-opencodeOnly ones via `tools/list` and dispatches `tools/call` to
1130
+ * their execute functions.
802
1131
  */
803
1132
  export const TOOL_DEFS: ToolDef[] = [
804
1133
  rememberDef,
@@ -819,4 +1148,11 @@ export const TOOL_DEFS: ToolDef[] = [
819
1148
  behaviorFeedbackDef,
820
1149
  behaviorListDef,
821
1150
  behaviorDeleteDef,
1151
+ getSessionInfoDef,
1152
+ sessionSearchDef,
1153
+ sessionGetDef,
1154
+ watchCreateDef,
1155
+ watchBranchCreateDef,
1156
+ watchListDef,
1157
+ watchCancelDef,
822
1158
  ];
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
  }