@jeffober/thatch 0.1.17 → 0.1.18

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 CHANGED
@@ -7,8 +7,8 @@
7
7
  Persistent memory and useful dev skills for AI coding agents that requires zero
8
8
  manual config.
9
9
 
10
- Works with **OpenCode** (as a plugin), **Claude Code** (as an MCP server), and
11
- **Cursor** (as a local `stdio` MCP server).
10
+ Works with **OpenCode** (as a plugin), **Claude Code** (as a local `stdio` MCP
11
+ server), and **Cursor** (as a local `stdio` MCP server).
12
12
 
13
13
  Each session inherits the accumulated knowledge of every session before it —
14
14
  project architecture, conventions, gotchas, user preferences — so your agent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffober/thatch",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Persistent memory for AI coding agents — local embeddings, SQLite stores, zero config. Works with OpenCode (as a plugin), Claude Code (as an MCP server), and Cursor (as an MCP server).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -99,13 +99,6 @@ export function consumeQueue(sessionID: string): void {
99
99
  try { unlinkSync(queuePath(sessionID)); } catch { /* not present is fine */ }
100
100
  }
101
101
 
102
- /** Read and delete a session's queue. Returns the buffered interactions in order. */
103
- export function flushQueue(sessionID: string): ToolInteraction[] {
104
- const interactions = peekQueue(sessionID);
105
- consumeQueue(sessionID);
106
- return interactions;
107
- }
108
-
109
102
  function readQueueFile(path: string): ToolInteraction[] {
110
103
  if (!existsSync(path)) return [];
111
104
  const text = readFileSync(path, "utf8").trim();
package/src/extraction.ts CHANGED
@@ -110,11 +110,24 @@ export class ExtractionPipeline {
110
110
  this.#buffers.delete(sessionID);
111
111
  }
112
112
 
113
- /** Returns the session's buffered interactions and clears them. */
114
- flush(sessionID: string): ToolInteraction[] {
115
- const batch = this.peek(sessionID);
116
- this.consume(sessionID);
117
- return batch;
113
+ /**
114
+ * Remove entries that were in the snapshot (by reference identity), keeping
115
+ * any that arrived after the snapshot was taken. Used by the child-parent
116
+ * drain: when a sub-agent writes a memory, only the entries present at
117
+ * dispatch time should be drained — not the entire buffer, which may now
118
+ * contain interleaved-turn entries the parent accumulated while the
119
+ * sub-agent was running.
120
+ */
121
+ consumeSnapshot(sessionID: string, snapshot: ToolInteraction[]): void {
122
+ const buf = this.#buffers.get(sessionID);
123
+ if (!buf) return;
124
+ const old = new Set(snapshot);
125
+ const remaining = buf.filter((ix) => !old.has(ix));
126
+ if (remaining.length === 0) {
127
+ this.#buffers.delete(sessionID);
128
+ } else {
129
+ this.#buffers.set(sessionID, remaining);
130
+ }
118
131
  }
119
132
 
120
133
  pending(sessionID: string): boolean {
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  extractionNudge,
13
13
  type NudgeMatch,
14
14
  } from "./prompts";
15
- import { ExtractionPipeline } from "./extraction";
15
+ import { ExtractionPipeline, type ToolInteraction } from "./extraction";
16
16
  import { installSkills, SHARED_SKILLS, OPENCODE_ONLY_SKILLS } from "./skills";
17
17
  import { hygieneReport } from "./hygiene";
18
18
 
@@ -64,6 +64,14 @@ export const server: Plugin = async ({ client, worktree }) => {
64
64
  // Populated from session.created events that carry a parentID.
65
65
  const childToParent = new Map<string, string>();
66
66
 
67
+ // Snapshot of the parent's buffer at the moment each child was dispatched.
68
+ // When the child writes a memory, consumeSnapshot drains only these entries
69
+ // (by reference identity), preserving interleaved-turn entries that arrived
70
+ // while the sub-agent was running. Without this, the child's memory_remember
71
+ // would drain the parent's ENTIRE buffer — silently dropping facts from
72
+ // any tool calls the parent made concurrenly with the sub-agent.
73
+ const parentSnapshots = new Map<string, ToolInteraction[]>();
74
+
67
75
  // Skills always install to the global opencode config — installing into the
68
76
  // worktree would mutate the user's repo (untracked files in git status).
69
77
  // A failed install degrades the nudge workflow but must not kill the plugin.
@@ -113,9 +121,11 @@ export const server: Plugin = async ({ client, worktree }) => {
113
121
  // grows with each missed cycle.
114
122
  //
115
123
  // A memory_remember call in a child session (sub-agent) also drains
116
- // the parent's buffer. Without this, dispatching the fact-extractor as
117
- // a background task writes memories in the child but never clears the
118
- // parent's queue — the nudge replays every turn.
124
+ // the parent's buffer — but only the entries that existed at dispatch
125
+ // time (the snapshot). Entries from interleaved turns survive so their
126
+ // facts aren't silently dropped. Without this, dispatching the
127
+ // fact-extractor as a background task writes memories in the child but
128
+ // never clears the parent's queue — the nudge replays every turn.
119
129
  //
120
130
  // thatch_extraction_done is an explicit acknowledgment tool the model
121
131
  // calls in the parent session after dispatching. Covers the case where
@@ -125,12 +135,19 @@ export const server: Plugin = async ({ client, worktree }) => {
125
135
  if (input.tool === "thatch_memory_remember") {
126
136
  extraction.consume(input.sessionID);
127
137
  missedNudges.delete(input.sessionID);
128
- // If this is a child session, also drain the parent so the parent's
129
- // extraction nudge stops replaying after a sub-agent writes memories
130
- // on its behalf.
138
+ // If this is a child session, drain only the parent's snapshot entries
139
+ // (those present at dispatch time). Interleaved-turn entries survive.
140
+ // If no snapshot was recorded (e.g. session.created event never
141
+ // arrived), fall back to draining the entire parent buffer.
131
142
  const parentID = childToParent.get(input.sessionID);
132
143
  if (parentID) {
133
- extraction.consume(parentID);
144
+ const snapshot = parentSnapshots.get(input.sessionID);
145
+ if (snapshot) {
146
+ extraction.consumeSnapshot(parentID, snapshot);
147
+ parentSnapshots.delete(input.sessionID);
148
+ } else {
149
+ extraction.consume(parentID);
150
+ }
134
151
  missedNudges.delete(parentID);
135
152
  }
136
153
  return;
@@ -218,18 +235,23 @@ export const server: Plugin = async ({ client, worktree }) => {
218
235
  // 5. Session-start reminder, carrying the hygiene heartbeat. Hygiene is
219
236
  // best-effort: a failure there must not cost the reminder itself.
220
237
  //
221
- // Also tracks parent-child session relationships for fix A (cross-session
222
- // buffer drain). session.created with a parentID records the mapping;
223
- // session.deleted cleans it up to avoid unbounded growth.
238
+ // Also tracks parent-child session relationships for cross-session buffer
239
+ // drain. session.created with a parentID records the mapping AND
240
+ // snapshots the parent's current buffer — so the child's later
241
+ // memory_remember drains only those snapshot entries, not the parent's
242
+ // entire buffer (which may have grown from interleaved turns).
243
+ // session.deleted cleans both maps to avoid unbounded growth.
224
244
  event: async ({ event }) => {
225
245
  if (event.type === "session.created") {
226
246
  const info = event.properties.info;
227
247
  if (info.parentID) {
228
248
  childToParent.set(info.id, info.parentID);
249
+ parentSnapshots.set(info.id, [...extraction.peek(info.parentID)]);
229
250
  }
230
251
  }
231
252
  if (event.type === "session.deleted") {
232
253
  childToParent.delete(event.properties.info.id);
254
+ parentSnapshots.delete(event.properties.info.id);
233
255
  return;
234
256
  }
235
257
 
package/src/mcp.ts CHANGED
@@ -5,6 +5,7 @@ import { detectRepo } from "./git";
5
5
  import { checkSetup } from "./setup";
6
6
  import { TOOL_DEFS, type CoreContext, type ToolDef } from "./tool-defs";
7
7
  import { SidebandServer, sidebandSocketPath } from "./sideband";
8
+ import pkg from "../package.json";
8
9
 
9
10
  // ---------------------------------------------------------------------------
10
11
  // JSON-RPC 2.0 types
@@ -34,7 +35,7 @@ const INTERNAL_ERROR = -32603;
34
35
  // ---------------------------------------------------------------------------
35
36
 
36
37
  const SERVER_NAME = "thatch";
37
- const SERVER_VERSION = "0.1.16";
38
+ const SERVER_VERSION = pkg.version;
38
39
 
39
40
  /**
40
41
  * MCP capabilities declared in the initialize response. Thatch is a tools-only