@jeffober/thatch 0.2.0 → 0.2.2

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.
Files changed (37) hide show
  1. package/README.md +4 -2
  2. package/artifacts/skills/thatch-fact-extractor.md +1 -1
  3. package/artifacts/skills/thatch-memory-verify.md +2 -2
  4. package/artifacts/skills/thatch-plan-refinement.md +3 -2
  5. package/artifacts/skills/thatch-refine-infra.md +66 -0
  6. package/artifacts/skills/thatch-refine-oss-contribution.md +68 -0
  7. package/artifacts/skills/thatch-refine-personal.md +61 -0
  8. package/artifacts/skills/thatch-refine-shared-lib.md +69 -0
  9. package/artifacts/skills/thatch-refine-spike.md +53 -0
  10. package/artifacts/skills/thatch-refine-team-app.md +69 -0
  11. package/artifacts/skills/thatch-refine.md +84 -0
  12. package/docs/dev/README.md +11 -7
  13. package/docs/dev/features/cross-session-chat.md +8 -1
  14. package/docs/dev/features/memory-store.md +1 -1
  15. package/docs/dev/features/nudge-pipeline.md +12 -0
  16. package/docs/dev/features/opencode-plugin.md +1 -1
  17. package/docs/dev/features/session-lifecycle.md +1 -1
  18. package/docs/dev/gotchas.md +71 -1
  19. package/docs/dev/mcp-parity.md +1 -1
  20. package/docs/dev/setup-and-hooks.md +1 -1
  21. package/docs/dev/skills.md +8 -1
  22. package/docs/user/README.md +8 -1
  23. package/docs/user/commands.md +8 -0
  24. package/docs/user/memory.md +1 -1
  25. package/docs/user/skills.md +12 -0
  26. package/package.json +1 -1
  27. package/src/chat.ts +17 -0
  28. package/src/commands.ts +23 -3
  29. package/src/db.ts +77 -12
  30. package/src/extraction.ts +89 -3
  31. package/src/index.ts +45 -0
  32. package/src/opencode/v2.ts +48 -4
  33. package/src/prompts.ts +118 -30
  34. package/src/runtime.ts +271 -72
  35. package/src/skills.ts +7 -0
  36. package/src/tool-defs.ts +156 -28
  37. package/src/watchers.ts +14 -0
package/src/tool-defs.ts CHANGED
@@ -22,9 +22,9 @@ import {
22
22
  type NotificationPrefs,
23
23
  } from "./config";
24
24
  import { sendNotification, defaultSpawner, type NotifyChannel, type Spawner } from "./notify";
25
- import { predictionVerb, chatInboxFrame } from "./prompts";
25
+ import { predictionVerb, formatWhenLine, chatInboxFrame } from "./prompts";
26
26
  import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToFullJson, messageToFullJson } from "./session-db";
27
- import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, commandTargetLabel, type WatcherRegistry, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
27
+ import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, commandTargetLabel, type WatcherRegistry, type Watcher, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
28
28
  import { CHAT_STALE_MS, chatLiveness, humanAge, renderChatParticipant, sortChatRoster, splitChatRoster, type ChatHostKind } from "./chat";
29
29
  import { detectWorktreeKind } from "./git";
30
30
 
