@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/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
- return pipeline("feature-extraction", modelName);
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(modelName = "Xenova/bge-small-en-v1.5", pipelineFactory?: PipelineFactory) {
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
- await this.load();
78
- const output = await this.#pipe(text, {
79
- pooling: "mean",
80
- normalize: true,
81
- });
82
- return output.data as Float32Array;
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, thatch_get_session_info,
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\`. Bare names used below for readability.
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\` - eight specialist review lenses. Run individually, then \`thatch-review-synthesizer\` to verify and aggregate.
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
@@ -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 = ? AND embedding IS NOT NULL`,
252
+ WHERE store IN (${placeholders}) AND embedding IS NOT NULL`,
232
253
  )
233
- .all(store) as any[];
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
@@ -41,6 +41,7 @@ function loadSharedSkills(): SkillDef[] {
41
41
  "thatch-review-breadcrumbs",
42
42
  "thatch-review-mark-and-sweep",
43
43
  "thatch-review-highlights",
44
+ "thatch-review-mentorship",
44
45
  "thatch-review-synthesizer",
45
46
  "thatch-review-context",
46
47
  "thatch-code-archaeology",