@jeffober/thatch 0.1.10 → 0.1.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffober/thatch",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
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",
package/src/db.ts CHANGED
@@ -9,6 +9,7 @@ export interface MemoryRow {
9
9
  model: string | null;
10
10
  branch: string | null;
11
11
  confidence: number | null;
12
+ archived: boolean;
12
13
  created_at: string;
13
14
  updated_at: string;
14
15
  recall_count: number;
@@ -69,6 +70,7 @@ export class ThatchDB {
69
70
  model TEXT,
70
71
  branch TEXT,
71
72
  confidence INTEGER,
73
+ archived INTEGER NOT NULL DEFAULT 0,
72
74
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
73
75
  updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
74
76
  recall_count INTEGER NOT NULL DEFAULT 0,
@@ -102,6 +104,7 @@ export class ThatchDB {
102
104
  const wanted: [string, string][] = [
103
105
  ["recall_count", "INTEGER NOT NULL DEFAULT 0"],
104
106
  ["last_recalled_at", "TEXT"],
107
+ ["archived", "INTEGER NOT NULL DEFAULT 0"],
105
108
  ];
106
109
  for (const [col, decl] of wanted) {
107
110
  if (!existing.has(col)) {
@@ -170,27 +173,23 @@ export class ThatchDB {
170
173
  content: string,
171
174
  embedding: Float32Array,
172
175
  model: string,
173
- opts?: { branch?: string; confidence?: number; overwrite?: boolean },
176
+ opts?: { branch?: string; confidence?: number; overwrite?: boolean; archived?: boolean },
174
177
  ): { ok: true } | { ok: false; error: string } {
175
178
  const slug = this.slugify(label);
176
179
  this.ensureStore(store);
177
180
 
178
- // The embedding may be a view into a larger buffer (transformers.js tensor
179
- // output); serialize exactly the view's bytes, not the whole backing buffer.
180
181
  const blob = new Uint8Array(embedding.buffer, embedding.byteOffset, embedding.byteLength);
181
182
  const branch = opts?.branch ?? null;
182
183
  const confidence = opts?.confidence ?? null;
184
+ const archived = opts?.archived === true ? 1 : (opts?.archived === false ? 0 : null);
183
185
  const now = new Date().toISOString();
184
- const params = [slug, store, label, content, blob, model, branch, confidence, now, now];
185
186
 
186
187
  if (!opts?.overwrite) {
187
- // Plain INSERT so the PK constraint enforces no-clobber atomically —
188
- // a check-then-upsert would race against concurrent sessions.
189
188
  try {
190
189
  this.#db.run(
191
- `INSERT INTO entries (slug, store, label, content, embedding, model, branch, confidence, created_at, updated_at)
192
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
193
- params as any,
190
+ `INSERT INTO entries (slug, store, label, content, embedding, model, branch, confidence, archived, created_at, updated_at)
191
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
192
+ [slug, store, label, content, blob, model, branch, confidence, archived ?? 0, now, now] as any,
194
193
  );
195
194
  return { ok: true };
196
195
  } catch (err: any) {
@@ -205,10 +204,25 @@ export class ThatchDB {
205
204
  }
206
205
  }
207
206
 
207
+ const existing = this.#db
208
+ .query("SELECT archived FROM entries WHERE slug = ? AND store = ?")
209
+ .get(slug, store) as { archived: number } | null;
210
+
211
+ if (existing && existing.archived && opts?.archived === undefined) {
212
+ return {
213
+ ok: false,
214
+ error: `"${label}" is archived. Pass archived: true to keep it archived, or archived: false to unarchive it.`,
215
+ };
216
+ }
217
+
218
+ // archived column for upsert: the INSERT VALUES always needs a non-null
219
+ // value (NOT NULL constraint), so use archived ?? 0. To preserve the
220
+ // existing value when the caller doesn't specify archived, the UPDATE
221
+ // clause accepts a nullable extra param: NULL means "keep existing."
208
222
  this.#db.run(
209
223
  `
210
- INSERT INTO entries (slug, store, label, content, embedding, model, branch, confidence, created_at, updated_at)
211
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
224
+ INSERT INTO entries (slug, store, label, content, embedding, model, branch, confidence, archived, created_at, updated_at)
225
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
212
226
  ON CONFLICT(slug, store) DO UPDATE SET
213
227
  label = excluded.label,
214
228
  content = excluded.content,
@@ -216,13 +230,12 @@ export class ThatchDB {
216
230
  model = excluded.model,
217
231
  branch = COALESCE(excluded.branch, entries.branch),
218
232
  confidence = COALESCE(excluded.confidence, entries.confidence),
233
+ archived = COALESCE(?, entries.archived),
219
234
  updated_at = excluded.updated_at
220
235
  `,
221
- params as any,
236
+ [slug, store, label, content, blob, model, branch, confidence, archived ?? 0, now, now, archived] as any,
222
237
  );
223
238
 
224
- // Content changed, so prior dedup verdicts involving this entry are stale;
225
- // clear them so findDuplicates can re-flag the pair.
226
239
  this.#db.run(
227
240
  "DELETE FROM dedup_pairs WHERE store = ? AND (slug_a = ? OR slug_b = ?)",
228
241
  [store, slug, slug],
@@ -242,22 +255,25 @@ export class ThatchDB {
242
255
  search(
243
256
  stores: string[],
244
257
  queryEmbedding: Float32Array,
245
- opts?: { branch?: string; limit?: number },
258
+ opts?: { branch?: string; limit?: number; includeArchived?: boolean },
246
259
  ): (MemoryRow & { _score: number })[] {
247
260
  if (stores.length === 0) return [];
248
261
 
249
262
  const limit = opts?.limit ?? 10;
250
263
  const branch = opts?.branch;
264
+ const includeArchived = opts?.includeArchived ?? false;
251
265
 
252
266
  const placeholders = stores.map(() => "?").join(", ");
253
267
 
254
268
  interface SqlParams { sql: string; params: any[] }
255
269
 
256
270
  const { sql, params }: SqlParams = (() => {
271
+ const clauses = ["store IN (" + placeholders + ")", "embedding IS NOT NULL"];
272
+ if (!includeArchived) clauses.push("archived = 0");
257
273
  const base = `
258
- SELECT slug, store, label, content, embedding, model, branch, confidence, created_at, updated_at, recall_count, last_recalled_at
274
+ SELECT slug, store, label, content, embedding, model, branch, confidence, archived, created_at, updated_at, recall_count, last_recalled_at
259
275
  FROM entries
260
- WHERE store IN (${placeholders}) AND embedding IS NOT NULL
276
+ WHERE ${clauses.join(" AND ")}
261
277
  `;
262
278
 
263
279
  if (branch) {
@@ -278,6 +294,7 @@ export class ThatchDB {
278
294
  // rather than ranked. Dimension is the discriminator — model tags are
279
295
  // informational only.
280
296
  const scored = rows.flatMap((row) => {
297
+ row.archived = !!row.archived;
281
298
  const emb = blobToVector(row.embedding!);
282
299
  if (emb.length !== queryEmbedding.length) return [];
283
300
  return [{ ...row, embedding: row.embedding, _score: cosineSimilarity(queryEmbedding, emb) }];
@@ -295,7 +312,7 @@ export class ThatchDB {
295
312
  recall(
296
313
  stores: string[],
297
314
  queryEmbedding: Float32Array,
298
- opts?: { branch?: string; limit?: number },
315
+ opts?: { branch?: string; limit?: number; includeArchived?: boolean },
299
316
  ): (MemoryRow & { _score: number })[] {
300
317
  const top = this.search(stores, queryEmbedding, opts);
301
318
 
@@ -326,7 +343,7 @@ export class ThatchDB {
326
343
  const limit = opts?.limit ?? 3;
327
344
 
328
345
  const rows = this.#db
329
- .query("SELECT slug, label, embedding FROM entries WHERE store = ? AND embedding IS NOT NULL")
346
+ .query("SELECT slug, label, embedding FROM entries WHERE store = ? AND archived = 0 AND embedding IS NOT NULL")
330
347
  .all(store) as any[];
331
348
 
332
349
  const hits: SimilarEntry[] = [];
@@ -345,10 +362,10 @@ export class ThatchDB {
345
362
  }
346
363
 
347
364
  /** Lists all entries in a store, returning metadata without content. */
348
- listEntries(store: string): { slug: string; label: string; branch: string | null; confidence: number | null; updated_at: string }[] {
365
+ listEntries(store: string): { slug: string; label: string; branch: string | null; confidence: number | null; archived: boolean; updated_at: string }[] {
349
366
  return this.#db
350
367
  .query(
351
- "SELECT slug, label, branch, confidence, updated_at FROM entries WHERE store = ? ORDER BY label",
368
+ "SELECT slug, label, branch, confidence, archived, updated_at FROM entries WHERE store = ? ORDER BY label",
352
369
  )
353
370
  .all(store)
354
371
  .map((r: any) => ({
@@ -356,6 +373,7 @@ export class ThatchDB {
356
373
  label: r.label,
357
374
  branch: r.branch,
358
375
  confidence: r.confidence,
376
+ archived: !!r.archived,
359
377
  updated_at: r.updated_at,
360
378
  }));
361
379
  }
@@ -363,11 +381,14 @@ export class ThatchDB {
363
381
  /** Full content of a single entry by label. */
364
382
  showEntry(store: string, label: string): MemoryRow | null {
365
383
  const slug = this.slugify(label);
366
- return this.#db
384
+ const row = this.#db
367
385
  .query(
368
- "SELECT slug, store, label, content, embedding, model, branch, confidence, created_at, updated_at, recall_count, last_recalled_at FROM entries WHERE slug = ? AND store = ?",
386
+ "SELECT slug, store, label, content, embedding, model, branch, confidence, archived, created_at, updated_at, recall_count, last_recalled_at FROM entries WHERE slug = ? AND store = ?",
369
387
  )
370
- .get(slug, store) as MemoryRow | null;
388
+ .get(slug, store) as Record<string, unknown> | null;
389
+ if (!row) return null;
390
+ row.archived = !!row.archived;
391
+ return row as unknown as MemoryRow;
371
392
  }
372
393
 
373
394
  forgetEntry(store: string, label: string): boolean {
@@ -386,7 +407,7 @@ export class ThatchDB {
386
407
  findDuplicates(store: string, threshold = 0.85): DedupCandidate[] {
387
408
  const rows = this.#db
388
409
  .query(
389
- "SELECT slug, label, content, embedding FROM entries WHERE store = ? AND embedding IS NOT NULL ORDER BY slug",
410
+ "SELECT slug, label, content, embedding FROM entries WHERE store = ? AND archived = 0 AND embedding IS NOT NULL ORDER BY slug",
390
411
  )
391
412
  .all(store) as any[];
392
413
 
@@ -446,7 +467,7 @@ export class ThatchDB {
446
467
  const row = this.#db
447
468
  .query(
448
469
  `SELECT COUNT(*) AS n FROM entries
449
- WHERE store = ? AND max(updated_at, COALESCE(last_recalled_at, updated_at)) < ?`,
470
+ WHERE store = ? AND archived = 0 AND max(updated_at, COALESCE(last_recalled_at, updated_at)) < ?`,
450
471
  )
451
472
  .get(store, cutoffIso) as any;
452
473
  return row?.n ?? 0;
@@ -465,7 +486,7 @@ export class ThatchDB {
465
486
  if (branches.length === 0) return 0;
466
487
  const placeholders = branches.map(() => "?").join(", ");
467
488
  const row = this.#db
468
- .query(`SELECT COUNT(*) AS n FROM entries WHERE store = ? AND branch IN (${placeholders})`)
489
+ .query(`SELECT COUNT(*) AS n FROM entries WHERE store = ? AND archived = 0 AND branch IN (${placeholders})`)
469
490
  .get(store, ...branches) as any;
470
491
  return row?.n ?? 0;
471
492
  }
package/src/prompts.ts CHANGED
@@ -69,6 +69,25 @@ new project knowledge, conventions, architectural decisions, investigation resul
69
69
  Session-specific context, incomplete/unverified info, anything already in
70
70
  CLAUDE.md or OPENCODE.md, speculative conclusions.
71
71
 
72
+ ## Archived Memories
73
+
74
+ Memories can be marked \`archived\` — a flag for stable, long-term historical
75
+ records that should not trigger hygiene nudges (stale, orphaned, duplicate).
76
+ Archived memories are excluded from search/recall results by default; pass
77
+ \`includeArchived: true\` to surface them for archaeological dives.
78
+
79
+ When a branch is merged or about to be deleted, consolidate its branch-scoped
80
+ memories into a single archived memory scoped to that same branch (preserving
81
+ provenance). Capture intent, design decisions, review back-and-forth, PR
82
+ number, unexpected pivots — the kind of git-archaeology context that explains
83
+ _why_ the code looks the way it does a year from now. Then memory_forget the
84
+ originals. The archived record outlives the branch. Future sessions searching
85
+ with \`includeArchived: true\` can pull it up when investigating ambiguous code.
86
+
87
+ Updating an archived memory requires passing \`archived\` explicitly — omit it
88
+ and the tool returns an error. Pass \`archived: true\` to keep it archived,
89
+ \`archived: false\` to unarchive.
90
+
72
91
  ## Explicit Requests
73
92
 
74
93
  "Remember X" — save immediately.
@@ -94,7 +113,7 @@ Use thatch_store_list then thatch_memory_list to see what's available, then that
94
113
  return `${base}
95
114
 
96
115
  [thatch hygiene] ${hygiene}
97
- When convenient this session (not before addressing the user's request), tend the store: run thatch_find_duplicates and consolidate clusters, review stale memories with thatch_memory_show and update or forget them, and forget memories scoped to deleted branches.`;
116
+ When convenient this session (not before addressing the user's request), tend the store: run thatch_find_duplicates and consolidate clusters, review stale memories with thatch_memory_show and update or forget them, and for memories scoped to deleted branches, consolidate them into an archived historical record before forgetting the originals.`;
98
117
  }
99
118
 
100
119
  /**
@@ -181,6 +200,25 @@ new project knowledge, conventions, architectural decisions, investigation resul
181
200
  Session-specific context, incomplete/unverified info, anything already in
182
201
  CLAUDE.md, speculative conclusions.
183
202
 
203
+ ## Archived Memories
204
+
205
+ Memories can be marked \`archived\` — a flag for stable, long-term historical
206
+ records that should not trigger hygiene nudges (stale, orphaned, duplicate).
207
+ Archived memories are excluded from search/recall results by default; pass
208
+ \`includeArchived: true\` to surface them for archaeological dives.
209
+
210
+ When a branch is merged or about to be deleted, consolidate its branch-scoped
211
+ memories into a single archived memory scoped to that same branch (preserving
212
+ provenance). Capture intent, design decisions, review back-and-forth, PR
213
+ number, unexpected pivots — the kind of git-archaeology context that explains
214
+ _why_ the code looks the way it does a year from now. Then memory_forget the
215
+ originals. The archived record outlives the branch. Future sessions searching
216
+ with \`includeArchived: true\` can pull it up when investigating ambiguous code.
217
+
218
+ Updating an archived memory requires passing \`archived\` explicitly — omit it
219
+ and the tool returns an error. Pass \`archived: true\` to keep it archived,
220
+ \`archived: false\` to unarchive.
221
+
184
222
  ## Explicit Requests
185
223
 
186
224
  "Remember X" — save immediately.
@@ -205,7 +243,7 @@ Use store_list then memory_list to see what's available, then memory_recall as n
205
243
  return `${base}
206
244
 
207
245
  [thatch hygiene] ${hygiene}
208
- When convenient this session (not before addressing the user's request), tend the store: run find_duplicates and consolidate clusters, review stale memories with memory_show and update or forget them, and forget memories scoped to deleted branches.`;
246
+ When convenient this session (not before addressing the user's request), tend the store: run find_duplicates and consolidate clusters, review stale memories with memory_show and update or forget them, and for memories scoped to deleted branches, consolidate them into an archived historical record before forgetting the originals.`;
209
247
  }
210
248
 
211
249
  /**
@@ -309,6 +347,25 @@ new project knowledge, conventions, architectural decisions, investigation resul
309
347
  Session-specific context, incomplete/unverified info, anything already in
310
348
  AGENTS.md, speculative conclusions.
311
349
 
350
+ ## Archived Memories
351
+
352
+ Memories can be marked \`archived\` — a flag for stable, long-term historical
353
+ records that should not trigger hygiene nudges (stale, orphaned, duplicate).
354
+ Archived memories are excluded from search/recall results by default; pass
355
+ \`includeArchived: true\` to surface them for archaeological dives.
356
+
357
+ When a branch is merged or about to be deleted, consolidate its branch-scoped
358
+ memories into a single archived memory scoped to that same branch (preserving
359
+ provenance). Capture intent, design decisions, review back-and-forth, PR
360
+ number, unexpected pivots — the kind of git-archaeology context that explains
361
+ _why_ the code looks the way it does a year from now. Then memory_forget the
362
+ originals. The archived record outlives the branch. Future sessions searching
363
+ with \`includeArchived: true\` can pull it up when investigating ambiguous code.
364
+
365
+ Updating an archived memory requires passing \`archived\` explicitly — omit it
366
+ and the tool returns an error. Pass \`archived: true\` to keep it archived,
367
+ \`archived: false\` to unarchive.
368
+
312
369
  ## Explicit Requests
313
370
 
314
371
  "Remember X" — save immediately.
package/src/tool-defs.ts CHANGED
@@ -40,6 +40,7 @@ function formatEntry(
40
40
  let meta = `[${entry.store}]`;
41
41
  if (entry.branch) meta += ` branch:${entry.branch}`;
42
42
  if (entry.confidence) meta += ` confidence:${entry.confidence}`;
43
+ if (entry.archived) meta += " archived:true";
43
44
  parts.push(meta, "", entry.content);
44
45
  return parts.join("\n");
45
46
  }
@@ -48,6 +49,7 @@ function formatRecallResult(entry: any): string {
48
49
  let meta = `store:${entry.store}`;
49
50
  if (entry.branch) meta += ` branch:${entry.branch}`;
50
51
  if (entry.confidence) meta += ` confidence:${entry.confidence}`;
52
+ if (entry.archived) meta += " archived:true";
51
53
  const score = entry._score.toFixed(3);
52
54
  return `[${meta}] [score:${score}]\n${entry.content}`;
53
55
  }
@@ -118,6 +120,11 @@ const rememberDef: ToolDef = {
118
120
  overwrite: z.boolean().optional().describe(
119
121
  "Set to true to replace an existing memory with the same label.",
120
122
  ),
123
+ archived: z.boolean().optional().describe(
124
+ "Mark this memory as a stable, long-term record that should not trigger hygiene nudges. " +
125
+ "When updating an already-archived memory, this param is REQUIRED — " +
126
+ "pass archived: true to keep it archived, or archived: false to unarchive.",
127
+ ),
121
128
  },
122
129
  async execute(args, ctx) {
123
130
  const store = (args.store as string) || ctx.defaultStore;
@@ -125,9 +132,6 @@ const rememberDef: ToolDef = {
125
132
  const content = `# ${label}\n\n${args.content as string}`;
126
133
  const embedding = await ctx.model.passageEmbed(content);
127
134
 
128
- // Write-time collision check: the embedding is already computed, so
129
- // scanning the store is nearly free. The save proceeds regardless —
130
- // the agent decides whether and how to reconcile.
131
135
  const similar = ctx.db.findSimilar(store, embedding, {
132
136
  excludeSlug: ctx.db.slugify(label),
133
137
  });
@@ -136,6 +140,7 @@ const rememberDef: ToolDef = {
136
140
  branch: args.branch as string | undefined,
137
141
  confidence: args.confidence as number | undefined,
138
142
  overwrite: args.overwrite as boolean | undefined,
143
+ archived: args.archived as boolean | undefined,
139
144
  });
140
145
 
141
146
  if (!result.ok) return result.error;
@@ -172,6 +177,9 @@ const recallDef: ToolDef = {
172
177
  limit: z.number().int().min(1).max(20).optional().describe(
173
178
  "Maximum number of results. Default 10.",
174
179
  ),
180
+ includeArchived: z.boolean().optional().describe(
181
+ "Include archived memories in results. Default false — archived memories are excluded.",
182
+ ),
175
183
  },
176
184
  async execute(args, ctx) {
177
185
  const stores = args.store
@@ -183,6 +191,7 @@ const recallDef: ToolDef = {
183
191
  const results = ctx.db.recall(stores, queryEmbedding, {
184
192
  branch: args.branch as string | undefined,
185
193
  limit,
194
+ includeArchived: args.includeArchived as boolean | undefined,
186
195
  });
187
196
 
188
197
  if (results.length === 0) return "No matching memories found.";
@@ -212,6 +221,7 @@ const listDef: ToolDef = {
212
221
  let line = `[${store}] ${e.label}`;
213
222
  if (e.branch) line += ` (branch:${e.branch})`;
214
223
  if (e.confidence) line += ` (c:${e.confidence})`;
224
+ if (e.archived) line += " (archived)";
215
225
  line += ` (${e.updated_at})`;
216
226
  return line;
217
227
  })