@jeffober/thatch 0.1.38 → 0.1.40
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 +8 -2
- package/artifacts/skills/common.md +4 -0
- package/artifacts/skills/thatch-code-review.md +10 -4
- package/artifacts/skills/thatch-review-mentorship.md +174 -0
- package/artifacts/skills/thatch-review-synthesizer.md +57 -6
- package/docs/dev/README.md +3 -1
- package/docs/dev/features/README.md +3 -2
- package/docs/dev/features/behavior-engine.md +5 -3
- package/docs/dev/features/database.md +2 -2
- package/docs/dev/features/memory-store.md +13 -0
- package/docs/dev/features/multi-host.md +2 -2
- package/docs/dev/features/notifications.md +106 -0
- package/docs/dev/features/prediction-engine.md +6 -3
- package/docs/dev/features/qa-system.md +18 -17
- package/docs/dev/gotchas.md +18 -0
- package/docs/dev/mcp-parity.md +1 -1
- package/docs/dev/skills.md +9 -6
- package/docs/user/README.md +37 -13
- package/docs/user/code-review.md +4 -3
- package/docs/user/memory.md +3 -0
- package/docs/user/notifications.md +84 -0
- package/docs/user/setup.md +4 -2
- package/docs/user/skills.md +2 -1
- package/package.json +2 -1
- package/src/behavior.ts +1 -1
- package/src/config.ts +103 -0
- package/src/db.ts +2 -2
- package/src/embeddings.ts +183 -8
- package/src/index.ts +4 -0
- package/src/notify.ts +123 -0
- package/src/prediction.ts +1 -1
- package/src/prompts.ts +27 -3
- package/src/scoring-engine.ts +25 -4
- package/src/skills.ts +1 -0
- package/src/tool-defs.ts +249 -40
package/src/embeddings.ts
CHANGED
|
@@ -16,6 +16,7 @@ export interface EmbeddingModel {
|
|
|
16
16
|
load(): Promise<void>;
|
|
17
17
|
queryEmbed(text: string): Promise<Float32Array>;
|
|
18
18
|
passageEmbed(text: string): Promise<Float32Array>;
|
|
19
|
+
dispose(): Promise<void>;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -24,11 +25,99 @@ export interface EmbeddingModel {
|
|
|
24
25
|
*/
|
|
25
26
|
export type PipelineFactory = (modelName: string) => Promise<any>;
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Embedding backend mode. "wasm" runs the ONNX model on onnxruntime-web's
|
|
30
|
+
* pure-JS/wasm runtime; "native" runs it on onnxruntime-node's NAPI addon.
|
|
31
|
+
*/
|
|
32
|
+
export type EmbeddingBackendMode = "wasm" | "native";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reads the backend override. Only "native" opts out; any other value,
|
|
36
|
+
* including unset, selects the wasm default. Exported for tests.
|
|
37
|
+
*/
|
|
38
|
+
export function backendMode(): EmbeddingBackendMode {
|
|
39
|
+
return process.env.THATCH_EMBEDDING_BACKEND === "native" ? "native" : "wasm";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ORT_SYMBOL = Symbol.for("onnxruntime");
|
|
43
|
+
|
|
44
|
+
let backendConfigured: Promise<EmbeddingBackendMode> | null = null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Chooses the ONNX backend before transformers initializes. Memoized: the
|
|
48
|
+
* choice is made once at transformers' module init and cannot be changed
|
|
49
|
+
* afterwards. Exported for tests.
|
|
50
|
+
*
|
|
51
|
+
* "wasm" mode pins globalThis[Symbol.for("onnxruntime")] to onnxruntime-web
|
|
52
|
+
* so transformers' node build creates sessions on its pure-JS/wasm runtime
|
|
53
|
+
* instead of the onnxruntime-node NAPI addon. The NAPI addon leaves wrap
|
|
54
|
+
* finalizers that panic Bun at process teardown (oven-sh/bun#34664); the wasm
|
|
55
|
+
* runtime creates none. A backend already chosen by the host (symbol present)
|
|
56
|
+
* is respected, never clobbered. A failed onnxruntime-web import falls back
|
|
57
|
+
* to native rather than blocking embedding.
|
|
58
|
+
*/
|
|
59
|
+
export async function configureBackend(): Promise<EmbeddingBackendMode> {
|
|
60
|
+
backendConfigured ??= (async () => {
|
|
61
|
+
const mode = backendMode();
|
|
62
|
+
if (mode === "native" || ORT_SYMBOL in globalThis) return mode;
|
|
63
|
+
try {
|
|
64
|
+
const ort = await import("onnxruntime-web");
|
|
65
|
+
(globalThis as Record<symbol, unknown>)[ORT_SYMBOL] = ort;
|
|
66
|
+
// Single-threaded wasm: deterministic, and avoids worker_threads,
|
|
67
|
+
// where Bun has known shutdown crashes. Latency at one thread is fine
|
|
68
|
+
// for background embedding.
|
|
69
|
+
ort.env.wasm.numThreads = 1;
|
|
70
|
+
} catch {
|
|
71
|
+
// onnxruntime-web unavailable - native sessions still work.
|
|
72
|
+
}
|
|
73
|
+
return mode;
|
|
74
|
+
})();
|
|
75
|
+
return backendConfigured;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Test hook: clears the configureBackend memo and unpins the onnxruntime
|
|
80
|
+
* global, so a test can exercise the pinning path itself. Needed because the
|
|
81
|
+
* test process is shared across files: initializing the plugin server seeds
|
|
82
|
+
* behaviors, which embeds, which runs configureBackend and pins the symbol
|
|
83
|
+
* as a side effect. Whether that happened before a given test file runs
|
|
84
|
+
* depends on bun's file order, which differs between machines.
|
|
85
|
+
*/
|
|
86
|
+
export function resetBackendForTests(): void {
|
|
87
|
+
backendConfigured = null;
|
|
88
|
+
delete (globalThis as Record<symbol, unknown>)[ORT_SYMBOL];
|
|
89
|
+
}
|
|
90
|
+
|
|
27
91
|
const defaultPipelineFactory: PipelineFactory = async (modelName) => {
|
|
92
|
+
const mode = await configureBackend();
|
|
28
93
|
const { pipeline } = await import("@huggingface/transformers");
|
|
29
|
-
|
|
94
|
+
if (mode === "native") {
|
|
95
|
+
return pipeline("feature-extraction", modelName);
|
|
96
|
+
}
|
|
97
|
+
// The ORT_SYMBOL override branch leaves transformers' device allowlist
|
|
98
|
+
// empty, so any named device throws; "auto" bypasses the check and the
|
|
99
|
+
// explicit execution provider does the real work.
|
|
100
|
+
return pipeline("feature-extraction", modelName, {
|
|
101
|
+
device: "auto",
|
|
102
|
+
session_options: { executionProviders: ["wasm"] },
|
|
103
|
+
});
|
|
30
104
|
};
|
|
31
105
|
|
|
106
|
+
/**
|
|
107
|
+
* How long a loaded pipeline may sit unused before its native ONNX sessions
|
|
108
|
+
* are released while the runtime is still healthy. Long enough to span the
|
|
109
|
+
* gap between extraction bursts, short enough to matter before process exit.
|
|
110
|
+
*/
|
|
111
|
+
const DEFAULT_IDLE_TTL_MS = 10 * 60 * 1000;
|
|
112
|
+
|
|
113
|
+
export interface BgeEmbeddingModelOptions {
|
|
114
|
+
/**
|
|
115
|
+
* Idle milliseconds before the pipeline's native sessions are released.
|
|
116
|
+
* A later embed lazily re-loads. 0 disables idle release.
|
|
117
|
+
*/
|
|
118
|
+
idleTtlMs?: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
32
121
|
/**
|
|
33
122
|
* Lazy-loads an embedding model via @huggingface/transformers.
|
|
34
123
|
* Model files (~34 MB for the default) are downloaded once and cached by HF Hub.
|
|
@@ -36,12 +125,19 @@ const defaultPipelineFactory: PipelineFactory = async (modelName) => {
|
|
|
36
125
|
export class BgeEmbeddingModel implements EmbeddingModel {
|
|
37
126
|
#modelName: string;
|
|
38
127
|
#pipelineFactory: PipelineFactory;
|
|
128
|
+
#idleTtlMs: number;
|
|
39
129
|
#pipe: any = null;
|
|
40
130
|
#loading: Promise<void> | null = null;
|
|
131
|
+
#idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
41
132
|
|
|
42
|
-
constructor(
|
|
133
|
+
constructor(
|
|
134
|
+
modelName = "Xenova/bge-small-en-v1.5",
|
|
135
|
+
pipelineFactory?: PipelineFactory,
|
|
136
|
+
options: BgeEmbeddingModelOptions = {},
|
|
137
|
+
) {
|
|
43
138
|
this.#modelName = modelName;
|
|
44
139
|
this.#pipelineFactory = pipelineFactory ?? defaultPipelineFactory;
|
|
140
|
+
this.#idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
|
|
45
141
|
}
|
|
46
142
|
|
|
47
143
|
get loaded(): boolean {
|
|
@@ -74,11 +170,90 @@ export class BgeEmbeddingModel implements EmbeddingModel {
|
|
|
74
170
|
}
|
|
75
171
|
|
|
76
172
|
async #embed(text: string): Promise<Float32Array> {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
173
|
+
// Clear any pending idle timer before touching the pipeline. The timer
|
|
174
|
+
// must never fire while an embed is in flight - its callback releases
|
|
175
|
+
// the pipeline this call is about to use.
|
|
176
|
+
this.#clearIdleTimer();
|
|
177
|
+
try {
|
|
178
|
+
await this.load();
|
|
179
|
+
const pipe = this.#pipe;
|
|
180
|
+
const output = await pipe(text, {
|
|
181
|
+
pooling: "mean",
|
|
182
|
+
normalize: true,
|
|
183
|
+
});
|
|
184
|
+
// Copy the vector before releasing the tensor: the real Tensor's data
|
|
185
|
+
// getter exposes memory the disposer frees.
|
|
186
|
+
const data = new Float32Array(output.data);
|
|
187
|
+
if (typeof output?.dispose === "function") {
|
|
188
|
+
try {
|
|
189
|
+
await output.dispose();
|
|
190
|
+
} catch {
|
|
191
|
+
// Tensor release is best-effort; the copied data is already valid.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return data;
|
|
195
|
+
} finally {
|
|
196
|
+
this.#armIdleTimer();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Releases the pipeline's native ONNX sessions. The host calls this during
|
|
202
|
+
* graceful shutdown so onnxruntime's NAPI wrap finalizers are freed while
|
|
203
|
+
* the runtime is still alive. If instead they survive to Bun's worker
|
|
204
|
+
* teardown, Bun panics creating their errors (oven-sh/bun#34664).
|
|
205
|
+
*
|
|
206
|
+
* The idle timer releases on the same principle during normal operation:
|
|
207
|
+
* the plugin's dispose hook is best-effort because opencode can exit
|
|
208
|
+
* without running it, so sessions are also freed after a quiet period.
|
|
209
|
+
*
|
|
210
|
+
* Best-effort by design: dispose never throws, and a failed release is
|
|
211
|
+
* indistinguishable from never loading. An embed call after dispose just
|
|
212
|
+
* lazily re-loads the model.
|
|
213
|
+
*/
|
|
214
|
+
async dispose(): Promise<void> {
|
|
215
|
+
this.#clearIdleTimer();
|
|
216
|
+
// An in-flight load memoizes a rejecting promise on failure. Await it
|
|
217
|
+
// defensively so its error neither escapes dispose nor kills the teardown.
|
|
218
|
+
if (this.#loading) {
|
|
219
|
+
try {
|
|
220
|
+
await this.#loading;
|
|
221
|
+
} catch {
|
|
222
|
+
// Load failed - nothing was loaded, so nothing to release.
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
await this.#release();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async #release(): Promise<void> {
|
|
229
|
+
const pipe = this.#pipe;
|
|
230
|
+
this.#pipe = null;
|
|
231
|
+
this.#loading = null;
|
|
232
|
+
if (!pipe) return;
|
|
233
|
+
try {
|
|
234
|
+
await pipe.dispose();
|
|
235
|
+
} catch {
|
|
236
|
+
// Best-effort - see dispose's doc comment.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#armIdleTimer(): void {
|
|
241
|
+
if (this.#idleTtlMs <= 0) return;
|
|
242
|
+
this.#clearIdleTimer();
|
|
243
|
+
this.#idleTimer = setTimeout(() => {
|
|
244
|
+
this.#idleTimer = null;
|
|
245
|
+
this.#release().catch(() => {
|
|
246
|
+
// Idle release is best-effort; a failed release retries on dispose.
|
|
247
|
+
});
|
|
248
|
+
}, this.#idleTtlMs);
|
|
249
|
+
// Never hold the host process open for an idle timer.
|
|
250
|
+
this.#idleTimer.unref?.();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#clearIdleTimer(): void {
|
|
254
|
+
if (this.#idleTimer) {
|
|
255
|
+
clearTimeout(this.#idleTimer);
|
|
256
|
+
this.#idleTimer = null;
|
|
257
|
+
}
|
|
83
258
|
}
|
|
84
259
|
}
|
package/src/index.ts
CHANGED
|
@@ -816,6 +816,10 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
816
816
|
dispose: async () => {
|
|
817
817
|
stopVersionChecker();
|
|
818
818
|
watchers.dispose();
|
|
819
|
+
// Release native ONNX sessions while the worker is still healthy. Left
|
|
820
|
+
// to Bun's teardown, their NAPI finalizers panic the process (see
|
|
821
|
+
// BgeEmbeddingModel.dispose).
|
|
822
|
+
await model.dispose();
|
|
819
823
|
db.close();
|
|
820
824
|
},
|
|
821
825
|
};
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Out-of-band user notification: desktop banner plus spoken voice. Both are
|
|
2
|
+
// plain child processes - no vendored binaries, no npm notifier dependency -
|
|
3
|
+
// because the per-platform commands are one-liners and the alternatives
|
|
4
|
+
// (node-notifier's bundled terminal-notifier) trip Gatekeeper on modern macOS.
|
|
5
|
+
// Dispatch is darwin and linux only by design: win32 support is not a goal,
|
|
6
|
+
// and the tool reports the gap honestly rather than half-working.
|
|
7
|
+
|
|
8
|
+
export type NotifyChannel = "both" | "banner" | "voice";
|
|
9
|
+
|
|
10
|
+
export interface NotifyRequest {
|
|
11
|
+
message: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
source?: string;
|
|
14
|
+
channel: NotifyChannel;
|
|
15
|
+
/** Voice name override; falls back to the platform default. */
|
|
16
|
+
voice?: string;
|
|
17
|
+
/** Banner alert sound (macOS); falls back to Submarine. */
|
|
18
|
+
sound?: string;
|
|
19
|
+
platform?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SpawnResult {
|
|
23
|
+
exitCode: number | null;
|
|
24
|
+
stderr: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type Spawner = (cmd: string[]) => Promise<SpawnResult>;
|
|
28
|
+
|
|
29
|
+
/** The real runner. Tests inject a mock so nothing actually speaks. */
|
|
30
|
+
export const defaultSpawner: Spawner = async (cmd) => {
|
|
31
|
+
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
|
|
32
|
+
const stderr = await new Response(proc.stderr).text();
|
|
33
|
+
await proc.exited;
|
|
34
|
+
return { exitCode: proc.exitCode, stderr };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The spoken line. The source label (ticket number, feature name) goes first
|
|
39
|
+
* so the listener immediately knows which session is talking.
|
|
40
|
+
*/
|
|
41
|
+
export function spokenText(request: Pick<NotifyRequest, "message" | "source">): string {
|
|
42
|
+
return request.source ? `${request.source}: ${request.message}` : request.message;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** AppleScript string literal - backslash and double-quote are the escapes. */
|
|
46
|
+
function appleScriptString(s: string): string {
|
|
47
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface StepOutcome {
|
|
51
|
+
label: string;
|
|
52
|
+
ok: boolean;
|
|
53
|
+
detail: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function runStep(label: string, cmd: string[], spawner: Spawner): Promise<StepOutcome> {
|
|
57
|
+
try {
|
|
58
|
+
const result = await spawner(cmd);
|
|
59
|
+
if (result.exitCode === 0) {
|
|
60
|
+
return { label, ok: true, detail: label };
|
|
61
|
+
}
|
|
62
|
+
return { label, ok: false, detail: `${label} failed: exited ${result.exitCode}${result.stderr.trim() ? `: ${result.stderr.trim()}` : ""}` };
|
|
63
|
+
} catch (err) {
|
|
64
|
+
// Missing binary surfaces here (ENOENT) rather than as an exit code.
|
|
65
|
+
return { label, ok: false, detail: `${label} failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Sends the notification and returns a result line for the tool response.
|
|
71
|
+
* A zero exit proves only that the command ran - macOS focus modes can still
|
|
72
|
+
* suppress the banner silently - so the result never claims the user saw it.
|
|
73
|
+
* Never throws: every failure becomes text the calling model can read.
|
|
74
|
+
*/
|
|
75
|
+
export async function sendNotification(
|
|
76
|
+
request: NotifyRequest,
|
|
77
|
+
spawner: Spawner = defaultSpawner,
|
|
78
|
+
): Promise<string> {
|
|
79
|
+
const platform = request.platform ?? process.platform;
|
|
80
|
+
if (platform !== "darwin" && platform !== "linux") {
|
|
81
|
+
return `[unsupported] notifications are not implemented on ${platform} (macOS and Linux only); nothing was sent.`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const outcomes: StepOutcome[] = [];
|
|
85
|
+
|
|
86
|
+
if (request.channel !== "voice") {
|
|
87
|
+
const title = request.title ?? request.source ?? "thatch";
|
|
88
|
+
if (platform === "darwin") {
|
|
89
|
+
const sound = request.sound ?? "Submarine";
|
|
90
|
+
const script =
|
|
91
|
+
`display notification ${appleScriptString(request.message)} ` +
|
|
92
|
+
`with title ${appleScriptString(title)} sound name ${appleScriptString(sound)}`;
|
|
93
|
+
outcomes.push(await runStep("banner (osascript)", ["/usr/bin/osascript", "-e", script], spawner));
|
|
94
|
+
} else {
|
|
95
|
+
outcomes.push(await runStep("banner (notify-send)", ["notify-send", title, request.message], spawner));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (request.channel !== "banner") {
|
|
100
|
+
const spoken = spokenText(request);
|
|
101
|
+
if (platform === "darwin") {
|
|
102
|
+
const voice = request.voice ?? "Zarvox";
|
|
103
|
+
outcomes.push(await runStep(`voice (${voice})`, ["/usr/bin/say", "-v", voice, spoken], spawner));
|
|
104
|
+
} else {
|
|
105
|
+
// spd-say has no voice flag, so a voice override goes straight to
|
|
106
|
+
// espeak (-v). Without an override, speech-dispatcher is the friendlier
|
|
107
|
+
// default; raw espeak is the fallback when it is absent.
|
|
108
|
+
if (request.voice) {
|
|
109
|
+
outcomes.push(await runStep(`voice (espeak -v ${request.voice})`, ["espeak", "-v", request.voice, spoken], spawner));
|
|
110
|
+
} else {
|
|
111
|
+
const spd = await runStep("voice (spd-say)", ["spd-say", "-w", spoken], spawner);
|
|
112
|
+
outcomes.push(spd.ok ? spd : await runStep("voice (espeak)", ["espeak", spoken], spawner));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const failed = outcomes.filter((o) => !o.ok);
|
|
118
|
+
if (outcomes.every((o) => !o.ok)) {
|
|
119
|
+
return `[failed] ${failed.map((o) => o.detail).join("; ")}`;
|
|
120
|
+
}
|
|
121
|
+
const parts = outcomes.map((o) => (o.ok ? `${o.detail} ok` : o.detail));
|
|
122
|
+
return `[notified] ${parts.join("; ")}`;
|
|
123
|
+
}
|
package/src/prediction.ts
CHANGED
|
@@ -89,7 +89,7 @@ export class PredictionEngine {
|
|
|
89
89
|
return this.#engine.createMatcher(store, description, embedding, model);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
findNearestPrediction(store: string, embedding: Float32Array, threshold: number): PredictionRow | null {
|
|
92
|
+
findNearestPrediction(store: string | string[], embedding: Float32Array, threshold: number): PredictionRow | null {
|
|
93
93
|
return this.#engine.findNearestItem(store, embedding, threshold) as PredictionRow | null;
|
|
94
94
|
}
|
|
95
95
|
|
package/src/prompts.ts
CHANGED
|
@@ -23,7 +23,8 @@ Tools: thatch_memory_remember, thatch_memory_recall, thatch_memory_list,
|
|
|
23
23
|
thatch_prediction_query, thatch_prediction_update, thatch_prediction_list,
|
|
24
24
|
thatch_prediction_delete,
|
|
25
25
|
thatch_behavior_codify, thatch_behavior_feedback, thatch_behavior_list,
|
|
26
|
-
thatch_behavior_delete,
|
|
26
|
+
thatch_behavior_delete, thatch_config_get, thatch_config_set,
|
|
27
|
+
thatch_notify_user, thatch_get_session_info,
|
|
27
28
|
thatch_session_search, thatch_session_get,
|
|
28
29
|
thatch_watch_create, thatch_watch_branch_create,
|
|
29
30
|
thatch_watch_list, thatch_watch_cancel
|
|
@@ -178,6 +179,17 @@ way that is about your own operational discipline (not a user preference):
|
|
|
178
179
|
Do not codify rules that make you lazier or less thorough. Rules should encode
|
|
179
180
|
discipline, not shortcuts.
|
|
180
181
|
|
|
182
|
+
## Notifications
|
|
183
|
+
|
|
184
|
+
Use thatch_notify_user for terminal-event outcomes the user should hear about
|
|
185
|
+
even when looking away: CI results, merge or deploy completion, watcher
|
|
186
|
+
events. Always include a source label (ticket number or feature name) so the
|
|
187
|
+
user knows which session spoke. Manage notification preferences with
|
|
188
|
+
thatch_config_get and thatch_config_set - call config_get first; config_set
|
|
189
|
+
merges fields and echoes the result. The config file
|
|
190
|
+
(~/.config/thatch/config.json) is also hand-editable; do not store thatch
|
|
191
|
+
config in memory.
|
|
192
|
+
|
|
181
193
|
## What to Store
|
|
182
194
|
|
|
183
195
|
- **Global store**: user observations, agent personality, system environment
|
|
@@ -288,7 +300,9 @@ Tools are prefixed in ${host}: \`mcp__thatch__memory_remember\`,
|
|
|
288
300
|
\`mcp__thatch__prediction_query\`, \`mcp__thatch__prediction_update\`,
|
|
289
301
|
\`mcp__thatch__prediction_list\`, \`mcp__thatch__prediction_delete\`,
|
|
290
302
|
\`mcp__thatch__behavior_codify\`, \`mcp__thatch__behavior_feedback\`,
|
|
291
|
-
\`mcp__thatch__behavior_list\`, \`mcp__thatch__behavior_delete
|
|
303
|
+
\`mcp__thatch__behavior_list\`, \`mcp__thatch__behavior_delete\`,
|
|
304
|
+
\`mcp__thatch__config_get\`, \`mcp__thatch__config_set\`,
|
|
305
|
+
\`mcp__thatch__notify_user\`. Bare names used below for readability.
|
|
292
306
|
get_session_info, session_search, session_get, watch_create,
|
|
293
307
|
watch_branch_create, watch_list,
|
|
294
308
|
and watch_cancel are intentionally absent: they are opencode-only (MCP
|
|
@@ -324,7 +338,7 @@ workflows. The host auto-discovers them, but reach for them proactively:
|
|
|
324
338
|
- \`thatch-code-walkthrough\` - produces a teaching walkthrough for a feature/workflow as it stands today with a specific format (SYNOPSIS + per-workflow orient/mechanism/numbered-stages + Key files) and calibrated prose rules. Also use proactively to draft high-level docs for new or undocumented features (suitable for pkg/README.md or docs/features/<name>.md). Load the skill for the format and prose rules, not just for the research method.
|
|
325
339
|
- \`thatch-review-context\` - gather project context (PRs, tickets, TODOs, deferred work) before a review.
|
|
326
340
|
- \`thatch-review-followup\` - alternate entrypoint for follow-up review rounds. Verifies whether the author's responses and code changes since your last review adequately addressed prior findings, offers to reply on resolved items, then optionally re-runs the full structured review.
|
|
327
|
-
- \`thatch-review-pedantic\` / \`-acceptance\` / \`-state-flow\` / \`-economy\` / \`-no-slop\` / \`-breadcrumbs\` / \`-mark-and-sweep\` / \`-highlights\` -
|
|
341
|
+
- \`thatch-review-pedantic\` / \`-acceptance\` / \`-state-flow\` / \`-economy\` / \`-no-slop\` / \`-breadcrumbs\` / \`-mark-and-sweep\` / \`-highlights\` / \`-mentorship\` - nine specialist review lenses. Run individually, then \`thatch-review-synthesizer\` to verify and aggregate.
|
|
328
342
|
- \`thatch-project-primer\` - investigate a new project and write foundational memories.
|
|
329
343
|
- \`thatch-session-reflection\` - record what you learned at end of session.
|
|
330
344
|
- \`thatch-fact-extractor\` - extract durable facts from recent tool interactions (auto-triggered by the extraction pipeline).
|
|
@@ -412,6 +426,16 @@ way that is about your own operational discipline (not a user preference):
|
|
|
412
426
|
Do not codify rules that make you lazier or less thorough. Rules should encode
|
|
413
427
|
discipline, not shortcuts.
|
|
414
428
|
|
|
429
|
+
## Notifications
|
|
430
|
+
|
|
431
|
+
Use notify_user for terminal-event outcomes the user should hear about even
|
|
432
|
+
when looking away: CI results, merge or deploy completion, watcher events.
|
|
433
|
+
Always include a source label (ticket number or feature name) so the user
|
|
434
|
+
knows which session spoke. Manage notification preferences with config_get
|
|
435
|
+
and config_set - call config_get first; config_set merges fields and echoes
|
|
436
|
+
the result. The config file (~/.config/thatch/config.json) is also
|
|
437
|
+
hand-editable; do not store thatch config in memory.
|
|
438
|
+
|
|
415
439
|
## What to Store
|
|
416
440
|
|
|
417
441
|
- **Global store**: user observations, agent personality, system environment
|
package/src/scoring-engine.ts
CHANGED
|
@@ -118,7 +118,7 @@ export class ScoringEngine {
|
|
|
118
118
|
const rows = this.#db
|
|
119
119
|
.query(
|
|
120
120
|
`SELECT p.id, p.statement, p.rationale, p.confidence, p.confirm_count, p.disconfirm_count,
|
|
121
|
-
e.matcher_id, e.weight
|
|
121
|
+
p.embedding, e.matcher_id, e.weight
|
|
122
122
|
FROM ${edgesTable} e
|
|
123
123
|
JOIN ${itemsTable} p ON e.${itemForeignKey} = p.id
|
|
124
124
|
WHERE e.matcher_id IN (${placeholders})
|
|
@@ -128,6 +128,7 @@ export class ScoringEngine {
|
|
|
128
128
|
|
|
129
129
|
const matcherMap = new Map(matchers.map((m) => [m.id, m]));
|
|
130
130
|
const scored: ScoredItem[] = [];
|
|
131
|
+
const embeddings = new Map<string, Float32Array>();
|
|
131
132
|
for (const r of rows) {
|
|
132
133
|
const matcher = matcherMap.get(r.matcher_id);
|
|
133
134
|
if (!matcher) continue;
|
|
@@ -135,6 +136,7 @@ export class ScoringEngine {
|
|
|
135
136
|
const confidence = r.confidence as number;
|
|
136
137
|
const evidence = Math.round(r.confirm_count + r.disconfirm_count);
|
|
137
138
|
const score = matcher.score * (r.weight as number) * confidence;
|
|
139
|
+
if (r.embedding) embeddings.set(r.id as string, blobToVector(r.embedding));
|
|
138
140
|
scored.push({
|
|
139
141
|
matcher_id: r.matcher_id,
|
|
140
142
|
matcher_description: matcher.description,
|
|
@@ -153,9 +155,25 @@ export class ScoringEngine {
|
|
|
153
155
|
// so the nudge does not repeat the same item with different matcher
|
|
154
156
|
// contexts.
|
|
155
157
|
const seen = new Set<string>();
|
|
158
|
+
// Semantic dedup across stores: the same preference saved to both the
|
|
159
|
+
// project and global stores has different row ids but near-identical
|
|
160
|
+
// embeddings. Without this, the multi-store nudge scan surfaces it
|
|
161
|
+
// twice every turn. Sorted by score descending, so the first occurrence
|
|
162
|
+
// (highest-scoring copy) wins.
|
|
163
|
+
const SEMANTIC_DEDUP_COSINE = 0.85;
|
|
164
|
+
const seenEmbeddings: Float32Array[] = [];
|
|
156
165
|
return scored.filter((s) => {
|
|
157
166
|
if (seen.has(s.item_id)) return false;
|
|
158
167
|
seen.add(s.item_id);
|
|
168
|
+
const emb = embeddings.get(s.item_id);
|
|
169
|
+
if (emb) {
|
|
170
|
+
for (const other of seenEmbeddings) {
|
|
171
|
+
if (other.length === emb.length && cosineSimilarity(other, emb) >= SEMANTIC_DEDUP_COSINE) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
seenEmbeddings.push(emb);
|
|
176
|
+
}
|
|
159
177
|
return true;
|
|
160
178
|
});
|
|
161
179
|
}
|
|
@@ -219,18 +237,21 @@ export class ScoringEngine {
|
|
|
219
237
|
}
|
|
220
238
|
|
|
221
239
|
findNearestItem(
|
|
222
|
-
store: string,
|
|
240
|
+
store: string | string[],
|
|
223
241
|
embedding: Float32Array,
|
|
224
242
|
threshold: number,
|
|
225
243
|
): ItemRow | null {
|
|
226
244
|
const { itemsTable } = this.#cfg;
|
|
245
|
+
const stores = Array.isArray(store) ? store : [store];
|
|
246
|
+
if (stores.length === 0) return null;
|
|
247
|
+
const placeholders = stores.map(() => "?").join(", ");
|
|
227
248
|
const rows = this.#db
|
|
228
249
|
.query(
|
|
229
250
|
`SELECT id, store, statement, rationale, confidence, confirm_count, disconfirm_count, created_at, updated_at, embedding
|
|
230
251
|
FROM ${itemsTable}
|
|
231
|
-
WHERE store
|
|
252
|
+
WHERE store IN (${placeholders}) AND embedding IS NOT NULL`,
|
|
232
253
|
)
|
|
233
|
-
.all(
|
|
254
|
+
.all(...(stores as [string, ...string[]])) as any[];
|
|
234
255
|
|
|
235
256
|
let best: { row: any; score: number } | null = null;
|
|
236
257
|
for (const r of rows) {
|
package/src/skills.ts
CHANGED