@jeffober/thatch 0.1.48 → 0.2.1
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/artifacts/skills/thatch-fact-extractor.md +1 -1
- package/artifacts/skills/thatch-memory-verify.md +2 -2
- package/bin/thatch +31 -16
- package/docs/dev/README.md +39 -13
- 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 +12 -3
- package/docs/dev/features/extraction.md +10 -2
- package/docs/dev/features/hygiene.md +3 -1
- package/docs/dev/features/memory-store.md +1 -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 +16 -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 +6 -3
- package/docs/dev/features/watchers.md +12 -5
- package/docs/dev/gotchas.md +94 -4
- package/docs/dev/mcp-parity.md +4 -3
- package/docs/dev/setup-and-hooks.md +7 -2
- package/docs/plans/llm-alerts.md +287 -0
- package/docs/plans/prediction-consolidation.md +3 -3
- package/docs/user/README.md +8 -4
- package/docs/user/memory.md +1 -1
- 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 +80 -5
- package/src/commands.ts +44 -5
- package/src/config.ts +32 -1
- package/src/db.ts +157 -12
- package/src/embeddings.ts +43 -0
- package/src/extraction.ts +210 -0
- package/src/index.ts +77 -1339
- package/src/opencode/v1.ts +54 -0
- package/src/opencode/v2.ts +605 -0
- package/src/os-args.ts +99 -0
- package/src/prompts.ts +181 -31
- package/src/runtime.ts +2004 -0
- package/src/seed-behaviors.ts +1 -1
- package/src/session-db.ts +32 -0
- package/src/tool-defs.ts +340 -46
- package/src/tools.ts +16 -35
- package/src/watchers.ts +109 -14
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;
|
|
@@ -836,6 +854,18 @@ export class WatcherRegistry {
|
|
|
836
854
|
commandRunner: CommandRunner;
|
|
837
855
|
commandTimeoutMs: number;
|
|
838
856
|
};
|
|
857
|
+
/**
|
|
858
|
+
* The most recently constructed instance in this process. opencode v2
|
|
859
|
+
* re-runs plugin setup() when the session's directory changes
|
|
860
|
+
* (session_move between directories of the same repo) or on plugin-file
|
|
861
|
+
* changes; each setup() built a fresh registry while the OLD instance's
|
|
862
|
+
* poller timer kept running - the watch tools then queried an empty
|
|
863
|
+
* registry ("No active watchers" / "No watcher in this session") while
|
|
864
|
+
* delivery kept flowing from the orphaned poller. The handoff stops the
|
|
865
|
+
* predecessor's poller at construction so at most one registry polls per
|
|
866
|
+
* process; the watch tools' journal reconcile covers the state transfer.
|
|
867
|
+
*/
|
|
868
|
+
static #live: WatcherRegistry | undefined;
|
|
839
869
|
|
|
840
870
|
constructor(options: WatcherRegistryOptions) {
|
|
841
871
|
this.#opts = {
|
|
@@ -846,6 +876,9 @@ export class WatcherRegistry {
|
|
|
846
876
|
commandTimeoutMs: defaultCommandTimeoutMs(),
|
|
847
877
|
...options,
|
|
848
878
|
};
|
|
879
|
+
this.#journal = options.journal;
|
|
880
|
+
WatcherRegistry.#live?.stop();
|
|
881
|
+
WatcherRegistry.#live = this;
|
|
849
882
|
}
|
|
850
883
|
|
|
851
884
|
// -- Lifecycle -----------------------------------------------------------
|
|
@@ -889,6 +922,59 @@ export class WatcherRegistry {
|
|
|
889
922
|
|
|
890
923
|
// -- CRUD ----------------------------------------------------------------
|
|
891
924
|
|
|
925
|
+
/** Journal the session's current watcher list (no-op without a hook). */
|
|
926
|
+
#emit(sessionID: string): void {
|
|
927
|
+
this.#journal?.(sessionID, this.listForSession(sessionID));
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** Restore persisted watchers (same-process reload rehydration). */
|
|
931
|
+
hydrate(watchers: Watcher[]): void {
|
|
932
|
+
for (const w of watchers) {
|
|
933
|
+
// Do not clobber a watcher created after the journal was written.
|
|
934
|
+
if (!this.#watchers.has(w.id)) this.#watchers.set(w.id, w);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Re-arms persisted watcher definitions recovered on session resume after
|
|
940
|
+
* a restart (the dormant journal rows the runtime keeps instead of
|
|
941
|
+
* pruning). Definitions whose TTL expired while the session was away are
|
|
942
|
+
* dropped - a watch that outlived its own expiry must not fire on a stale
|
|
943
|
+
* baseline. PR and branch definitions are REVALIDATED by re-fetching
|
|
944
|
+
* their baseline: a deleted branch or closed PR would otherwise re-arm
|
|
945
|
+
* into silent poll failure, and a stale baseline would flood the session
|
|
946
|
+
* with every event that happened while it was away; fetch failures drop
|
|
947
|
+
* the definition and count as failed. The rest join the live registry
|
|
948
|
+
* newest-first up to the per-session limit (the session may have created
|
|
949
|
+
* watchers since the restart), and the journal is rewritten either way:
|
|
950
|
+
* with the current process's pid when anything re-arms, deleted when
|
|
951
|
+
* nothing does.
|
|
952
|
+
*/
|
|
953
|
+
async rearm(sessionID: string, defs: Watcher[]): Promise<{ rearmed: Watcher[]; expired: number; failed: number }> {
|
|
954
|
+
const now = Date.now();
|
|
955
|
+
const fresh = defs.filter((w) => now <= w.expiresAt).sort((a, b) => b.createdAt - a.createdAt);
|
|
956
|
+
const budget = Math.max(0, this.#opts.maxPerSession - this.listForSession(sessionID).length);
|
|
957
|
+
const rearmed: Watcher[] = [];
|
|
958
|
+
let failed = 0;
|
|
959
|
+
for (const def of fresh.slice(0, budget)) {
|
|
960
|
+
if (def.source !== "command") {
|
|
961
|
+
try {
|
|
962
|
+
def.state = def.source === "pr"
|
|
963
|
+
? await fetchPrState(this.#opts.ghRunner, def.repo, def.pr)
|
|
964
|
+
: await fetchBranchState(this.#opts.ghRunner, def.repo, def.branch);
|
|
965
|
+
} catch {
|
|
966
|
+
failed++;
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
// Do not clobber a watcher created after the journal was written.
|
|
971
|
+
if (!this.#watchers.has(def.id)) this.#watchers.set(def.id, def);
|
|
972
|
+
rearmed.push(def);
|
|
973
|
+
}
|
|
974
|
+
this.#emit(sessionID);
|
|
975
|
+
return { rearmed, expired: defs.length - fresh.length, failed };
|
|
976
|
+
}
|
|
977
|
+
|
|
892
978
|
/**
|
|
893
979
|
* Registers a PR watcher and captures the baseline state immediately. The
|
|
894
980
|
* baseline fetch doubles as validation: a bad repo, missing PR, or broken
|
|
@@ -929,6 +1015,7 @@ export class WatcherRegistry {
|
|
|
929
1015
|
state,
|
|
930
1016
|
};
|
|
931
1017
|
this.#watchers.set(watcher.id, watcher);
|
|
1018
|
+
this.#emit(sessionID);
|
|
932
1019
|
return { ok: true, watcher };
|
|
933
1020
|
}
|
|
934
1021
|
|
|
@@ -973,6 +1060,7 @@ export class WatcherRegistry {
|
|
|
973
1060
|
state,
|
|
974
1061
|
};
|
|
975
1062
|
this.#watchers.set(watcher.id, watcher);
|
|
1063
|
+
this.#emit(sessionID);
|
|
976
1064
|
return { ok: true, watcher };
|
|
977
1065
|
}
|
|
978
1066
|
|
|
@@ -1028,6 +1116,7 @@ export class WatcherRegistry {
|
|
|
1028
1116
|
state: { lastExit: baseline.timedOut ? 124 : baseline.exitCode },
|
|
1029
1117
|
};
|
|
1030
1118
|
this.#watchers.set(watcher.id, watcher);
|
|
1119
|
+
this.#emit(sessionID);
|
|
1031
1120
|
return { ok: true, watcher };
|
|
1032
1121
|
}
|
|
1033
1122
|
|
|
@@ -1051,6 +1140,7 @@ export class WatcherRegistry {
|
|
|
1051
1140
|
const watcher = this.#watchers.get(id);
|
|
1052
1141
|
if (!watcher || watcher.sessionID !== sessionID) return false;
|
|
1053
1142
|
this.#watchers.delete(id);
|
|
1143
|
+
this.#emit(sessionID);
|
|
1054
1144
|
return true;
|
|
1055
1145
|
}
|
|
1056
1146
|
|
|
@@ -1060,6 +1150,7 @@ export class WatcherRegistry {
|
|
|
1060
1150
|
if (w.sessionID === sessionID) this.#watchers.delete(id);
|
|
1061
1151
|
}
|
|
1062
1152
|
this.#pending.delete(sessionID);
|
|
1153
|
+
this.#emit(sessionID);
|
|
1063
1154
|
}
|
|
1064
1155
|
|
|
1065
1156
|
// -- Polling and delivery ------------------------------------------------
|
|
@@ -1079,6 +1170,7 @@ export class WatcherRegistry {
|
|
|
1079
1170
|
for (const [id, watcher] of this.#watchers) {
|
|
1080
1171
|
if (now > watcher.expiresAt) {
|
|
1081
1172
|
this.#watchers.delete(id);
|
|
1173
|
+
this.#emit(watcher.sessionID);
|
|
1082
1174
|
continue;
|
|
1083
1175
|
}
|
|
1084
1176
|
try {
|
|
@@ -1092,7 +1184,10 @@ export class WatcherRegistry {
|
|
|
1092
1184
|
// if the session is busy. Cancellation at detection time keeps
|
|
1093
1185
|
// "wait for this run to finish" from leaving a standing watch
|
|
1094
1186
|
// polling a target nobody is waiting on anymore.
|
|
1095
|
-
if (watcher.once)
|
|
1187
|
+
if (watcher.once) {
|
|
1188
|
+
this.#watchers.delete(id);
|
|
1189
|
+
this.#emit(watcher.sessionID);
|
|
1190
|
+
}
|
|
1096
1191
|
}
|
|
1097
1192
|
} catch (err) {
|
|
1098
1193
|
console.error(`[thatch] watcher ${id} poll failed: ${err}`);
|
|
@@ -1108,7 +1203,7 @@ export class WatcherRegistry {
|
|
|
1108
1203
|
async #pollOne(watcher: Watcher): Promise<WatcherEvent[]> {
|
|
1109
1204
|
if (watcher.source === "pr") {
|
|
1110
1205
|
const after = await fetchPrState(this.#opts.ghRunner, watcher.repo, watcher.pr);
|
|
1111
|
-
const events = diffPrState(watcher.state, after,
|
|
1206
|
+
const events = diffPrState(watcher.state, after, watcherTarget(watcher), this.#prUrl(watcher))
|
|
1112
1207
|
.filter((e) => watcher.events.includes(e.type as PrWatcherEventType));
|
|
1113
1208
|
watcher.state = after;
|
|
1114
1209
|
return events;
|
|
@@ -1124,13 +1219,13 @@ export class WatcherRegistry {
|
|
|
1124
1219
|
if (result.timedOut || result.exitCode !== 0) return [];
|
|
1125
1220
|
return [{
|
|
1126
1221
|
type: "command_success",
|
|
1127
|
-
target:
|
|
1222
|
+
target: watcherTarget(watcher),
|
|
1128
1223
|
summary: `command exited 0 (took ${(result.durationMs / 1000).toFixed(1)}s)`,
|
|
1129
1224
|
url: "",
|
|
1130
1225
|
}];
|
|
1131
1226
|
}
|
|
1132
1227
|
const after = await fetchBranchState(this.#opts.ghRunner, watcher.repo, watcher.branch);
|
|
1133
|
-
const all = diffBranchState(watcher.state, after,
|
|
1228
|
+
const all = diffBranchState(watcher.state, after, watcherTarget(watcher), watcher.repo);
|
|
1134
1229
|
watcher.state = after;
|
|
1135
1230
|
// The workflow-name filter narrows branch_workflow events only -
|
|
1136
1231
|
// commits and check runs pass through unfiltered, or a watch filtered
|