@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.
- package/README.md +4 -2
- package/artifacts/skills/thatch-fact-extractor.md +1 -1
- package/artifacts/skills/thatch-memory-verify.md +2 -2
- package/artifacts/skills/thatch-plan-refinement.md +3 -2
- package/artifacts/skills/thatch-refine-infra.md +66 -0
- package/artifacts/skills/thatch-refine-oss-contribution.md +68 -0
- package/artifacts/skills/thatch-refine-personal.md +61 -0
- package/artifacts/skills/thatch-refine-shared-lib.md +69 -0
- package/artifacts/skills/thatch-refine-spike.md +53 -0
- package/artifacts/skills/thatch-refine-team-app.md +69 -0
- package/artifacts/skills/thatch-refine.md +84 -0
- package/docs/dev/README.md +11 -7
- package/docs/dev/features/cross-session-chat.md +8 -1
- package/docs/dev/features/memory-store.md +1 -1
- package/docs/dev/features/nudge-pipeline.md +12 -0
- package/docs/dev/features/opencode-plugin.md +1 -1
- package/docs/dev/features/session-lifecycle.md +1 -1
- package/docs/dev/gotchas.md +71 -1
- package/docs/dev/mcp-parity.md +1 -1
- package/docs/dev/setup-and-hooks.md +1 -1
- package/docs/dev/skills.md +8 -1
- package/docs/user/README.md +8 -1
- package/docs/user/commands.md +8 -0
- package/docs/user/memory.md +1 -1
- package/docs/user/skills.md +12 -0
- package/package.json +1 -1
- package/src/chat.ts +17 -0
- package/src/commands.ts +23 -3
- package/src/db.ts +77 -12
- package/src/extraction.ts +89 -3
- package/src/index.ts +45 -0
- package/src/opencode/v2.ts +48 -4
- package/src/prompts.ts +118 -30
- package/src/runtime.ts +271 -72
- package/src/skills.ts +7 -0
- package/src/tool-defs.ts +156 -28
- package/src/watchers.ts +14 -0
|
@@ -75,7 +75,7 @@ the shared runtime needs. It is the lifecycle-level sibling of `CoreContext`
|
|
|
75
75
|
| TUI app exit | `client.tui.publish` | none | degrade (the wrap-up exit's checklist and flush run; the exit does not - upstream [anomalyco/opencode#50984](https://github.com/anomalyco/opencode/issues/50984). On v2 the right target closes THIS session's tab (tab-scoped), not `app.exit`: the daemon hosts every tab; the `tui` companion entrypoint's `ui.tabs.close` is the known lift candidate) |
|
|
76
76
|
| tab close | n/a | no session.deleted on v2 tab close | known gap: a closed tab's watchers keep polling until their TTL (deliveries fail their idle gate and stay pending); restart dormancy covers the daemon-restart case, not tab close |
|
|
77
77
|
| wake gate after reload | `session.status` event map | map rehydrates empty | known gap: v2's fetchStatuses stub is structural (the promise context has no session.status), so in the reload window - until the session's next status event - the proactive-prompt gate is fully open |
|
|
78
|
-
| dispose | `dispose` hook | cleanup returned from `setup` | must be idempotent: v2 auto-reloads plugins on file change, and a non-idempotent cleanup doubles pollers/pumps/nudges. Volatile runtime state (extraction buffer + accepted entries, child bookkeeping, wrap-up arms, watcher definitions) is journaled to the `runtime_state` table - instance-scoped by the writing location's directory - and rehydrated on the next `setup()`: rows from the same process AND directory (a reload) all restore; rows from a dead process are pruned unless the session is the `-c`/`-s` startup resume, which inherits recovery-safe state only (buffer requeued, no live child bookkeeping - the child died with the old process). Exception: a dead process's watcher definitions are kept as DORMANT rows (no polling, no delivery) instead of pruned - resuming the session re-arms them on its first message (a `chat.message` scan: own rows re-arm, same-directory rows of still-dead sessions earn the live session a one-line death notice gated on the owning pid being dead, rows past the watcher TTL plus a 24h grace age out). Restored sessions on v1 get a synthetic noReply re-attach notice (v2 has no turn-free delivery and skips it). The chat poller re-hosts the instance's journaled hosted set on reload, so pending chat mail wakes sessions the reload left asleep and heartbeats keep the reaper from reaping live registrations. The embedding model is refcounted per db path, so N location instances share one resident model |
|
|
78
|
+
| dispose | `dispose` hook | cleanup returned from `setup` | must be idempotent: v2 auto-reloads plugins on file change, and a non-idempotent cleanup doubles pollers/pumps/nudges. Volatile runtime state (extraction buffer + accepted entries, child bookkeeping, wrap-up arms, watcher definitions) is journaled to the `runtime_state` table - instance-scoped by the writing location's directory - and rehydrated on the next `setup()`: rows from the same process AND directory (a reload) all restore; child rows carry a kind marker (extraction vs task-dispatched sub-agent, defaulting to extraction for pre-marker rows) so only extraction children restore the `extracting`/`extractionChildren` sets; rows from a dead process are pruned unless the session is the `-c`/`-s` startup resume, which inherits recovery-safe state only (buffer requeued, no live child bookkeeping - the child died with the old process). Exception: a dead process's watcher definitions are kept as DORMANT rows (no polling, no delivery) instead of pruned - resuming the session re-arms them on its first message (a `chat.message` scan: own rows re-arm, same-directory rows of still-dead sessions earn the live session a one-line death notice gated on the owning pid being dead, rows past the watcher TTL plus a 24h grace age out). Restored sessions on v1 get a synthetic noReply re-attach notice (v2 has no turn-free delivery and skips it). The chat poller re-hosts the instance's journaled hosted set on reload, so pending chat mail wakes sessions the reload left asleep and heartbeats keep the reaper from reaping live registrations. The embedding model is refcounted per db path, so N location instances share one resident model |
|
|
79
79
|
|
|
80
80
|
## Live verification and remaining unknowns
|
|
81
81
|
|
|
@@ -117,7 +117,7 @@ first-ever install is invisible until the next server start.
|
|
|
117
117
|
## Interactions with other features
|
|
118
118
|
|
|
119
119
|
- Extraction pipeline ([extraction.md](extraction.md)): direct extraction is triggered by `session.status idle`; child lifecycle managed here
|
|
120
|
-
- Nudge pipeline ([nudge-pipeline.md](nudge-pipeline.md)): `extracting` set suppresses tier 1; `compacting` set suppresses all tiers
|
|
120
|
+
- Nudge pipeline ([nudge-pipeline.md](nudge-pipeline.md)): `extracting` set suppresses tier 1; `compacting` set suppresses all tiers; task-dispatched sub-agents (in `childToParent`, not in `extractionChildren`) suppress all tiers
|
|
121
121
|
- Hygiene ([hygiene.md](hygiene.md)): hygiene report runs at `session.created` for top-level sessions
|
|
122
122
|
- Compaction recovery ([compaction-recovery.md](compaction-recovery.md)): `session.compacted` event clears the compacting flag
|
|
123
123
|
- Memory store ([memory-store.md](memory-store.md)): child sessions write memories via `memory_remember`, which triggers drain via `tool.execute.after`
|
package/docs/dev/gotchas.md
CHANGED
|
@@ -41,7 +41,7 @@ here first. These are the things that have already cost time.
|
|
|
41
41
|
a memory, write it with `archived: true`; to unarchive, `archived: false`.
|
|
42
42
|
- **Updating an archived memory requires explicit `archived` param.** If the
|
|
43
43
|
entry is already archived and a `remember` call omits the `archived` param,
|
|
44
|
-
the tool returns an error (`db.ts:
|
|
44
|
+
the tool returns an error (`db.ts:585`). Pass `archived: true` to keep it
|
|
45
45
|
archived or `archived: false` to unarchive. This guard prevents accidental
|
|
46
46
|
unarchival via an unrelated content update.
|
|
47
47
|
|
|
@@ -229,3 +229,73 @@ wrapped tool's hook semantics instead of buffering. Lesson for sibling
|
|
|
229
229
|
hooks: when a filter reasons about tool identity, an aggregator tool
|
|
230
230
|
(arbitrary code execution over other tools) defeats name-based matching -
|
|
231
231
|
unwrap the aggregation before classifying.
|
|
232
|
+
|
|
233
|
+
## LLMs guess tool-call shapes on the first call, so teach the shape at the mention site
|
|
234
|
+
|
|
235
|
+
A September 2026 audit of every thatch tool-call error in the opencode
|
|
236
|
+
session database (~19k calls, both v1 and v2 storage) found the failures
|
|
237
|
+
cluster on the model's FIRST thatch call - usually the startup recall round -
|
|
238
|
+
and fall into a short list of avoidable mistakes:
|
|
239
|
+
|
|
240
|
+
- **Bare positional string instead of an object argument** (the largest
|
|
241
|
+
bucket): `thatch_memory_recall({ query: "..." })` called as
|
|
242
|
+
`thatch_memory_recall("...")`. Root cause: the opencode system prompt's
|
|
243
|
+
startup section and the recall nudge both used positional-style examples
|
|
244
|
+
(`thatch_memory_recall "query"`), and Code Mode models mimic that shape
|
|
245
|
+
inside `execute`. Both now teach the object form.
|
|
246
|
+
- **Required discriminator params omitted**: `memory_remember` without
|
|
247
|
+
`label` (models pass `title`/`id`/`statement` instead, or nothing),
|
|
248
|
+
`prediction_update` without `signal`, `behavior_codify` without
|
|
249
|
+
`situation`. Fixes: `label` is optional and derived from content;
|
|
250
|
+
tool descriptions state the all-args-required contract.
|
|
251
|
+
- **Nudges leak into tool-less agent contexts**: task-dispatched sub-agents
|
|
252
|
+
(explore/general and friends) have restricted tool lists that exclude the
|
|
253
|
+
thatch tools, but `chat.message` nudges fired for them anyway, producing
|
|
254
|
+
guaranteed "No tool named ..." error rounds. The chat.message hook now
|
|
255
|
+
skips nudges for child sessions that are not extraction children.
|
|
256
|
+
|
|
257
|
+
The general lesson: any surface that names a tool to an LLM (system prompt,
|
|
258
|
+
nudge, skill text) is a usage-shape prior. Give the argument shape at the
|
|
259
|
+
mention site, because the model's first call happens before it has seen a
|
|
260
|
+
working example. Also note Code Mode `execute` validation failures are only
|
|
261
|
+
persisted in the v2 `session_message` store - they are invisible in the v1
|
|
262
|
+
`part` table, so error-rate analysis on old data undercounts them.
|
|
263
|
+
|
|
264
|
+
## ThatchDB lazy reopen: new accessors must go through the private getters, and journal writes stay guarded at call sites
|
|
265
|
+
|
|
266
|
+
`ThatchDB` reopens its SQLite connection on first use after `close()`
|
|
267
|
+
(`src/db.ts`, the `#db`/`#predictions`/`#behaviors`/`#chat` private getters), so
|
|
268
|
+
an in-flight tool call that races a v2 plugin reload's `dispose()` completes
|
|
269
|
+
instead of throwing "Cannot use a closed database". Two rules keep this intact:
|
|
270
|
+
|
|
271
|
+
- **New facade methods need no extra wiring, but new state-holding members
|
|
272
|
+
do.** The engine facades (`ChatStore`, `PredictionEngine`, `BehaviorEngine`)
|
|
273
|
+
capture the `Database` handle at construction; the reopen rebuilds them. A
|
|
274
|
+
new member that captures the Database directly must get its own private
|
|
275
|
+
getter that ensures the handle is open first - a plain field keeps serving
|
|
276
|
+
the stale closed handle and the reopen silently never fires for it (this
|
|
277
|
+
exact bug is pinned by the "engine facades are rebuilt" test in
|
|
278
|
+
tests/db.test.ts).
|
|
279
|
+
- **Post-dispose journal writes are no-ops at their call sites, not in
|
|
280
|
+
db.ts.** After a reload, the RELOADED instance owns the `runtime_state`
|
|
281
|
+
rows, so delayed writers in `src/runtime.ts` check the `disposed` flag
|
|
282
|
+
BEFORE touching the db - the lazy reopen would otherwise hand a stale
|
|
283
|
+
writer a fresh connection. All three journal callbacks are guarded (the
|
|
284
|
+
extraction pipeline's finalization, the WatcherRegistry journal - a poll
|
|
285
|
+
cycle suspended at an await can outlive `watchers.dispose()` - and the
|
|
286
|
+
wrap-up arming write, whose command executes are not drained by the v2
|
|
287
|
+
cleanup). Do not "fix" the guard away by moving it into db.ts: the
|
|
288
|
+
accessor cannot tell a legitimate in-flight caller from a stale writer.
|
|
289
|
+
|
|
290
|
+
## Command frontmatter descriptions must be quoted YAML scalars
|
|
291
|
+
|
|
292
|
+
- **A plain-scalar frontmatter value containing a colon followed by a space
|
|
293
|
+
is invalid YAML, and not every host path rescues it.** `/thatch/hygiene`'s
|
|
294
|
+
description ("Tend the memory store: stale entries, ...") shipped unquoted;
|
|
295
|
+
the first parse throws
|
|
296
|
+
and only opencode's fallback sanitizer recovers it — and a long-lived
|
|
297
|
+
server was observed dropping the description entirely while a fresh server
|
|
298
|
+
parsed it fine, which made the bug look like a TUI rendering issue. The
|
|
299
|
+
renderer quotes every description (`yamlQuote` in src/commands.ts) and a
|
|
300
|
+
unit test pins the quoted form. Keep it that way for any new frontmatter
|
|
301
|
+
field whose value is free-form prose.
|
package/docs/dev/mcp-parity.md
CHANGED
|
@@ -23,7 +23,7 @@ This document maps the feature parity and documents the remaining gaps.
|
|
|
23
23
|
| **Session-start reminder** (recall nudge + hygiene heartbeat) | `session.created` event → `client.session.prompt` injects a synthetic message | `SessionStart` hook → `thatch reminder`; stdout becomes context | `sessionStart` hook → `thatch reminder --json`; output is `additional_context` JSON | **Full** |
|
|
24
24
|
| **Compaction context** (re-familiarize after compaction) | `experimental.session.compacting` hook appends context to the compaction output | `PostCompact` hook — side-effects only, **cannot inject context** | No equivalent hook | **Gapped** — see below |
|
|
25
25
|
| **Wrap-up commands** (`/thatch/compact`, `/thatch/exit`) | Command markdown synced to `$XDG_CONFIG_HOME/opencode/command/thatch/` at plugin init; `command.execute.before` arms the session and the next idle resolves the greenlight token into a TUI action (`session.compact` via `executeCommand`, `app.exit` via `publish`) | No equivalent — no plugin host to arm the check or trigger the action | No equivalent | **Gapped** — needs the plugin's SDK client and TUI control routes |
|
|
26
|
-
| **On-demand actions** (`/thatch/defrag`, `extract`, `hygiene`, `reflect`) | Command markdown synced at plugin init; bodies rendered from the shared prompt cores with `thatch_` tool spellings; `extract` is opencode-only (needs session identity via `get_session_info` and the plugin's payload provider) | Command markdown synced by `thatch setup --claude` into `<claudeDir>/commands/thatch/`; same cores with `mcp__thatch__` spellings; `extract` excluded | MCP Prompts (`prompts/list` + `prompts/get`) from the same cores; Cursor surfaces them as slash commands; `extract` excluded | **Full** for the shared actions (same cores, per-host tool spellings); `extract` opencode-only |
|
|
26
|
+
| **On-demand actions** (`/thatch/defrag`, `extract`, `hygiene`, `reflect`, `refine`) | Command markdown synced at plugin init; bodies rendered from the shared prompt cores with `thatch_` tool spellings; `extract` is opencode-only (needs session identity via `get_session_info` and the plugin's payload provider) | Command markdown synced by `thatch setup --claude` into `<claudeDir>/commands/thatch/`; same cores with `mcp__thatch__` spellings; `extract` excluded | MCP Prompts (`prompts/list` + `prompts/get`) from the same cores; Cursor surfaces them as slash commands; `extract` excluded | **Full** for the shared actions (same cores, per-host tool spellings); `extract` opencode-only |
|
|
27
27
|
| **Extraction** (buffer tool calls, extract durable facts) | `tool.execute.after` buffers non-meta tool calls in-process (`isMetaToolName` in `src/extraction.ts`: thatch_*, skill, task, agent, subagent — execute calls unwrapping only thatch tools are also excluded); **direct extraction** is the primary path — when the parent session goes idle with pending buffer entries, the plugin creates a child session via the SDK (`client.session.create` with `parentID`) and prompts it to call `get_extraction_payload` (nudge suppressed via the `extracting` set); the `chat.message` nudge path is a **fallback** used when direct extraction throws | `PostToolBatch` → `thatch buffer-batch` (file-backed JSONL queue); `UserPromptSubmit` → `thatch flush-tools` peeks the queue and prints the nudge with session ID + fetch tool name (does not drain — the queue persists until a memory write or `extraction_done`) | `postToolUse` → `thatch buffer-tool` (single-tool, file-backed queue); `beforeSubmitPrompt` → `thatch flush-tools --json` | **Partial** — opencode uses direct extraction (parent idle → child session) as the primary path, with the nudge as fallback; MCP hosts are nudge-only. Both paths use `get_extraction_payload` to fetch the queued interactions as a tool response, keeping the full payload out of the main session's context window. Nudge timing differs: the nudge arrives at the **start** of the next turn in Claude Code/Cursor; opencode's direct extraction fires at **end of turn** (session idle), and the nudge fallback fires at the start of the next turn like MCP |
|
|
28
28
|
| **Extraction feedback** (toast notifications) | `client.tui.showToast` fires when the extraction child goes idle — shows `[thatch] new: N, updated: M, deleted: K` (success) or `extraction complete — nothing to save` (info); best-effort, silently ignored in headless mode | No TUI connection — MCP hosts have no equivalent | No TUI connection | **Gapped** — MCP hosts cannot show toast notifications |
|
|
29
29
|
| **Automatic alerts** (notify on LLM pause / turn end) | Plugin event hook drives the `alerts.ts` state machine: `question.asked`/`permission.asked` → pause alert; `session.status` idle with a real-work round → done alert; unrecovered errors → needs-attention. Delivered via the shared `notify.ts` dispatcher, channel per event from the `alerts` config section | No event stream — only `thatch_notify_user` when the agent chooses | No event stream — only `thatch_notify_user` when the agent chooses | **Gapped** — MCP hosts have no plugin event hook to watch; Claude Code's `Notification`/`Stop` hooks could feed the same classifier later |
|
|
@@ -25,7 +25,7 @@ code (`src/opencode/v1.ts`):
|
|
|
25
25
|
| `experimental.session.compacting` | `{ sessionID }` | `{ context: string[] }` | Marks the session as compacting, pushes re-familiarization context. |
|
|
26
26
|
| `experimental.compaction.autocontinue` | `{ sessionID }` | `{ enabled: boolean }` | Clears the compacting flag so `chat.message` nudges resume post-compaction. |
|
|
27
27
|
| `tool.execute.after` | `{ tool, sessionID, callID, args }` | `{ title, output, metadata }` | Buffers non-`thatch_`, non-`skill`, non-`task` tool calls into the in-memory extraction ring buffer (max 20). In child sessions, also tracks new/updated/deleted counts via `childMetrics` for the extraction toast. |
|
|
28
|
-
| `chat.message` | `{ sessionID, messageID }` | `{ message, parts }` | Two tiers: extraction nudge if pending and not already extracting (fallback — direct extraction via the `event` hook is the primary path), else prompt-aware recall nudge. Suppressed while compacting
|
|
28
|
+
| `chat.message` | `{ sessionID, messageID }` | `{ message, parts }` | Two tiers: extraction nudge if pending and not already extracting (fallback — direct extraction via the `event` hook is the primary path), else prompt-aware recall nudge. Suppressed while compacting, while `extracting` is set, or for task-dispatched sub-agent sessions (children that are not extraction children). |
|
|
29
29
|
| `event` | `{ event: { type, properties } }` | — | Dispatches on `event.type`: `session.created` (top-level) → `client.session.prompt` with reminder + hygiene heartbeat; `session.created` (child with `parentID`) → records `childToParent` + snapshots parent's pending buffer. `session.status` idle (parent, pending interactions) → `triggerExtraction` (direct child-session extraction); idle (child) → complete/drain, `showToast` with metrics, delete child. `session.error` (child) → requeue parent's accepted entries. `session.deleted` → requeue if child, drop accepted if parent, clean maps. `session.compacted` → clear compacting flag. |
|
|
30
30
|
| `dispose` | — | — | Closes the DB. |
|
|
31
31
|
|
package/docs/dev/skills.md
CHANGED
|
@@ -55,7 +55,14 @@ description: Extract durable project facts ... Use when ...
|
|
|
55
55
|
| `thatch-code-walkthrough` | Explain a feature, module, or workflow to the user as a teaching walkthrough: identify the code area (optionally from a branch or PR), research how it works, teach it with file:line citations and analogies, list the key files. |
|
|
56
56
|
| `thatch-session-reflection` | End-of-session memory recording (project, user, tools, self). |
|
|
57
57
|
| `thatch-coding-workflow` | Plan and execute code changes with a task-list-driven workflow. Use when implementing features, fixing bugs, or making multi-file changes. |
|
|
58
|
-
| `thatch-plan-refinement` | Refine a plan before implementing it: fresh-context reviewer rounds until consensus, with an optional deep-mode lens fan-out (reuse, alternatives, hidden problems, safe-to-modify, archaeology). |
|
|
58
|
+
| `thatch-plan-refinement` | Refine a plan before implementing it: fresh-context reviewer rounds until consensus, with an optional deep-mode lens fan-out (reuse, alternatives, hidden problems, safe-to-modify, archaeology). The generic core loop. |
|
|
59
|
+
| `thatch-refine` | Classify the project (audience, lifecycle, surface) and refine a plan under that type's requirements. Entry point for the `thatch-refine-*` family; also the `/thatch/refine` command. |
|
|
60
|
+
| `thatch-refine-team-app` | Refinement deltas for multi-developer team codebases: consistency outranks cleverness, intent breadcrumbs are deliverables, pre-mortem and safe-to-modify lenses dominate. |
|
|
61
|
+
| `thatch-refine-personal` | Refinement deltas for solo projects with no downstream consumers: relaxed coordination, cleverness and experimentation welcome, premise verification stays honest. |
|
|
62
|
+
| `thatch-refine-shared-lib` | Refinement deltas for changes touching a library's API surface: flexible on input, strict on output, keep special cases out of the API, never foreclose caller options, docs are a deliverable. |
|
|
63
|
+
| `thatch-refine-spike` | Refinement deltas for throwaway prototypes: skip the loop, verify only the premises the experiment's conclusion depends on, state the question and the time box. |
|
|
64
|
+
| `thatch-refine-infra` | Refinement deltas for CI, Terraform, and k8s changes: blast radius across pipelines and environments, rollback plan and staged verification are mandatory, pattern-following is near-absolute. |
|
|
65
|
+
| `thatch-refine-oss-contribution` | Refinement deltas for PRs to repos you do not maintain: the maintainer's design authority governs, minimal diff, upstream discussion gates significant design work. |
|
|
59
66
|
| `thatch-pr-description` | Draft PR descriptions with SYNOPSIS / PURPOSE / DESCRIPTION / WALK-THROUGH / NOTES, project-context research, clarity checks, and bold+italic emphasis for scanning. |
|
|
60
67
|
| `thatch-ticket-description` | Draft ticket/issue descriptions with clear sections, project-context research, clarity checks, and bold+italic emphasis for scanning. |
|
|
61
68
|
| `thatch-split-overlarge-pr` | Split already-completed work from an overlarge PR into human-reviewable, release-safe PRs targeting main. |
|
package/docs/user/README.md
CHANGED
|
@@ -104,7 +104,7 @@ Stores are created automatically. No setup required.
|
|
|
104
104
|
|
|
105
105
|
| Tool | What it does |
|
|
106
106
|
|------|-------------|
|
|
107
|
-
| `thatch_memory_remember` | Save a piece of information
|
|
107
|
+
| `thatch_memory_remember` | Save a piece of information; thatch embeds it for later recall. The label is optional - it is derived from the content's first heading or line when omitted. |
|
|
108
108
|
| `thatch_memory_recall` | Search for relevant information using natural language. Searches both the current project's store and `global` by default. |
|
|
109
109
|
| `thatch_memory_list` | List all memory labels in a store. |
|
|
110
110
|
| `thatch_memory_show` | Read the full content of a memory by exact label. |
|
|
@@ -328,6 +328,13 @@ response, walkthrough, and workflow skills that support the review pipeline:
|
|
|
328
328
|
| `thatch-code-walkthrough` | Explains a feature, module, or workflow to the user as a teaching walkthrough with file:line citations. Also used to draft high-level docs for new or undocumented features. |
|
|
329
329
|
| `thatch-coding-workflow` | Plans and executes code changes with a task-list-driven workflow: complexity triage, milestone planning, research before coding, post-coding verification. Pairs with `thatch-code-archaeology` (research first, then plan). |
|
|
330
330
|
| `thatch-plan-refinement` | Refines a plan before it is implemented: a fresh-context reviewer subagent re-checks the plan each round until consensus; deep mode adds specialist lenses (reuse, alternatives, hidden problems, safe-to-modify, intent archaeology). |
|
|
331
|
+
| `thatch-refine` | Classifies the project (audience, lifecycle, surface) and refines a plan under that type's requirements. The entry point for the `thatch-refine-*` family; also the `/thatch/refine` command. |
|
|
332
|
+
| `thatch-refine-team-app` | Team codebases: consistency outranks cleverness, intent breadcrumbs are deliverables, pre-mortem and safe-to-modify lenses dominate. |
|
|
333
|
+
| `thatch-refine-personal` | Solo projects: relaxed coordination, cleverness and experimentation welcome, premise verification stays honest. |
|
|
334
|
+
| `thatch-refine-shared-lib` | Library API surfaces: flexible on input, strict on output, keep special cases out of the API, never foreclose caller options, docs are a deliverable. |
|
|
335
|
+
| `thatch-refine-spike` | Throwaway prototypes: skip the loop, verify only the premises the experiment's conclusion depends on. |
|
|
336
|
+
| `thatch-refine-infra` | CI, Terraform, and k8s changes: blast radius, mandatory rollback plan and staged verification, pattern-following is near-absolute. |
|
|
337
|
+
| `thatch-refine-oss-contribution` | PRs to repos you do not maintain: the maintainer's design authority governs, minimal diff, upstream discussion gates significant design work. |
|
|
331
338
|
|
|
332
339
|
### Writing skills
|
|
333
340
|
|
package/docs/user/commands.md
CHANGED
|
@@ -17,6 +17,13 @@ or you invoke it.
|
|
|
17
17
|
The agent asks before forgetting anything that may still matter.
|
|
18
18
|
- `/thatch/reflect` — run the session-reflection skill to persist what the
|
|
19
19
|
current session learned.
|
|
20
|
+
- `/thatch/refine` — refine a plan for a code change under the current
|
|
21
|
+
project type's requirements: the agent classifies the project (team,
|
|
22
|
+
personal, shared library, spike, infrastructure, or open source
|
|
23
|
+
contribution), confirms the pick, and runs the refinement loop with
|
|
24
|
+
that type's expectations applied. Text after the command can name the
|
|
25
|
+
type directly (for example, `/thatch/refine shared-lib`) to skip
|
|
26
|
+
classification.
|
|
20
27
|
- `/thatch/compact` and `/thatch/exit` — the wrap-up commands. They run a
|
|
21
28
|
pre-flight checklist (flush extraction, finish promised memory writes,
|
|
22
29
|
surface loose ends) before a compaction or an exit, gated by a greenlight
|
|
@@ -31,6 +38,7 @@ or you invoke it.
|
|
|
31
38
|
| `/thatch/extract` | yes | no | no |
|
|
32
39
|
| `/thatch/hygiene` | yes | yes | yes (as an MCP prompt) |
|
|
33
40
|
| `/thatch/reflect` | yes | yes | yes (as an MCP prompt) |
|
|
41
|
+
| `/thatch/refine` | yes | yes | yes (as an MCP prompt) |
|
|
34
42
|
| `/thatch/compact`, `/thatch/exit` | yes | no | no |
|
|
35
43
|
|
|
36
44
|
Why the gaps: `/thatch/extract` needs the invoking session's ID, which only
|
package/docs/user/memory.md
CHANGED
|
@@ -33,7 +33,7 @@ store when recalling.
|
|
|
33
33
|
|
|
34
34
|
| Tool | What it does |
|
|
35
35
|
|------|-------------|
|
|
36
|
-
| `thatch_memory_remember` | Save a memory with a label and content. Optional: branch, confidence (1-10), archived flag, overwrite. |
|
|
36
|
+
| `thatch_memory_remember` | Save a memory with a label and content. The label may be omitted - it is derived from the content's first heading or line. Optional: branch, confidence (1-10), archived flag, overwrite. |
|
|
37
37
|
| `thatch_memory_recall` | Semantic search across stores. Returns matching memories with similarity scores. |
|
|
38
38
|
| `thatch_memory_list` | List all memories in a store (metadata only, no content). |
|
|
39
39
|
| `thatch_memory_show` | Show full content of a single memory by label. |
|
package/docs/user/skills.md
CHANGED
|
@@ -77,6 +77,18 @@ Skills are split into shared (all hosts) and opencode-only:
|
|
|
77
77
|
| `thatch-coding-workflow` | Plans and executes code changes with a task-list-driven workflow: complexity triage, milestone planning, research before coding, post-coding verification. Pairs with `thatch-code-archaeology` (research first, then plan). |
|
|
78
78
|
| `thatch-plan-refinement` | Refines a plan before it is implemented: a fresh-context reviewer subagent re-checks the plan each round until consensus; deep mode adds specialist lenses (reuse, alternatives, hidden problems, safe-to-modify, intent archaeology). |
|
|
79
79
|
|
|
80
|
+
### Plan refinement skills
|
|
81
|
+
|
|
82
|
+
| Skill | Purpose |
|
|
83
|
+
|-------|---------|
|
|
84
|
+
| `thatch-refine` | Classifies the project (audience, lifecycle, surface) and refines a plan under that type's requirements. The entry point for the `thatch-refine-*` family; also the `/thatch/refine` command. |
|
|
85
|
+
| `thatch-refine-team-app` | Team codebases: consistency outranks cleverness, intent breadcrumbs are deliverables, pre-mortem and safe-to-modify lenses dominate. |
|
|
86
|
+
| `thatch-refine-personal` | Solo projects: relaxed coordination, cleverness and experimentation welcome, premise verification stays honest. |
|
|
87
|
+
| `thatch-refine-shared-lib` | Library API surfaces: flexible on input, strict on output, keep special cases out of the API, never foreclose caller options, docs are a deliverable. |
|
|
88
|
+
| `thatch-refine-spike` | Throwaway prototypes: skip the loop, verify only the premises the experiment's conclusion depends on. |
|
|
89
|
+
| `thatch-refine-infra` | CI, Terraform, and k8s changes: blast radius, mandatory rollback plan and staged verification, pattern-following is near-absolute. |
|
|
90
|
+
| `thatch-refine-oss-contribution` | PRs to repos you do not maintain: the maintainer's design authority governs, minimal diff, upstream discussion gates significant design work. |
|
|
91
|
+
|
|
80
92
|
## Stale skill cleanup
|
|
81
93
|
|
|
82
94
|
When skills are renamed or removed in a new release, thatch automatically
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jeffober/thatch",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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/chat.ts
CHANGED
|
@@ -1371,6 +1371,23 @@ export function hostedSessionIds(options: {
|
|
|
1371
1371
|
return [...new Set(hosted)].filter((id) => !excluded.has(id));
|
|
1372
1372
|
}
|
|
1373
1373
|
|
|
1374
|
+
/**
|
|
1375
|
+
* Grace-rehost ids still inside their rehost window, pruning expired
|
|
1376
|
+
* entries as a side effect (the map is the runtime's live restart-grace
|
|
1377
|
+
* state - see CHAT_REHOST_GRACE_MS in runtime.ts). A restart re-hosts the
|
|
1378
|
+
* dead daemon's hosted set under this grace: open TUI tabs reconnect but
|
|
1379
|
+
* emit no events until the user types, so without it they go falsely
|
|
1380
|
+
* stale while alive-idle.
|
|
1381
|
+
*/
|
|
1382
|
+
export function unexpiredGraceRehosts(grace: Map<string, number>, now: number): string[] {
|
|
1383
|
+
const live: string[] = [];
|
|
1384
|
+
for (const [id, deadline] of grace) {
|
|
1385
|
+
if (now >= deadline) grace.delete(id);
|
|
1386
|
+
else live.push(id);
|
|
1387
|
+
}
|
|
1388
|
+
return live;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1374
1391
|
export class ChatPoller {
|
|
1375
1392
|
#opts: Required<ChatPollerOptions>;
|
|
1376
1393
|
#timer: ReturnType<typeof setInterval> | null = null;
|
package/src/commands.ts
CHANGED
|
@@ -100,6 +100,16 @@ After dispatching, call thatch_extraction_done to acknowledge.`,
|
|
|
100
100
|
description: "Tend the memory store: stale entries, orphans, pending dedup pairs",
|
|
101
101
|
body: hygieneCore(),
|
|
102
102
|
},
|
|
103
|
+
{
|
|
104
|
+
name: "refine",
|
|
105
|
+
description: "Refine a plan under the project type's requirements (classifies the project first)",
|
|
106
|
+
// A skill trigger, not a nudge behavior: the body points at the
|
|
107
|
+
// thatch-refine router skill, which classifies the project and loads
|
|
108
|
+
// the matching type deltas on top of the plan-refinement core loop.
|
|
109
|
+
body: `Load and follow the thatch-refine skill to refine a plan for the current project.
|
|
110
|
+
|
|
111
|
+
The user message may name a project type (team-app, personal, shared-lib, spike, infra, oss-contribution); if it does, skip classification and use that type. If no plan is drafted yet in this session, the skill's loop starts with drafting one.`,
|
|
112
|
+
},
|
|
103
113
|
{
|
|
104
114
|
name: "reflect",
|
|
105
115
|
description: "Persist what this session learned as memories",
|
|
@@ -108,10 +118,20 @@ After dispatching, call thatch_extraction_done to acknowledge.`,
|
|
|
108
118
|
];
|
|
109
119
|
}
|
|
110
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Renders a YAML double-quoted scalar. Plain scalars break on values
|
|
123
|
+
* containing ": " (invalid YAML - the mapping key would be ambiguous), and
|
|
124
|
+
* opencode's lenient fallback parser does not rescue every host path, so the
|
|
125
|
+
* description must always be quoted.
|
|
126
|
+
*/
|
|
127
|
+
function yamlQuote(value: string): string {
|
|
128
|
+
return JSON.stringify(value);
|
|
129
|
+
}
|
|
130
|
+
|
|
111
131
|
/** Renders one action as a full command markdown file. */
|
|
112
132
|
export function renderActionCommand(action: ActionDef): string {
|
|
113
133
|
return `---
|
|
114
|
-
description: ${action.description}
|
|
134
|
+
description: ${yamlQuote(action.description)}
|
|
115
135
|
---
|
|
116
136
|
${userMessageSection}
|
|
117
137
|
|
|
@@ -121,7 +141,7 @@ ${action.body}`;
|
|
|
121
141
|
}
|
|
122
142
|
|
|
123
143
|
const COMPACT_TEMPLATE = `---
|
|
124
|
-
description: Flush thatch persistence, check for loose ends, then compact if clear
|
|
144
|
+
description: ${yamlQuote("Flush thatch persistence, check for loose ends, then compact if clear")}
|
|
125
145
|
---
|
|
126
146
|
${userMessageSection}
|
|
127
147
|
|
|
@@ -138,7 +158,7 @@ ${COMPACT_READY_TOKEN}
|
|
|
138
158
|
If anything is outstanding, list the items concisely so the user can address them, and do NOT include the token. The session will only be compacted when the token is present.`;
|
|
139
159
|
|
|
140
160
|
const EXIT_TEMPLATE = `---
|
|
141
|
-
description: Flush thatch persistence, check for loose ends, then exit opencode if clear
|
|
161
|
+
description: ${yamlQuote("Flush thatch persistence, check for loose ends, then exit opencode if clear")}
|
|
142
162
|
---
|
|
143
163
|
${userMessageSection}
|
|
144
164
|
|
package/src/db.ts
CHANGED
|
@@ -55,12 +55,67 @@ export interface DedupCandidate {
|
|
|
55
55
|
* stored as BLOBs. Similarity search is brute-force cosine in JS.
|
|
56
56
|
*/
|
|
57
57
|
export class ThatchDB {
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
58
|
+
#path: string;
|
|
59
|
+
#handle: Database | null = null;
|
|
60
|
+
#predictionsStore!: PredictionEngine;
|
|
61
|
+
#behaviorsStore!: BehaviorEngine;
|
|
62
|
+
#chatStore!: ChatStore;
|
|
62
63
|
|
|
63
64
|
constructor(path: string) {
|
|
65
|
+
this.#path = path;
|
|
66
|
+
this.#open();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Connection accessor. Every read goes through here, so a handle closed by
|
|
71
|
+
* dispose() (a v2 plugin reload tears down the old activation while tool
|
|
72
|
+
* calls may still be in flight) transparently reopens on the next touch
|
|
73
|
+
* instead of surfacing bun:sqlite's "Cannot use a closed database". WAL
|
|
74
|
+
* journaling plus busy_timeout make the extra connection safe - the chat
|
|
75
|
+
* subsystem already runs as several concurrent connections by design.
|
|
76
|
+
*
|
|
77
|
+
* OWNERSHIP RULE: reopen serves READS and in-flight callers only. After a
|
|
78
|
+
* reload, the RELOADED instance owns the runtime_state journal rows, so
|
|
79
|
+
* post-dispose journal writes must no-op - they are guarded by the
|
|
80
|
+
* disposed flag at their call sites in src/runtime.ts, checked BEFORE any
|
|
81
|
+
* db access reaches this accessor. Do not move that guard in here: this
|
|
82
|
+
* method cannot distinguish a legitimate in-flight caller from a stale
|
|
83
|
+
* writer.
|
|
84
|
+
*
|
|
85
|
+
* Reopen caveat: a `:memory:` database has no file behind it, so a
|
|
86
|
+
* close-then-use cycle on one yields a brand-new EMPTY database. No
|
|
87
|
+
* production path does that (:memory: is test-only and tests never use the
|
|
88
|
+
* instance after close), but keep it that way - use a tmp-file db if a
|
|
89
|
+
* test needs close-then-use.
|
|
90
|
+
*/
|
|
91
|
+
get #db(): Database {
|
|
92
|
+
if (this.#handle === null) this.#open();
|
|
93
|
+
return this.#handle!;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The engine facades get the same treatment as #db: every accessor
|
|
97
|
+
// ensures an open connection first, so delegation routes through the
|
|
98
|
+
// facade REBUILT by the reopen rather than the stale one. Without this,
|
|
99
|
+
// a post-close chat call would reach the old ChatStore's captured handle
|
|
100
|
+
// and throw "Cannot use a closed database" even though ThatchDB itself
|
|
101
|
+
// reopened.
|
|
102
|
+
get #predictions(): PredictionEngine {
|
|
103
|
+
if (this.#handle === null) this.#open();
|
|
104
|
+
return this.#predictionsStore;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
get #behaviors(): BehaviorEngine {
|
|
108
|
+
if (this.#handle === null) this.#open();
|
|
109
|
+
return this.#behaviorsStore;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
get #chat(): ChatStore {
|
|
113
|
+
if (this.#handle === null) this.#open();
|
|
114
|
+
return this.#chatStore;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#open(): void {
|
|
118
|
+
const path = this.#path;
|
|
64
119
|
// bun:sqlite creates the file but not its parent directory. On a fresh
|
|
65
120
|
// machine the default path (~/.config/thatch/thatch.db) has no dir yet, so
|
|
66
121
|
// create it here - otherwise every command fails with SQLITE_CANTOPEN.
|
|
@@ -68,18 +123,23 @@ export class ThatchDB {
|
|
|
68
123
|
if (path !== ":memory:" && !path.startsWith("file:")) {
|
|
69
124
|
mkdirSync(dirname(path), { recursive: true });
|
|
70
125
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
126
|
+
const db = new Database(path, { create: true });
|
|
127
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
128
|
+
db.run("PRAGMA busy_timeout = 5000");
|
|
74
129
|
// Enforce FK constraints per connection. The prediction tables use
|
|
75
130
|
// ON DELETE CASCADE for edges and provenance; enabling this pragma
|
|
76
131
|
// ensures those cascades fire. Also enforces the entries table's
|
|
77
132
|
// existing FK to stores(name), which was declarative but unenforced
|
|
78
133
|
// before this branch.
|
|
79
|
-
|
|
80
|
-
this.#
|
|
81
|
-
|
|
82
|
-
|
|
134
|
+
db.run("PRAGMA foreign_keys = ON");
|
|
135
|
+
this.#handle = db;
|
|
136
|
+
// The engine facades hold the Database handle they were constructed
|
|
137
|
+
// with, so a reopened connection rebuilds them. They are stateless
|
|
138
|
+
// views over SQLite - all durable state lives in the tables - so
|
|
139
|
+
// reconstruction is lossless.
|
|
140
|
+
this.#predictionsStore = new PredictionEngine(db);
|
|
141
|
+
this.#behaviorsStore = new BehaviorEngine(db);
|
|
142
|
+
this.#chatStore = new ChatStore(db);
|
|
83
143
|
this.#initSchema();
|
|
84
144
|
}
|
|
85
145
|
|
|
@@ -1169,7 +1229,12 @@ export class ThatchDB {
|
|
|
1169
1229
|
}
|
|
1170
1230
|
|
|
1171
1231
|
close(): void {
|
|
1172
|
-
|
|
1232
|
+
// Drops the current handle only. A later use reopens (see the #db
|
|
1233
|
+
// accessor) - the MCP server and CLI call this on process shutdown,
|
|
1234
|
+
// where nothing follows; the opencode plugin's dispose calls it on
|
|
1235
|
+
// reload, where straggler in-flight tool calls must survive.
|
|
1236
|
+
this.#handle?.close();
|
|
1237
|
+
this.#handle = null;
|
|
1173
1238
|
}
|
|
1174
1239
|
}
|
|
1175
1240
|
|
package/src/extraction.ts
CHANGED
|
@@ -233,6 +233,93 @@ export class ExtractionPipeline {
|
|
|
233
233
|
this.#journal?.("accepted", sessionID, undefined);
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
#claims = new Map<string, { target: string; entries: ToolInteraction[] }>();
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Claim the session's pending + previously-accepted entries for a fetcher
|
|
240
|
+
* (the extractor sub-agent fetching its payload). The entries stay in the
|
|
241
|
+
* accepted holding area; the claim records WHICH fetcher they were
|
|
242
|
+
* delivered to, so that fetcher's completion signal consumes only its own
|
|
243
|
+
* delivery. Without this, any child's completion dropped the whole
|
|
244
|
+
* accepted set - a sibling sub-agent going idle at the wrong moment
|
|
245
|
+
* silently discarded another extractor's in-flight payload (the
|
|
246
|
+
* accept-then-racing-completion loss, observed September 2026).
|
|
247
|
+
*
|
|
248
|
+
* Entries are claimed by reference identity: later accepts append new
|
|
249
|
+
* arrays, so a stale claim never reaches entries claimed after it.
|
|
250
|
+
*/
|
|
251
|
+
claim(sessionID: string, fetcherID: string): ToolInteraction[] {
|
|
252
|
+
this.accept(sessionID);
|
|
253
|
+
const accepted = this.#accepted.get(sessionID) ?? [];
|
|
254
|
+
this.#claims.set(fetcherID, { target: sessionID, entries: accepted });
|
|
255
|
+
return accepted;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Consume only the fetcher's claimed entries. Returns false when the
|
|
260
|
+
* fetcher holds no claim - a sibling that never fetched, or a fetch that
|
|
261
|
+
* never happened. Callers must NOT fall back to whole-set completion on
|
|
262
|
+
* the idle path: a fetcher that never fetched processed nothing, and
|
|
263
|
+
* dropping the set would lose another extractor's claimed payload.
|
|
264
|
+
*/
|
|
265
|
+
completeClaimed(fetcherID: string): boolean {
|
|
266
|
+
const claim = this.#claims.get(fetcherID);
|
|
267
|
+
if (!claim) return false;
|
|
268
|
+
this.#claims.delete(fetcherID);
|
|
269
|
+
const accepted = this.#accepted.get(claim.target);
|
|
270
|
+
if (accepted) {
|
|
271
|
+
const claimed = new Set(claim.entries);
|
|
272
|
+
const rest = accepted.filter((ix) => !claimed.has(ix));
|
|
273
|
+
if (rest.length > 0) {
|
|
274
|
+
this.#accepted.set(claim.target, rest);
|
|
275
|
+
this.#journal?.("accepted", claim.target, rest);
|
|
276
|
+
} else {
|
|
277
|
+
this.#accepted.delete(claim.target);
|
|
278
|
+
this.#acceptedAt.delete(claim.target);
|
|
279
|
+
this.#journal?.("accepted", claim.target, undefined);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Forget a fetcher's claim (its session was deleted or cleaned up). */
|
|
286
|
+
dropClaim(fetcherID: string): void {
|
|
287
|
+
this.#claims.delete(fetcherID);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Requeue only the fetcher's claimed entries back to pending: its session
|
|
292
|
+
* errored, so the delivery was never processed. Returns false when the
|
|
293
|
+
* fetcher holds no claim - a task-kind child that never fetched processed
|
|
294
|
+
* nothing, and requeueing the whole accepted set here would yank another
|
|
295
|
+
* extractor's in-flight claim back to pending for duplicate extraction.
|
|
296
|
+
* Callers decide whether a claim-less error falls back to whole-set
|
|
297
|
+
* requeue (extraction children: yes - the error is a strong immediate
|
|
298
|
+
* death signal; task children: no).
|
|
299
|
+
*/
|
|
300
|
+
requeueClaimed(fetcherID: string): boolean {
|
|
301
|
+
const claim = this.#claims.get(fetcherID);
|
|
302
|
+
if (!claim) return false;
|
|
303
|
+
this.#claims.delete(fetcherID);
|
|
304
|
+
const accepted = this.#accepted.get(claim.target);
|
|
305
|
+
if (accepted) {
|
|
306
|
+
const claimed = new Set(claim.entries);
|
|
307
|
+
const rest = accepted.filter((ix) => !claimed.has(ix));
|
|
308
|
+
if (rest.length > 0) {
|
|
309
|
+
this.#accepted.set(claim.target, rest);
|
|
310
|
+
this.#journal?.("accepted", claim.target, rest);
|
|
311
|
+
} else {
|
|
312
|
+
this.#accepted.delete(claim.target);
|
|
313
|
+
this.#acceptedAt.delete(claim.target);
|
|
314
|
+
this.#journal?.("accepted", claim.target, undefined);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const buf = this.#buffers.get(claim.target) ?? [];
|
|
318
|
+
this.#buffers.set(claim.target, [...claim.entries, ...buf]);
|
|
319
|
+
this.#journal?.("buffer", claim.target, this.#buffers.get(claim.target));
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
236
323
|
/**
|
|
237
324
|
* Move accepted entries back to pending: the extractor errored or its
|
|
238
325
|
* session was deleted before completing, so the facts were never
|
|
@@ -254,9 +341,8 @@ export class ExtractionPipeline {
|
|
|
254
341
|
/**
|
|
255
342
|
* Requeues accepted entries that have waited longer than maxAgeMs for a
|
|
256
343
|
* completion signal that never came: the extractor sub-agent crashed
|
|
257
|
-
* without acking, or
|
|
258
|
-
*
|
|
259
|
-
* un-nudged - silent loss. Returns the session ids that had entries
|
|
344
|
+
* without acking, or the completion ack was lost. Without this, accepted
|
|
345
|
+
* entries linger unprocessed AND un-nudged - silent loss. Returns the session ids that had entries
|
|
260
346
|
* requeued; the regular nudge path picks them up from pending on the
|
|
261
347
|
* session's next message.
|
|
262
348
|
*/
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// SDK. The pure helpers re-exported here live in SDK-free modules for the
|
|
15
15
|
// same reason.
|
|
16
16
|
|
|
17
|
+
import { appendFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
17
19
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
18
20
|
import type { Plugin as V2Plugin } from "@opencode/plugin";
|
|
19
21
|
|
|
@@ -27,6 +29,49 @@ export {
|
|
|
27
29
|
} from "./os-args";
|
|
28
30
|
export { hygieneReport } from "./hygiene";
|
|
29
31
|
|
|
32
|
+
// One-per-process unhandledRejection logger, opt-in via THATCH_DEBUG. The
|
|
33
|
+
// host logs these bare ("unhandled rejection" + message, no stack), which is
|
|
34
|
+
// exactly wrong for diagnosing which async continuation in the plugin
|
|
35
|
+
// escaped its error handling - the dispose-race class in particular escapes
|
|
36
|
+
// through several surfaces (pollers, hooks, delayed finalizers). Writes to
|
|
37
|
+
// the debug log beside the database (same file createDebugLog uses), tagged
|
|
38
|
+
// so a grep on "[rejection:" pulls the story out.
|
|
39
|
+
//
|
|
40
|
+
// Registered at module scope because rejections can fire before or after
|
|
41
|
+
// setup runs, and once per process because v2 re-evaluates this module on
|
|
42
|
+
// every plugin reload (a bare process.on would pile up listeners). The key
|
|
43
|
+
// is a Symbol.for so the guard survives module re-evaluation in the same
|
|
44
|
+
// thread; worker threads have their own globalThis and process, and one
|
|
45
|
+
// logger per thread is correct anyway. Registering the listener marks the
|
|
46
|
+
// rejections "handled" - suppressing the host's default crash-on-unhandled -
|
|
47
|
+
// so the handler also echoes the detail to console.error to keep the
|
|
48
|
+
// failure visible while diagnosing.
|
|
49
|
+
const rejectionLoggerKey = Symbol.for("thatch.unhandledRejectionLogger");
|
|
50
|
+
if (process.env.THATCH_DEBUG && !(globalThis as any)[rejectionLoggerKey]) {
|
|
51
|
+
const home = process.env.HOME ?? "/tmp";
|
|
52
|
+
const configHome = process.env.XDG_CONFIG_HOME ?? `${home}/.config`;
|
|
53
|
+
const dbPath = process.env.THATCH_DB_PATH ?? `${configHome}/thatch/thatch.db`;
|
|
54
|
+
// A file: URI db path has no meaningful sibling debug.log (appendFileSync
|
|
55
|
+
// cannot open "file:.../debug.log"); skip logging rather than silently
|
|
56
|
+
// drop every rejection into the catch below.
|
|
57
|
+
(globalThis as any)[rejectionLoggerKey] = !dbPath.startsWith("file:");
|
|
58
|
+
if ((globalThis as any)[rejectionLoggerKey]) {
|
|
59
|
+
const file = join(dirname(dbPath), "debug.log");
|
|
60
|
+
process.on("unhandledRejection", (reason) => {
|
|
61
|
+
const detail =
|
|
62
|
+
reason instanceof Error
|
|
63
|
+
? `${reason.message}\n${reason.stack ?? "(no stack)"}`
|
|
64
|
+
: String(reason);
|
|
65
|
+
console.error(`[thatch] unhandled rejection: ${detail.split("\n")[0]}`);
|
|
66
|
+
try {
|
|
67
|
+
appendFileSync(file, `${new Date().toISOString()} [rejection:unhandled] ${detail}\n`);
|
|
68
|
+
} catch {
|
|
69
|
+
// Diagnostics must not break the host (same contract as debug.log).
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
30
75
|
// Named v1 export, preserved for the shim and tests. Lazy wrapper: never
|
|
31
76
|
// evaluate the v1 adapter (and its SDK import) under a v2 host.
|
|
32
77
|
export const server: Plugin = async (input, options) => (await import("./opencode/v1")).server(input, options);
|