@jeffober/thatch 0.1.48 → 0.2.0
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/bin/thatch +31 -16
- package/docs/dev/README.md +28 -6
- 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 +4 -2
- package/docs/dev/features/extraction.md +10 -2
- package/docs/dev/features/hygiene.md +3 -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 +4 -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 +5 -2
- package/docs/dev/features/watchers.md +12 -5
- package/docs/dev/gotchas.md +36 -3
- package/docs/dev/mcp-parity.md +4 -3
- package/docs/dev/setup-and-hooks.md +6 -1
- package/docs/plans/llm-alerts.md +287 -0
- package/docs/plans/prediction-consolidation.md +3 -3
- package/docs/user/README.md +7 -3
- 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 +63 -5
- package/src/commands.ts +44 -5
- package/src/config.ts +32 -1
- package/src/db.ts +80 -0
- package/src/embeddings.ts +43 -0
- package/src/extraction.ts +124 -0
- package/src/index.ts +38 -1345
- package/src/opencode/v1.ts +54 -0
- package/src/opencode/v2.ts +561 -0
- package/src/os-args.ts +99 -0
- package/src/prompts.ts +77 -1
- package/src/runtime.ts +1815 -0
- package/src/seed-behaviors.ts +1 -1
- package/src/session-db.ts +32 -0
- package/src/tool-defs.ts +185 -19
- package/src/tools.ts +16 -35
- package/src/watchers.ts +95 -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
|
@@ -297,6 +297,18 @@ export class ThatchDB {
|
|
|
297
297
|
)
|
|
298
298
|
`);
|
|
299
299
|
|
|
300
|
+
// Reaped auto-registered rows leave their name-to-session binding here,
|
|
301
|
+
// so the SAME session can reclaim its name when it re-registers (a
|
|
302
|
+
// crash-and-resume must not churn the identity). Names are still never
|
|
303
|
+
// reissued to a DIFFERENT session: a claim only ever resolves for the
|
|
304
|
+
// session id it was recorded with.
|
|
305
|
+
this.#db.run(`
|
|
306
|
+
CREATE TABLE IF NOT EXISTS chat_name_claims (
|
|
307
|
+
name TEXT PRIMARY KEY,
|
|
308
|
+
session_id TEXT NOT NULL
|
|
309
|
+
)
|
|
310
|
+
`);
|
|
311
|
+
|
|
300
312
|
// Explicit leaves (chat_unregister). Suppresses auto-registration so
|
|
301
313
|
// the next idle event cannot silently re-register the session; cleared
|
|
302
314
|
// when the session explicitly rejoins via chat_register. Survives the
|
|
@@ -322,6 +334,24 @@ export class ThatchDB {
|
|
|
322
334
|
)
|
|
323
335
|
`);
|
|
324
336
|
|
|
337
|
+
// Volatile plugin-runtime state, persisted so a v2 plugin reload (the
|
|
338
|
+
// host rebuilds the location graph and re-runs setup) or a process
|
|
339
|
+
// restart can rehydrate instead of silently dropping it. The writer's
|
|
340
|
+
// pid is the discriminator: a row written by the CURRENT process means
|
|
341
|
+
// "reload - keep and rehydrate"; a row from another (dead) pid means
|
|
342
|
+
// "restart - prune unless the session was resumed".
|
|
343
|
+
this.#db.exec(`
|
|
344
|
+
CREATE TABLE IF NOT EXISTS runtime_state (
|
|
345
|
+
kind TEXT NOT NULL,
|
|
346
|
+
session_id TEXT NOT NULL,
|
|
347
|
+
directory TEXT NOT NULL DEFAULT '',
|
|
348
|
+
value TEXT NOT NULL,
|
|
349
|
+
pid INTEGER NOT NULL,
|
|
350
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
351
|
+
PRIMARY KEY (kind, session_id)
|
|
352
|
+
)
|
|
353
|
+
`);
|
|
354
|
+
|
|
325
355
|
this.#migrateColumns();
|
|
326
356
|
this.#migrateChatNameCollation();
|
|
327
357
|
this.#migrateChatTopic();
|
|
@@ -330,6 +360,14 @@ export class ThatchDB {
|
|
|
330
360
|
this.#migrateChatAuto();
|
|
331
361
|
this.#migrateChatWorktree();
|
|
332
362
|
this.#migrateChatHostPid();
|
|
363
|
+
this.#migrateRuntimeStateDirectory();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
#migrateRuntimeStateDirectory(): void {
|
|
367
|
+
const cols = (this.#db.query("PRAGMA table_info(runtime_state)").all() as any[]).map((r) => r.name);
|
|
368
|
+
if (cols.length > 0 && !cols.includes("directory")) {
|
|
369
|
+
this.#db.run("ALTER TABLE runtime_state ADD COLUMN directory TEXT NOT NULL DEFAULT ''");
|
|
370
|
+
}
|
|
333
371
|
}
|
|
334
372
|
|
|
335
373
|
// chat_sessions tables created before the auto column lack it; the ALTER
|
|
@@ -961,6 +999,48 @@ export class ThatchDB {
|
|
|
961
999
|
return this.#chat.unregister(sessionID);
|
|
962
1000
|
}
|
|
963
1001
|
|
|
1002
|
+
/**
|
|
1003
|
+
* Persist one runtime-state record (upsert by kind+session). `directory`
|
|
1004
|
+
* is the writing instance's location: one v2 serve hosts one plugin
|
|
1005
|
+
* instance per directory sharing this db, and rehydration must be
|
|
1006
|
+
* instance-scoped, not process-scoped.
|
|
1007
|
+
*/
|
|
1008
|
+
runtimeStatePut(kind: string, sessionId: string, value: unknown, directory: string, pid: number = process.pid) {
|
|
1009
|
+
this.#db
|
|
1010
|
+
.query(
|
|
1011
|
+
`INSERT INTO runtime_state (kind, session_id, directory, value, pid) VALUES (?, ?, ?, ?, ?)
|
|
1012
|
+
ON CONFLICT (kind, session_id) DO UPDATE SET directory = excluded.directory, value = excluded.value, pid = excluded.pid, created_at = excluded.created_at`,
|
|
1013
|
+
)
|
|
1014
|
+
.run(kind, sessionId ?? "", directory ?? "", JSON.stringify(value), pid);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** Drop one runtime-state record. */
|
|
1018
|
+
runtimeStateDelete(kind: string, sessionId: string) {
|
|
1019
|
+
this.#db.query(`DELETE FROM runtime_state WHERE kind = ? AND session_id = ?`).run(kind, sessionId);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** Every runtime-state record, oldest first (rehydration source). */
|
|
1023
|
+
runtimeStateAll(): { kind: string; sessionID: string; directory: string; value: unknown; pid: number }[] {
|
|
1024
|
+
return this.#db
|
|
1025
|
+
.query(`SELECT kind, session_id, directory, value, pid FROM runtime_state ORDER BY created_at, session_id`)
|
|
1026
|
+
.all()
|
|
1027
|
+
.map((row: any) => {
|
|
1028
|
+
let value: unknown;
|
|
1029
|
+
try {
|
|
1030
|
+
value = JSON.parse(row.value);
|
|
1031
|
+
} catch {
|
|
1032
|
+
value = null;
|
|
1033
|
+
}
|
|
1034
|
+
return {
|
|
1035
|
+
kind: row.kind as string,
|
|
1036
|
+
sessionID: row.session_id as string,
|
|
1037
|
+
directory: (row.directory ?? "") as string,
|
|
1038
|
+
value,
|
|
1039
|
+
pid: row.pid as number,
|
|
1040
|
+
};
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
|
|
964
1044
|
listChatSessions() {
|
|
965
1045
|
return this.#chat.list();
|
|
966
1046
|
}
|
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,16 @@ 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);
|
|
132
234
|
}
|
|
133
235
|
|
|
134
236
|
/**
|
|
@@ -142,8 +244,28 @@ export class ExtractionPipeline {
|
|
|
142
244
|
const accepted = this.#accepted.get(sessionID);
|
|
143
245
|
if (!accepted || accepted.length === 0) return;
|
|
144
246
|
this.#accepted.delete(sessionID);
|
|
247
|
+
this.#acceptedAt.delete(sessionID);
|
|
145
248
|
const buf = this.#buffers.get(sessionID) ?? [];
|
|
146
249
|
this.#buffers.set(sessionID, [...accepted, ...buf]);
|
|
250
|
+
this.#journal?.("buffer", sessionID, this.#buffers.get(sessionID));
|
|
251
|
+
this.#journal?.("accepted", sessionID, undefined);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Requeues accepted entries that have waited longer than maxAgeMs for a
|
|
256
|
+
* completion signal that never came: the extractor sub-agent crashed
|
|
257
|
+
* without acking, or (v2) completed without a parent linkage the plugin
|
|
258
|
+
* could see. Without this, accepted entries linger unprocessed AND
|
|
259
|
+
* un-nudged - silent loss. Returns the session ids that had entries
|
|
260
|
+
* requeued; the regular nudge path picks them up from pending on the
|
|
261
|
+
* session's next message.
|
|
262
|
+
*/
|
|
263
|
+
requeueStaleAccepted(maxAgeMs = ACCEPTED_STALE_MS): string[] {
|
|
264
|
+
const stale = [...this.#acceptedAt.entries()]
|
|
265
|
+
.filter(([, acceptedAt]) => Date.now() - acceptedAt > maxAgeMs)
|
|
266
|
+
.map(([sessionID]) => sessionID);
|
|
267
|
+
for (const sessionID of stale) this.requeueAccepted(sessionID);
|
|
268
|
+
return stale;
|
|
147
269
|
}
|
|
148
270
|
|
|
149
271
|
/** Returns the session's accepted (held) interactions. Test/introspection. */
|
|
@@ -166,8 +288,10 @@ export class ExtractionPipeline {
|
|
|
166
288
|
const remaining = buf.filter((ix) => !old.has(ix));
|
|
167
289
|
if (remaining.length === 0) {
|
|
168
290
|
this.#buffers.delete(sessionID);
|
|
291
|
+
this.#journal?.("buffer", sessionID, undefined);
|
|
169
292
|
} else {
|
|
170
293
|
this.#buffers.set(sessionID, remaining);
|
|
294
|
+
this.#journal?.("buffer", sessionID, remaining);
|
|
171
295
|
}
|
|
172
296
|
}
|
|
173
297
|
|