@jeffober/thatch 0.1.35 → 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/skills.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import pkg from "../package.json";
4
3
 
5
4
  export interface SkillFile {
6
5
  name: string;
@@ -73,46 +72,32 @@ export const OPENCODE_ONLY_SKILLS: SkillDef[] = loadOpencodeOnlySkills();
73
72
  // Stale skill cleanup - runs before install on every skillsDir
74
73
  // ---------------------------------------------------------------------------
75
74
 
76
- // Skills renamed from non-prefixed to thatch-prefixed in v0.1.27. The old
77
- // names are cleaned up during install for a limited version window because
78
- // non-prefixed names could be adopted by third parties after we abandon them.
79
- // After RENAME_MIGRATION_MAX_VERSION, the cleanup stops and leftover stale
80
- // files are the user's responsibility.
81
- const RENAMED_SKILLS = new Set([
82
- "pr-description",
83
- "ticket-description",
84
- "split-overlarge-pr",
85
- ]);
86
-
87
- // Last version that ships the non-prefixed-to-prefixed migration cleanup.
88
- // Users who upgrade to this version or earlier get automatic cleanup of the
89
- // old skill directories. Users who skip past this version must manually
90
- // delete the stale directories.
91
- const RENAME_MIGRATION_MAX_VERSION = "0.1.35";
92
-
93
- function compareVersions(a: string, b: string): number {
94
- const [aMaj, aMin, aPatch] = a.split(".").map(Number);
95
- const [bMaj, bMin, bPatch] = b.split(".").map(Number);
96
- if (aMaj !== bMaj) return aMaj - bMaj;
97
- if (aMin !== bMin) return aMin - bMin;
98
- return aPatch - bPatch;
99
- }
75
+ // ---------------------------------------------------------------------------
76
+ // Stale skill cleanup - runs before install on every skillsDir
77
+ // ---------------------------------------------------------------------------
78
+
79
+ // Skills renamed from non-prefixed to thatch-prefixed in v0.1.27
80
+ // (pr-description, ticket-description, split-overlarge-pr) were cleaned up
81
+ // during install through a version-gated window that closed with v0.1.35.
82
+ // The gate is gone because it can never reopen: package versions only grow,
83
+ // so this code only ever runs at a version past the window. The standing
84
+ // contract it protected remains: non-prefixed skill names belong to third
85
+ // parties and are NEVER removed - cleanup only touches thatch-* dirs, where
86
+ // the prefix is our namespace. Users upgrading from before v0.1.27 delete
87
+ // the old directories manually.
100
88
 
101
89
  /**
102
90
  * Remove stale skill directories before installing the current set.
103
91
  *
104
- * Two cleanup rules:
92
+ * One rule: delete any `thatch-*` skill directory that is not in the current
93
+ * install set. The `thatch-` prefix is our namespace - no third party should
94
+ * use it, so unconditional removal is safe. This catches skills we've
95
+ * renamed or removed in any release.
105
96
  *
106
- * 1. Always: delete any `thatch-*` skill directory that is not in the current
107
- * install set. The `thatch-` prefix is our namespace - no third party should
108
- * use it, so unconditional removal is safe. This catches skills we've
109
- * renamed or removed in any release.
110
- *
111
- * 2. Version-gated: delete directories matching old non-prefixed skill names
112
- * (pr-description, ticket-description, split-overlarge-pr) through
113
- * RENAME_MIGRATION_MAX_VERSION. These names lack the thatch- prefix and
114
- * could be adopted by third parties after we abandon them, so the cleanup
115
- * is time-boxed. Users who upgrade past the window must manually clean up.
97
+ * Non-prefixed directories are never touched. The v0.1.27 rename migration
98
+ * (pr-description -> thatch-pr-description and siblings) removed old
99
+ * non-prefixed dirs through a version window that closed with v0.1.35; see
100
+ * the block comment above.
116
101
  */
117
102
  function cleanupStaleSkills(skillsDir: string, currentNames: string[]): void {
118
103
  const currentSet = new Set(currentNames);
@@ -123,9 +108,6 @@ function cleanupStaleSkills(skillsDir: string, currentNames: string[]): void {
123
108
  return; // dir doesn't exist or isn't readable - nothing to clean
124
109
  }
125
110
 
126
- const withinMigrationWindow =
127
- compareVersions(pkg.version, RENAME_MIGRATION_MAX_VERSION) <= 0;
128
-
129
111
  for (const entry of entries) {
130
112
  // isDirectory() returns false for symlinks (even symlinked dirs), so
131
113
  // check isSymbolicLink() too - a symlinked skill dir is just as stale
@@ -149,15 +131,9 @@ function cleanupStaleSkills(skillsDir: string, currentNames: string[]): void {
149
131
  }
150
132
  };
151
133
 
152
- // Rule 1: stale thatch-* dirs (our namespace, always safe to remove).
134
+ // Stale thatch-* dirs: our namespace, always safe to remove.
153
135
  if (name.startsWith("thatch-") && !currentSet.has(name)) {
154
136
  remove();
155
- continue;
156
- }
157
-
158
- // Rule 2: renamed non-prefixed dirs (version-gated, third-party risk).
159
- if (RENAMED_SKILLS.has(name) && withinMigrationWindow) {
160
- remove();
161
137
  }
162
138
  }
163
139
  }
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
  }