@jeffober/thatch 0.1.48 → 0.2.0
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 +43 -4
- package/bin/thatch +31 -16
- package/docs/dev/README.md +28 -6
- package/docs/dev/features/behavior-engine.md +3 -2
- package/docs/dev/features/cicd.md +1 -1
- package/docs/dev/features/compaction-recovery.md +1 -1
- package/docs/dev/features/cross-session-chat.md +4 -2
- package/docs/dev/features/extraction.md +10 -2
- package/docs/dev/features/hygiene.md +3 -1
- package/docs/dev/features/multi-host.md +4 -2
- package/docs/dev/features/notifications.md +121 -4
- package/docs/dev/features/nudge-pipeline.md +4 -2
- package/docs/dev/features/opencode-plugin.md +133 -0
- package/docs/dev/features/qa-system.md +43 -0
- package/docs/dev/features/repo-identity.md +1 -1
- package/docs/dev/features/session-lifecycle.md +5 -2
- package/docs/dev/features/watchers.md +12 -5
- package/docs/dev/gotchas.md +36 -3
- package/docs/dev/mcp-parity.md +4 -3
- package/docs/dev/setup-and-hooks.md +6 -1
- package/docs/plans/llm-alerts.md +287 -0
- package/docs/plans/prediction-consolidation.md +3 -3
- package/docs/user/README.md +7 -3
- package/docs/user/notifications.md +63 -4
- package/docs/user/setup.md +4 -0
- package/docs/user/watchers.md +15 -7
- package/package.json +3 -1
- package/src/alerts.ts +360 -0
- package/src/capabilities.ts +174 -0
- package/src/chat.ts +63 -5
- package/src/commands.ts +44 -5
- package/src/config.ts +32 -1
- package/src/db.ts +80 -0
- package/src/embeddings.ts +43 -0
- package/src/extraction.ts +124 -0
- package/src/index.ts +38 -1345
- package/src/opencode/v1.ts +54 -0
- package/src/opencode/v2.ts +561 -0
- package/src/os-args.ts +99 -0
- package/src/prompts.ts +77 -1
- package/src/runtime.ts +1815 -0
- package/src/seed-behaviors.ts +1 -1
- package/src/session-db.ts +32 -0
- package/src/tool-defs.ts +185 -19
- package/src/tools.ts +16 -35
- package/src/watchers.ts +95 -14
package/src/seed-behaviors.ts
CHANGED
|
@@ -30,7 +30,7 @@ interface DefaultBehavior {
|
|
|
30
30
|
* (2) a user-codified behavior that happens to match a seed situation
|
|
31
31
|
* would be found and silently deleted.
|
|
32
32
|
*
|
|
33
|
-
* Called by both the opencode plugin (src/
|
|
33
|
+
* Called by both the opencode plugin (src/runtime.ts) and the MCP server
|
|
34
34
|
* (src/mcp.ts) at startup, after DB and model initialization.
|
|
35
35
|
*/
|
|
36
36
|
const DEFAULT_BEHAVIORS: DefaultBehavior[] = [
|
package/src/session-db.ts
CHANGED
|
@@ -54,6 +54,38 @@ export function resolveOpencodeDbPath(): string | null {
|
|
|
54
54
|
return existsSync(path) ? path : null;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Resolves the session `opencode -c` continues: the most recently updated
|
|
59
|
+
* top-level session in the directory, read straight from the session
|
|
60
|
+
* database - the same pick the TUI makes from client.session.list(), which
|
|
61
|
+
* the v2 plugin context cannot call (its session domain has no list) and
|
|
62
|
+
* which would arrive too late for the runtime's synchronous rehydration
|
|
63
|
+
* partition regardless. Null when the db is unavailable or no top-level
|
|
64
|
+
* session exists in the directory.
|
|
65
|
+
*/
|
|
66
|
+
export function mostRecentTopLevelSessionId(directory: string): string | null {
|
|
67
|
+
const dbPath = resolveOpencodeDbPath();
|
|
68
|
+
if (!dbPath) return null;
|
|
69
|
+
let db: Database;
|
|
70
|
+
try {
|
|
71
|
+
db = new Database(dbPath, { readonly: true });
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const row = db
|
|
77
|
+
.query(
|
|
78
|
+
"SELECT id FROM session WHERE directory = ? AND (parent_id IS NULL OR parent_id = '') ORDER BY time_updated DESC LIMIT 1",
|
|
79
|
+
)
|
|
80
|
+
.get(directory) as { id: string } | undefined;
|
|
81
|
+
return row?.id ?? null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
} finally {
|
|
85
|
+
db.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
57
89
|
export interface SessionRow {
|
|
58
90
|
id: string;
|
|
59
91
|
title: string;
|
package/src/tool-defs.ts
CHANGED
|
@@ -2,15 +2,20 @@ import { z } from "zod";
|
|
|
2
2
|
import type { ThatchDB, DedupCandidate, MemoryRow } from "./db";
|
|
3
3
|
import type { EmbeddingModel } from "./embeddings";
|
|
4
4
|
import {
|
|
5
|
+
ALERT_EVENT_KINDS,
|
|
5
6
|
CONFIG_SECTIONS,
|
|
7
|
+
alertEventPrefsSchema,
|
|
8
|
+
alertsPrefsSchema,
|
|
6
9
|
chatEnabled,
|
|
7
10
|
chatPrefsSchema,
|
|
8
11
|
loadConfig,
|
|
12
|
+
mergeAlertsPrefs,
|
|
9
13
|
mergeChatPrefs,
|
|
10
14
|
mergeNotificationPrefs,
|
|
11
15
|
notificationDefaults,
|
|
12
16
|
notificationPrefsSchema,
|
|
13
17
|
saveConfig,
|
|
18
|
+
type AlertsPrefs,
|
|
14
19
|
type ChatPrefs,
|
|
15
20
|
type Config,
|
|
16
21
|
type ConfigSection,
|
|
@@ -49,8 +54,39 @@ function dedupScanStores(store: string): string[] {
|
|
|
49
54
|
return store === "global" ? ["global"] : [store, "global"];
|
|
50
55
|
}
|
|
51
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Resolve a behavior from feedback/delete text. The behavior nudge renders
|
|
59
|
+
* "When <matcher description>: <verb> <statement>" - and models copy back
|
|
60
|
+
* EITHER half (observed live three times: the situation half was fed back
|
|
61
|
+
* and statement search failed, even though the text was verbatim from the
|
|
62
|
+
* nudge). Try the statement first, then the matcher description resolved
|
|
63
|
+
* through its edge to the linked behavior. Returns the behavior row, an
|
|
64
|
+
* ambiguity list (the situation is shared by multiple behaviors), or null.
|
|
65
|
+
*/
|
|
66
|
+
async function resolveBehaviorForFeedback(
|
|
67
|
+
ctx: CoreContext,
|
|
68
|
+
store: string,
|
|
69
|
+
text: string,
|
|
70
|
+
): Promise<{ behavior: { id: string; statement: string }; via: string } | { ambiguous: string[] } | null> {
|
|
71
|
+
const embed = await ctx.model.passageEmbed(text);
|
|
72
|
+
const direct = ctx.db.findNearestBehavior(dedupScanStores(store), embed, BEHAVIOR_DEDUP_COSINE);
|
|
73
|
+
if (direct) return { behavior: direct, via: "statement" };
|
|
74
|
+
for (const s of dedupScanStores(store)) {
|
|
75
|
+
const matcher = ctx.db.findNearestBehaviorMatcher(s, embed, BEHAVIOR_DEDUP_COSINE);
|
|
76
|
+
if (!matcher) continue;
|
|
77
|
+
const linked = ctx.db.scoreBehaviors([{ id: matcher.id, description: matcher.description, score: 1 }]);
|
|
78
|
+
if (linked.length === 1) {
|
|
79
|
+
const row = ctx.db.getBehavior(linked[0].behavior_id);
|
|
80
|
+
if (row) return { behavior: row, via: "matcher description" };
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (linked.length > 1) return { ambiguous: linked.map((l) => l.statement) };
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
52
88
|
// Minimum matcher cosine to consider a prediction relevant. Matches the
|
|
53
|
-
// auto-fire threshold in
|
|
89
|
+
// auto-fire threshold in runtime.ts (PREDICTION_THRESHOLD). The query tool
|
|
54
90
|
// should not return predictions from near-zero-similarity matchers that
|
|
55
91
|
// would never fire in the auto-fire.
|
|
56
92
|
const PREDICTION_QUERY_THRESHOLD = 0.60;
|
|
@@ -579,7 +615,11 @@ const predictionUpdateDef: ToolDef = {
|
|
|
579
615
|
"and confidence adjustment automatically.",
|
|
580
616
|
args: {
|
|
581
617
|
matcher: z.string().describe(
|
|
582
|
-
"Description of the situation. What decision was being made?"
|
|
618
|
+
"Description of the situation. What decision was being made? Word it " +
|
|
619
|
+
"for semantic matching: after writing it, re-read it against " +
|
|
620
|
+
"everything that ought to trigger it and widen the wording to the " +
|
|
621
|
+
"broadest surface of the class - a matcher that only names this " +
|
|
622
|
+
"incident never fires for the next one.",
|
|
583
623
|
),
|
|
584
624
|
prediction: z.string().describe(
|
|
585
625
|
"The user's preference or tendency in this situation.",
|
|
@@ -740,11 +780,18 @@ const behaviorCodifyDef: ToolDef = {
|
|
|
740
780
|
"discipline, not what the user wants. Examples: check the whole " +
|
|
741
781
|
"codebase for a library before importing it; investigate disabled " +
|
|
742
782
|
"tests before touching the area; read a large function fully before " +
|
|
743
|
-
"editing it."
|
|
783
|
+
"editing it. When a review, bug, or incident reveals a CLASS of " +
|
|
784
|
+
"mistake you are prone to (not a one-off), codify a guard against the " +
|
|
785
|
+
"class.",
|
|
744
786
|
args: {
|
|
745
787
|
situation: z.string().describe(
|
|
746
788
|
"Description of the situation that triggers this behavior. What context " +
|
|
747
|
-
"or task type makes this rule apply?"
|
|
789
|
+
"or task type makes this rule apply? Word it for semantic matching: " +
|
|
790
|
+
"describe the context a future session will be in when this class of " +
|
|
791
|
+
"trouble starts, then re-read your wording against everything that " +
|
|
792
|
+
"ought to trigger it and widen it to the broadest surface of the " +
|
|
793
|
+
"class - a matcher that only names this incident never fires for the " +
|
|
794
|
+
"next one.",
|
|
748
795
|
),
|
|
749
796
|
behavior: z.string().describe(
|
|
750
797
|
"The behavioral rule. What should you do when this situation arises?",
|
|
@@ -802,11 +849,19 @@ const behaviorFeedbackDef: ToolDef = {
|
|
|
802
849
|
"or relevant: false (spam) if it does not. This trains the classifier " +
|
|
803
850
|
"so future nudges are more accurate. Also use when the user corrects " +
|
|
804
851
|
"your behavior and you realize a codified rule led you astray or " +
|
|
805
|
-
"should have been followed."
|
|
852
|
+
"should have been followed. The nudge line carries two texts - the " +
|
|
853
|
+
"situation (\"When ...\") and the behavior statement (after \"do\") - " +
|
|
854
|
+
"and EITHER is accepted: the tool resolves the situation back to its " +
|
|
855
|
+
"linked behavior. If it reports no match, the behavior may live in a " +
|
|
856
|
+
"different store than your default (seeded behaviors live in the " +
|
|
857
|
+
"global store): list with behavior_list and retry, passing the store " +
|
|
858
|
+
"the behavior was listed from.",
|
|
806
859
|
args: {
|
|
807
860
|
behavior: z.string().describe(
|
|
808
|
-
"The behavior statement to provide feedback on.
|
|
809
|
-
"
|
|
861
|
+
"The behavior statement to provide feedback on. The situation line " +
|
|
862
|
+
"from the nudge (\"When ...\") is also accepted and resolves to its " +
|
|
863
|
+
"linked behavior. Use behavior_list to find the exact text; " +
|
|
864
|
+
"matching is semantic (cosine >= 0.85).",
|
|
810
865
|
),
|
|
811
866
|
relevant: z.boolean().describe(
|
|
812
867
|
"true (ham) if the behavior is relevant to the current situation. " +
|
|
@@ -826,9 +881,20 @@ const behaviorFeedbackDef: ToolDef = {
|
|
|
826
881
|
const relevant = args.relevant as boolean;
|
|
827
882
|
const contextText = args.context as string;
|
|
828
883
|
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
|
|
884
|
+
const resolved = await resolveBehaviorForFeedback(ctx, store, behaviorText);
|
|
885
|
+
if (!resolved) {
|
|
886
|
+
return (
|
|
887
|
+
`No behavior matching "${behaviorText}" found in any of ${dedupScanStores(store).join(", ")} ` +
|
|
888
|
+
`(tried both statements and matcher descriptions). Use behavior_list to see the exact statements.`
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
if ("ambiguous" in resolved) {
|
|
892
|
+
return (
|
|
893
|
+
`That text matches a situation shared by multiple behaviors - re-run with the exact statement: ` +
|
|
894
|
+
resolved.ambiguous.map((s) => `"${s}"`).join("; ")
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
const behavior = resolved.behavior;
|
|
832
898
|
|
|
833
899
|
const signal = relevant ? "confirm" : "disconfirm";
|
|
834
900
|
ctx.db.transaction(() => {
|
|
@@ -876,7 +942,9 @@ const behaviorDeleteDef: ToolDef = {
|
|
|
876
942
|
"automatically (cascade).",
|
|
877
943
|
args: {
|
|
878
944
|
statement: z.string().describe(
|
|
879
|
-
"The behavior statement to delete.
|
|
945
|
+
"The behavior statement to delete. The situation line from the " +
|
|
946
|
+
"nudge (\"When ...\") is also accepted and resolves to its linked " +
|
|
947
|
+
"behavior. Use behavior_list to find the " +
|
|
880
948
|
"exact text; matching is semantic (cosine >= 0.85).",
|
|
881
949
|
),
|
|
882
950
|
store: z.string().optional().describe(
|
|
@@ -886,12 +954,24 @@ const behaviorDeleteDef: ToolDef = {
|
|
|
886
954
|
async execute(args, ctx) {
|
|
887
955
|
const store = resolveStore(args, ctx);
|
|
888
956
|
const statementText = args.statement as string;
|
|
889
|
-
const
|
|
890
|
-
|
|
891
|
-
|
|
957
|
+
const resolved = await resolveBehaviorForFeedback(ctx, store, statementText);
|
|
958
|
+
if (!resolved) {
|
|
959
|
+
return (
|
|
960
|
+
`No behavior matching "${statementText}" found in any of ${dedupScanStores(store).join(", ")} ` +
|
|
961
|
+
`(tried both statements and matcher descriptions). Use behavior_list to see the exact statements.`
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
if ("ambiguous" in resolved) {
|
|
965
|
+
return (
|
|
966
|
+
`That text matches a situation shared by multiple behaviors - re-run with the exact statement: ` +
|
|
967
|
+
resolved.ambiguous.map((s) => `"${s}"`).join("; ")
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
const behavior = resolved.behavior;
|
|
971
|
+
const rowBefore = ctx.db.getBehavior(behavior.id);
|
|
892
972
|
const deleted = ctx.db.deleteBehavior(behavior.id);
|
|
893
973
|
if (!deleted) return `Failed to delete behavior "${behavior.statement}".`;
|
|
894
|
-
return `[deleted] "${behavior.statement}" from "${store}"`;
|
|
974
|
+
return `[deleted] "${behavior.statement}" from "${rowBefore?.store ?? store}"`;
|
|
895
975
|
},
|
|
896
976
|
};
|
|
897
977
|
|
|
@@ -925,6 +1005,15 @@ function renderChatSection(prefs: ChatPrefs | undefined): string {
|
|
|
925
1005
|
].join("\n");
|
|
926
1006
|
}
|
|
927
1007
|
|
|
1008
|
+
/** Renders the alerts section. Every event defaults to banner. */
|
|
1009
|
+
function renderAlertsSection(prefs: AlertsPrefs | undefined): string {
|
|
1010
|
+
const lines = ["alerts:"];
|
|
1011
|
+
for (const kind of ALERT_EVENT_KINDS) {
|
|
1012
|
+
lines.push(` ${kind}.mode: ${prefs?.[kind]?.mode ?? "<unset>"} (default: banner)`);
|
|
1013
|
+
}
|
|
1014
|
+
return lines.join("\n");
|
|
1015
|
+
}
|
|
1016
|
+
|
|
928
1017
|
/** Drops keys explicitly set to undefined so a merge never overwrites. */
|
|
929
1018
|
function stripUndefined(obj: Record<string, unknown>): Record<string, unknown> {
|
|
930
1019
|
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
|
@@ -938,7 +1027,7 @@ const configGetDef: ToolDef = {
|
|
|
938
1027
|
"The config file (~/.config/thatch/config.json, beside thatch.db) is " +
|
|
939
1028
|
"also hand-editable. Call this before config_set.",
|
|
940
1029
|
args: {
|
|
941
|
-
section: z.enum(["notifications", "chat"]).optional().describe(
|
|
1030
|
+
section: z.enum(["notifications", "chat", "alerts"]).optional().describe(
|
|
942
1031
|
"Restrict output to one section. Omit to read all sections.",
|
|
943
1032
|
),
|
|
944
1033
|
},
|
|
@@ -956,6 +1045,9 @@ const configGetDef: ToolDef = {
|
|
|
956
1045
|
if (name === "chat") {
|
|
957
1046
|
lines.push(renderChatSection(loaded.config.chat));
|
|
958
1047
|
}
|
|
1048
|
+
if (name === "alerts") {
|
|
1049
|
+
lines.push(renderAlertsSection(loaded.config.alerts));
|
|
1050
|
+
}
|
|
959
1051
|
}
|
|
960
1052
|
lines.push("", `file: ${loaded.path}`);
|
|
961
1053
|
return lines.join("\n");
|
|
@@ -1002,19 +1094,40 @@ const configSetDef: ToolDef = {
|
|
|
1002
1094
|
"Chat preferences to update. Fields you omit keep their current " +
|
|
1003
1095
|
"values.",
|
|
1004
1096
|
),
|
|
1097
|
+
alerts: z.strictObject({
|
|
1098
|
+
pause: alertEventPrefsSchema.optional().describe(
|
|
1099
|
+
"Alert when the LLM blocks on a question or permission prompt.",
|
|
1100
|
+
),
|
|
1101
|
+
done: alertEventPrefsSchema.optional().describe(
|
|
1102
|
+
"Alert when a round of real work finishes. Rounds triggered by " +
|
|
1103
|
+
"synthetic input (thatch nudges, task completions, watcher " +
|
|
1104
|
+
"wake-ups) and rounds of only bookkeeping tools stay silent.",
|
|
1105
|
+
),
|
|
1106
|
+
error: alertEventPrefsSchema.optional().describe(
|
|
1107
|
+
"Alert when a session fails with nothing to recover it (user " +
|
|
1108
|
+
"aborts stay silent; retried failures stay silent).",
|
|
1109
|
+
),
|
|
1110
|
+
}).optional().describe(
|
|
1111
|
+
"Automatic alert preferences (desktop notification when the LLM " +
|
|
1112
|
+
"pauses or finishes). Each event takes a mode: both, banner only, " +
|
|
1113
|
+
"voice only, or none. Default: banner.",
|
|
1114
|
+
),
|
|
1005
1115
|
},
|
|
1006
1116
|
async execute(args) {
|
|
1007
1117
|
const loaded = loadConfig();
|
|
1008
1118
|
const config: Config = { ...loaded.config };
|
|
1009
1119
|
const notifPatch = args.notifications as Record<string, unknown> | undefined;
|
|
1010
1120
|
const chatPatch = args.chat as Record<string, unknown> | undefined;
|
|
1121
|
+
const alertsPatch = args.alerts as Record<string, unknown> | undefined;
|
|
1011
1122
|
const notifClean = notifPatch ? stripUndefined(notifPatch) : {};
|
|
1012
1123
|
const chatClean = chatPatch ? stripUndefined(chatPatch) : {};
|
|
1013
|
-
|
|
1124
|
+
const alertsClean = alertsPatch ? stripUndefined(alertsPatch) : {};
|
|
1125
|
+
if (Object.keys(notifClean).length === 0 && Object.keys(chatClean).length === 0 && Object.keys(alertsClean).length === 0) {
|
|
1014
1126
|
return (
|
|
1015
1127
|
"Nothing to update: pass a section with fields to change.\n" +
|
|
1016
1128
|
renderNotificationSection(config.notifications) + "\n" +
|
|
1017
|
-
renderChatSection(config.chat)
|
|
1129
|
+
renderChatSection(config.chat) + "\n" +
|
|
1130
|
+
renderAlertsSection(config.alerts)
|
|
1018
1131
|
);
|
|
1019
1132
|
}
|
|
1020
1133
|
if (Object.keys(notifClean).length > 0) {
|
|
@@ -1027,11 +1140,17 @@ const configSetDef: ToolDef = {
|
|
|
1027
1140
|
mergeChatPrefs(config.chat, chatClean),
|
|
1028
1141
|
);
|
|
1029
1142
|
}
|
|
1143
|
+
if (Object.keys(alertsClean).length > 0) {
|
|
1144
|
+
config.alerts = alertsPrefsSchema.parse(
|
|
1145
|
+
mergeAlertsPrefs(config.alerts, alertsClean),
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1030
1148
|
const path = saveConfig(config);
|
|
1031
1149
|
return (
|
|
1032
1150
|
`[saved] ${path}\n` +
|
|
1033
1151
|
(Object.keys(notifClean).length > 0 ? renderNotificationSection(config.notifications) + "\n" : "") +
|
|
1034
|
-
(Object.keys(chatClean).length > 0 ? renderChatSection(config.chat) : "")
|
|
1152
|
+
(Object.keys(chatClean).length > 0 ? renderChatSection(config.chat) + "\n" : "") +
|
|
1153
|
+
(Object.keys(alertsClean).length > 0 ? renderAlertsSection(config.alerts) : "")
|
|
1035
1154
|
);
|
|
1036
1155
|
},
|
|
1037
1156
|
};
|
|
@@ -2006,3 +2125,50 @@ export const TOOL_DEFS: ToolDef[] = [
|
|
|
2006
2125
|
chatBroadcastDef,
|
|
2007
2126
|
chatStatusDef,
|
|
2008
2127
|
];
|
|
2128
|
+
|
|
2129
|
+
/** Extensions a host adapter passes into the shared CoreContext. */
|
|
2130
|
+
export interface CoreContextExtensions {
|
|
2131
|
+
extractionPayloadProvider?: CoreContext["extractionPayloadProvider"];
|
|
2132
|
+
drainExtractionQueue?: CoreContext["drainExtractionQueue"];
|
|
2133
|
+
watcherRegistry?: WatcherRegistry;
|
|
2134
|
+
projectDir?: string;
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
/**
|
|
2138
|
+
* Builds the host-agnostic CoreContext both adapters share: the opencode
|
|
2139
|
+
* plugin path and the MCP server each construct a CoreContext once per
|
|
2140
|
+
* init and reuse it for every tool call. This helper is the plugin-side
|
|
2141
|
+
* constructor; the MCP server builds its own inline (src/mcp.ts).
|
|
2142
|
+
*
|
|
2143
|
+
* Lives in this module - NOT in tools.ts - because tools.ts imports the v1
|
|
2144
|
+
* SDK at runtime, and the v2 adapter's entry graph must stay SDK-free (the
|
|
2145
|
+
* v2 plugin install skips optional peers, so evaluating the v1 SDK there
|
|
2146
|
+
* crashes the load).
|
|
2147
|
+
*/
|
|
2148
|
+
export function buildCoreContext(
|
|
2149
|
+
db: ThatchDB,
|
|
2150
|
+
model: EmbeddingModel,
|
|
2151
|
+
defaultStore: string,
|
|
2152
|
+
extensions?: CoreContextExtensions,
|
|
2153
|
+
): CoreContext {
|
|
2154
|
+
return {
|
|
2155
|
+
db,
|
|
2156
|
+
model,
|
|
2157
|
+
defaultStore,
|
|
2158
|
+
extractionPayloadProvider: extensions?.extractionPayloadProvider,
|
|
2159
|
+
drainExtractionQueue: extensions?.drainExtractionQueue,
|
|
2160
|
+
watchers: extensions?.watcherRegistry,
|
|
2161
|
+
projectDir: extensions?.projectDir,
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
/**
|
|
2166
|
+
* Trim opencode's per-call ToolContext to the host-agnostic fields the
|
|
2167
|
+
* shared definitions know about. Both adapters do this same two-field trim;
|
|
2168
|
+
* tests and MCP paths pass no host context.
|
|
2169
|
+
*/
|
|
2170
|
+
export function trimHostContext(
|
|
2171
|
+
hostContext: { sessionID: string; agent: string } | undefined,
|
|
2172
|
+
): HostToolContext | undefined {
|
|
2173
|
+
return hostContext ? { sessionID: hostContext.sessionID, agent: hostContext.agent } : undefined;
|
|
2174
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -1,48 +1,29 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin";
|
|
2
|
-
import type
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import { TOOL_DEFS, trimHostContext, type CoreContext } from "./tool-defs";
|
|
3
|
+
|
|
4
|
+
// V1-ONLY module: this file imports the opencode v1 SDK at runtime, so
|
|
5
|
+
// nothing shared may import from it. The host-agnostic pieces
|
|
6
|
+
// (buildCoreContext, trimHostContext, CoreContext, TOOL_DEFS) live in
|
|
7
|
+
// tool-defs.ts precisely so the v2 adapter and the MCP server can use them
|
|
8
|
+
// without evaluating the v1 SDK - the v2 plugin install skips optional
|
|
9
|
+
// peers, and a missing SDK import here would kill the whole plugin load.
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
|
-
* Builds the opencode tool map from shared tool definitions. Each
|
|
9
|
-
* in TOOL_DEFS is wrapped in opencode's `tool()` with a
|
|
10
|
-
* on the name - opencode uses the object key as the tool
|
|
11
|
-
* lives here, not in the shared definitions.
|
|
12
|
+
* Builds the v1 opencode tool map from shared tool definitions. Each
|
|
13
|
+
* definition in TOOL_DEFS is wrapped in opencode's `tool()` with a
|
|
14
|
+
* `thatch_` prefix on the name - opencode uses the object key as the tool
|
|
15
|
+
* name, so the prefix lives here, not in the shared definitions. Takes the
|
|
16
|
+
* shared CoreContext (buildCoreContext) rather than raw deps, so both host
|
|
17
|
+
* adapters feed the same context construction.
|
|
12
18
|
*/
|
|
13
|
-
export function createTools(
|
|
14
|
-
db: ThatchDB,
|
|
15
|
-
model: EmbeddingModel,
|
|
16
|
-
defaultStore: string,
|
|
17
|
-
extensions?: {
|
|
18
|
-
extractionPayloadProvider?: CoreContext["extractionPayloadProvider"];
|
|
19
|
-
drainExtractionQueue?: CoreContext["drainExtractionQueue"];
|
|
20
|
-
watcherRegistry?: WatcherRegistry;
|
|
21
|
-
projectDir?: string;
|
|
22
|
-
},
|
|
23
|
-
): Record<string, ReturnType<typeof tool>> {
|
|
24
|
-
const ctx: CoreContext = {
|
|
25
|
-
db,
|
|
26
|
-
model,
|
|
27
|
-
defaultStore,
|
|
28
|
-
extractionPayloadProvider: extensions?.extractionPayloadProvider,
|
|
29
|
-
drainExtractionQueue: extensions?.drainExtractionQueue,
|
|
30
|
-
watchers: extensions?.watcherRegistry,
|
|
31
|
-
projectDir: extensions?.projectDir,
|
|
32
|
-
};
|
|
33
|
-
|
|
19
|
+
export function createTools(coreContext: CoreContext): Record<string, ReturnType<typeof tool>> {
|
|
34
20
|
const tools: Record<string, ReturnType<typeof tool>> = {};
|
|
35
21
|
for (const def of TOOL_DEFS) {
|
|
36
22
|
tools[`thatch_${def.name}`] = tool({
|
|
37
23
|
description: def.description,
|
|
38
24
|
args: def.args,
|
|
39
25
|
async execute(args, hostContext) {
|
|
40
|
-
|
|
41
|
-
// definitions know about. Tests and MCP paths pass no host context.
|
|
42
|
-
const host: HostToolContext | undefined = hostContext
|
|
43
|
-
? { sessionID: hostContext.sessionID, agent: hostContext.agent }
|
|
44
|
-
: undefined;
|
|
45
|
-
return def.execute(args as Record<string, unknown>, ctx, host);
|
|
26
|
+
return def.execute(args as Record<string, unknown>, coreContext, trimHostContext(hostContext));
|
|
46
27
|
},
|
|
47
28
|
});
|
|
48
29
|
}
|
package/src/watchers.ts
CHANGED
|
@@ -16,16 +16,14 @@
|
|
|
16
16
|
* never its output (injection hygiene: command output can be external
|
|
17
17
|
* content, so it never rides a notification).
|
|
18
18
|
*
|
|
19
|
-
* Lifetime is deliberately process-scoped
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* into a session that no longer knows why it is being watched is worse than
|
|
28
|
-
* a lost watch.
|
|
19
|
+
* Lifetime is deliberately process-scoped for the LIVE registry: the
|
|
20
|
+
* poller and delivery state live in plugin memory, never in SQLite -
|
|
21
|
+
* opencode loads thatch in-process, so the plugin process is the natural
|
|
22
|
+
* owner of its watchers, and no cross-process "who polls this?" claim
|
|
23
|
+
* logic is needed. The DEFINITIONS, however, are journaled to
|
|
24
|
+
* runtime_state (the optional journal hook) so they survive v2 plugin
|
|
25
|
+
* reloads and, as dormant rows, full restarts - see the rearm() and the
|
|
26
|
+
* runtime's dormant-watcher scan.
|
|
29
27
|
*/
|
|
30
28
|
|
|
31
29
|
// ---------------------------------------------------------------------------
|
|
@@ -280,6 +278,8 @@ export interface WatcherRegistryOptions {
|
|
|
280
278
|
pollIntervalMs?: number;
|
|
281
279
|
ttlMinutes?: number;
|
|
282
280
|
maxPerSession?: number;
|
|
281
|
+
/** Persistence hook: called after membership changes with the session's watcher list. */
|
|
282
|
+
journal?: (sessionID: string, watchers: Watcher[]) => void;
|
|
283
283
|
}
|
|
284
284
|
|
|
285
285
|
// ---------------------------------------------------------------------------
|
|
@@ -391,6 +391,17 @@ export async function ghApiRunWithTimeout(apiArgs: string[], timeoutMs: number):
|
|
|
391
391
|
}
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
+
/**
|
|
395
|
+
* The stable target label a watcher reports in events, lists, and recovery
|
|
396
|
+
* notices: "owner/repo#N" for PRs, "owner/repo@branch" for branches, and a
|
|
397
|
+
* one-line clip of the command for command watches.
|
|
398
|
+
*/
|
|
399
|
+
export function watcherTarget(watcher: Watcher): string {
|
|
400
|
+
if (watcher.source === "pr") return `${watcher.repo}#${watcher.pr}`;
|
|
401
|
+
if (watcher.source === "branch") return `${watcher.repo}@${watcher.branch}`;
|
|
402
|
+
return commandTargetLabel(watcher.command);
|
|
403
|
+
}
|
|
404
|
+
|
|
394
405
|
let ghAvailability: boolean | null = null;
|
|
395
406
|
|
|
396
407
|
/**
|
|
@@ -829,6 +840,13 @@ export class WatcherRegistry {
|
|
|
829
840
|
#timer: ReturnType<typeof setInterval> | null = null;
|
|
830
841
|
#delivering = false;
|
|
831
842
|
#polling = false;
|
|
843
|
+
/**
|
|
844
|
+
* Persistence hook (optional): called after every membership change with
|
|
845
|
+
* the session's current watcher list, so the runtime can journal the
|
|
846
|
+
* definitions and re-arm them after a v2 plugin reload. Passing no hook
|
|
847
|
+
* (tests, MCP) keeps the registry in-memory only.
|
|
848
|
+
*/
|
|
849
|
+
#journal?: (sessionID: string, watchers: Watcher[]) => void;
|
|
832
850
|
readonly #opts: WatcherRegistryOptions & {
|
|
833
851
|
pollIntervalMs: number;
|
|
834
852
|
ttlMinutes: number;
|
|
@@ -846,6 +864,7 @@ export class WatcherRegistry {
|
|
|
846
864
|
commandTimeoutMs: defaultCommandTimeoutMs(),
|
|
847
865
|
...options,
|
|
848
866
|
};
|
|
867
|
+
this.#journal = options.journal;
|
|
849
868
|
}
|
|
850
869
|
|
|
851
870
|
// -- Lifecycle -----------------------------------------------------------
|
|
@@ -889,6 +908,59 @@ export class WatcherRegistry {
|
|
|
889
908
|
|
|
890
909
|
// -- CRUD ----------------------------------------------------------------
|
|
891
910
|
|
|
911
|
+
/** Journal the session's current watcher list (no-op without a hook). */
|
|
912
|
+
#emit(sessionID: string): void {
|
|
913
|
+
this.#journal?.(sessionID, this.listForSession(sessionID));
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** Restore persisted watchers (same-process reload rehydration). */
|
|
917
|
+
hydrate(watchers: Watcher[]): void {
|
|
918
|
+
for (const w of watchers) {
|
|
919
|
+
// Do not clobber a watcher created after the journal was written.
|
|
920
|
+
if (!this.#watchers.has(w.id)) this.#watchers.set(w.id, w);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Re-arms persisted watcher definitions recovered on session resume after
|
|
926
|
+
* a restart (the dormant journal rows the runtime keeps instead of
|
|
927
|
+
* pruning). Definitions whose TTL expired while the session was away are
|
|
928
|
+
* dropped - a watch that outlived its own expiry must not fire on a stale
|
|
929
|
+
* baseline. PR and branch definitions are REVALIDATED by re-fetching
|
|
930
|
+
* their baseline: a deleted branch or closed PR would otherwise re-arm
|
|
931
|
+
* into silent poll failure, and a stale baseline would flood the session
|
|
932
|
+
* with every event that happened while it was away; fetch failures drop
|
|
933
|
+
* the definition and count as failed. The rest join the live registry
|
|
934
|
+
* newest-first up to the per-session limit (the session may have created
|
|
935
|
+
* watchers since the restart), and the journal is rewritten either way:
|
|
936
|
+
* with the current process's pid when anything re-arms, deleted when
|
|
937
|
+
* nothing does.
|
|
938
|
+
*/
|
|
939
|
+
async rearm(sessionID: string, defs: Watcher[]): Promise<{ rearmed: Watcher[]; expired: number; failed: number }> {
|
|
940
|
+
const now = Date.now();
|
|
941
|
+
const fresh = defs.filter((w) => now <= w.expiresAt).sort((a, b) => b.createdAt - a.createdAt);
|
|
942
|
+
const budget = Math.max(0, this.#opts.maxPerSession - this.listForSession(sessionID).length);
|
|
943
|
+
const rearmed: Watcher[] = [];
|
|
944
|
+
let failed = 0;
|
|
945
|
+
for (const def of fresh.slice(0, budget)) {
|
|
946
|
+
if (def.source !== "command") {
|
|
947
|
+
try {
|
|
948
|
+
def.state = def.source === "pr"
|
|
949
|
+
? await fetchPrState(this.#opts.ghRunner, def.repo, def.pr)
|
|
950
|
+
: await fetchBranchState(this.#opts.ghRunner, def.repo, def.branch);
|
|
951
|
+
} catch {
|
|
952
|
+
failed++;
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
// Do not clobber a watcher created after the journal was written.
|
|
957
|
+
if (!this.#watchers.has(def.id)) this.#watchers.set(def.id, def);
|
|
958
|
+
rearmed.push(def);
|
|
959
|
+
}
|
|
960
|
+
this.#emit(sessionID);
|
|
961
|
+
return { rearmed, expired: defs.length - fresh.length, failed };
|
|
962
|
+
}
|
|
963
|
+
|
|
892
964
|
/**
|
|
893
965
|
* Registers a PR watcher and captures the baseline state immediately. The
|
|
894
966
|
* baseline fetch doubles as validation: a bad repo, missing PR, or broken
|
|
@@ -929,6 +1001,7 @@ export class WatcherRegistry {
|
|
|
929
1001
|
state,
|
|
930
1002
|
};
|
|
931
1003
|
this.#watchers.set(watcher.id, watcher);
|
|
1004
|
+
this.#emit(sessionID);
|
|
932
1005
|
return { ok: true, watcher };
|
|
933
1006
|
}
|
|
934
1007
|
|
|
@@ -973,6 +1046,7 @@ export class WatcherRegistry {
|
|
|
973
1046
|
state,
|
|
974
1047
|
};
|
|
975
1048
|
this.#watchers.set(watcher.id, watcher);
|
|
1049
|
+
this.#emit(sessionID);
|
|
976
1050
|
return { ok: true, watcher };
|
|
977
1051
|
}
|
|
978
1052
|
|
|
@@ -1028,6 +1102,7 @@ export class WatcherRegistry {
|
|
|
1028
1102
|
state: { lastExit: baseline.timedOut ? 124 : baseline.exitCode },
|
|
1029
1103
|
};
|
|
1030
1104
|
this.#watchers.set(watcher.id, watcher);
|
|
1105
|
+
this.#emit(sessionID);
|
|
1031
1106
|
return { ok: true, watcher };
|
|
1032
1107
|
}
|
|
1033
1108
|
|
|
@@ -1051,6 +1126,7 @@ export class WatcherRegistry {
|
|
|
1051
1126
|
const watcher = this.#watchers.get(id);
|
|
1052
1127
|
if (!watcher || watcher.sessionID !== sessionID) return false;
|
|
1053
1128
|
this.#watchers.delete(id);
|
|
1129
|
+
this.#emit(sessionID);
|
|
1054
1130
|
return true;
|
|
1055
1131
|
}
|
|
1056
1132
|
|
|
@@ -1060,6 +1136,7 @@ export class WatcherRegistry {
|
|
|
1060
1136
|
if (w.sessionID === sessionID) this.#watchers.delete(id);
|
|
1061
1137
|
}
|
|
1062
1138
|
this.#pending.delete(sessionID);
|
|
1139
|
+
this.#emit(sessionID);
|
|
1063
1140
|
}
|
|
1064
1141
|
|
|
1065
1142
|
// -- Polling and delivery ------------------------------------------------
|
|
@@ -1079,6 +1156,7 @@ export class WatcherRegistry {
|
|
|
1079
1156
|
for (const [id, watcher] of this.#watchers) {
|
|
1080
1157
|
if (now > watcher.expiresAt) {
|
|
1081
1158
|
this.#watchers.delete(id);
|
|
1159
|
+
this.#emit(watcher.sessionID);
|
|
1082
1160
|
continue;
|
|
1083
1161
|
}
|
|
1084
1162
|
try {
|
|
@@ -1092,7 +1170,10 @@ export class WatcherRegistry {
|
|
|
1092
1170
|
// if the session is busy. Cancellation at detection time keeps
|
|
1093
1171
|
// "wait for this run to finish" from leaving a standing watch
|
|
1094
1172
|
// polling a target nobody is waiting on anymore.
|
|
1095
|
-
if (watcher.once)
|
|
1173
|
+
if (watcher.once) {
|
|
1174
|
+
this.#watchers.delete(id);
|
|
1175
|
+
this.#emit(watcher.sessionID);
|
|
1176
|
+
}
|
|
1096
1177
|
}
|
|
1097
1178
|
} catch (err) {
|
|
1098
1179
|
console.error(`[thatch] watcher ${id} poll failed: ${err}`);
|
|
@@ -1108,7 +1189,7 @@ export class WatcherRegistry {
|
|
|
1108
1189
|
async #pollOne(watcher: Watcher): Promise<WatcherEvent[]> {
|
|
1109
1190
|
if (watcher.source === "pr") {
|
|
1110
1191
|
const after = await fetchPrState(this.#opts.ghRunner, watcher.repo, watcher.pr);
|
|
1111
|
-
const events = diffPrState(watcher.state, after,
|
|
1192
|
+
const events = diffPrState(watcher.state, after, watcherTarget(watcher), this.#prUrl(watcher))
|
|
1112
1193
|
.filter((e) => watcher.events.includes(e.type as PrWatcherEventType));
|
|
1113
1194
|
watcher.state = after;
|
|
1114
1195
|
return events;
|
|
@@ -1124,13 +1205,13 @@ export class WatcherRegistry {
|
|
|
1124
1205
|
if (result.timedOut || result.exitCode !== 0) return [];
|
|
1125
1206
|
return [{
|
|
1126
1207
|
type: "command_success",
|
|
1127
|
-
target:
|
|
1208
|
+
target: watcherTarget(watcher),
|
|
1128
1209
|
summary: `command exited 0 (took ${(result.durationMs / 1000).toFixed(1)}s)`,
|
|
1129
1210
|
url: "",
|
|
1130
1211
|
}];
|
|
1131
1212
|
}
|
|
1132
1213
|
const after = await fetchBranchState(this.#opts.ghRunner, watcher.repo, watcher.branch);
|
|
1133
|
-
const all = diffBranchState(watcher.state, after,
|
|
1214
|
+
const all = diffBranchState(watcher.state, after, watcherTarget(watcher), watcher.repo);
|
|
1134
1215
|
watcher.state = after;
|
|
1135
1216
|
// The workflow-name filter narrows branch_workflow events only -
|
|
1136
1217
|
// commits and check runs pass through unfiltered, or a watch filtered
|