@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/commands.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import {
|
|
4
4
|
defragCore,
|
|
@@ -161,12 +161,26 @@ export interface CommandDef {
|
|
|
161
161
|
content: string;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The wrap-up command's prompt body, for hosts that register commands in
|
|
166
|
+
* code (opencode v2 CommandEditor): the execute hook arms the greenlight
|
|
167
|
+
* check and delivers this same text as the session prompt.
|
|
168
|
+
*/
|
|
169
|
+
export function wrapUpCommandContent(kind: "compact" | "exit"): string {
|
|
170
|
+
return kind === "compact" ? COMPACT_TEMPLATE : EXIT_TEMPLATE;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The action commands only - no wrap-ups (used where wrap-ups register in code). */
|
|
174
|
+
export function opencodeActionCommandDefs(): CommandDef[] {
|
|
175
|
+
return actionDefs(opencodeToolName).map((a) => ({ name: a.name, content: renderActionCommand(a) }));
|
|
176
|
+
}
|
|
177
|
+
|
|
164
178
|
/** The opencode command set: wrap-ups plus every action, as full file contents. */
|
|
165
179
|
export function opencodeCommandDefs(): CommandDef[] {
|
|
166
180
|
return [
|
|
167
181
|
{ name: "compact", content: COMPACT_TEMPLATE },
|
|
168
182
|
{ name: "exit", content: EXIT_TEMPLATE },
|
|
169
|
-
...
|
|
183
|
+
...opencodeActionCommandDefs(),
|
|
170
184
|
];
|
|
171
185
|
}
|
|
172
186
|
|
|
@@ -205,9 +219,34 @@ function syncCommandFiles(dir: string, defs: CommandDef[]): string[] {
|
|
|
205
219
|
return written;
|
|
206
220
|
}
|
|
207
221
|
|
|
208
|
-
/**
|
|
209
|
-
|
|
210
|
-
|
|
222
|
+
/**
|
|
223
|
+
* Sync opencode's commands into <configHome>/opencode/command/thatch/.
|
|
224
|
+
* defs overrides the set (hosts whose plugin API registers wrap-up commands
|
|
225
|
+
* in code pass the action-only set - a registered command and a file with
|
|
226
|
+
* the same name would collide).
|
|
227
|
+
*/
|
|
228
|
+
export function installOpencodeCommands(configHome: string, defs: CommandDef[] = opencodeCommandDefs()): string[] {
|
|
229
|
+
return syncCommandFiles(join(configHome, "opencode", "command", "thatch"), defs);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Removes wrap-up command files left in the opencode command dir. Hosts
|
|
234
|
+
* that register wrap-up commands in code (v2) pass the action-only set to
|
|
235
|
+
* installOpencodeCommands, but files written by earlier v1 runs persist -
|
|
236
|
+
* syncCommandFiles never removes - and a stale file collides with the
|
|
237
|
+
* registered command (both versions share the config dir). Call this
|
|
238
|
+
* before installing on such hosts. Idempotent: missing files are fine.
|
|
239
|
+
*/
|
|
240
|
+
export function removeWrapUpCommandFiles(configHome: string): void {
|
|
241
|
+
const dir = join(configHome, "opencode", "command", "thatch");
|
|
242
|
+
for (const kind of ["compact", "exit"] as const) {
|
|
243
|
+
try {
|
|
244
|
+
unlinkSync(join(dir, `${kind}.md`));
|
|
245
|
+
} catch {
|
|
246
|
+
// ENOENT is the expected steady state; anything else is equally
|
|
247
|
+
// harmless for a best-effort cleanup.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
211
250
|
}
|
|
212
251
|
|
|
213
252
|
/** Sync Claude Code's commands into <claudeDir>/commands/thatch/ (claudeDir is ~/.claude or <project>/.claude). */
|
package/src/config.ts
CHANGED
|
@@ -21,17 +21,30 @@ export const chatPrefsSchema = z.strictObject({
|
|
|
21
21
|
autoRegister: z.boolean().optional(),
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
/** Channel preferences for one automatic alert event (pause / done / error). */
|
|
25
|
+
export const alertEventPrefsSchema = z.strictObject({
|
|
26
|
+
mode: z.enum(["both", "banner", "voice", "none"]).optional(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const alertsPrefsSchema = z.strictObject({
|
|
30
|
+
pause: alertEventPrefsSchema.optional(),
|
|
31
|
+
done: alertEventPrefsSchema.optional(),
|
|
32
|
+
error: alertEventPrefsSchema.optional(),
|
|
33
|
+
});
|
|
34
|
+
|
|
24
35
|
export const configSchema = z.strictObject({
|
|
25
36
|
notifications: notificationPrefsSchema.optional(),
|
|
26
37
|
chat: chatPrefsSchema.optional(),
|
|
38
|
+
alerts: alertsPrefsSchema.optional(),
|
|
27
39
|
});
|
|
28
40
|
|
|
29
41
|
export type NotificationPrefs = z.infer<typeof notificationPrefsSchema>;
|
|
30
42
|
export type ChatPrefs = z.infer<typeof chatPrefsSchema>;
|
|
43
|
+
export type AlertsPrefs = z.infer<typeof alertsPrefsSchema>;
|
|
31
44
|
export type Config = z.infer<typeof configSchema>;
|
|
32
45
|
|
|
33
46
|
/** Sections in presentation order. config_get / config_set iterate this. */
|
|
34
|
-
export const CONFIG_SECTIONS = ["notifications", "chat"] as const;
|
|
47
|
+
export const CONFIG_SECTIONS = ["notifications", "chat", "alerts"] as const;
|
|
35
48
|
export type ConfigSection = (typeof CONFIG_SECTIONS)[number];
|
|
36
49
|
|
|
37
50
|
/**
|
|
@@ -102,6 +115,11 @@ export function mergeChatPrefs(current: ChatPrefs | undefined, patch: ChatPrefs)
|
|
|
102
115
|
return { ...current, ...patch };
|
|
103
116
|
}
|
|
104
117
|
|
|
118
|
+
/** Field-level merge for one section: omitted fields keep their values. */
|
|
119
|
+
export function mergeAlertsPrefs(current: AlertsPrefs | undefined, patch: AlertsPrefs): AlertsPrefs {
|
|
120
|
+
return { ...current, ...patch };
|
|
121
|
+
}
|
|
122
|
+
|
|
105
123
|
/**
|
|
106
124
|
* Whether cross-session chat is on. Unset means on - chat is the default,
|
|
107
125
|
* and the toggle exists for users who want the feature entirely dark: every
|
|
@@ -134,3 +152,16 @@ export function notificationDefaults(): { mode: "both" | "banner" | "voice" | "n
|
|
|
134
152
|
}
|
|
135
153
|
return { mode: "both" };
|
|
136
154
|
}
|
|
155
|
+
|
|
156
|
+
/** The automatic alert events, in the order config_get renders them. */
|
|
157
|
+
export type AlertEventKind = "pause" | "done" | "error";
|
|
158
|
+
export const ALERT_EVENT_KINDS: AlertEventKind[] = ["pause", "done", "error"];
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Channel for one automatic alert event (pause / done / error). Unset means
|
|
162
|
+
* banner: the alerts exist to reach a user who walked away, so voice is
|
|
163
|
+
* opt-in per event rather than bundled with the notify_user default.
|
|
164
|
+
*/
|
|
165
|
+
export function alertMode(config: Config, kind: AlertEventKind): "both" | "banner" | "voice" | "none" {
|
|
166
|
+
return config.alerts?.[kind]?.mode ?? "banner";
|
|
167
|
+
}
|
package/src/db.ts
CHANGED
|
@@ -55,12 +55,67 @@ export interface DedupCandidate {
|
|
|
55
55
|
* stored as BLOBs. Similarity search is brute-force cosine in JS.
|
|
56
56
|
*/
|
|
57
57
|
export class ThatchDB {
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
58
|
+
#path: string;
|
|
59
|
+
#handle: Database | null = null;
|
|
60
|
+
#predictionsStore!: PredictionEngine;
|
|
61
|
+
#behaviorsStore!: BehaviorEngine;
|
|
62
|
+
#chatStore!: ChatStore;
|
|
62
63
|
|
|
63
64
|
constructor(path: string) {
|
|
65
|
+
this.#path = path;
|
|
66
|
+
this.#open();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Connection accessor. Every read goes through here, so a handle closed by
|
|
71
|
+
* dispose() (a v2 plugin reload tears down the old activation while tool
|
|
72
|
+
* calls may still be in flight) transparently reopens on the next touch
|
|
73
|
+
* instead of surfacing bun:sqlite's "Cannot use a closed database". WAL
|
|
74
|
+
* journaling plus busy_timeout make the extra connection safe - the chat
|
|
75
|
+
* subsystem already runs as several concurrent connections by design.
|
|
76
|
+
*
|
|
77
|
+
* OWNERSHIP RULE: reopen serves READS and in-flight callers only. After a
|
|
78
|
+
* reload, the RELOADED instance owns the runtime_state journal rows, so
|
|
79
|
+
* post-dispose journal writes must no-op - they are guarded by the
|
|
80
|
+
* disposed flag at their call sites in src/runtime.ts, checked BEFORE any
|
|
81
|
+
* db access reaches this accessor. Do not move that guard in here: this
|
|
82
|
+
* method cannot distinguish a legitimate in-flight caller from a stale
|
|
83
|
+
* writer.
|
|
84
|
+
*
|
|
85
|
+
* Reopen caveat: a `:memory:` database has no file behind it, so a
|
|
86
|
+
* close-then-use cycle on one yields a brand-new EMPTY database. No
|
|
87
|
+
* production path does that (:memory: is test-only and tests never use the
|
|
88
|
+
* instance after close), but keep it that way - use a tmp-file db if a
|
|
89
|
+
* test needs close-then-use.
|
|
90
|
+
*/
|
|
91
|
+
get #db(): Database {
|
|
92
|
+
if (this.#handle === null) this.#open();
|
|
93
|
+
return this.#handle!;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The engine facades get the same treatment as #db: every accessor
|
|
97
|
+
// ensures an open connection first, so delegation routes through the
|
|
98
|
+
// facade REBUILT by the reopen rather than the stale one. Without this,
|
|
99
|
+
// a post-close chat call would reach the old ChatStore's captured handle
|
|
100
|
+
// and throw "Cannot use a closed database" even though ThatchDB itself
|
|
101
|
+
// reopened.
|
|
102
|
+
get #predictions(): PredictionEngine {
|
|
103
|
+
if (this.#handle === null) this.#open();
|
|
104
|
+
return this.#predictionsStore;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
get #behaviors(): BehaviorEngine {
|
|
108
|
+
if (this.#handle === null) this.#open();
|
|
109
|
+
return this.#behaviorsStore;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
get #chat(): ChatStore {
|
|
113
|
+
if (this.#handle === null) this.#open();
|
|
114
|
+
return this.#chatStore;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#open(): void {
|
|
118
|
+
const path = this.#path;
|
|
64
119
|
// bun:sqlite creates the file but not its parent directory. On a fresh
|
|
65
120
|
// machine the default path (~/.config/thatch/thatch.db) has no dir yet, so
|
|
66
121
|
// create it here - otherwise every command fails with SQLITE_CANTOPEN.
|
|
@@ -68,18 +123,23 @@ export class ThatchDB {
|
|
|
68
123
|
if (path !== ":memory:" && !path.startsWith("file:")) {
|
|
69
124
|
mkdirSync(dirname(path), { recursive: true });
|
|
70
125
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
126
|
+
const db = new Database(path, { create: true });
|
|
127
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
128
|
+
db.run("PRAGMA busy_timeout = 5000");
|
|
74
129
|
// Enforce FK constraints per connection. The prediction tables use
|
|
75
130
|
// ON DELETE CASCADE for edges and provenance; enabling this pragma
|
|
76
131
|
// ensures those cascades fire. Also enforces the entries table's
|
|
77
132
|
// existing FK to stores(name), which was declarative but unenforced
|
|
78
133
|
// before this branch.
|
|
79
|
-
|
|
80
|
-
this.#
|
|
81
|
-
|
|
82
|
-
|
|
134
|
+
db.run("PRAGMA foreign_keys = ON");
|
|
135
|
+
this.#handle = db;
|
|
136
|
+
// The engine facades hold the Database handle they were constructed
|
|
137
|
+
// with, so a reopened connection rebuilds them. They are stateless
|
|
138
|
+
// views over SQLite - all durable state lives in the tables - so
|
|
139
|
+
// reconstruction is lossless.
|
|
140
|
+
this.#predictionsStore = new PredictionEngine(db);
|
|
141
|
+
this.#behaviorsStore = new BehaviorEngine(db);
|
|
142
|
+
this.#chatStore = new ChatStore(db);
|
|
83
143
|
this.#initSchema();
|
|
84
144
|
}
|
|
85
145
|
|
|
@@ -297,6 +357,18 @@ export class ThatchDB {
|
|
|
297
357
|
)
|
|
298
358
|
`);
|
|
299
359
|
|
|
360
|
+
// Reaped auto-registered rows leave their name-to-session binding here,
|
|
361
|
+
// so the SAME session can reclaim its name when it re-registers (a
|
|
362
|
+
// crash-and-resume must not churn the identity). Names are still never
|
|
363
|
+
// reissued to a DIFFERENT session: a claim only ever resolves for the
|
|
364
|
+
// session id it was recorded with.
|
|
365
|
+
this.#db.run(`
|
|
366
|
+
CREATE TABLE IF NOT EXISTS chat_name_claims (
|
|
367
|
+
name TEXT PRIMARY KEY,
|
|
368
|
+
session_id TEXT NOT NULL
|
|
369
|
+
)
|
|
370
|
+
`);
|
|
371
|
+
|
|
300
372
|
// Explicit leaves (chat_unregister). Suppresses auto-registration so
|
|
301
373
|
// the next idle event cannot silently re-register the session; cleared
|
|
302
374
|
// when the session explicitly rejoins via chat_register. Survives the
|
|
@@ -322,6 +394,24 @@ export class ThatchDB {
|
|
|
322
394
|
)
|
|
323
395
|
`);
|
|
324
396
|
|
|
397
|
+
// Volatile plugin-runtime state, persisted so a v2 plugin reload (the
|
|
398
|
+
// host rebuilds the location graph and re-runs setup) or a process
|
|
399
|
+
// restart can rehydrate instead of silently dropping it. The writer's
|
|
400
|
+
// pid is the discriminator: a row written by the CURRENT process means
|
|
401
|
+
// "reload - keep and rehydrate"; a row from another (dead) pid means
|
|
402
|
+
// "restart - prune unless the session was resumed".
|
|
403
|
+
this.#db.exec(`
|
|
404
|
+
CREATE TABLE IF NOT EXISTS runtime_state (
|
|
405
|
+
kind TEXT NOT NULL,
|
|
406
|
+
session_id TEXT NOT NULL,
|
|
407
|
+
directory TEXT NOT NULL DEFAULT '',
|
|
408
|
+
value TEXT NOT NULL,
|
|
409
|
+
pid INTEGER NOT NULL,
|
|
410
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
411
|
+
PRIMARY KEY (kind, session_id)
|
|
412
|
+
)
|
|
413
|
+
`);
|
|
414
|
+
|
|
325
415
|
this.#migrateColumns();
|
|
326
416
|
this.#migrateChatNameCollation();
|
|
327
417
|
this.#migrateChatTopic();
|
|
@@ -330,6 +420,14 @@ export class ThatchDB {
|
|
|
330
420
|
this.#migrateChatAuto();
|
|
331
421
|
this.#migrateChatWorktree();
|
|
332
422
|
this.#migrateChatHostPid();
|
|
423
|
+
this.#migrateRuntimeStateDirectory();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#migrateRuntimeStateDirectory(): void {
|
|
427
|
+
const cols = (this.#db.query("PRAGMA table_info(runtime_state)").all() as any[]).map((r) => r.name);
|
|
428
|
+
if (cols.length > 0 && !cols.includes("directory")) {
|
|
429
|
+
this.#db.run("ALTER TABLE runtime_state ADD COLUMN directory TEXT NOT NULL DEFAULT ''");
|
|
430
|
+
}
|
|
333
431
|
}
|
|
334
432
|
|
|
335
433
|
// chat_sessions tables created before the auto column lack it; the ALTER
|
|
@@ -961,6 +1059,48 @@ export class ThatchDB {
|
|
|
961
1059
|
return this.#chat.unregister(sessionID);
|
|
962
1060
|
}
|
|
963
1061
|
|
|
1062
|
+
/**
|
|
1063
|
+
* Persist one runtime-state record (upsert by kind+session). `directory`
|
|
1064
|
+
* is the writing instance's location: one v2 serve hosts one plugin
|
|
1065
|
+
* instance per directory sharing this db, and rehydration must be
|
|
1066
|
+
* instance-scoped, not process-scoped.
|
|
1067
|
+
*/
|
|
1068
|
+
runtimeStatePut(kind: string, sessionId: string, value: unknown, directory: string, pid: number = process.pid) {
|
|
1069
|
+
this.#db
|
|
1070
|
+
.query(
|
|
1071
|
+
`INSERT INTO runtime_state (kind, session_id, directory, value, pid) VALUES (?, ?, ?, ?, ?)
|
|
1072
|
+
ON CONFLICT (kind, session_id) DO UPDATE SET directory = excluded.directory, value = excluded.value, pid = excluded.pid, created_at = excluded.created_at`,
|
|
1073
|
+
)
|
|
1074
|
+
.run(kind, sessionId ?? "", directory ?? "", JSON.stringify(value), pid);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/** Drop one runtime-state record. */
|
|
1078
|
+
runtimeStateDelete(kind: string, sessionId: string) {
|
|
1079
|
+
this.#db.query(`DELETE FROM runtime_state WHERE kind = ? AND session_id = ?`).run(kind, sessionId);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/** Every runtime-state record, oldest first (rehydration source). */
|
|
1083
|
+
runtimeStateAll(): { kind: string; sessionID: string; directory: string; value: unknown; pid: number }[] {
|
|
1084
|
+
return this.#db
|
|
1085
|
+
.query(`SELECT kind, session_id, directory, value, pid FROM runtime_state ORDER BY created_at, session_id`)
|
|
1086
|
+
.all()
|
|
1087
|
+
.map((row: any) => {
|
|
1088
|
+
let value: unknown;
|
|
1089
|
+
try {
|
|
1090
|
+
value = JSON.parse(row.value);
|
|
1091
|
+
} catch {
|
|
1092
|
+
value = null;
|
|
1093
|
+
}
|
|
1094
|
+
return {
|
|
1095
|
+
kind: row.kind as string,
|
|
1096
|
+
sessionID: row.session_id as string,
|
|
1097
|
+
directory: (row.directory ?? "") as string,
|
|
1098
|
+
value,
|
|
1099
|
+
pid: row.pid as number,
|
|
1100
|
+
};
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
|
|
964
1104
|
listChatSessions() {
|
|
965
1105
|
return this.#chat.list();
|
|
966
1106
|
}
|
|
@@ -1089,7 +1229,12 @@ export class ThatchDB {
|
|
|
1089
1229
|
}
|
|
1090
1230
|
|
|
1091
1231
|
close(): void {
|
|
1092
|
-
|
|
1232
|
+
// Drops the current handle only. A later use reopens (see the #db
|
|
1233
|
+
// accessor) - the MCP server and CLI call this on process shutdown,
|
|
1234
|
+
// where nothing follows; the opencode plugin's dispose calls it on
|
|
1235
|
+
// reload, where straggler in-flight tool calls must survive.
|
|
1236
|
+
this.#handle?.close();
|
|
1237
|
+
this.#handle = null;
|
|
1093
1238
|
}
|
|
1094
1239
|
}
|
|
1095
1240
|
|
package/src/embeddings.ts
CHANGED
|
@@ -159,6 +159,49 @@ export interface BgeEmbeddingModelOptions {
|
|
|
159
159
|
idleTtlMs?: number;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Process-wide model pool, keyed by db path. A v2 serve hosts one plugin
|
|
164
|
+
* instance per location and each would otherwise keep its own ONNX model
|
|
165
|
+
* resident; the pool refcounts so N instances share one model and it is
|
|
166
|
+
* disposed only when the last holder releases (the model's ONNX sessions
|
|
167
|
+
* must be explicitly closed before process exit - Bun's NAPI finalizers
|
|
168
|
+
* panic otherwise, see BgeEmbeddingModel.dispose).
|
|
169
|
+
*/
|
|
170
|
+
export class SharedModelPool {
|
|
171
|
+
#entries = new Map<string, { model: BgeEmbeddingModel; refs: number }>();
|
|
172
|
+
|
|
173
|
+
acquire(dbPath: string, modelName: string): BgeEmbeddingModel {
|
|
174
|
+
const entry = this.#entries.get(dbPath);
|
|
175
|
+
if (entry) {
|
|
176
|
+
entry.refs += 1;
|
|
177
|
+
return entry.model;
|
|
178
|
+
}
|
|
179
|
+
const created = { model: new BgeEmbeddingModel(modelName), refs: 1 };
|
|
180
|
+
this.#entries.set(dbPath, created);
|
|
181
|
+
return created.model;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Release one reference. Resolves when the model's ONNX sessions are
|
|
186
|
+
* released (at zero refs) - the caller MUST await it before its process
|
|
187
|
+
* can exit, or Bun's NAPI finalizers panic on teardown.
|
|
188
|
+
*/
|
|
189
|
+
async release(dbPath: string): Promise<void> {
|
|
190
|
+
const entry = this.#entries.get(dbPath);
|
|
191
|
+
if (!entry) return;
|
|
192
|
+
entry.refs -= 1;
|
|
193
|
+
if (entry.refs <= 0) {
|
|
194
|
+
this.#entries.delete(dbPath);
|
|
195
|
+
await entry.model.dispose();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Test/introspection: number of live (held) models. */
|
|
200
|
+
get size(): number {
|
|
201
|
+
return this.#entries.size;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
162
205
|
/**
|
|
163
206
|
* Lazy-loads an embedding model via @huggingface/transformers.
|
|
164
207
|
* Model files (~34 MB for the default) are downloaded once and cached by HF Hub.
|
package/src/extraction.ts
CHANGED
|
@@ -59,6 +59,58 @@ function truncate(text: string, maxLen: number): string {
|
|
|
59
59
|
return text.slice(0, maxLen) + "...";
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/** The scan result for a Code Mode `execute` call - see unwrapExecuteThatchCalls. */
|
|
63
|
+
export interface ExecuteUnwrap {
|
|
64
|
+
/** Distinct thatch_* tool names the code invokes, in first-appearance order. */
|
|
65
|
+
tools: string[];
|
|
66
|
+
/** Whether the code passes `overwrite: true` to a memory_remember call. */
|
|
67
|
+
overwrite: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* The session_id a wrapped extraction_done call passes, when present -
|
|
70
|
+
* the sub-agent naming the parent whose accepted entries it completed.
|
|
71
|
+
*/
|
|
72
|
+
sessionID?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Meta tools: bookkeeping and dispatch, never real work. The extraction
|
|
77
|
+
* buffer excludes them because buffering the pipeline's own traffic feeds
|
|
78
|
+
* the nudge with its own exhaust (the dispatch/ack loops, September 2026);
|
|
79
|
+
* the alert classifier excludes them because a round made of only these
|
|
80
|
+
* did nothing the user asked for. "task" is v1's dispatch tool, "subagent"
|
|
81
|
+
* v2's, "agent" the MCP-path spelling - match all three, never one name.
|
|
82
|
+
*/
|
|
83
|
+
export function isMetaToolName(tool: string): boolean {
|
|
84
|
+
const t = tool.toLowerCase();
|
|
85
|
+
return t.startsWith("thatch_") || t === "skill" || t === "task" || t === "agent" || t === "subagent";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Detect thatch_* tool invocations wrapped inside a Code Mode `execute`
|
|
90
|
+
* call's code (`tools.thatch_extraction_done({session_id: "..."})` - dot
|
|
91
|
+
* and bracket forms both count; the bracket form
|
|
92
|
+
* `tools["thatch_extraction_done"]` used to slip past the unwrap and
|
|
93
|
+
* re-open the dispatch/ack loop). The extraction buffer matches on TOOL
|
|
94
|
+
* NAME, so without unwrapping, the pipeline's own ack/drain traffic
|
|
95
|
+
* re-enters the buffer as extractable interactions: each extraction run
|
|
96
|
+
* queues the next one and the nudge never runs out of self-generated
|
|
97
|
+
* material (observed as an infinite dispatch/ack loop, September 2026).
|
|
98
|
+
* Returns an empty tool list when the args are not execute-shaped or the
|
|
99
|
+
* code invokes no thatch tools - such calls buffer as normal, because they
|
|
100
|
+
* did real non-thatch work.
|
|
101
|
+
*/
|
|
102
|
+
export function unwrapExecuteThatchCalls(args: unknown): ExecuteUnwrap {
|
|
103
|
+
const code = (args as Record<string, unknown> | undefined)?.code;
|
|
104
|
+
if (typeof code !== "string" || !code.includes("thatch_")) return { tools: [], overwrite: false };
|
|
105
|
+
const tools: string[] = [];
|
|
106
|
+
for (const match of code.matchAll(/tools\.thatch_([a-z_]+)|tools\[\s*["']thatch_([a-z_]+)["']\s*\]/g)) {
|
|
107
|
+
const name = `thatch_${match[1] ?? match[2]}`;
|
|
108
|
+
if (!tools.includes(name)) tools.push(name);
|
|
109
|
+
}
|
|
110
|
+
const doneCall = code.match(/thatch_extraction_done["']?\]?\s*\(\s*\{[^}]*?\bsession_id:\s*["']([^"']+)["']/);
|
|
111
|
+
return { tools, overwrite: /\boverwrite\s*:\s*true\b/.test(code), sessionID: doneCall?.[1] };
|
|
112
|
+
}
|
|
113
|
+
|
|
62
114
|
/**
|
|
63
115
|
* Serialize a list of tool interactions into the JSON payload the
|
|
64
116
|
* thatch-fact-extractor skill expects. Shared by the opencode plugin path
|
|
@@ -84,6 +136,27 @@ export function buildExtractionPayload(
|
|
|
84
136
|
});
|
|
85
137
|
}
|
|
86
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Persistence hook: called after every buffer/accepted mutation with the
|
|
141
|
+
* session's current state (undefined = the list is gone - drop the record).
|
|
142
|
+
* The runtime wires this to the runtime_state journal so a v2 plugin reload
|
|
143
|
+
* or process restart can rehydrate the buffer instead of losing it.
|
|
144
|
+
*/
|
|
145
|
+
export type ExtractionJournal = (
|
|
146
|
+
kind: "buffer" | "accepted",
|
|
147
|
+
sessionID: string,
|
|
148
|
+
value: ToolInteraction[] | undefined,
|
|
149
|
+
) => void;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* How long accepted entries wait for a completion signal before
|
|
153
|
+
* requeueStaleAccepted gives up waiting and requeues them. Generous - the
|
|
154
|
+
* extractor sub-agent can legitimately run for many minutes. The point is
|
|
155
|
+
* bounding the linger when the completion signal never comes: a crashed
|
|
156
|
+
* extractor, or a v2 dispatch whose completion ack never names the parent.
|
|
157
|
+
*/
|
|
158
|
+
export const ACCEPTED_STALE_MS = 15 * 60_000;
|
|
159
|
+
|
|
87
160
|
/**
|
|
88
161
|
* Per-session in-memory ring buffer used by the opencode plugin path. Claude
|
|
89
162
|
* Code's MCP server has no equivalent plugin lifecycle, so its CLI subcommands
|
|
@@ -92,13 +165,36 @@ export function buildExtractionPayload(
|
|
|
92
165
|
export class ExtractionPipeline {
|
|
93
166
|
#buffers = new Map<string, ToolInteraction[]>();
|
|
94
167
|
#accepted = new Map<string, ToolInteraction[]>();
|
|
168
|
+
/** When the session's buffer was accepted - drives requeueStaleAccepted. */
|
|
169
|
+
#acceptedAt = new Map<string, number>();
|
|
95
170
|
#maxBuffer = 20;
|
|
171
|
+
#journal?: ExtractionJournal;
|
|
172
|
+
|
|
173
|
+
constructor(journal?: ExtractionJournal) {
|
|
174
|
+
this.#journal = journal;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Restore persisted state (same-process reload rehydration). */
|
|
178
|
+
hydrate(buffers: ToolInteraction[], accepted: ToolInteraction[]): void {
|
|
179
|
+
for (const ix of buffers) {
|
|
180
|
+
const buf = this.#buffers.get(ix.sessionID) ?? [];
|
|
181
|
+
buf.push(ix);
|
|
182
|
+
this.#buffers.set(ix.sessionID, buf);
|
|
183
|
+
}
|
|
184
|
+
for (const ix of accepted) {
|
|
185
|
+
const buf = this.#accepted.get(ix.sessionID) ?? [];
|
|
186
|
+
buf.push(ix);
|
|
187
|
+
this.#accepted.set(ix.sessionID, buf);
|
|
188
|
+
if (!this.#acceptedAt.has(ix.sessionID)) this.#acceptedAt.set(ix.sessionID, Date.now());
|
|
189
|
+
}
|
|
190
|
+
}
|
|
96
191
|
|
|
97
192
|
/** Record a tool execution for later extraction. */
|
|
98
193
|
push(interaction: ToolInteraction): void {
|
|
99
194
|
const buf = this.#buffers.get(interaction.sessionID) ?? [];
|
|
100
195
|
buf.push(interaction);
|
|
101
196
|
this.#buffers.set(interaction.sessionID, buf.slice(-this.#maxBuffer));
|
|
197
|
+
this.#journal?.("buffer", interaction.sessionID, this.#buffers.get(interaction.sessionID));
|
|
102
198
|
}
|
|
103
199
|
|
|
104
200
|
/** Returns the session's buffered interactions without clearing them. */
|
|
@@ -109,6 +205,7 @@ export class ExtractionPipeline {
|
|
|
109
205
|
/** Clears the session's buffer. Called when the agent writes a memory. */
|
|
110
206
|
consume(sessionID: string): void {
|
|
111
207
|
this.#buffers.delete(sessionID);
|
|
208
|
+
this.#journal?.("buffer", sessionID, undefined);
|
|
112
209
|
}
|
|
113
210
|
|
|
114
211
|
/**
|
|
@@ -124,11 +221,103 @@ export class ExtractionPipeline {
|
|
|
124
221
|
this.#buffers.delete(sessionID);
|
|
125
222
|
const existing = this.#accepted.get(sessionID) ?? [];
|
|
126
223
|
this.#accepted.set(sessionID, [...existing, ...buf]);
|
|
224
|
+
this.#acceptedAt.set(sessionID, Date.now());
|
|
225
|
+
this.#journal?.("buffer", sessionID, this.#buffers.get(sessionID));
|
|
226
|
+
this.#journal?.("accepted", sessionID, this.#accepted.get(sessionID));
|
|
127
227
|
}
|
|
128
228
|
|
|
129
229
|
/** Drop accepted entries: the extractor finished (save or no-save). */
|
|
130
230
|
completeAccepted(sessionID: string): void {
|
|
131
231
|
this.#accepted.delete(sessionID);
|
|
232
|
+
this.#acceptedAt.delete(sessionID);
|
|
233
|
+
this.#journal?.("accepted", sessionID, undefined);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#claims = new Map<string, { target: string; entries: ToolInteraction[] }>();
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Claim the session's pending + previously-accepted entries for a fetcher
|
|
240
|
+
* (the extractor sub-agent fetching its payload). The entries stay in the
|
|
241
|
+
* accepted holding area; the claim records WHICH fetcher they were
|
|
242
|
+
* delivered to, so that fetcher's completion signal consumes only its own
|
|
243
|
+
* delivery. Without this, any child's completion dropped the whole
|
|
244
|
+
* accepted set - a sibling sub-agent going idle at the wrong moment
|
|
245
|
+
* silently discarded another extractor's in-flight payload (the
|
|
246
|
+
* accept-then-racing-completion loss, observed September 2026).
|
|
247
|
+
*
|
|
248
|
+
* Entries are claimed by reference identity: later accepts append new
|
|
249
|
+
* arrays, so a stale claim never reaches entries claimed after it.
|
|
250
|
+
*/
|
|
251
|
+
claim(sessionID: string, fetcherID: string): ToolInteraction[] {
|
|
252
|
+
this.accept(sessionID);
|
|
253
|
+
const accepted = this.#accepted.get(sessionID) ?? [];
|
|
254
|
+
this.#claims.set(fetcherID, { target: sessionID, entries: accepted });
|
|
255
|
+
return accepted;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Consume only the fetcher's claimed entries. Returns false when the
|
|
260
|
+
* fetcher holds no claim - a sibling that never fetched, or a fetch that
|
|
261
|
+
* never happened. Callers must NOT fall back to whole-set completion on
|
|
262
|
+
* the idle path: a fetcher that never fetched processed nothing, and
|
|
263
|
+
* dropping the set would lose another extractor's claimed payload.
|
|
264
|
+
*/
|
|
265
|
+
completeClaimed(fetcherID: string): boolean {
|
|
266
|
+
const claim = this.#claims.get(fetcherID);
|
|
267
|
+
if (!claim) return false;
|
|
268
|
+
this.#claims.delete(fetcherID);
|
|
269
|
+
const accepted = this.#accepted.get(claim.target);
|
|
270
|
+
if (accepted) {
|
|
271
|
+
const claimed = new Set(claim.entries);
|
|
272
|
+
const rest = accepted.filter((ix) => !claimed.has(ix));
|
|
273
|
+
if (rest.length > 0) {
|
|
274
|
+
this.#accepted.set(claim.target, rest);
|
|
275
|
+
this.#journal?.("accepted", claim.target, rest);
|
|
276
|
+
} else {
|
|
277
|
+
this.#accepted.delete(claim.target);
|
|
278
|
+
this.#acceptedAt.delete(claim.target);
|
|
279
|
+
this.#journal?.("accepted", claim.target, undefined);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Forget a fetcher's claim (its session was deleted or cleaned up). */
|
|
286
|
+
dropClaim(fetcherID: string): void {
|
|
287
|
+
this.#claims.delete(fetcherID);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Requeue only the fetcher's claimed entries back to pending: its session
|
|
292
|
+
* errored, so the delivery was never processed. Returns false when the
|
|
293
|
+
* fetcher holds no claim - a task-kind child that never fetched processed
|
|
294
|
+
* nothing, and requeueing the whole accepted set here would yank another
|
|
295
|
+
* extractor's in-flight claim back to pending for duplicate extraction.
|
|
296
|
+
* Callers decide whether a claim-less error falls back to whole-set
|
|
297
|
+
* requeue (extraction children: yes - the error is a strong immediate
|
|
298
|
+
* death signal; task children: no).
|
|
299
|
+
*/
|
|
300
|
+
requeueClaimed(fetcherID: string): boolean {
|
|
301
|
+
const claim = this.#claims.get(fetcherID);
|
|
302
|
+
if (!claim) return false;
|
|
303
|
+
this.#claims.delete(fetcherID);
|
|
304
|
+
const accepted = this.#accepted.get(claim.target);
|
|
305
|
+
if (accepted) {
|
|
306
|
+
const claimed = new Set(claim.entries);
|
|
307
|
+
const rest = accepted.filter((ix) => !claimed.has(ix));
|
|
308
|
+
if (rest.length > 0) {
|
|
309
|
+
this.#accepted.set(claim.target, rest);
|
|
310
|
+
this.#journal?.("accepted", claim.target, rest);
|
|
311
|
+
} else {
|
|
312
|
+
this.#accepted.delete(claim.target);
|
|
313
|
+
this.#acceptedAt.delete(claim.target);
|
|
314
|
+
this.#journal?.("accepted", claim.target, undefined);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const buf = this.#buffers.get(claim.target) ?? [];
|
|
318
|
+
this.#buffers.set(claim.target, [...claim.entries, ...buf]);
|
|
319
|
+
this.#journal?.("buffer", claim.target, this.#buffers.get(claim.target));
|
|
320
|
+
return true;
|
|
132
321
|
}
|
|
133
322
|
|
|
134
323
|
/**
|
|
@@ -142,8 +331,27 @@ export class ExtractionPipeline {
|
|
|
142
331
|
const accepted = this.#accepted.get(sessionID);
|
|
143
332
|
if (!accepted || accepted.length === 0) return;
|
|
144
333
|
this.#accepted.delete(sessionID);
|
|
334
|
+
this.#acceptedAt.delete(sessionID);
|
|
145
335
|
const buf = this.#buffers.get(sessionID) ?? [];
|
|
146
336
|
this.#buffers.set(sessionID, [...accepted, ...buf]);
|
|
337
|
+
this.#journal?.("buffer", sessionID, this.#buffers.get(sessionID));
|
|
338
|
+
this.#journal?.("accepted", sessionID, undefined);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Requeues accepted entries that have waited longer than maxAgeMs for a
|
|
343
|
+
* completion signal that never came: the extractor sub-agent crashed
|
|
344
|
+
* without acking, or the completion ack was lost. Without this, accepted
|
|
345
|
+
* entries linger unprocessed AND un-nudged - silent loss. Returns the session ids that had entries
|
|
346
|
+
* requeued; the regular nudge path picks them up from pending on the
|
|
347
|
+
* session's next message.
|
|
348
|
+
*/
|
|
349
|
+
requeueStaleAccepted(maxAgeMs = ACCEPTED_STALE_MS): string[] {
|
|
350
|
+
const stale = [...this.#acceptedAt.entries()]
|
|
351
|
+
.filter(([, acceptedAt]) => Date.now() - acceptedAt > maxAgeMs)
|
|
352
|
+
.map(([sessionID]) => sessionID);
|
|
353
|
+
for (const sessionID of stale) this.requeueAccepted(sessionID);
|
|
354
|
+
return stale;
|
|
147
355
|
}
|
|
148
356
|
|
|
149
357
|
/** Returns the session's accepted (held) interactions. Test/introspection. */
|
|
@@ -166,8 +374,10 @@ export class ExtractionPipeline {
|
|
|
166
374
|
const remaining = buf.filter((ix) => !old.has(ix));
|
|
167
375
|
if (remaining.length === 0) {
|
|
168
376
|
this.#buffers.delete(sessionID);
|
|
377
|
+
this.#journal?.("buffer", sessionID, undefined);
|
|
169
378
|
} else {
|
|
170
379
|
this.#buffers.set(sessionID, remaining);
|
|
380
|
+
this.#journal?.("buffer", sessionID, remaining);
|
|
171
381
|
}
|
|
172
382
|
}
|
|
173
383
|
|