@jeffober/thatch 0.1.12 → 0.1.13

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.
@@ -1,7 +1,7 @@
1
1
  # Skills
2
2
 
3
3
  Thatch installs SKILL.md files that give the agent task-specific workflows. All
4
- skill content is defined in `src/skills.ts` and installed to a host-specific
4
+ skill content lives in `artifacts/skills/*.md` and is loaded by `src/skills.ts` and installed to a host-specific
5
5
  directory. See `mcp-parity.md` for which skills each host gets.
6
6
 
7
7
  ## Skill file format
@@ -102,13 +102,10 @@ and `--cursor` pass only the shared set.
102
102
 
103
103
  ## Adding a skill
104
104
 
105
- 1. Write the `SKILL.md` content as a template literal in `src/skills.ts`.
106
- Interpolate `${REVIEW_COMMON}` if it's a review specialist.
107
- 2. Add a `{ name, content }` entry to `SHARED_SKILLS` (or `OPENCODE_ONLY_SKILLS`
108
- if it needs sub-agents).
109
- 3. If the skill name appears in a tool's expected workflow (e.g. the extraction
110
- nudge references `thatch-fact-extractor`), update that prompt in `src/prompts.ts`.
111
- 4. The opencode plugin and `thatch setup` pick it up automatically on next init.
105
+ 1. Write `artifacts/skills/thatch-<name>.md` with YAML frontmatter. If it's a review specialist, include `${REVIEW_COMMON}` (interpolated from `artifacts/skills/common.md`).
106
+ 2. Add the skill name to the `names` array in `loadSharedSkills()` (or `loadOpencodeOnlySkills()` if sub-agents are needed). The loader in `src/skills.ts` reads `.md` files at init.
107
+ 3. If the skill name appears in a tool's workflow (e.g. `thatch-fact-extractor`), update `src/prompts.ts`.
108
+ 4. Run `mise run check` — tests verify counts; `installSkills` picks up new files on next init.
112
109
 
113
110
  ## Memory review skills in practice
114
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffober/thatch",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
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",
@@ -15,7 +15,8 @@
15
15
  "files": [
16
16
  "src",
17
17
  "docs",
18
- "bin"
18
+ "bin",
19
+ "artifacts"
19
20
  ],