@@ -100,12 +100,16 @@ export interface CoreContext {
100
100
  db: ThatchDB;
101
101
  model: EmbeddingModel;
102
102
  defaultStore: string;
103
- /** Peeks the extraction buffer for a session and returns the serialized
104
- * JSON payload (same shape as buildExtractionPayload). Returns null when
105
- * no interactions are queued. Used by the get_extraction_payload tool so
106
- * the sub-agent fetches the payload as a tool response instead of
107
- * receiving it inline in the nudge text. */
108
- extractionPayloadProvider?: (sessionID: string) => string | null;
103
+ /** Fetches a session's extraction payload for a fetcher. Returns the
104
+ * serialized JSON payload (same shape as buildExtractionPayload), or
105
+ * null when no interactions are queued. With a fetcherID, the fetch is
106
+ * a CLAIM: the fetched entries stay accepted and that fetcher's
107
+ * completion signal consumes only this delivery (sibling sub-agents
108
+ * going idle cannot drop it). Without a fetcherID, the fetch is a pure
109
+ * peek. Used by the get_extraction_payload tool so the sub-agent
110
+ * fetches the payload as a tool response instead of receiving it
111
+ * inline in the nudge text. */
112
+ extractionPayloadProvider?: (sessionID: string, fetcherID?: string) => string | null;
109
113
  /** Drains a session's extraction queue. On the MCP path this deletes the
110
114
  * file-backed queue and resets the missed-nudge counter. On the opencode
111
115
  * path this is unused (the tool.execute.after hook handles drain via
@@ -165,6 +169,73 @@ export interface ToolDef {
165
169
  // Formatting helpers - shared by all tools that render entries
166
170
  // ---------------------------------------------------------------------------
167
171
 
172
+ /**
173
+ * Human-readable age of a memory, rendered at output time so the reading
174
+ * model can weigh staleness semantically (an ISO timestamp does not get
175
+ * weighed; "47 days ago" does). Keyed on updated_at - the last CONTENT
176
+ * change - and deliberately NOT on last_recalled_at: hygiene's staleness
177
+ * sweep treats a recall as refreshing the clock, but for content accuracy
178
+ * the opposite holds. A memory recalled yesterday but written eight months
179
+ * ago is exactly the case where the world may have moved on since the
180
+ * claim was recorded. Freshness-of-use is not freshness-of-claim.
181
+ *
182
+ * Bucket boundaries are a formatting choice, independent of hygiene's
183
+ * 90-day staleness window (db.ts STALE_DAYS) - the two 90s are a
184
+ * coincidence, do not couple them. Compact sibling: humanAge() in
185
+ * chat.ts renders roster-style "5m ago" for chat surfaces; keep the two
186
+ * apart when changing either.
187
+ */
188
+ export function formatAge(iso: string, now: Date = new Date()): string {
189
+ const then = new Date(iso).getTime();
190
+ if (!Number.isFinite(then)) return "unknown age";
191
+ const seconds = Math.max(0, Math.floor((now.getTime() - then) / 1000));
192
+ if (seconds < 60) return "just now";
193
+ const minutes = Math.floor(seconds / 60);
194
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
195
+ const hours = Math.floor(minutes / 60);
196
+ if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
197
+ const days = Math.floor(hours / 24);
198
+ if (days < 90) return `${days} day${days === 1 ? "" : "s"} ago`;
199
+ if (days < 365) return `${Math.floor(days / 30)} month${Math.floor(days / 30) === 1 ? "" : "s"} ago`;
200
+ const years = Math.floor(days / 365);
201
+ return `${years} year${years === 1 ? "" : "s"} ago`;
202
+ }
203
+
204
+ /**
205
+ * The age sentence inserted between the meta line and a memory's content,
206
+ * so the model weighs staleness BEFORE reading the claim it qualifies.
207
+ */
208
+ function ageLine(iso: string): string {
209
+ return `This memory was last updated ${formatAge(iso)}.`;
210
+ }
211
+
212
+ /**
213
+ * Caveat attached to memory_recall and memory_show tool output at render
214
+ * time - the persisted rows are untouched. The sibling of chatInboxFrame's
215
+ * untrusted-content framing in prompts.ts: a content-classification line
216
+ * that rides with the claims it qualifies, at the moment they enter the
217
+ * model's context, instead of a general rule it has to recall from the
218
+ * system prompt (or a nudge it learns to tune out). memory_list is
219
+ * excluded: a labels-only listing carries no artifact claims to qualify.
220
+ *
221
+ * Tool names use the BARE form (memory_remember, memory_forget) on
222
+ * purpose: tool-defs.ts is host-shared and has no host context. On MCP
223
+ * hosts the bare-name convention is established by the instructions in
224
+ * prompts.ts ("Bare names used below for readability"); on opencode the
225
+ * system prompt uses thatch_ spellings, but the model sees its own tool
226
+ * list and maps the bare name without a stated convention. If either
227
+ * convention changes, update this string with it.
228
+ */
229
+ const memoryCaveat =
230
+ `[thatch] Memories are a point-in-time record - a guide to where to look, ` +
231
+ `not a source of truth. Verify artifact claims (code, docs, tickets, git) ` +
232
+ `against current sources before relying on them; if a memory has drifted, ` +
233
+ `correct it with memory_remember (overwrite: true) or memory_forget.`;
234
+
235
+ function withMemoryCaveat(body: string): string {
236
+ return `${memoryCaveat}\n\n${body}`;
237
+ }
238
+
168
239
  function formatEntry(
169
240
  entry: Awaited<ReturnType<ThatchDB["showEntry"]>>,
170
241
  ): string | null {
@@ -175,8 +246,8 @@ function formatEntry(
175
246
  if (entry.branch) meta += ` branch:${entry.branch}`;
176
247
  if (entry.confidence) meta += ` confidence:${entry.confidence}`;
177
248
  if (entry.archived) meta += " archived:true";
178
- meta += ` created:${entry.created_at} updated:${entry.updated_at}`;
179
- parts.push(meta, "", entry.content);
249
+ meta += ` created:${entry.created_at}`;
250
+ parts.push(meta, "", ageLine(entry.updated_at), entry.content);
180
251
  return parts.join("\n");
181
252
  }
182
253
 
@@ -185,9 +256,8 @@ function formatRecallResult(entry: MemoryRow & { _score: number }): string {
185
256
  if (entry.branch) meta += ` branch:${entry.branch}`;
186
257
  if (entry.confidence) meta += ` confidence:${entry.confidence}`;
187
258
  if (entry.archived) meta += " archived:true";
188
- meta += ` updated:${entry.updated_at}`;
189
259
  const score = entry._score.toFixed(3);
190
- return `[${meta}] [score:${score}]\n${entry.content}`;
260
+ return `[${meta}] [score:${score}]\n${ageLine(entry.updated_at)}\n${entry.content}`;
191
261
  }
192
262
 
193
263
  /**
@@ -195,8 +265,26 @@ function formatRecallResult(entry: MemoryRow & { _score: number }): string {
195
265
  * so a topic fragmented across N entries reads as one cluster instead of
196
266
  * O(N²) pairs. Verdicts stay pairwise (markChecked) - this is presentation only.
197
267
  */
198
- function renderClusters(candidates: DedupCandidate[]): string {
199
- const parent = new Map<string, string>();
268
+ /**
269
+ * Self-heal the watcher registry from the journal before a watch tool reads
270
+ * it. The runtime (and its registry) is rebuilt on session directory changes
271
+ * and plugin reloads, leaving journaled watchers absent from the live set
272
+ * while the old instance's poller keeps delivering: watch_list reported "No
273
+ * active watchers" and watch_cancel "No watcher in this session" for
274
+ * watchers that were alive and firing (observed + reproduced September
275
+ * 2026). Hydrating the CURRENT process's journal row for the session makes
276
+ * every watch-tool call converge. Cross-process (dormant) rows stay owned
277
+ * by the runtime's scanDormantWatchers and its revalidation.
278
+ */
279
+ function reconcileSessionWatchers(ctx: CoreContext, sessionID: string): void {
280
+ if (!ctx.watchers) return;
281
+ const row = ctx.db
282
+ .runtimeStateAll()
283
+ .find((r) => r.kind === "watchers" && r.sessionID === sessionID && r.pid === process.pid);
284
+ if (row && Array.isArray(row.value)) ctx.watchers.hydrate(row.value as Watcher[]);
285
+ }
286
+
287
+ function renderClusters(candidates: DedupCandidate[]): string { const parent = new Map<string, string>();
200
288
  const find = (x: string): string => {
201
289
  let root = parent.get(x) ?? x;
202
290
  while (root !== (parent.get(root) ?? root)) root = parent.get(root) ?? root;
@@ -238,8 +326,9 @@ const rememberDef: ToolDef = {
238
326
  "Each memory should be focused on a single topic. Write memories as " +
239
327
  "reference material for a future instance of yourself with zero context.",
240
328
  args: {
241
- label: z.string().describe(
242
- "Short descriptive title. Used for deduplication - same label in the same store is the same entry.",
329
+ label: z.string().optional().describe(
330
+ "Short descriptive title. Used for deduplication - same label in the same store is the same entry. " +
331
+ "Omit to derive it from the content's first heading or line (capped at 80 characters).",
243
332
  ),
244
333
  content: z.string().describe(
245
334
  "The information to remember. Self-contained, understandable without session context.",
@@ -264,8 +353,32 @@ const rememberDef: ToolDef = {
264
353
  },
265
354
  async execute(args, ctx) {
266
355
  const store = resolveStore(args, ctx);
267
- const label = args.label as string;
268
- const content = `# ${label}\n\n${args.content as string}`;
356
+ const rawContent = (args.content as string | undefined)?.trim();
357
+ if (!rawContent) {
358
+ return "No content provided - pass the information to remember in the content parameter.";
359
+ }
360
+ let label = (args.label as string | undefined)?.trim();
361
+ let content = rawContent;
362
+ if (!label) {
363
+ // Label omitted (or arrived under a misnamed key that schema
364
+ // validation stripped): derive it from the content so the write
365
+ // still lands instead of erroring. A leading markdown heading
366
+ // becomes the label and is stripped from the stored content
367
+ // (heading-only content keeps the heading text as the body so the
368
+ // entry is never empty). The 80-char cap keeps a derived label -
369
+ // which can be a whole first line - from bloating list output and
370
+ // slug keys; matches deriveTitle's cap in extraction.ts.
371
+ const firstLine = rawContent.split("\n")[0]?.trim() ?? "";
372
+ const heading = /^#{1,6}\s+(.+)$/.exec(firstLine);
373
+ if (heading) {
374
+ label = heading[1].trim().slice(0, 80);
375
+ const rest = rawContent.split("\n").slice(1).join("\n").trim();
376
+ content = rest.length > 0 ? rest : heading[1].trim();
377
+ } else {
378
+ label = firstLine.slice(0, 80);
379
+ }
380
+ }
381
+ content = `# ${label}\n\n${content}`;
269
382
  const embedding = await ctx.model.passageEmbed(content);
270
383
 
271
384
  const similar = ctx.db.findSimilar(store, embedding, {
@@ -332,7 +445,7 @@ const recallDef: ToolDef = {
332
445
 
333
446
  if (results.length === 0) return "No matching memories found.";
334
447
 
335
- return results.map(formatRecallResult).join("\n\n-----\n\n");
448
+ return withMemoryCaveat(results.map(formatRecallResult).join("\n\n-----\n\n"));
336
449
  },
337
450
  };
338
451
 
@@ -382,7 +495,7 @@ const showDef: ToolDef = {
382
495
 
383
496
  if (!entry) return `No memory labeled "${args.label as string}" found in store "${store}".`;
384
497
 
385
- return formatEntry(entry) || "Error formatting entry.";
498
+ return withMemoryCaveat(formatEntry(entry) || "Error formatting entry.");
386
499
  },
387
500
  };
388
501
 
@@ -510,7 +623,7 @@ const getExtractionPayloadDef: ToolDef = {
510
623
  if (!sessionID) {
511
624
  return 'session_id is required on this host (it has no session context). Pass the parent session\'s session_id from the extraction nudge.';
512
625
  }
513
- const payload = ctx.extractionPayloadProvider(sessionID);
626
+ const payload = ctx.extractionPayloadProvider(sessionID, host?.sessionID);
514
627
  if (!payload) {
515
628
  return "No queued tool interactions found for this session.";
516
629
  }
@@ -524,8 +637,10 @@ const getExtractionPayloadDef: ToolDef = {
524
637
  * Called in a PARENT session after dispatching the fact-extractor, it accepts
525
638
  * the buffer: entries move to a holding area and the nudge quiets, but they
526
639
  * are not dropped until the extractor completes. Called in a CHILD extractor
527
- * at the end of its run, it completes the parent's accepted entries -
528
- * including no-save runs that write no memory. If the child errors or is
640
+ * at the end of its run, it completes the entries THAT child claimed via its
641
+ * payload fetch - never the whole accepted set, so a sibling sub-agent's
642
+ * completion cannot drop another extractor's in-flight payload. Completion
643
+ * includes no-save runs that write no memory. If the child errors or is
529
644
  * deleted before either signal, the host requeues the entries so the facts
530
645
  * are not lost.
531
646
  *
@@ -599,8 +714,7 @@ const predictionQueryDef: ToolDef = {
599
714
  if (scored.length === 0) return "No matching predictions found.";
600
715
  return scored.map((s) => {
601
716
  const verb = predictionVerb(s.evidence_count);
602
- return `[${s.confidence.toFixed(2)} conf, ${s.evidence_count} tests] ` +
603
- `When ${s.matcher_description}: ${verb} ${s.statement}`;
717
+ return formatWhenLine(s.confidence, s.evidence_count, s.matcher_description, s.statement, verb);
604
718
  }).join("\n");
605
719
  },
606
720
  };
@@ -612,7 +726,11 @@ const predictionUpdateDef: ToolDef = {
612
726
  "model. Use when the user corrects you, answers a question, or " +
613
727
  "provides a clear signal about their preferences or decision-making " +
614
728
  "strategy. The tool handles matcher and prediction lookup, dedup, " +
615
- "and confidence adjustment automatically.",
729
+ "and confidence adjustment automatically. " +
730
+ "ALL FOUR of matcher, prediction, signal, and rationale are required " +
731
+ "in the SAME call - calls omitting any of them fail validation. " +
732
+ "signal must be exactly one of: create, confirm, disconfirm, soft " +
733
+ "(use \"create\" for a new observation - never a prose description).",
616
734
  args: {
617
735
  matcher: z.string().describe(
618
736
  "Description of the situation. What decision was being made? Word it " +
@@ -622,7 +740,11 @@ const predictionUpdateDef: ToolDef = {
622
740
  "incident never fires for the next one.",
623
741
  ),
624
742
  prediction: z.string().describe(
625
- "The user's preference or tendency in this situation.",
743
+ "The user's preference or tendency in this situation. Phrase it as a " +
744
+ "tendency starting with a base-form verb (e.g. \"prefer X over Y\", " +
745
+ "\"push back on Z\") rather than " +
746
+ "a full sentence - the nudge and query renderers prefix the evidence " +
747
+ "verb (\"you tend to ...\") onto it.",
626
748
  ),
627
749
  signal: z.enum(["confirm", "disconfirm", "soft", "create"]).describe(
628
750
  "What happened: confirm (user confirmed the prediction), " +
@@ -782,7 +904,8 @@ const behaviorCodifyDef: ToolDef = {
782
904
  "tests before touching the area; read a large function fully before " +
783
905
  "editing it. When a review, bug, or incident reveals a CLASS of " +
784
906
  "mistake you are prone to (not a one-off), codify a guard against the " +
785
- "class.",
907
+ "class. ALL THREE of situation, behavior, and rationale are required " +
908
+ "in the SAME call - calls omitting any of them fail validation.",
786
909
  args: {
787
910
  situation: z.string().describe(
788
911
  "Description of the situation that triggers this behavior. What context " +
@@ -1394,6 +1517,7 @@ const watchCreateDef: ToolDef = {
1394
1517
  if (!ctx.watchers) {
1395
1518
  return "Watching is unavailable: no watcher registry was wired by this host.";
1396
1519
  }
1520
+ reconcileSessionWatchers(ctx, host.sessionID);
1397
1521
  const repo = (args.repo as string | undefined) ?? ctx.defaultStore;
1398
1522
  const events = (args.events as PrWatcherEventType[] | undefined) ?? [...PR_EVENT_TYPES];
1399
1523
  const result = await ctx.watchers.createPr(host.sessionID, repo, args.pr as number, events, { once: args.once === true });
@@ -1433,6 +1557,7 @@ const watchListDef: ToolDef = {
1433
1557
  if (!ctx.watchers) {
1434
1558
  return "Watching is unavailable: no watcher registry was wired by this host.";
1435
1559
  }
1560
+ reconcileSessionWatchers(ctx, host.sessionID);
1436
1561
  const watchers = ctx.watchers.listForSession(host.sessionID);
1437
1562
  if (watchers.length === 0) return "No active watchers.";
1438
1563
  const minutesLeft = (w: { expiresAt: number }) => Math.max(0, Math.round((w.expiresAt - Date.now()) / 60_000));
@@ -1498,6 +1623,7 @@ const watchBranchCreateDef: ToolDef = {
1498
1623
  if (!ctx.watchers) {
1499
1624
  return "Watching is unavailable: no watcher registry was wired by this host.";
1500
1625
  }
1626
+ reconcileSessionWatchers(ctx, host.sessionID);
1501
1627
  const repo = (args.repo as string | undefined) ?? ctx.defaultStore;
1502
1628
  const events = (args.events as BranchWatcherEventType[] | undefined) ?? [...BRANCH_EVENT_TYPES];
1503
1629
  const result = await ctx.watchers.createBranch(
@@ -1582,6 +1708,7 @@ const watchCommandCreateDef: ToolDef = {
1582
1708
  if (!ctx.watchers) {
1583
1709
  return "Watching is unavailable: no watcher registry was wired by this host.";
1584
1710
  }
1711
+ reconcileSessionWatchers(ctx, host.sessionID);
1585
1712
  const dir = (args.cd as string | undefined) ?? ctx.projectDir;
1586
1713
  if (!dir) {
1587
1714
  return "Watching is unavailable: no project directory was wired by this host.";
@@ -1622,6 +1749,7 @@ const watchCancelDef: ToolDef = {
1622
1749
  if (!ctx.watchers) {
1623
1750
  return "Watching is unavailable: no watcher registry was wired by this host.";
1624
1751
  }
1752
+ reconcileSessionWatchers(ctx, host.sessionID);
1625
1753
  const cancelled = ctx.watchers.cancel(host.sessionID, args.id as string);
1626
1754
  if (!cancelled) return `No watcher "${args.id}" in this session.`;
1627
1755
  return `[cancelled] ${args.id}`;
package/src/watchers.ts CHANGED
@@ -854,6 +854,18 @@ export class WatcherRegistry {
854
854
  commandRunner: CommandRunner;
855
855
  commandTimeoutMs: number;
856
856
  };
857
+ /**
858
+ * The most recently constructed instance in this process. opencode v2
859
+ * re-runs plugin setup() when the session's directory changes
860
+ * (session_move between directories of the same repo) or on plugin-file
861
+ * changes; each setup() built a fresh registry while the OLD instance's
862
+ * poller timer kept running - the watch tools then queried an empty
863
+ * registry ("No active watchers" / "No watcher in this session") while
864
+ * delivery kept flowing from the orphaned poller. The handoff stops the
865
+ * predecessor's poller at construction so at most one registry polls per
866
+ * process; the watch tools' journal reconcile covers the state transfer.
867
+ */
868
+ static #live: WatcherRegistry | undefined;
857
869
 
858
870
  constructor(options: WatcherRegistryOptions) {
859
871
  this.#opts = {
@@ -865,6 +877,8 @@ export class WatcherRegistry {
865
877
  ...options,
866
878
  };
867
879
  this.#journal = options.journal;
880
+ WatcherRegistry.#live?.stop();
881
+ WatcherRegistry.#live = this;
868
882
  }
869
883
 
870
884
  // -- Lifecycle -----------------------------------------------------------