@jeffober/thatch 0.1.39 → 0.1.41
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 +10 -3
- package/artifacts/skills/common.md +4 -0
- package/artifacts/skills/thatch-code-review.md +10 -4
- package/artifacts/skills/thatch-review-synthesizer.md +57 -6
- package/artifacts/skills/thatch-review-technique.md +173 -0
- package/bin/thatch +160 -16
- package/docs/dev/README.md +26 -14
- package/docs/dev/features/README.md +1 -0
- package/docs/dev/features/behavior-engine.md +5 -3
- package/docs/dev/features/cli.md +22 -4
- package/docs/dev/features/cross-session-chat.md +256 -0
- package/docs/dev/features/database.md +52 -4
- package/docs/dev/features/memory-store.md +13 -0
- package/docs/dev/features/multi-host.md +17 -9
- package/docs/dev/features/prediction-engine.md +6 -3
- package/docs/dev/features/setup.md +12 -5
- package/docs/dev/gotchas.md +7 -4
- package/docs/dev/mcp-parity.md +2 -2
- package/docs/dev/setup-and-hooks.md +5 -4
- package/docs/dev/skills.md +7 -3
- package/docs/user/README.md +24 -4
- package/docs/user/cli.md +30 -0
- package/docs/user/code-review.md +4 -3
- package/docs/user/cross-session-chat.md +102 -0
- package/docs/user/memory.md +3 -0
- package/docs/user/setup.md +20 -4
- package/docs/user/skills.md +7 -5
- package/package.json +2 -1
- package/src/behavior.ts +1 -1
- package/src/chat-names.ts +121 -0
- package/src/chat.ts +879 -0
- package/src/db.ts +181 -2
- package/src/embeddings.ts +152 -9
- package/src/index.ts +113 -5
- package/src/prediction.ts +1 -1
- package/src/prompts.ts +133 -8
- package/src/scoring-engine.ts +25 -4
- package/src/setup.ts +83 -29
- package/src/skills.ts +47 -16
- package/src/tool-defs.ts +422 -40
package/src/db.ts
CHANGED
|
@@ -4,11 +4,13 @@ import {
|
|
|
4
4
|
PredictionEngine,
|
|
5
5
|
} from "./prediction";
|
|
6
6
|
import { BehaviorEngine } from "./behavior";
|
|
7
|
+
import { ChatStore, type ChatHostKind } from "./chat";
|
|
7
8
|
import { PREDICTION_K, PREDICTION_P0, PREDICTION_W_SOFT } from "./scoring-engine";
|
|
8
9
|
|
|
9
10
|
export { cosineSimilarity } from "./vector-math";
|
|
10
11
|
export type { PredictionNudgeItem, MatcherRow, PredictionRow, ScoredPrediction } from "./prediction";
|
|
11
12
|
export type { BehaviorNudgeItem, BehaviorRow, ScoredBehavior } from "./behavior";
|
|
13
|
+
export type { ChatSessionRow, ChatInboxItem, ChatNotificationRow, ChatResult } from "./chat";
|
|
12
14
|
|
|
13
15
|
export interface MemoryRow {
|
|
14
16
|
slug: string;
|
|
@@ -53,6 +55,7 @@ export class ThatchDB {
|
|
|
53
55
|
#db: Database;
|
|
54
56
|
#predictions: PredictionEngine;
|
|
55
57
|
#behaviors: BehaviorEngine;
|
|
58
|
+
#chat: ChatStore;
|
|
56
59
|
|
|
57
60
|
constructor(path: string) {
|
|
58
61
|
this.#db = new Database(path, { create: true });
|
|
@@ -66,6 +69,7 @@ export class ThatchDB {
|
|
|
66
69
|
this.#db.run("PRAGMA foreign_keys = ON");
|
|
67
70
|
this.#predictions = new PredictionEngine(this.#db);
|
|
68
71
|
this.#behaviors = new BehaviorEngine(this.#db);
|
|
72
|
+
this.#chat = new ChatStore(this.#db);
|
|
69
73
|
this.#initSchema();
|
|
70
74
|
}
|
|
71
75
|
|
|
@@ -218,9 +222,80 @@ export class ThatchDB {
|
|
|
218
222
|
)
|
|
219
223
|
`);
|
|
220
224
|
|
|
225
|
+
// Cross-session chat: the machine-wide session directory and message
|
|
226
|
+
// inbox, shared by every opencode process (delivery itself stays local
|
|
227
|
+
// to each host process - see src/chat.ts). Display-name uniqueness is
|
|
228
|
+
// case-insensitive ("Landru" and "landru" are one name), so two visually
|
|
229
|
+
// identical identities cannot coexist. Message endpoints are plain
|
|
230
|
+
// columns, not foreign keys, on purpose: unregistering a session must
|
|
231
|
+
// not be blocked by message history, and a departed sender degrades to
|
|
232
|
+
// an unknown name in the reader's view rather than vanishing rows.
|
|
233
|
+
this.#db.run(`
|
|
234
|
+
CREATE TABLE IF NOT EXISTS chat_sessions (
|
|
235
|
+
session_id TEXT PRIMARY KEY,
|
|
236
|
+
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
237
|
+
topic TEXT,
|
|
238
|
+
project TEXT,
|
|
239
|
+
host_kind TEXT NOT NULL DEFAULT ('opencode'),
|
|
240
|
+
registered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
241
|
+
last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
242
|
+
)
|
|
243
|
+
`);
|
|
244
|
+
|
|
245
|
+
// delivered_at is the last wake-prompt stamp: set when the recipient's
|
|
246
|
+
// host accepts a nudge, restamped on re-nudges (which resets the
|
|
247
|
+
// re-nudge timer). read_at is set when the recipient drains its inbox.
|
|
248
|
+
// via_broadcast marks rows a chat_broadcast fan-out created, so the
|
|
249
|
+
// tail feed can render one broadcast line instead of N direct sends.
|
|
250
|
+
this.#db.run(`
|
|
251
|
+
CREATE TABLE IF NOT EXISTS chat_messages (
|
|
252
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
253
|
+
from_session TEXT NOT NULL,
|
|
254
|
+
to_session TEXT NOT NULL,
|
|
255
|
+
body TEXT NOT NULL,
|
|
256
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
257
|
+
delivered_at TEXT,
|
|
258
|
+
read_at TEXT,
|
|
259
|
+
via_broadcast INTEGER NOT NULL DEFAULT 0
|
|
260
|
+
)
|
|
261
|
+
`);
|
|
262
|
+
|
|
221
263
|
this.#db.run("INSERT OR IGNORE INTO stores (name) VALUES ('global')");
|
|
222
264
|
|
|
223
265
|
this.#migrateColumns();
|
|
266
|
+
this.#migrateChatNameCollation();
|
|
267
|
+
this.#migrateChatTopic();
|
|
268
|
+
this.#migrateChatHostKind();
|
|
269
|
+
this.#migrateChatBroadcastFlag();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// chat_sessions tables created before the host_kind column lack it; the
|
|
273
|
+
// ALTER adds it, and existing rows read as opencode (which they were).
|
|
274
|
+
#migrateChatHostKind(): void {
|
|
275
|
+
const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
|
|
276
|
+
if (cols.length > 0 && !cols.includes("host_kind")) {
|
|
277
|
+
this.#db.run("ALTER TABLE chat_sessions ADD COLUMN host_kind TEXT NOT NULL DEFAULT 'opencode'");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// chat_messages tables created before the via_broadcast column lack it;
|
|
282
|
+
// the ALTER adds it, and existing rows read as direct sends (which they
|
|
283
|
+
// were).
|
|
284
|
+
#migrateChatBroadcastFlag(): void {
|
|
285
|
+
const cols = (this.#db.query("PRAGMA table_info(chat_messages)").all() as any[]).map((r) => r.name);
|
|
286
|
+
if (cols.length > 0 && !cols.includes("via_broadcast")) {
|
|
287
|
+
this.#db.run("ALTER TABLE chat_messages ADD COLUMN via_broadcast INTEGER NOT NULL DEFAULT 0");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// chat_sessions tables created before the topic column lack it; the
|
|
292
|
+
// ALTER adds it, and rows degrade to a NULL topic (rendered as no topic
|
|
293
|
+
// in chat_list).
|
|
294
|
+
#migrateChatTopic(): void {
|
|
295
|
+
const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
|
|
296
|
+
if (cols.length > 0 && !cols.includes("topic")) {
|
|
297
|
+
this.#db.run("ALTER TABLE chat_sessions ADD COLUMN topic TEXT");
|
|
298
|
+
}
|
|
224
299
|
}
|
|
225
300
|
|
|
226
301
|
// Databases created before recall telemetry lack these columns; the CREATE
|
|
@@ -241,6 +316,43 @@ export class ThatchDB {
|
|
|
241
316
|
}
|
|
242
317
|
}
|
|
243
318
|
|
|
319
|
+
// A chat_sessions table created before the NOCASE uniqueness change has a
|
|
320
|
+
// case-SENSITIVE unique constraint, which let "Landru" and "landru"
|
|
321
|
+
// coexist as distinct identities. SQLite cannot ALTER a column
|
|
322
|
+
// constraint, so the repair is a table rebuild. Detection reads the
|
|
323
|
+
// stored CREATE statement from sqlite_master: the NOCASE schema text
|
|
324
|
+
// contains COLLATE NOCASE, the older one does not. Colliding rows
|
|
325
|
+
// collapse via INSERT OR IGNORE (one row wins per case group; scan order
|
|
326
|
+
// decides which). chat_messages has no foreign key into this table, so
|
|
327
|
+
// message history survives untouched.
|
|
328
|
+
//
|
|
329
|
+
// ORDERING TRAP: the rebuild's CREATE and SELECT are pinned to the
|
|
330
|
+
// columns the OLDEST chat_sessions schema had, because a legacy table
|
|
331
|
+
// only has those columns - a SELECT naming a newer column would throw.
|
|
332
|
+
// This migration must run BEFORE any ALTER-based chat_sessions migration
|
|
333
|
+
// (which add newer columns afterward); when adding a column to
|
|
334
|
+
// chat_sessions, extend the ALTER-based migrations, not this SELECT.
|
|
335
|
+
#migrateChatNameCollation(): void {
|
|
336
|
+
const row = this.#db
|
|
337
|
+
.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'chat_sessions'")
|
|
338
|
+
.get() as any;
|
|
339
|
+
if (!row?.sql || /COLLATE\s+NOCASE/i.test(row.sql)) return;
|
|
340
|
+
this.transaction(() => {
|
|
341
|
+
this.#db.run(`
|
|
342
|
+
CREATE TABLE chat_sessions_migrated (
|
|
343
|
+
session_id TEXT PRIMARY KEY,
|
|
344
|
+
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
345
|
+
project TEXT,
|
|
346
|
+
registered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
347
|
+
last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
348
|
+
)
|
|
349
|
+
`);
|
|
350
|
+
this.#db.run("INSERT OR IGNORE INTO chat_sessions_migrated SELECT session_id, name, project, registered_at, last_seen FROM chat_sessions");
|
|
351
|
+
this.#db.run("DROP TABLE chat_sessions");
|
|
352
|
+
this.#db.run("ALTER TABLE chat_sessions_migrated RENAME TO chat_sessions");
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
244
356
|
// ---------------------------------------------------------------------------
|
|
245
357
|
// Stores
|
|
246
358
|
// ---------------------------------------------------------------------------
|
|
@@ -612,7 +724,7 @@ export class ThatchDB {
|
|
|
612
724
|
return this.#predictions.createMatcher(store, description, embedding, model);
|
|
613
725
|
}
|
|
614
726
|
|
|
615
|
-
findNearestPrediction(store: string, embedding: Float32Array, threshold: number) {
|
|
727
|
+
findNearestPrediction(store: string | string[], embedding: Float32Array, threshold: number) {
|
|
616
728
|
return this.#predictions.findNearestPrediction(store, embedding, threshold);
|
|
617
729
|
}
|
|
618
730
|
|
|
@@ -672,7 +784,7 @@ export class ThatchDB {
|
|
|
672
784
|
return this.#behaviors.createBehaviorMatcher(store, description, embedding, model);
|
|
673
785
|
}
|
|
674
786
|
|
|
675
|
-
findNearestBehavior(store: string, embedding: Float32Array, threshold: number) {
|
|
787
|
+
findNearestBehavior(store: string | string[], embedding: Float32Array, threshold: number) {
|
|
676
788
|
return this.#behaviors.findNearestBehavior(store, embedding, threshold);
|
|
677
789
|
}
|
|
678
790
|
|
|
@@ -708,6 +820,73 @@ export class ThatchDB {
|
|
|
708
820
|
return this.#behaviors.listBehaviors(store);
|
|
709
821
|
}
|
|
710
822
|
|
|
823
|
+
// ---------------------------------------------------------------------------
|
|
824
|
+
// Cross-session chat: delegates to ChatStore
|
|
825
|
+
// ---------------------------------------------------------------------------
|
|
826
|
+
|
|
827
|
+
registerChatSession(sessionID: string, name: string, project: string | null, topic: string | null, kind: ChatHostKind) {
|
|
828
|
+
return this.#chat.register(sessionID, name, project, topic, kind);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
assignChatName(sessionID: string, project: string | null, topic: string | null, kind: ChatHostKind) {
|
|
832
|
+
return this.#chat.assign(sessionID, project, topic, kind);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
broadcastChatMessage(fromSession: string, body: string) {
|
|
836
|
+
return this.#chat.broadcast(fromSession, body);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
chatMessageStatus(sessionID: string) {
|
|
840
|
+
return this.#chat.status(sessionID);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
chatPendingByProject(project: string) {
|
|
844
|
+
return this.#chat.pendingByProject(project);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
chatMessageFeed() {
|
|
848
|
+
return this.#chat.messageFeed();
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
unregisterChatSession(sessionID: string) {
|
|
852
|
+
return this.#chat.unregister(sessionID);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
listChatSessions() {
|
|
856
|
+
return this.#chat.list();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
findChatSession(nameOrID: string) {
|
|
860
|
+
// Facade surface: production resolves recipients inside send()/find();
|
|
861
|
+
// this delegation exists so callers (and tests) can resolve a session
|
|
862
|
+
// by name or ID without reaching past the ThatchDB API.
|
|
863
|
+
return this.#chat.find(nameOrID);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
sendChatMessage(fromSession: string, toNameOrID: string, body: string) {
|
|
867
|
+
return this.#chat.send(fromSession, toNameOrID, body);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
readChatMessages(sessionID: string) {
|
|
871
|
+
return this.#chat.read(sessionID);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
unreadChatCount(sessionID: string) {
|
|
875
|
+
return this.#chat.unreadCount(sessionID);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
heartbeatChatSessions(sessionIDs: string[]) {
|
|
879
|
+
return this.#chat.heartbeat(sessionIDs);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
pendingChatNotifications(sessionIDs: string[], renudgeCutoff: string) {
|
|
883
|
+
return this.#chat.pendingNotifications(sessionIDs, renudgeCutoff);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
markChatDelivered(ids: number[]) {
|
|
887
|
+
return this.#chat.markDelivered(ids);
|
|
888
|
+
}
|
|
889
|
+
|
|
711
890
|
// ---------------------------------------------------------------------------
|
|
712
891
|
// Hygiene - signals for the session-start heartbeat. Staleness means
|
|
713
892
|
// neither written nor recalled since the cutoff; recall telemetry keeps
|
package/src/embeddings.ts
CHANGED
|
@@ -25,11 +25,99 @@ export interface EmbeddingModel {
|
|
|
25
25
|
*/
|
|
26
26
|
export type PipelineFactory = (modelName: string) => Promise<any>;
|
|
27
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
|
+
|
|
28
91
|
const defaultPipelineFactory: PipelineFactory = async (modelName) => {
|
|
92
|
+
const mode = await configureBackend();
|
|
29
93
|
const { pipeline } = await import("@huggingface/transformers");
|
|
30
|
-
|
|
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
|
+
});
|
|
31
104
|
};
|
|
32
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
|
+
|
|
33
121
|
/**
|
|
34
122
|
* Lazy-loads an embedding model via @huggingface/transformers.
|
|
35
123
|
* Model files (~34 MB for the default) are downloaded once and cached by HF Hub.
|
|
@@ -37,12 +125,19 @@ const defaultPipelineFactory: PipelineFactory = async (modelName) => {
|
|
|
37
125
|
export class BgeEmbeddingModel implements EmbeddingModel {
|
|
38
126
|
#modelName: string;
|
|
39
127
|
#pipelineFactory: PipelineFactory;
|
|
128
|
+
#idleTtlMs: number;
|
|
40
129
|
#pipe: any = null;
|
|
41
130
|
#loading: Promise<void> | null = null;
|
|
131
|
+
#idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
42
132
|
|
|
43
|
-
constructor(
|
|
133
|
+
constructor(
|
|
134
|
+
modelName = "Xenova/bge-small-en-v1.5",
|
|
135
|
+
pipelineFactory?: PipelineFactory,
|
|
136
|
+
options: BgeEmbeddingModelOptions = {},
|
|
137
|
+
) {
|
|
44
138
|
this.#modelName = modelName;
|
|
45
139
|
this.#pipelineFactory = pipelineFactory ?? defaultPipelineFactory;
|
|
140
|
+
this.#idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
|
|
46
141
|
}
|
|
47
142
|
|
|
48
143
|
get loaded(): boolean {
|
|
@@ -75,12 +170,31 @@ export class BgeEmbeddingModel implements EmbeddingModel {
|
|
|
75
170
|
}
|
|
76
171
|
|
|
77
172
|
async #embed(text: string): Promise<Float32Array> {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
}
|
|
84
198
|
}
|
|
85
199
|
|
|
86
200
|
/**
|
|
@@ -89,11 +203,16 @@ export class BgeEmbeddingModel implements EmbeddingModel {
|
|
|
89
203
|
* the runtime is still alive. If instead they survive to Bun's worker
|
|
90
204
|
* teardown, Bun panics creating their errors (oven-sh/bun#34664).
|
|
91
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
|
+
*
|
|
92
210
|
* Best-effort by design: dispose never throws, and a failed release is
|
|
93
211
|
* indistinguishable from never loading. An embed call after dispose just
|
|
94
212
|
* lazily re-loads the model.
|
|
95
213
|
*/
|
|
96
214
|
async dispose(): Promise<void> {
|
|
215
|
+
this.#clearIdleTimer();
|
|
97
216
|
// An in-flight load memoizes a rejecting promise on failure. Await it
|
|
98
217
|
// defensively so its error neither escapes dispose nor kills the teardown.
|
|
99
218
|
if (this.#loading) {
|
|
@@ -103,6 +222,10 @@ export class BgeEmbeddingModel implements EmbeddingModel {
|
|
|
103
222
|
// Load failed - nothing was loaded, so nothing to release.
|
|
104
223
|
}
|
|
105
224
|
}
|
|
225
|
+
await this.#release();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async #release(): Promise<void> {
|
|
106
229
|
const pipe = this.#pipe;
|
|
107
230
|
this.#pipe = null;
|
|
108
231
|
this.#loading = null;
|
|
@@ -110,7 +233,27 @@ export class BgeEmbeddingModel implements EmbeddingModel {
|
|
|
110
233
|
try {
|
|
111
234
|
await pipe.dispose();
|
|
112
235
|
} catch {
|
|
113
|
-
// Best-effort - see doc comment
|
|
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;
|
|
114
257
|
}
|
|
115
258
|
}
|
|
116
259
|
}
|
package/src/index.ts
CHANGED
|
@@ -22,7 +22,8 @@ import { hygieneReport } from "./hygiene";
|
|
|
22
22
|
import { seedDefaultBehaviors } from "./seed-behaviors";
|
|
23
23
|
import { startVersionChecker, stopVersionChecker, getVersionChecker, readOnDiskVersion, compareSemver } from "./version-check";
|
|
24
24
|
import { WatcherRegistry, ghApiRun, ghAvailable } from "./watchers";
|
|
25
|
-
import { watcherNotificationNudge } from "./prompts";
|
|
25
|
+
import { watcherNotificationNudge, chatNotificationNudge, chatEchoText, isChatEchoParts } from "./prompts";
|
|
26
|
+
import { ChatPoller } from "./chat";
|
|
26
27
|
import pkg from "../package.json";
|
|
27
28
|
|
|
28
29
|
// ---------------------------------------------------------------------------
|
|
@@ -86,9 +87,12 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
86
87
|
const runningVersion = pkg.version;
|
|
87
88
|
|
|
88
89
|
// Latest observed opencode session status per session ("busy" | "idle" |
|
|
89
|
-
// "retry").
|
|
90
|
-
//
|
|
91
|
-
//
|
|
90
|
+
// "retry"). Keys enter from session.status events and leave only in
|
|
91
|
+
// session.deleted (which shrinks the chat poller's hosted set). Readers:
|
|
92
|
+
// the watcher registry's and the chat poller's canDeliver gates
|
|
93
|
+
// (proactive prompts only fire into idle sessions, never into a running
|
|
94
|
+
// turn), and the chat poller's hostedSessions set (the sessions this
|
|
95
|
+
// process may deliver chat mail to - the map's keys).
|
|
92
96
|
const sessionStatus = new Map<string, string>();
|
|
93
97
|
|
|
94
98
|
// In-memory watcher registry for proactive event notifications (GitHub PR
|
|
@@ -140,6 +144,50 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
140
144
|
});
|
|
141
145
|
watchers.start();
|
|
142
146
|
|
|
147
|
+
// Cross-session chat delivery. The inbox is shared SQLite state (any
|
|
148
|
+
// process reads/writes it), but this poller only ever delivers to sessions
|
|
149
|
+
// hosted by THIS process - the recipient's host is the single deliverer,
|
|
150
|
+
// so there is no cross-process double-delivery race. Wake prompts use the
|
|
151
|
+
// same synthetic-part mechanism as watcher notifications; the toast
|
|
152
|
+
// covers the TUI-hidden part so the user sees why the model woke up.
|
|
153
|
+
// canDeliver closes over `compacting` (declared below): the closure reads
|
|
154
|
+
// it at call time, after full initialization, same as the watcher gate.
|
|
155
|
+
const chatPoller = new ChatPoller({
|
|
156
|
+
store: db,
|
|
157
|
+
hostedSessions: () => {
|
|
158
|
+
// Sub-agent children share this process's sessionStatus (their
|
|
159
|
+
// status events land here), but they are never legitimate chat
|
|
160
|
+
// participants - a registered child would be heartbeat-ed
|
|
161
|
+
// fresh-forever and burn nudge budget on undeliverable wake
|
|
162
|
+
// prompts. Keep them out of the hosted set even when guidance
|
|
163
|
+
// against registering them is ignored.
|
|
164
|
+
const hosted = [...sessionStatus.keys()].filter((id) => !childToParent.has(id));
|
|
165
|
+
return hosted;
|
|
166
|
+
},
|
|
167
|
+
deliver: async (sessionID, senders, count) => {
|
|
168
|
+
await client.session.promptAsync({
|
|
169
|
+
path: { id: sessionID },
|
|
170
|
+
body: {
|
|
171
|
+
parts: [{ type: "text", text: chatNotificationNudge(senders, count), synthetic: true }],
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
try {
|
|
175
|
+
await client.tui.showToast({
|
|
176
|
+
body: {
|
|
177
|
+
message: `\u{1F4AC} chat: ${count} unread from ${senders.join(", ")}`,
|
|
178
|
+
variant: "info",
|
|
179
|
+
duration: 5000,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
} catch {
|
|
183
|
+
// TUI may not be connected. Best-effort.
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
canDeliver: (sessionID) =>
|
|
187
|
+
!compacting.has(sessionID) && sessionStatus.get(sessionID) === "idle",
|
|
188
|
+
});
|
|
189
|
+
chatPoller.start();
|
|
190
|
+
|
|
143
191
|
// Sessions currently being compacted. chat.message nudges are skipped while
|
|
144
192
|
// a session is in this set - the agent can't call tools during summary
|
|
145
193
|
// generation, so a recall or extraction nudge would cause a blocked-tool
|
|
@@ -407,6 +455,40 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
407
455
|
childMetrics.set(input.sessionID, metrics);
|
|
408
456
|
return;
|
|
409
457
|
}
|
|
458
|
+
// Chat conversational events echo back into the transcript as visible
|
|
459
|
+
// message parts. Plugin tools render as muted one-line entries with
|
|
460
|
+
// the output hidden behind a default-off TUI toggle, so without this
|
|
461
|
+
// the human watching a session never sees the conversation happen.
|
|
462
|
+
// Delivery is a non-synthetic noReply promptAsync: the part renders
|
|
463
|
+
// as a visible bubble and starts no model turn (the server's prompt
|
|
464
|
+
// path returns before the completion loop). Non-synthetic also means
|
|
465
|
+
// later turns see the echo in context - a small duplication of the
|
|
466
|
+
// tool call it mirrors, accepted for visibility. Fire-and-forget: an
|
|
467
|
+
// echo failure must never fail the tool call it follows.
|
|
468
|
+
if (input.tool.startsWith("thatch_chat_")) {
|
|
469
|
+
const echo = chatEchoText(
|
|
470
|
+
input.tool,
|
|
471
|
+
(input.args ?? {}) as Record<string, unknown>,
|
|
472
|
+
typeof output.output === "string" ? output.output : "",
|
|
473
|
+
);
|
|
474
|
+
if (echo) {
|
|
475
|
+
void client.session
|
|
476
|
+
.promptAsync({
|
|
477
|
+
path: { id: input.sessionID },
|
|
478
|
+
body: {
|
|
479
|
+
noReply: true,
|
|
480
|
+
parts: [{ type: "text", text: echo }],
|
|
481
|
+
},
|
|
482
|
+
})
|
|
483
|
+
.catch((err) => {
|
|
484
|
+
// The echo must never fail the tool call, but a silent catch
|
|
485
|
+
// would hide a systemic promptAsync failure (bubbles would
|
|
486
|
+
// stop appearing everywhere with no diagnostics).
|
|
487
|
+
console.error(`[thatch] chat echo delivery failed: ${err}`);
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
410
492
|
if (input.tool.startsWith("thatch_") || input.tool === "skill" || input.tool === "task") return;
|
|
411
493
|
extraction.push({
|
|
412
494
|
tool: input.tool,
|
|
@@ -434,6 +516,12 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
434
516
|
// a summary, so a nudge that says "use thatch_memory_recall" triggers a
|
|
435
517
|
// blocked-tool error.
|
|
436
518
|
"chat.message": async (input, output) => {
|
|
519
|
+
// Chat transcript echoes are non-synthetic noReply parts, so they
|
|
520
|
+
// arrive here like any user message - but no model turn ever reads
|
|
521
|
+
// them, and the nudge machinery would embed and scan their text for
|
|
522
|
+
// nothing (and fire spurious toasts when thresholds cross). Skip
|
|
523
|
+
// them entirely.
|
|
524
|
+
if (isChatEchoParts(output.parts)) return;
|
|
437
525
|
if (compacting.has(input.sessionID)) {
|
|
438
526
|
// The session is marked as compacting. If this message is the
|
|
439
527
|
// compaction summary generation itself (has a compaction-type part),
|
|
@@ -757,10 +845,22 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
757
845
|
} catch (err) {
|
|
758
846
|
console.error(`[thatch] watcher delivery on idle failed: ${err}`);
|
|
759
847
|
}
|
|
848
|
+
// Same for chat messages: mail that arrived mid-turn lands now
|
|
849
|
+
// instead of waiting for the next poll cycle.
|
|
850
|
+
try {
|
|
851
|
+
await chatPoller.deliverPending();
|
|
852
|
+
} catch (err) {
|
|
853
|
+
console.error(`[thatch] chat delivery on idle failed: ${err}`);
|
|
854
|
+
}
|
|
760
855
|
return;
|
|
761
856
|
}
|
|
762
857
|
if (event.type === "session.deleted") {
|
|
763
858
|
const id = event.properties.info.id;
|
|
859
|
+
// Shrink the chat poller's hosted set FIRST: the rest of this
|
|
860
|
+
// branch runs extraction calls that could throw on a transient DB
|
|
861
|
+
// error, and a leaked status key would keep heartbeat-ing a dead
|
|
862
|
+
// session as permanently fresh.
|
|
863
|
+
sessionStatus.delete(id);
|
|
764
864
|
// A child deleted before completing never processed its payload.
|
|
765
865
|
const parentID = childToParent.get(id);
|
|
766
866
|
if (parentID) {
|
|
@@ -776,8 +876,15 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
776
876
|
// notifications no longer exists.
|
|
777
877
|
extraction.completeAccepted(id);
|
|
778
878
|
extracting.delete(id);
|
|
779
|
-
sessionStatus.delete(id);
|
|
780
879
|
watchers.cancelSession(id);
|
|
880
|
+
// Leaving the chat directory is the graceful-exit fast path; a
|
|
881
|
+
// crashed process never fires session.deleted, so the heartbeat
|
|
882
|
+
// staleness in chat_list is the covering signal for that case.
|
|
883
|
+
try {
|
|
884
|
+
db.unregisterChatSession(id);
|
|
885
|
+
} catch (err) {
|
|
886
|
+
console.error(`[thatch] chat unregister on delete failed: ${err}`);
|
|
887
|
+
}
|
|
781
888
|
return;
|
|
782
889
|
}
|
|
783
890
|
|
|
@@ -816,6 +923,7 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
816
923
|
dispose: async () => {
|
|
817
924
|
stopVersionChecker();
|
|
818
925
|
watchers.dispose();
|
|
926
|
+
chatPoller.dispose();
|
|
819
927
|
// Release native ONNX sessions while the worker is still healthy. Left
|
|
820
928
|
// to Bun's teardown, their NAPI finalizers panic the process (see
|
|
821
929
|
// BgeEmbeddingModel.dispose).
|
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
|
|