20
21
  "scripts": {
21
22
  "test": "bun test",
@@ -37,11 +37,16 @@ function queuePath(sessionID: string): string {
37
37
  }
38
38
 
39
39
  /**
40
- * Buffer a batch of tool calls for later extraction. Filters out thatch's own
41
- * MCP tools (mcp__thatch__*) to avoid self-echo — extracting facts from memory
42
- * operations would just write the store back into the store. Each surviving
43
- * call is mapped to a ToolInteraction (title synthesized since Claude Code
44
- * doesn't supply one) and appended as one JSON line.
40
+ * Buffer a batch of tool calls for later extraction. Filters out:
41
+ * - thatch's own MCP tools (mcp__thatch__*) to avoid self-echo — extracting
42
+ * facts from memory operations would just write the store back into itself.
43
+ * - skill/task/agent meta-tools — these orchestrate agent behavior (loading
44
+ * skills, dispatching sub-agents) and buffering them creates a feedback
45
+ * loop where the extraction nudge triggers a skill load, which gets
46
+ * buffered, which triggers another nudge on the next turn.
47
+ * Memory writes reset the missed-nudge escalation counter.
48
+ * Each surviving call is mapped to a ToolInteraction (title synthesized since
49
+ * Claude Code doesn't supply one) and appended as one JSON line.
45
50
  */
46
51
  export function appendBatch(sessionID: string, toolCalls: BatchToolCall[]): void {
47
52
  const dir = queueDir();
@@ -50,7 +55,14 @@ export function appendBatch(sessionID: string, toolCalls: BatchToolCall[]): void
50
55
  const existing = readQueueFile(path);
51
56
  const additions: ToolInteraction[] = [];
52
57
  for (const tc of toolCalls) {
53
- if (tc.tool_name.startsWith("mcp__thatch__")) continue;
58
+ const lower = tc.tool_name.toLowerCase();
59
+ if (lower === "mcp__thatch__memory_remember") {
60
+ resetMissedCount(sessionID);
61
+ consumeQueue(sessionID);
62
+ existing.length = 0;
63
+ }
64
+ if (lower.startsWith("mcp__thatch__")) continue;
65
+ if (lower === "skill" || lower === "task" || lower === "agent") continue;
54
66
  const response = typeof tc.tool_response === "string"
55
67
  ? tc.tool_response
56
68
  : JSON.stringify(tc.tool_response).slice(0, 500);
@@ -70,8 +82,8 @@ export function appendBatch(sessionID: string, toolCalls: BatchToolCall[]): void
70
82
  writeFileSync(path, all.map((ix) => JSON.stringify(ix)).join("\n") + "\n");
71
83
  }
72
84
 
73
- /** Read and delete a session's queue. Returns the buffered interactions in order. */
74
- export function flushQueue(sessionID: string): ToolInteraction[] {
85
+ /** Read a session's queued interactions without removing them. */
86
+ export function peekQueue(sessionID: string): ToolInteraction[] {
75
87
  let path: string;
76
88
  try {
77
89
  path = queuePath(sessionID);
@@ -79,8 +91,18 @@ export function flushQueue(sessionID: string): ToolInteraction[] {
79
91
  return [];
80
92
  }
81
93
  if (!existsSync(path)) return [];
82
- const interactions = readQueueFile(path);
83
- unlinkSync(path);
94
+ return readQueueFile(path);
95
+ }
96
+
97
+ /** Delete a session's queue file. Called when the agent writes a memory. */
98
+ export function consumeQueue(sessionID: string): void {
99
+ try { unlinkSync(queuePath(sessionID)); } catch { /* not present is fine */ }
100
+ }
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);
84
106
  return interactions;
85
107
  }
86
108
 
@@ -98,4 +120,33 @@ function readQueueFile(path: string): ToolInteraction[] {
98
120
  }
99
121
  })
100
122
  .filter((ix): ix is ToolInteraction => ix !== null);
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Missed-nudge counter — file-backed per-session escalation state. Tracks
127
+ // consecutive extraction nudges delivered without any memory_remember call.
128
+ // Reset by appendBatch when the agent writes a memory. Read and incremented
129
+ // by flush-tools when the nudge is delivered.
130
+ // ---------------------------------------------------------------------------
131
+
132
+ function countPath(sessionID: string): string {
133
+ return join(queueDir(), `${safeName(sessionID)}.count`);
134
+ }
135
+
136
+ export function resetMissedCount(sessionID: string): void {
137
+ try { unlinkSync(countPath(sessionID)); } catch { /* not present is fine */ }
138
+ }
139
+
140
+ export function getMissedCount(sessionID: string): number {
141
+ try {
142
+ return parseInt(readFileSync(countPath(sessionID), "utf8"), 10) || 0;
143
+ } catch {
144
+ return 0;
145
+ }
146
+ }
147
+
148
+ export function incrementMissedCount(sessionID: string): void {
149
+ const next = getMissedCount(sessionID) + 1;
150
+ mkdirSync(queueDir(), { recursive: true });
151
+ writeFileSync(countPath(sessionID), String(next));
101
152
  }
package/src/extraction.ts CHANGED
@@ -100,10 +100,20 @@ export class ExtractionPipeline {
100
100
  this.#buffers.set(interaction.sessionID, buf.slice(-this.#maxBuffer));
101
101
  }
102
102
 
103
+ /** Returns the session's buffered interactions without clearing them. */
104
+ peek(sessionID: string): ToolInteraction[] {
105
+ return this.#buffers.get(sessionID) ?? [];
106
+ }
107
+
108
+ /** Clears the session's buffer. Called when the agent writes a memory. */
109
+ consume(sessionID: string): void {
110
+ this.#buffers.delete(sessionID);
111
+ }
112
+
103
113
  /** Returns the session's buffered interactions and clears them. */
104
114
  flush(sessionID: string): ToolInteraction[] {
105
- const batch = this.#buffers.get(sessionID) ?? [];
106
- this.#buffers.delete(sessionID);
115
+ const batch = this.peek(sessionID);
116
+ this.consume(sessionID);
107
117
  return batch;
108
118
  }
109
119
 
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  compactionContext,
10
10
  sessionStartReminder,
11
11
  recallNudge,
12
+ extractionNudge,
12
13
  type NudgeMatch,
13
14
  } from "./prompts";
14
15
  import { ExtractionPipeline } from "./extraction";
@@ -49,6 +50,12 @@ export const server: Plugin = async ({ client, worktree }) => {
49
50
  // session, but no crash).
50
51
  const compacting = new Set<string>();
51
52
 
53
+ // Per-session count of consecutive extraction nudges delivered without any
54
+ // memory_remember call in between. Drives nudge escalation: the agent gets
55
+ // a couple of polite chances, then the tone shifts to directive, then to
56
+ // all-caps shouting. Reset to 0 whenever the agent writes a memory.
57
+ const missedNudges = new Map<string, number>();
58
+
52
59
  // Skills always install to the global opencode config — installing into the
53
60
  // worktree would mutate the user's repo (untracked files in git status).
54
61
  // A failed install degrades the nudge workflow but must not kill the plugin.
@@ -86,11 +93,23 @@ export const server: Plugin = async ({ client, worktree }) => {
86
93
  compacting.delete(input.sessionID);
87
94
  },
88
95
 
89
- // 3. Tool buffering — feeds the extraction nudge. Thatch's own tools are
90
- // excluded: extracting facts from memory operations would just echo the
91
- // store back into itself.
96
+ // 3. Tool buffering — feeds the extraction nudge. Excluded tools:
97
+ // - thatch_*: extracting facts from memory ops would echo the store
98
+ // - skill/task: meta-tools that orchestrate agent behavior (loading
99
+ // skills, dispatching sub-agents). Buffering them creates a feedback
100
+ // loop — the nudge triggers a skill load, which gets buffered, which
101
+ // triggers another nudge on the next turn.
102
+ // Memory writes consume the buffer and reset the missed-nudge counter.
103
+ // The buffer is NOT drained on nudge delivery — it persists until the
104
+ // agent writes a memory, so ignored nudges accumulate and the payload
105
+ // grows with each missed cycle.
92
106
  "tool.execute.after": async (input, output) => {
93
- if (input.tool.startsWith("thatch_")) return;
107
+ if (input.tool === "thatch_memory_remember") {
108
+ extraction.consume(input.sessionID);
109
+ missedNudges.delete(input.sessionID);
110
+ return;
111
+ }
112
+ if (input.tool.startsWith("thatch_") || input.tool === "skill" || input.tool === "task") return;
94
113
  extraction.push({
95
114
  tool: input.tool,
96
115
  sessionID: input.sessionID,
@@ -113,12 +132,11 @@ export const server: Plugin = async ({ client, worktree }) => {
113
132
  "chat.message": async (input, output) => {
114
133
  if (compacting.has(input.sessionID)) return;
115
134
  if (extraction.pending(input.sessionID)) {
116
- const batch = extraction.flush(input.sessionID);
135
+ const batch = extraction.peek(input.sessionID);
117
136
  const payload = extraction.buildPayload(batch, repo);
118
- const text =
119
- `[thatch] ${batch.length} recent tool interactions are queued for fact extraction. ` +
120
- `Use the skill tool to load thatch-fact-extractor, then use thatch_memory_remember ` +
121
- `to save any new durable facts from this payload:\n${payload}`;
137
+ const missed = missedNudges.get(input.sessionID) ?? 0;
138
+ const text = extractionNudge(batch.length, missed, "thatch_memory_remember", payload);
139
+ missedNudges.set(input.sessionID, missed + 1);
122
140
 
123
141
  output.parts.push({
124
142
  id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
package/src/prompts.ts CHANGED
@@ -255,16 +255,45 @@ export function claudeWriteNudge(): string {
255
255
  }
256
256
 
257
257
  /**
258
- * The UserPromptSubmit hook stdout when `thatch flush-tools` drained buffered
259
- * tool interactions. Mirrors the opencode plugin's in-process ExtractionPipeline
260
- * nudge (see src/index.ts) so the thatch-fact-extractor skill receives the same
261
- * JSON contract regardless of host. The skill picks the store from the
262
- * payload's `projectStore` field and calls `mcp__thatch__memory_remember`.
258
+ * Extraction nudge with escalation. Both the opencode plugin (src/index.ts)
259
+ * and the Claude Code/Cursor CLI (bin/thatch flush-tools) call this with
260
+ * their respective tool name prefix. The missedCount parameter tracks
261
+ * consecutive nudges delivered without any memory_remember call in between,
262
+ * escalating the tone from polite to insistent to shouting.
263
+ */
264
+ export function extractionNudge(
265
+ count: number,
266
+ missedCount: number,
267
+ toolName: string,
268
+ payload: string,
269
+ ): string {
270
+ const plural = count === 1 ? "" : "s";
271
+ if (missedCount >= 3) {
272
+ return `[thatch] YOU ARE IGNORING EXTRACTION INSTRUCTIONS. ` +
273
+ `${count} TOOL INTERACTION${plural.toUpperCase()} ARE QUEUED AND YOU HAVE NOT PROCESSED ANY OF THEM. ` +
274
+ `THE USER INSTALLED THIS PLUGIN FOR A REASON. ` +
275
+ `SPAWN A SUB-AGENT NOW TO RUN THE thatch-fact-extractor SKILL ` +
276
+ `AND USE ${toolName.toUpperCase()} TO SAVE DURABLE FACTS FROM THIS PAYLOAD:\n${payload}`;
277
+ }
278
+ if (missedCount >= 2) {
279
+ return `[thatch] YOU HAVE NOT PROCESSED YOUR PREVIOUS EXTRACTION PAYLOAD${plural.toUpperCase()}. ` +
280
+ `${count} more tool interaction${plural} are now queued. ` +
281
+ `Spawn a background sub-agent to run the thatch-fact-extractor skill on this payload. ` +
282
+ `Use ${toolName} to save durable facts from this payload:\n${payload}`;
283
+ }
284
+ return `[thatch] Spawn a background sub-agent to run the thatch-fact-extractor skill ` +
285
+ `on ${count} queued tool interaction${plural}. ` +
286
+ `Then answer the user. ` +
287
+ `Use ${toolName} to save durable facts from this payload:\n${payload}`;
288
+ }
289
+
290
+ /**
291
+ * Backwards-compatible wrapper for the Claude Code/Cursor CLI path.
292
+ * Maintained for any external callers; bin/thatch should use extractionNudge
293
+ * directly with a missedCount from extract-queue.ts.
263
294
  */
264
295
  export function claudeExtractionNudge(count: number, payload: string): string {
265
- return `[thatch] ${count} recent tool interaction${count === 1 ? "" : "s"} queued for fact extraction. ` +
266
- `Load the thatch-fact-extractor skill, then use mcp__thatch__memory_remember ` +
267
- `to save any new durable facts from this payload:\n${payload}`;
296
+ return extractionNudge(count, 0, "mcp__thatch__memory_remember", payload);
268
297
  }
269
298
 
270
299
  /**