@jeffober/thatch 0.1.37 → 0.1.39
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 +8 -2
- package/docs/dev/README.md +3 -1
- package/docs/dev/features/README.md +3 -2
- package/docs/dev/features/database.md +2 -2
- package/docs/dev/features/multi-host.md +2 -2
- package/docs/dev/features/notifications.md +106 -0
- package/docs/dev/features/qa-system.md +18 -17
- package/docs/dev/gotchas.md +18 -0
- package/docs/dev/mcp-parity.md +1 -1
- package/docs/dev/skills.md +6 -6
- package/docs/user/README.md +35 -13
- package/docs/user/notifications.md +84 -0
- package/docs/user/skills.md +1 -1
- package/package.json +1 -1
- package/src/config.ts +103 -0
- package/src/embeddings.ts +32 -0
- package/src/index.ts +22 -0
- package/src/notify.ts +123 -0
- package/src/prompts.ts +26 -2
- package/src/tool-defs.ts +173 -0
- package/src/watchers.ts +20 -3
package/README.md
CHANGED
|
@@ -92,8 +92,14 @@ Thatch gives your agent three layers of persistent context:
|
|
|
92
92
|
only). The agent registers a watch on a GitHub PR or branch (main); thatch polls it in the
|
|
93
93
|
background and prompts the session when comments, commits, CI results, or
|
|
94
94
|
status changes arrive. Notifications carry pointer data only.
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
- **Notifications + user config** -- the agent can ping you out-of-band when
|
|
96
|
+
a long-running outcome lands: a desktop banner, a spoken voice
|
|
97
|
+
announcement, or both (macOS and Linux). Preferences live in a
|
|
98
|
+
hand-editable config file (`~/.config/thatch/config.json`) that the agent
|
|
99
|
+
manages through `config_get`/`config_set` -- ask it to change your voice
|
|
100
|
+
or quiet notifications entirely.
|
|
101
|
+
|
|
102
|
+
Plus **skills** for memory workflows, structured multi-specialist code
|
|
97
103
|
review, review response, change and feature walkthroughs, memory verification,
|
|
98
104
|
knowledge export, and writing tasks
|
|
99
105
|
(PR descriptions, tickets, PR splitting).
|
package/docs/dev/README.md
CHANGED
|
@@ -72,11 +72,13 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
|
|
|
72
72
|
| `embeddings.ts` | Lazy-load the embedding model. Expose `queryEmbed`/`passageEmbed` and the model `name` (stored as an informational tag). `MockEmbeddingModel` for tests. |
|
|
73
73
|
| `extraction.ts` | Per-session in-memory ring buffer (cap 20) that buffers non-thatch tool interactions and serializes them into the JSON payload the `get_extraction_payload` tool returns. The in-memory pipeline is opencode-only, but the payload builders (`buildExtractionPayload`, `deriveTitle`) are shared by both paths — `extract-queue.ts` imports `deriveTitle`, `mcp.ts` imports `buildExtractionPayload` for the extraction payload provider. `summarizeArgs` is used internally by `buildExtractionPayload`. |
|
|
74
74
|
| `extract-queue.ts` | File-backed per-session JSONL queue (caps 20, oldest dropped). Shared by the Claude Code and Cursor hook paths, which fire one-shot per event with no cross-call state. This is the MCP-side equivalent of `extraction.ts`. |
|
|
75
|
+
| `config.ts` | User config file (`~/.config/thatch/config.json`, beside the DB): Zod section schemas (strict — unknown keys fail validation), load with missing/invalid fallback to defaults, field-level merge, atomic save (temp + rename). Backs the `config_get`/`config_set` tools. |
|
|
76
|
+
| `notify.ts` | Out-of-band notification dispatch: darwin (`osascript` banner + `/usr/bin/say` voice), linux (`notify-send` + `spd-say`/`espeak`), other platforms report unsupported. Injectable spawner (`CoreContext.spawner`) so tests never fire real commands. Backs the `notify_user` tool. |
|
|
75
77
|
| `session-db.ts` | Read-only access to the opencode session database (opencode.db): session timeline (`session list`), full part/message retrieval (`session get`), OpenAI chat-completions transcript builder (`session transcript`), and decoded-content search (`session search`). Backs the `thatch session` CLI subcommands and the opencode-only `session_search`/`session_get` tools. Path resolution honors `OPENCODE_DB`, then the XDG default; the db opens read-only so a thatch bug cannot corrupt host history. |
|
|
76
78
|
| `watchers.ts` | In-memory watcher registry and background poller for event-driven notifications (opencode-only). Watches GitHub PRs and branches via `gh api` (injected `GhRunner`), diffs against last-seen state, queues pointer-only events per session, and delivers them through a plugin-supplied callback when the session is idle. Process-lifetime state by design - no SQLite. |
|
|
77
79
|
| `sideband.ts` | Unix domain socket server + client. The MCP server (long-lived, warm model) runs `SidebandServer` so one-shot hook processes can ask it to embed a prompt and search for matches without loading the model themselves. Handles three methods: `match` (recall nudge), `predictions` (prediction auto-fire), and `behaviors` (behavior auto-fire). Socket path is a hash of the DB path — both processes compute it independently. |
|
|
78
80
|
| `prompts.ts` | Text constants: opencode system prompt, compaction context, session-start reminder, prompt-aware recall nudge (`recallNudge` / `claudeRecallNudge`), prediction nudge (`predictionNudge`), behavior nudge (`behaviorNudge`), prediction verb selection (`predictionVerb`), Claude Code CLAUDE.md instructions, Cursor AGENTS.md instructions, Claude Code hook text. |
|
|
79
|
-
| `skills.ts` | `SKILL.md` content for all thatch skills, plus the installer. Skills are split into `SHARED_SKILLS` (
|
|
81
|
+
| `skills.ts` | `SKILL.md` content for all thatch skills, plus the installer. Skills are split into `SHARED_SKILLS` (fact-extractor, dedup-classifier, project-primer, the review specialists, review synthesizer, review context, code archaeology, review followup, review response, change walkthrough, code walkthrough, session reflection, coding-workflow, thatch-pr-description, thatch-ticket-description, thatch-split-overlarge-pr, memory-verify, knowledge-export — work on all three hosts) and `OPENCODE_ONLY_SKILLS` (code-review coordinator — requires sub-agent support, not installed for Claude Code or Cursor). `installSkills(dir, skills)` defaults to `SHARED_SKILLS`; the opencode plugin passes `[...SHARED_SKILLS, ...OPENCODE_ONLY_SKILLS]`. |
|
|
80
82
|
| `scoring-engine.ts` | Generic four-table scoring engine with Bayesian confidence. Shared base for prediction and behavior engines — each wraps it with table-specific names. |
|
|
81
83
|
| `prediction.ts` | Thin wrapper around `ScoringEngine` with prediction-specific table names. |
|
|
82
84
|
| `behavior.ts` | Thin wrapper around `ScoringEngine` with behavior-specific table names. |
|
|
@@ -27,8 +27,9 @@ system, see [../skills.md](../skills.md). For non-obvious invariants, see
|
|
|
27
27
|
| [compaction-recovery.md](compaction-recovery.md) | Compaction recovery (opencode compaction hooks) |
|
|
28
28
|
| [session-lifecycle.md](session-lifecycle.md) | Session lifecycle management (opencode events, child tracking) |
|
|
29
29
|
| [watchers.md](watchers.md) | Event-driven notifications (in-memory registry, background poller, PR watching) |
|
|
30
|
+
| [notifications.md](notifications.md) | Out-of-band notification (banner + voice) and the user config file |
|
|
30
31
|
| [setup.md](setup.md) | Setup system (markers, checkSetup, auto-refresh, binary resolution) |
|
|
31
32
|
| [cli.md](cli.md) | CLI (subcommands, environment variables) |
|
|
32
|
-
| [database.md](database.md) | Database (schema, migrations, WAL
|
|
33
|
-
| [qa-system.md](qa-system.md) | QA system (
|
|
33
|
+
| [database.md](database.md) | Database (schema, migrations, WAL) |
|
|
34
|
+
| [qa-system.md](qa-system.md) | QA system (automatable vs live, mise tasks) |
|
|
34
35
|
| [cicd.md](cicd.md) | CI/CD (CI pipeline, OIDC publishing, release helper) |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Database
|
|
2
2
|
|
|
3
|
-
Thatch stores all data in a single SQLite file. The schema
|
|
3
|
+
Thatch stores all data in a single SQLite file. The schema is organized around three concerns: memory entries, the prediction engine, and the behavior engine.
|
|
4
4
|
|
|
5
5
|
## Configuration
|
|
6
6
|
|
|
@@ -10,7 +10,7 @@ Thatch stores all data in a single SQLite file. The schema has 11 tables organiz
|
|
|
10
10
|
|
|
11
11
|
## Schema overview
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
The tables fall into three groups:
|
|
14
14
|
|
|
15
15
|
| Group | Tables | Purpose |
|
|
16
16
|
|-------|--------|---------|
|
|
@@ -187,7 +187,7 @@ For the full parity matrix, see [../mcp-parity.md](../mcp-parity.md).
|
|
|
187
187
|
|
|
188
188
|
## Interactions with other features
|
|
189
189
|
|
|
190
|
-
The
|
|
190
|
+
The shared tools are available on all hosts via the single source of truth
|
|
191
191
|
in `src/tool-defs.ts`; opencode-only tools (e.g. `get_session_info`) are
|
|
192
192
|
filtered out by the MCP server. Individual tool behavior is documented in
|
|
193
193
|
[memory-store.md](memory-store.md), [extraction.md](extraction.md),
|
|
@@ -227,7 +227,7 @@ the opencode-only skills require sub-agent dispatch or in-process hooks.
|
|
|
227
227
|
|
|
228
228
|
## Key invariants
|
|
229
229
|
|
|
230
|
-
- All hosts share the tool definitions from `src/tool-defs.ts` (
|
|
230
|
+
- All hosts share the tool definitions from `src/tool-defs.ts` (shared tools plus opencode-only tools marked `opencodeOnly: true`, which the MCP server filters out). Adding a tool requires updating `TOOL_DEFS` and all three prompt functions; opencode-only tools go in the opencode prompt only.
|
|
231
231
|
- The MCP server always exposes bare names. The `mcp__thatch__` prefix is applied by the MCP client, not thatch.
|
|
232
232
|
- The three system prompt variants are independent string constants in `src/prompts.ts` with no shared template. Editing shared prose in one requires mirroring in the other two, or they drift.
|
|
233
233
|
- The tool list line in all three prompts must match `TOOL_DEFS` in `src/tool-defs.ts` (opencode's list includes opencode-only tools; the MCP lists exclude them). The historical failure mode: a tool is added to `TOOL_DEFS` but only two of three prompts are updated.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Notifications and user config
|
|
2
|
+
|
|
3
|
+
Out-of-band user notification (desktop banner + spoken voice) backed by a
|
|
4
|
+
hand-editable user config file. Both are plain child processes and file I/O,
|
|
5
|
+
so the feature works identically on every host -- opencode, Claude Code, and
|
|
6
|
+
Cursor -- with no host-specific plumbing and no new npm dependencies.
|
|
7
|
+
|
|
8
|
+
## Source files
|
|
9
|
+
|
|
10
|
+
| File | Role |
|
|
11
|
+
|------|------|
|
|
12
|
+
| `src/config.ts` | Zod section schemas, config file load/save/merge, platform defaults. |
|
|
13
|
+
| `src/notify.ts` | Per-platform dispatch for banner + voice, with an injectable spawner. |
|
|
14
|
+
| `src/tool-defs.ts` | `config_get`, `config_set`, `notify_user` tool definitions (shared registry, non-opencodeOnly). |
|
|
15
|
+
|
|
16
|
+
## Config design
|
|
17
|
+
|
|
18
|
+
The config is one JSON file at `~/.config/thatch/config.json`, placed next to
|
|
19
|
+
`thatch.db` by deriving the path from `THATCH_DB_PATH` (or the XDG default the
|
|
20
|
+
same way `index.ts` and `mcp.ts` derive the DB path). Tests pass an explicit
|
|
21
|
+
dbPath to keep writes inside a tempdir.
|
|
22
|
+
|
|
23
|
+
Key decisions:
|
|
24
|
+
|
|
25
|
+
- **Sections are `z.strictObject`.** Unknown keys fail validation instead of
|
|
26
|
+
being silently stripped, so a newer-version config round-tripping through an
|
|
27
|
+
older process cannot lose data. An unparseable file is *ignored* (empty
|
|
28
|
+
config + warning), never fatal -- a broken hand edit must not take the agent
|
|
29
|
+
down.
|
|
30
|
+
- **Field-level merge in `config_set`.** `mergeNotificationPrefs` is a shallow
|
|
31
|
+
spread of current + patch (with `undefined` values stripped first). The
|
|
32
|
+
tool's description and the system prompt both state the merge semantics, and
|
|
33
|
+
the setter *echoes the resulting section* so the model can verify the write
|
|
34
|
+
in the same turn. This is the guardrail against the classic
|
|
35
|
+
read-modify-write failure where a model reconstructs a document and drops
|
|
36
|
+
sibling fields.
|
|
37
|
+
- **Atomic writes.** `saveConfig` writes a temp file then renames, so a crash
|
|
38
|
+
mid-write cannot leave a truncated config and concurrent readers never see a
|
|
39
|
+
partial document.
|
|
40
|
+
- **No env-var overrides for notification prefs.** The config file is the
|
|
41
|
+
single source of truth; `THATCH_*` env vars remain reserved for
|
|
42
|
+
infrastructure (DB path, model, thresholds). Preferences the LLM manages
|
|
43
|
+
belong in the file the LLM manages.
|
|
44
|
+
|
|
45
|
+
`notificationDefaults()` supplies the platform fallbacks shown by `config_get`
|
|
46
|
+
and applied by `notify_user`: darwin pins `Zarvox` + `Submarine` (the author's
|
|
47
|
+
preference); other platforms defer to their tools' defaults.
|
|
48
|
+
|
|
49
|
+
## Notification dispatch
|
|
50
|
+
|
|
51
|
+
`sendNotification(request, spawner)` composes one or two command steps and
|
|
52
|
+
returns a result string for the tool response:
|
|
53
|
+
|
|
54
|
+
- **darwin**: banner via `/usr/bin/osascript -e 'display notification ...'`
|
|
55
|
+
(title defaults to `source`, then `thatch`; sound defaults to `Submarine`),
|
|
56
|
+
voice via `/usr/bin/say -v <voice>` (absolute path -- `~/bin/say` shadows
|
|
57
|
+
the system binary on the author's machine).
|
|
58
|
+
- **linux**: banner via `notify-send`; voice via `spd-say -w`, falling back to
|
|
59
|
+
`espeak` when speech-dispatcher is absent. A voice override goes straight to
|
|
60
|
+
`espeak -v` because `spd-say` has no voice flag.
|
|
61
|
+
- **other platforms** (win32 included): `[unsupported]` result, nothing runs.
|
|
62
|
+
Windows support is deliberately not a goal.
|
|
63
|
+
|
|
64
|
+
Details that matter:
|
|
65
|
+
|
|
66
|
+
- **The spawner is a `CoreContext` extension field** (`ctx.spawner`), the same
|
|
67
|
+
pattern as `watchers` and `extractionPayloadProvider`. Tests inject a mock;
|
|
68
|
+
production falls back to `defaultSpawner` (Bun.spawn). No test ever fires a
|
|
69
|
+
real banner or speaks.
|
|
70
|
+
- **Failures never throw.** Every step's exit code and stderr are captured;
|
|
71
|
+
the result string is `[notified]` when anything succeeded, `[failed]` when
|
|
72
|
+
all requested channels failed, `[skipped]` when configured `mode` is
|
|
73
|
+
`none`, `[unsupported]` on other platforms.
|
|
74
|
+
- **No delivery claims.** osascript exits 0 whether or not the banner
|
|
75
|
+
displayed (focus modes silently drop it), so result text reports command
|
|
76
|
+
success only.
|
|
77
|
+
- **The spoken line prefixes the `source` label** ("PLAT-280: CI is green") so
|
|
78
|
+
a user with several concurrent agent sessions knows which one spoke. The
|
|
79
|
+
tool description enforces this on the model side.
|
|
80
|
+
- Speech is awaited (the tool returns when the sentence finishes) so error
|
|
81
|
+
reporting stays honest. There is no debounce; polling agents notify once at
|
|
82
|
+
the end.
|
|
83
|
+
|
|
84
|
+
## Prompt surface
|
|
85
|
+
|
|
86
|
+
- `systemPrompt()` and `mcpInstructions()` list the three new tools and carry
|
|
87
|
+
a short "Notifications" section: when to notify, the source-label rule, and
|
|
88
|
+
the config_get-before-config_set instruction.
|
|
89
|
+
- `notify_user` is not `opencodeOnly`: it needs no host capabilities, so MCP
|
|
90
|
+
hosts get it too. Nothing was added to `src/tools.ts` or `src/index.ts`.
|
|
91
|
+
|
|
92
|
+
## Tests
|
|
93
|
+
|
|
94
|
+
- `tests/config.test.ts` -- path derivation, missing/invalid/strict-schema
|
|
95
|
+
handling, round-trip, atomic save leaves no temp files, merge keeps
|
|
96
|
+
siblings, darwin defaults.
|
|
97
|
+
- `tests/notify.test.ts` -- command construction per platform (mock spawner
|
|
98
|
+
records argv), AppleScript escaping, partial vs total failure, unsupported
|
|
99
|
+
platform, plus tool-level tests (merge-no-wipe, echo, `mode: none` no-op,
|
|
100
|
+
configured voice honored) with `THATCH_DB_PATH` pointed at a tempdir.
|
|
101
|
+
- `tests/tool-defs.test.ts` -- registry count and name list (hardcoded literals that fail loudly when the surface changes).
|
|
102
|
+
|
|
103
|
+
## QA coverage
|
|
104
|
+
|
|
105
|
+
`tests/qa/auto/uc-059-tool-prefixing.ts` asserts the full bare-name list; the
|
|
106
|
+
three new names are in its `expected` array.
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
# QA System
|
|
2
2
|
|
|
3
|
-
Thatch ships
|
|
4
|
-
a `bun:test` file that runs in an isolated environment — a
|
|
5
|
-
of the repo, not the working tree itself. Use cases are
|
|
6
|
-
**automatable** (no LLM, fast) and **live** (spawns opencode, costs
|
|
7
|
-
tokens).
|
|
3
|
+
Thatch ships a suite of QA use cases that verify end-to-end behavior. Each use
|
|
4
|
+
case is a `bun:test` file that runs in an isolated environment — a
|
|
5
|
+
temp-directory copy of the repo, not the working tree itself. Use cases are
|
|
6
|
+
split into **automatable** (no LLM, fast) and **live** (spawns opencode, costs
|
|
7
|
+
model tokens).
|
|
8
8
|
|
|
9
9
|
## What it does
|
|
10
10
|
|
|
11
|
-
- 95 use cases: 75 automatable + 20 live
|
|
12
11
|
- Automatable use cases run without an LLM — they test CLI behavior, hooks, DB
|
|
13
12
|
operations, and tool execution directly via bun assertions
|
|
14
13
|
- Live use cases spawn real opencode sessions — they test the full
|
|
@@ -39,7 +38,7 @@ registers all use cases in one suite, enabling `--concurrent
|
|
|
39
38
|
--max-concurrency 5` to run 5 use cases at once (33s vs 88s sequential for the
|
|
40
39
|
auto suite).
|
|
41
40
|
|
|
42
|
-
### Automatable use cases
|
|
41
|
+
### Automatable use cases
|
|
43
42
|
|
|
44
43
|
Run without an LLM. Test CLI behavior, hooks, DB operations, and tool
|
|
45
44
|
execution directly via bun assertions. These are fast and run in parallel.
|
|
@@ -66,14 +65,15 @@ execution directly via bun assertions. These are fast and run in parallel.
|
|
|
66
65
|
| UC-027 | behavior-confidence-model | ham/spam adjusts Bayesian confidence |
|
|
67
66
|
| UC-028 | behavior-delete | semantic-match deletion with cascade |
|
|
68
67
|
|
|
69
|
-
### Live use cases
|
|
68
|
+
### Live use cases
|
|
70
69
|
|
|
71
70
|
Spawn real opencode sessions. Test the full pipeline with a real model. These
|
|
72
|
-
cost model tokens and have a 20-minute per-use-case timeout.
|
|
73
|
-
|
|
74
|
-
(UC-006, UC-010, UC-013) are
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
cost model tokens and have a 20-minute per-use-case timeout. Live use cases
|
|
72
|
+
check for `opencode` on PATH at runtime: if it is present they run; if not,
|
|
73
|
+
they skip with `[MANUAL]`. Three (UC-006, UC-010, UC-013) are
|
|
74
|
+
`manualOnly: true` for infrastructure reasons (Cursor hook lifecycle, nested
|
|
75
|
+
opencode session timeout, compaction trigger). Run them locally with
|
|
76
|
+
`mise run qa-live`.
|
|
77
77
|
|
|
78
78
|
| UC | Name | What it tests |
|
|
79
79
|
|----|------|---------------|
|
|
@@ -182,8 +182,8 @@ includes all of `tests/`), just not executed during `check`. See
|
|
|
182
182
|
| `tests/qa/runner.ts` | `registerUseCase`, `UseCase` type, `ensureMaster`, `createFixture`, `runViaOpencode`, execution logic |
|
|
183
183
|
| `tests/qa/auto/index.test.ts` | Automatable use case barrel — imports all auto use case modules |
|
|
184
184
|
| `tests/qa/live/index.test.ts` | Live use case barrel — imports all live use case modules |
|
|
185
|
-
| `tests/qa/auto/uc-NNN-*.ts` |
|
|
186
|
-
| `tests/qa/live/uc-NNN-*.ts` |
|
|
185
|
+
| `tests/qa/auto/uc-NNN-*.ts` | Automatable use case files, one per use case |
|
|
186
|
+
| `tests/qa/live/uc-NNN-*.ts` | Live use case files, one per use case |
|
|
187
187
|
|
|
188
188
|
No separate QA docs directory exists. QA use cases live in `tests/qa/` as executable tests, not docs.
|
|
189
189
|
|
|
@@ -191,8 +191,9 @@ No separate QA docs directory exists. QA use cases live in `tests/qa/` as execut
|
|
|
191
191
|
|
|
192
192
|
1. **Automatable use cases never spawn an LLM.** They test CLI, hooks, DB, and
|
|
193
193
|
tool execution directly.
|
|
194
|
-
2. **Live use cases spawn real opencode sessions and cost model tokens.**
|
|
195
|
-
|
|
194
|
+
2. **Live use cases spawn real opencode sessions and cost model tokens.** They
|
|
195
|
+
skip with `[MANUAL]` when `opencode` is not on PATH; three are
|
|
196
|
+
`manualOnly: true` for infrastructure reasons.
|
|
196
197
|
3. **`QA_DRY_RUN=1` lists use cases without executing.** Use this for CI and
|
|
197
198
|
coverage review.
|
|
198
199
|
4. **Each use case follows the structured format**: preconditions, steps,
|
package/docs/dev/gotchas.md
CHANGED
|
@@ -146,3 +146,21 @@ here first. These are the things that have already cost time.
|
|
|
146
146
|
- **`bun test` does not typecheck**, and `tsconfig.json` excludes `tests`. Test
|
|
147
147
|
type errors are editor-only noise unless you run `tsc` on the test files
|
|
148
148
|
directly. Keep test files type-clean anyway so the editor stays quiet.
|
|
149
|
+
|
|
150
|
+
## Config and notifications
|
|
151
|
+
|
|
152
|
+
- **Tests touching the config must set `THATCH_DB_PATH`.** The config tools
|
|
153
|
+
derive the config file path from the environment (`THATCH_DB_PATH`, then the
|
|
154
|
+
XDG default), not from the test's tempdir `ThatchDB`. A config test that
|
|
155
|
+
skips this reads and writes the developer's real
|
|
156
|
+
`~/.config/thatch/config.json`. See `tests/notify.test.ts` for the
|
|
157
|
+
save-and-restore pattern.
|
|
158
|
+
- **osascript exits 0 even when macOS drops the banner.** Focus/Do Not Disturb
|
|
159
|
+
or missing notification permission silently swallow it. `notify_user` result
|
|
160
|
+
text therefore reports command success only; never interpret exit 0 as
|
|
161
|
+
delivery.
|
|
162
|
+
- **New QA use cases must be imported into the directory's barrel file**
|
|
163
|
+
(`tests/qa/auto/index.test.ts` or the live one). The `.ts` files are not
|
|
164
|
+
discovered by bun on their own. UC-095 sat out of the suite for a release
|
|
165
|
+
cycle and rotted (asserted 3 watch tools when 4 existed) because nobody
|
|
166
|
+
noticed it never ran.
|
package/docs/dev/mcp-parity.md
CHANGED
|
@@ -18,7 +18,7 @@ This document maps the feature parity and documents the remaining gaps.
|
|
|
18
18
|
|
|
19
19
|
| Feature | OpenCode plugin | Claude Code MCP + hooks | Cursor MCP + hooks | Parity |
|
|
20
20
|
|---------|----------------|------------------------|---------------------|--------|
|
|
21
|
-
| **Tools** (
|
|
21
|
+
| **Tools** (single source of truth) | Plugin tool registration, `thatch_` prefix | MCP `tools/list` + `tools/call`, `mcp__thatch__` prefix (opencode-only tools filtered out) | MCP `tools/list` + `tools/call`, `mcp__thatch__` prefix (opencode-only tools filtered out) | **Full** for the shared tools; `get_session_info` (session identity) and `session_search`/`session_get` (conversation archaeology) are opencode-only — MCP hosts have no session concept. The `thatch session` CLI subcommands cover the same archaeology on any host with an opencode.db file. The `watch_*` tools (PR and branch watchers) are opencode-only — MCP hosts have no poller or proactive-prompt channel |
|
|
22
22
|
| **System prompt** (store names, usage rules) | `experimental.chat.system.transform` — dynamic, repo baked in at runtime | CLAUDE.md static text appended by `thatch setup`; repo auto-detected by the MCP server at startup | AGENTS.md static text appended by `thatch setup`; repo auto-detected at startup | **Approximate** — static text persists through compaction, so the agent retains the usage instructions, but the dynamic per-turn refresh is lost |
|
|
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 |
|
package/docs/dev/skills.md
CHANGED
|
@@ -29,9 +29,9 @@ description: Extract durable project facts ... Use when ...
|
|
|
29
29
|
Code, and Cursor all auto-discover skills and use the description for
|
|
30
30
|
relevance).
|
|
31
31
|
|
|
32
|
-
## The
|
|
32
|
+
## The skills
|
|
33
33
|
|
|
34
|
-
**Shared
|
|
34
|
+
**Shared** — installed everywhere; no sub-agents required:
|
|
35
35
|
|
|
36
36
|
| Skill | Role |
|
|
37
37
|
|-------|------|
|
|
@@ -61,11 +61,11 @@ description: Extract durable project facts ... Use when ...
|
|
|
61
61
|
| `thatch-memory-verify` | Fact-check a single memory against the current codebase and correct stale claims. Uses git archaeology to preserve historical context when changes were intentional. |
|
|
62
62
|
| `thatch-knowledge-export` | Compile everything thatch knows about a topic into a curated markdown file for knowledge transfer. Searches across stores, curates out personal noise, fact-checks code-related memories via `thatch-memory-verify`. |
|
|
63
63
|
|
|
64
|
-
**opencode-only
|
|
64
|
+
**opencode-only** — the coordinator needs sub-agent support:
|
|
65
65
|
|
|
66
66
|
| Skill | Role |
|
|
67
67
|
|-------|------|
|
|
68
|
-
| `thatch-code-review` | Resolve review target (incl. VCS detection and connected PR/MR lookup for follow-up round detection), gather project context, research affected workflows, estimate complexity, partition, dispatch the
|
|
68
|
+
| `thatch-code-review` | Resolve review target (incl. VCS detection and connected PR/MR lookup for follow-up round detection), gather project context, research affected workflows, estimate complexity, partition, dispatch the review specialists in parallel, synthesize with a workflow-change preface and prior-comment cross-reference. |
|
|
69
69
|
|
|
70
70
|
## REVIEW_COMMON
|
|
71
71
|
|
|
@@ -99,7 +99,7 @@ The synthesizer reuses the same verification rigor but has its own structure
|
|
|
99
99
|
## The two arrays
|
|
100
100
|
|
|
101
101
|
```ts
|
|
102
|
-
const SHARED_SKILLS: SkillDef[] = [ /*
|
|
102
|
+
const SHARED_SKILLS: SkillDef[] = [ /* all shared skills above */ ];
|
|
103
103
|
const OPENCODE_ONLY_SKILLS: SkillDef[] = [ /* code-review coordinator */ ];
|
|
104
104
|
```
|
|
105
105
|
|
|
@@ -138,7 +138,7 @@ and `--cursor` pass only the shared set.
|
|
|
138
138
|
## Memory review skills in practice
|
|
139
139
|
|
|
140
140
|
- **Quick single lens**: load any specialist directly and point it at a branch.
|
|
141
|
-
- **Full review on opencode**: load `thatch-code-review` — it gathers project context, researches affected workflows, dispatches all
|
|
141
|
+
- **Full review on opencode**: load `thatch-code-review` — it gathers project context, researches affected workflows, dispatches all review specialists in parallel (with both the context brief and workflow guide injected into each briefing), then synthesizes a report that starts with the workflow changes before findings.
|
|
142
142
|
- **Full review on Claude Code/Cursor**: run each specialist in sequence, then
|
|
143
143
|
run `thatch-review-synthesizer` to verify and aggregate.
|
|
144
144
|
- **Follow-up round (re-review)**: load `thatch-review-followup` when the author
|
package/docs/user/README.md
CHANGED
|
@@ -12,6 +12,7 @@ This README is the overview. Each feature has its own guide:
|
|
|
12
12
|
- [memory.md](memory.md): persistent memory store
|
|
13
13
|
- [extraction.md](extraction.md): automatic fact extraction from tool calls
|
|
14
14
|
- [watchers.md](watchers.md): event-driven notifications (PR and branch watching)
|
|
15
|
+
- [notifications.md](notifications.md): banner and voice notifications, user config
|
|
15
16
|
- [prediction-engine.md](prediction-engine.md): user decision model
|
|
16
17
|
- [behavior-engine.md](behavior-engine.md): agent self-discipline rules
|
|
17
18
|
- [default-behaviors.md](default-behaviors.md): what ships automatically
|
|
@@ -143,6 +144,22 @@ Stores are created automatically. No setup required.
|
|
|
143
144
|
| `thatch_behavior_list` | List all codified behaviors with matchers, confidence, and provenance. |
|
|
144
145
|
| `thatch_behavior_delete` | Delete a behavior by semantic match. Edges and provenance are cascade-deleted. |
|
|
145
146
|
|
|
147
|
+
### Config tools
|
|
148
|
+
|
|
149
|
+
| Tool | What it does |
|
|
150
|
+
|------|-------------|
|
|
151
|
+
| `thatch_config_get` | Read the user config (`~/.config/thatch/config.json`), with defaults annotated. The agent manages config through these tools; you can also edit the file by hand. |
|
|
152
|
+
| `thatch_config_set` | Update config fields. Field-level merge: only fields you pass change. Returns the resulting section so the agent can verify. |
|
|
153
|
+
|
|
154
|
+
### Notification tools
|
|
155
|
+
|
|
156
|
+
| Tool | What it does |
|
|
157
|
+
|------|-------------|
|
|
158
|
+
| `thatch_notify_user` | Notify the user out-of-band: desktop banner and/or spoken voice. Use for long-running outcomes worth interrupting for (CI results, deploys, watcher events). macOS and Linux. |
|
|
159
|
+
|
|
160
|
+
See [notifications.md](notifications.md) for the full behavior and
|
|
161
|
+
configuration.
|
|
162
|
+
|
|
146
163
|
### Session tools (opencode only)
|
|
147
164
|
|
|
148
165
|
| Tool | What it does |
|
|
@@ -159,7 +176,8 @@ search` - JSONL output designed for piping to `jq` or `grep`.
|
|
|
159
176
|
|
|
160
177
|
| Tool | What it does |
|
|
161
178
|
|------|-------------|
|
|
162
|
-
| `thatch_watch_create` | Watch a GitHub PR for events (comments, review comments and replies, commits, status changes, description edits, CI completions). The plugin polls in the background and prompts the session when a watched event happens. |
|
|
179
|
+
| `thatch_watch_create` | Watch a GitHub PR for events (comments, review comments and replies, thread resolutions, commits, status changes, description edits, CI completions). The plugin polls in the background and prompts the session when a watched event happens. |
|
|
180
|
+
| `thatch_watch_branch_create` | Watch a GitHub branch (typically main) for commit landings, CI check-run completions, and workflow runs, with an optional workflow-name filter. |
|
|
163
181
|
| `thatch_watch_list` | List this session's active watchers. |
|
|
164
182
|
| `thatch_watch_cancel` | Cancel one of this session's watchers by id. |
|
|
165
183
|
|
|
@@ -287,14 +305,14 @@ response, walkthrough, and workflow skills that support the review pipeline:
|
|
|
287
305
|
|
|
288
306
|
| Skill | opencode | Claude Code | Cursor |
|
|
289
307
|
|-------|----------|-------------|--------|
|
|
290
|
-
| Memory skills
|
|
291
|
-
| Review specialists
|
|
308
|
+
| Memory skills | Yes | Yes | Yes |
|
|
309
|
+
| Review specialists | Yes | Yes | Yes |
|
|
292
310
|
| Review synthesizer | Yes | Yes | Yes |
|
|
293
311
|
| Review context + code archaeology | Yes | Yes | Yes |
|
|
294
312
|
| Review followup | Yes | Yes | Yes |
|
|
295
313
|
| Review response (author-side) | Yes | Yes | Yes |
|
|
296
|
-
| Walkthrough skills
|
|
297
|
-
| Writing skills
|
|
314
|
+
| Walkthrough skills | Yes | Yes | Yes |
|
|
315
|
+
| Writing skills | Yes | Yes | Yes |
|
|
298
316
|
| Code review coordinator | Yes | No (requires sub-agents) | No (requires sub-agents) |
|
|
299
317
|
|
|
300
318
|
### Using review skills
|
|
@@ -379,20 +397,24 @@ Store defaults to your current git repo.
|
|
|
379
397
|
|
|
380
398
|
## Configuration
|
|
381
399
|
|
|
382
|
-
|
|
400
|
+
Most configuration needs none. Two layers exist:
|
|
383
401
|
|
|
384
|
-
- **
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
### Environment variables
|
|
402
|
+
- **Preferences the agent manages** live in
|
|
403
|
+
`~/.config/thatch/config.json` (beside the database): notification mode,
|
|
404
|
+
voice, and sound. Ask your agent ("set notifications to banner only") or
|
|
405
|
+
edit the file by hand. See [notifications.md](notifications.md).
|
|
406
|
+
- **Environment variables** for infrastructure:
|
|
390
407
|
|
|
391
408
|
| Variable | Default | Description |
|
|
392
409
|
|----------|---------|-------------|
|
|
393
|
-
| `THATCH_DB_PATH` | `$XDG_CONFIG_HOME/thatch/thatch.db` | Override database location |
|
|
410
|
+
| `THATCH_DB_PATH` | `$XDG_CONFIG_HOME/thatch/thatch.db` | Override database location (the config file follows it) |
|
|
394
411
|
| `THATCH_MODEL` | `Xenova/bge-small-en-v1.5` | Override embedding model |
|
|
395
412
|
|
|
413
|
+
Unchanged defaults: the database is created automatically, the embedding
|
|
414
|
+
model downloads once and is cached, the store name is auto-detected from
|
|
415
|
+
`git remote get-url origin`, and search always includes the project store
|
|
416
|
+
and `global`.
|
|
417
|
+
|
|
396
418
|
`$XDG_CONFIG_HOME` defaults to `~/.config` when unset.
|
|
397
419
|
|
|
398
420
|
**Changing `THATCH_MODEL` on an existing database:** memories embedded by a
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Notifications
|
|
2
|
+
|
|
3
|
+
Thatch can notify you out-of-band when something happens worth leaving the
|
|
4
|
+
terminal for: a CI run finishing, a merge or deploy completing, a watched PR
|
|
5
|
+
receiving a comment. The notification is a desktop banner, a spoken voice
|
|
6
|
+
announcement, or both, depending on your preferences.
|
|
7
|
+
|
|
8
|
+
This works on every host (opencode, Claude Code, Cursor) because it is plain
|
|
9
|
+
child processes, not host plumbing.
|
|
10
|
+
|
|
11
|
+
## The tools
|
|
12
|
+
|
|
13
|
+
| Tool | What it does |
|
|
14
|
+
|------|-------------|
|
|
15
|
+
| `thatch_notify_user` | Send a banner and/or spoken notification. |
|
|
16
|
+
| `thatch_config_get` | Read the current config, with defaults annotated. |
|
|
17
|
+
| `thatch_config_set` | Update config fields. Returns the resulting section. |
|
|
18
|
+
|
|
19
|
+
### notify_user
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
thatch_notify_user(message: "CI is green, clear for merge",
|
|
23
|
+
source: "PLAT-280")
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- **macOS**: banner via Notification Center (with an alert sound) and voice
|
|
27
|
+
via the built-in `say` command.
|
|
28
|
+
- **Linux**: banner via `notify-send` (libnotify) and voice via
|
|
29
|
+
`spd-say`/`espeak` when installed. Missing tools are reported, never fatal.
|
|
30
|
+
- **Windows**: not supported. The tool reports this instead of guessing.
|
|
31
|
+
|
|
32
|
+
The agent includes a `source` label (a ticket number or feature name) so you
|
|
33
|
+
know which of your concurrent sessions is speaking. Voice text spells out
|
|
34
|
+
letter sequences ("C I green", not "CI green") so text-to-speech pronounces
|
|
35
|
+
them.
|
|
36
|
+
|
|
37
|
+
The tool result reports command success only. macOS focus modes can silently
|
|
38
|
+
swallow banners; if you are not seeing them, check Focus settings and that
|
|
39
|
+
your terminal app has notification permission.
|
|
40
|
+
|
|
41
|
+
## Configuring notifications
|
|
42
|
+
|
|
43
|
+
You have two options, and they edit the same file:
|
|
44
|
+
|
|
45
|
+
1. **Ask your agent.** "Set notifications to banner only" -- the agent reads
|
|
46
|
+
current values with `config_get`, changes what you asked for with
|
|
47
|
+
`config_set`, and shows you the result.
|
|
48
|
+
2. **Edit the file yourself.** The config lives at
|
|
49
|
+
`~/.config/thatch/config.json` (next to `thatch.db`, or under
|
|
50
|
+
`$XDG_CONFIG_HOME`).
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"notifications": {
|
|
55
|
+
"mode": "both",
|
|
56
|
+
"voice": "Zarvox",
|
|
57
|
+
"sound": "Submarine"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
| Field | Values | Default | Applies to |
|
|
63
|
+
|-------|--------|---------|------------|
|
|
64
|
+
| `mode` | `both`, `banner`, `voice`, `none` | `both` | Which channels `notify_user` uses. `none` disables notifications entirely; the agent is told it no-opped. |
|
|
65
|
+
| `voice` | any installed voice name | `Zarvox` (macOS) | Spoken announcements. List macOS voices with `/usr/bin/say -v '?'`. |
|
|
66
|
+
| `sound` | any system sound name | `Submarine` (macOS) | Banner alert sound. Sounds live in `/System/Library/Sounds`. |
|
|
67
|
+
|
|
68
|
+
Fields you leave out fall back to the defaults. Unknown sections or fields
|
|
69
|
+
are rejected when the file is read, and the config is ignored with a warning
|
|
70
|
+
if it cannot be parsed -- a broken hand edit never crashes the agent, it just
|
|
71
|
+
falls back to defaults.
|
|
72
|
+
|
|
73
|
+
The agent can manage the whole file for you through `config_get` /
|
|
74
|
+
`config_set`; edits merge at the field level, so the agent changing your
|
|
75
|
+
voice cannot wipe your mode.
|
|
76
|
+
|
|
77
|
+
## Limitations
|
|
78
|
+
|
|
79
|
+
- Banners are best-effort by nature of the OS. The agent can only report
|
|
80
|
+
whether the command ran, not whether you saw the banner.
|
|
81
|
+
- Speech is awaited: the tool returns after the sentence finishes. Notifications
|
|
82
|
+
are short by design.
|
|
83
|
+
- There is no debounce. If an agent polls a status in a tight loop, it should
|
|
84
|
+
notify once at the end, not per poll.
|
package/docs/user/skills.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jeffober/thatch",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.39",
|
|
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/config.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
// Thatch user config: one JSON file beside thatch.db, hand-editable and
|
|
7
|
+
// managed by the config_get / config_set tools. Sections are strict (unknown
|
|
8
|
+
// keys fail validation instead of being silently dropped) so a hand edit or a
|
|
9
|
+
// newer-version file can never lose data by round-tripping through an older
|
|
10
|
+
// process. config_set merges at the field level: omitted fields keep their
|
|
11
|
+
// values, so a partial section update never wipes sibling preferences.
|
|
12
|
+
|
|
13
|
+
export const notificationPrefsSchema = z.strictObject({
|
|
14
|
+
mode: z.enum(["both", "banner", "voice", "none"]).optional(),
|
|
15
|
+
voice: z.string().optional(),
|
|
16
|
+
sound: z.string().optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const configSchema = z.strictObject({
|
|
20
|
+
notifications: notificationPrefsSchema.optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type NotificationPrefs = z.infer<typeof notificationPrefsSchema>;
|
|
24
|
+
export type Config = z.infer<typeof configSchema>;
|
|
25
|
+
|
|
26
|
+
/** Sections in presentation order. config_get / config_set iterate this. */
|
|
27
|
+
export const CONFIG_SECTIONS = ["notifications"] as const;
|
|
28
|
+
export type ConfigSection = (typeof CONFIG_SECTIONS)[number];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolves the config file path. By convention it lives next to thatch.db,
|
|
32
|
+
* so a custom THATCH_DB_PATH moves the config with it. Tests pass an
|
|
33
|
+
* explicit dbPath to keep writes inside a tempdir.
|
|
34
|
+
*/
|
|
35
|
+
export function configFilePath(dbPath?: string): string {
|
|
36
|
+
if (dbPath) return join(dirname(dbPath), "config.json");
|
|
37
|
+
const dir = process.env.THATCH_DB_PATH
|
|
38
|
+
? dirname(process.env.THATCH_DB_PATH)
|
|
39
|
+
: join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "thatch");
|
|
40
|
+
return join(dir, "config.json");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface LoadedConfig {
|
|
44
|
+
config: Config;
|
|
45
|
+
/** Non-null when the file exists but is unparseable; the file is ignored. */
|
|
46
|
+
warning: string | null;
|
|
47
|
+
path: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Reads the config file. A missing file is the empty config, not an error. */
|
|
51
|
+
export function loadConfig(dbPath?: string): LoadedConfig {
|
|
52
|
+
const path = configFilePath(dbPath);
|
|
53
|
+
let raw: string;
|
|
54
|
+
try {
|
|
55
|
+
raw = readFileSync(path, "utf8");
|
|
56
|
+
} catch {
|
|
57
|
+
return { config: {}, warning: null, path };
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const parsed = configSchema.parse(JSON.parse(raw));
|
|
61
|
+
return { config: parsed, warning: null, path };
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return {
|
|
64
|
+
config: {},
|
|
65
|
+
warning: `config file at ${path} is invalid and was ignored (${err instanceof Error ? err.message : String(err)})`,
|
|
66
|
+
path,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Writes the config atomically (temp file + rename) so a crash mid-write
|
|
73
|
+
* cannot leave a truncated file, and concurrent readers never see a partial
|
|
74
|
+
* document.
|
|
75
|
+
*/
|
|
76
|
+
export function saveConfig(config: Config, dbPath?: string): string {
|
|
77
|
+
const path = configFilePath(dbPath);
|
|
78
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
79
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
80
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n");
|
|
81
|
+
renameSync(tmp, path);
|
|
82
|
+
return path;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Field-level merge for one section: omitted fields keep their values. */
|
|
86
|
+
export function mergeNotificationPrefs(
|
|
87
|
+
current: NotificationPrefs | undefined,
|
|
88
|
+
patch: NotificationPrefs,
|
|
89
|
+
): NotificationPrefs {
|
|
90
|
+
return { ...current, ...patch };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Platform fallbacks shown by config_get and applied by notify_user when the
|
|
95
|
+
* user has not set a preference. Only darwin has opinionated defaults; other
|
|
96
|
+
* platforms use their tools' own voices and sounds.
|
|
97
|
+
*/
|
|
98
|
+
export function notificationDefaults(): { mode: "both" | "banner" | "voice" | "none"; voice?: string; sound?: string } {
|
|
99
|
+
if (process.platform === "darwin") {
|
|
100
|
+
return { mode: "both", voice: "Zarvox", sound: "Submarine" };
|
|
101
|
+
}
|
|
102
|
+
return { mode: "both" };
|
|
103
|
+
}
|
package/src/embeddings.ts
CHANGED
|
@@ -16,6 +16,7 @@ export interface EmbeddingModel {
|
|
|
16
16
|
load(): Promise<void>;
|
|
17
17
|
queryEmbed(text: string): Promise<Float32Array>;
|
|
18
18
|
passageEmbed(text: string): Promise<Float32Array>;
|
|
19
|
+
dispose(): Promise<void>;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -81,4 +82,35 @@ export class BgeEmbeddingModel implements EmbeddingModel {
|
|
|
81
82
|
});
|
|
82
83
|
return output.data as Float32Array;
|
|
83
84
|
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Releases the pipeline's native ONNX sessions. The host calls this during
|
|
88
|
+
* graceful shutdown so onnxruntime's NAPI wrap finalizers are freed while
|
|
89
|
+
* the runtime is still alive. If instead they survive to Bun's worker
|
|
90
|
+
* teardown, Bun panics creating their errors (oven-sh/bun#34664).
|
|
91
|
+
*
|
|
92
|
+
* Best-effort by design: dispose never throws, and a failed release is
|
|
93
|
+
* indistinguishable from never loading. An embed call after dispose just
|
|
94
|
+
* lazily re-loads the model.
|
|
95
|
+
*/
|
|
96
|
+
async dispose(): Promise<void> {
|
|
97
|
+
// An in-flight load memoizes a rejecting promise on failure. Await it
|
|
98
|
+
// defensively so its error neither escapes dispose nor kills the teardown.
|
|
99
|
+
if (this.#loading) {
|
|
100
|
+
try {
|
|
101
|
+
await this.#loading;
|
|
102
|
+
} catch {
|
|
103
|
+
// Load failed - nothing was loaded, so nothing to release.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const pipe = this.#pipe;
|
|
107
|
+
this.#pipe = null;
|
|
108
|
+
this.#loading = null;
|
|
109
|
+
if (!pipe) return;
|
|
110
|
+
try {
|
|
111
|
+
await pipe.dispose();
|
|
112
|
+
} catch {
|
|
113
|
+
// Best-effort - see doc comment above.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
84
116
|
}
|
package/src/index.ts
CHANGED
|
@@ -110,6 +110,24 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
110
110
|
parts: [{ type: "text", text: watcherNotificationNudge(events[0]?.target ?? "watched target", events), synthetic: true }],
|
|
111
111
|
},
|
|
112
112
|
});
|
|
113
|
+
// Toast: the notification part is TUI-hidden, so without this the
|
|
114
|
+
// user watching the session sees the model wake up with no visible
|
|
115
|
+
// cause. Announce what fired. Failed checks/workflows surface as a
|
|
116
|
+
// warning variant; routine events as info. Best-effort - the TUI may
|
|
117
|
+
// not be connected (headless mode).
|
|
118
|
+
const failed = events.some((e) => e.summary.includes("failure"));
|
|
119
|
+
const more = events.length > 1 ? ` +${events.length - 1} more` : "";
|
|
120
|
+
try {
|
|
121
|
+
await client.tui.showToast({
|
|
122
|
+
body: {
|
|
123
|
+
message: `\u23F0 ${events[0]?.target ?? "watched target"}: ${events[0]?.summary ?? ""}${more}`,
|
|
124
|
+
variant: failed ? "warning" : "info",
|
|
125
|
+
duration: 5000,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
} catch {
|
|
129
|
+
// TUI may not be connected. Best-effort.
|
|
130
|
+
}
|
|
113
131
|
},
|
|
114
132
|
canDeliver: (sessionID) =>
|
|
115
133
|
!compacting.has(sessionID) && sessionStatus.get(sessionID) === "idle",
|
|
@@ -798,6 +816,10 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
798
816
|
dispose: async () => {
|
|
799
817
|
stopVersionChecker();
|
|
800
818
|
watchers.dispose();
|
|
819
|
+
// Release native ONNX sessions while the worker is still healthy. Left
|
|
820
|
+
// to Bun's teardown, their NAPI finalizers panic the process (see
|
|
821
|
+
// BgeEmbeddingModel.dispose).
|
|
822
|
+
await model.dispose();
|
|
801
823
|
db.close();
|
|
802
824
|
},
|
|
803
825
|
};
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Out-of-band user notification: desktop banner plus spoken voice. Both are
|
|
2
|
+
// plain child processes - no vendored binaries, no npm notifier dependency -
|
|
3
|
+
// because the per-platform commands are one-liners and the alternatives
|
|
4
|
+
// (node-notifier's bundled terminal-notifier) trip Gatekeeper on modern macOS.
|
|
5
|
+
// Dispatch is darwin and linux only by design: win32 support is not a goal,
|
|
6
|
+
// and the tool reports the gap honestly rather than half-working.
|
|
7
|
+
|
|
8
|
+
export type NotifyChannel = "both" | "banner" | "voice";
|
|
9
|
+
|
|
10
|
+
export interface NotifyRequest {
|
|
11
|
+
message: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
source?: string;
|
|
14
|
+
channel: NotifyChannel;
|
|
15
|
+
/** Voice name override; falls back to the platform default. */
|
|
16
|
+
voice?: string;
|
|
17
|
+
/** Banner alert sound (macOS); falls back to Submarine. */
|
|
18
|
+
sound?: string;
|
|
19
|
+
platform?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SpawnResult {
|
|
23
|
+
exitCode: number | null;
|
|
24
|
+
stderr: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type Spawner = (cmd: string[]) => Promise<SpawnResult>;
|
|
28
|
+
|
|
29
|
+
/** The real runner. Tests inject a mock so nothing actually speaks. */
|
|
30
|
+
export const defaultSpawner: Spawner = async (cmd) => {
|
|
31
|
+
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
|
|
32
|
+
const stderr = await new Response(proc.stderr).text();
|
|
33
|
+
await proc.exited;
|
|
34
|
+
return { exitCode: proc.exitCode, stderr };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The spoken line. The source label (ticket number, feature name) goes first
|
|
39
|
+
* so the listener immediately knows which session is talking.
|
|
40
|
+
*/
|
|
41
|
+
export function spokenText(request: Pick<NotifyRequest, "message" | "source">): string {
|
|
42
|
+
return request.source ? `${request.source}: ${request.message}` : request.message;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** AppleScript string literal - backslash and double-quote are the escapes. */
|
|
46
|
+
function appleScriptString(s: string): string {
|
|
47
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface StepOutcome {
|
|
51
|
+
label: string;
|
|
52
|
+
ok: boolean;
|
|
53
|
+
detail: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function runStep(label: string, cmd: string[], spawner: Spawner): Promise<StepOutcome> {
|
|
57
|
+
try {
|
|
58
|
+
const result = await spawner(cmd);
|
|
59
|
+
if (result.exitCode === 0) {
|
|
60
|
+
return { label, ok: true, detail: label };
|
|
61
|
+
}
|
|
62
|
+
return { label, ok: false, detail: `${label} failed: exited ${result.exitCode}${result.stderr.trim() ? `: ${result.stderr.trim()}` : ""}` };
|
|
63
|
+
} catch (err) {
|
|
64
|
+
// Missing binary surfaces here (ENOENT) rather than as an exit code.
|
|
65
|
+
return { label, ok: false, detail: `${label} failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Sends the notification and returns a result line for the tool response.
|
|
71
|
+
* A zero exit proves only that the command ran - macOS focus modes can still
|
|
72
|
+
* suppress the banner silently - so the result never claims the user saw it.
|
|
73
|
+
* Never throws: every failure becomes text the calling model can read.
|
|
74
|
+
*/
|
|
75
|
+
export async function sendNotification(
|
|
76
|
+
request: NotifyRequest,
|
|
77
|
+
spawner: Spawner = defaultSpawner,
|
|
78
|
+
): Promise<string> {
|
|
79
|
+
const platform = request.platform ?? process.platform;
|
|
80
|
+
if (platform !== "darwin" && platform !== "linux") {
|
|
81
|
+
return `[unsupported] notifications are not implemented on ${platform} (macOS and Linux only); nothing was sent.`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const outcomes: StepOutcome[] = [];
|
|
85
|
+
|
|
86
|
+
if (request.channel !== "voice") {
|
|
87
|
+
const title = request.title ?? request.source ?? "thatch";
|
|
88
|
+
if (platform === "darwin") {
|
|
89
|
+
const sound = request.sound ?? "Submarine";
|
|
90
|
+
const script =
|
|
91
|
+
`display notification ${appleScriptString(request.message)} ` +
|
|
92
|
+
`with title ${appleScriptString(title)} sound name ${appleScriptString(sound)}`;
|
|
93
|
+
outcomes.push(await runStep("banner (osascript)", ["/usr/bin/osascript", "-e", script], spawner));
|
|
94
|
+
} else {
|
|
95
|
+
outcomes.push(await runStep("banner (notify-send)", ["notify-send", title, request.message], spawner));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (request.channel !== "banner") {
|
|
100
|
+
const spoken = spokenText(request);
|
|
101
|
+
if (platform === "darwin") {
|
|
102
|
+
const voice = request.voice ?? "Zarvox";
|
|
103
|
+
outcomes.push(await runStep(`voice (${voice})`, ["/usr/bin/say", "-v", voice, spoken], spawner));
|
|
104
|
+
} else {
|
|
105
|
+
// spd-say has no voice flag, so a voice override goes straight to
|
|
106
|
+
// espeak (-v). Without an override, speech-dispatcher is the friendlier
|
|
107
|
+
// default; raw espeak is the fallback when it is absent.
|
|
108
|
+
if (request.voice) {
|
|
109
|
+
outcomes.push(await runStep(`voice (espeak -v ${request.voice})`, ["espeak", "-v", request.voice, spoken], spawner));
|
|
110
|
+
} else {
|
|
111
|
+
const spd = await runStep("voice (spd-say)", ["spd-say", "-w", spoken], spawner);
|
|
112
|
+
outcomes.push(spd.ok ? spd : await runStep("voice (espeak)", ["espeak", spoken], spawner));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const failed = outcomes.filter((o) => !o.ok);
|
|
118
|
+
if (outcomes.every((o) => !o.ok)) {
|
|
119
|
+
return `[failed] ${failed.map((o) => o.detail).join("; ")}`;
|
|
120
|
+
}
|
|
121
|
+
const parts = outcomes.map((o) => (o.ok ? `${o.detail} ok` : o.detail));
|
|
122
|
+
return `[notified] ${parts.join("; ")}`;
|
|
123
|
+
}
|
package/src/prompts.ts
CHANGED
|
@@ -23,7 +23,8 @@ Tools: thatch_memory_remember, thatch_memory_recall, thatch_memory_list,
|
|
|
23
23
|
thatch_prediction_query, thatch_prediction_update, thatch_prediction_list,
|
|
24
24
|
thatch_prediction_delete,
|
|
25
25
|
thatch_behavior_codify, thatch_behavior_feedback, thatch_behavior_list,
|
|
26
|
-
thatch_behavior_delete,
|
|
26
|
+
thatch_behavior_delete, thatch_config_get, thatch_config_set,
|
|
27
|
+
thatch_notify_user, thatch_get_session_info,
|
|
27
28
|
thatch_session_search, thatch_session_get,
|
|
28
29
|
thatch_watch_create, thatch_watch_branch_create,
|
|
29
30
|
thatch_watch_list, thatch_watch_cancel
|
|
@@ -178,6 +179,17 @@ way that is about your own operational discipline (not a user preference):
|
|
|
178
179
|
Do not codify rules that make you lazier or less thorough. Rules should encode
|
|
179
180
|
discipline, not shortcuts.
|
|
180
181
|
|
|
182
|
+
## Notifications
|
|
183
|
+
|
|
184
|
+
Use thatch_notify_user for terminal-event outcomes the user should hear about
|
|
185
|
+
even when looking away: CI results, merge or deploy completion, watcher
|
|
186
|
+
events. Always include a source label (ticket number or feature name) so the
|
|
187
|
+
user knows which session spoke. Manage notification preferences with
|
|
188
|
+
thatch_config_get and thatch_config_set - call config_get first; config_set
|
|
189
|
+
merges fields and echoes the result. The config file
|
|
190
|
+
(~/.config/thatch/config.json) is also hand-editable; do not store thatch
|
|
191
|
+
config in memory.
|
|
192
|
+
|
|
181
193
|
## What to Store
|
|
182
194
|
|
|
183
195
|
- **Global store**: user observations, agent personality, system environment
|
|
@@ -288,7 +300,9 @@ Tools are prefixed in ${host}: \`mcp__thatch__memory_remember\`,
|
|
|
288
300
|
\`mcp__thatch__prediction_query\`, \`mcp__thatch__prediction_update\`,
|
|
289
301
|
\`mcp__thatch__prediction_list\`, \`mcp__thatch__prediction_delete\`,
|
|
290
302
|
\`mcp__thatch__behavior_codify\`, \`mcp__thatch__behavior_feedback\`,
|
|
291
|
-
\`mcp__thatch__behavior_list\`, \`mcp__thatch__behavior_delete
|
|
303
|
+
\`mcp__thatch__behavior_list\`, \`mcp__thatch__behavior_delete\`,
|
|
304
|
+
\`mcp__thatch__config_get\`, \`mcp__thatch__config_set\`,
|
|
305
|
+
\`mcp__thatch__notify_user\`. Bare names used below for readability.
|
|
292
306
|
get_session_info, session_search, session_get, watch_create,
|
|
293
307
|
watch_branch_create, watch_list,
|
|
294
308
|
and watch_cancel are intentionally absent: they are opencode-only (MCP
|
|
@@ -412,6 +426,16 @@ way that is about your own operational discipline (not a user preference):
|
|
|
412
426
|
Do not codify rules that make you lazier or less thorough. Rules should encode
|
|
413
427
|
discipline, not shortcuts.
|
|
414
428
|
|
|
429
|
+
## Notifications
|
|
430
|
+
|
|
431
|
+
Use notify_user for terminal-event outcomes the user should hear about even
|
|
432
|
+
when looking away: CI results, merge or deploy completion, watcher events.
|
|
433
|
+
Always include a source label (ticket number or feature name) so the user
|
|
434
|
+
knows which session spoke. Manage notification preferences with config_get
|
|
435
|
+
and config_set - call config_get first; config_set merges fields and echoes
|
|
436
|
+
the result. The config file (~/.config/thatch/config.json) is also
|
|
437
|
+
hand-editable; do not store thatch config in memory.
|
|
438
|
+
|
|
415
439
|
## What to Store
|
|
416
440
|
|
|
417
441
|
- **Global store**: user observations, agent personality, system environment
|
package/src/tool-defs.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { ThatchDB, DedupCandidate, MemoryRow } from "./db";
|
|
3
3
|
import type { EmbeddingModel } from "./embeddings";
|
|
4
|
+
import {
|
|
5
|
+
CONFIG_SECTIONS,
|
|
6
|
+
loadConfig,
|
|
7
|
+
mergeNotificationPrefs,
|
|
8
|
+
notificationDefaults,
|
|
9
|
+
notificationPrefsSchema,
|
|
10
|
+
saveConfig,
|
|
11
|
+
type Config,
|
|
12
|
+
type ConfigSection,
|
|
13
|
+
type NotificationPrefs,
|
|
14
|
+
} from "./config";
|
|
15
|
+
import { sendNotification, defaultSpawner, type NotifyChannel, type Spawner } from "./notify";
|
|
4
16
|
import { predictionVerb } from "./prompts";
|
|
5
17
|
import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToFullJson, messageToFullJson } from "./session-db";
|
|
6
18
|
import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, type WatcherRegistry, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
|
|
@@ -42,6 +54,9 @@ export interface CoreContext {
|
|
|
42
54
|
* path - MCP hosts have no poller, no event bus, and no way to deliver a
|
|
43
55
|
* proactive prompt, so watch tools are opencode-only. */
|
|
44
56
|
watchers?: WatcherRegistry;
|
|
57
|
+
/** Injectable command runner for notify_user. Defaults to Bun.spawn.
|
|
58
|
+
* Tests inject a mock so notifications never actually fire or speak. */
|
|
59
|
+
spawner?: Spawner;
|
|
45
60
|
}
|
|
46
61
|
|
|
47
62
|
/**
|
|
@@ -818,6 +833,161 @@ const behaviorDeleteDef: ToolDef = {
|
|
|
818
833
|
},
|
|
819
834
|
};
|
|
820
835
|
|
|
836
|
+
// ---------------------------------------------------------------------------
|
|
837
|
+
// Config and notification tools. The config is a single JSON file beside
|
|
838
|
+
// thatch.db, hand-editable and also managed through these tools. notify_user
|
|
839
|
+
// shells out to the platform's banner and TTS commands - see src/notify.ts.
|
|
840
|
+
// ---------------------------------------------------------------------------
|
|
841
|
+
|
|
842
|
+
/** Notification preference fields, in presentation order. */
|
|
843
|
+
const NOTIFICATION_FIELDS = ["mode", "voice", "sound"] as const;
|
|
844
|
+
|
|
845
|
+
/** Renders one section with per-field default annotations for echo output. */
|
|
846
|
+
function renderNotificationSection(prefs: NotificationPrefs | undefined): string {
|
|
847
|
+
const defaults = notificationDefaults();
|
|
848
|
+
const lines = ["notifications:"];
|
|
849
|
+
for (const field of NOTIFICATION_FIELDS) {
|
|
850
|
+
const current = prefs?.[field];
|
|
851
|
+
const fallback = defaults[field];
|
|
852
|
+
lines.push(` ${field}: ${current ?? "<unset>"}${fallback ? ` (default: ${fallback})` : ""}`);
|
|
853
|
+
}
|
|
854
|
+
return lines.join("\n");
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Drops keys explicitly set to undefined so a merge never overwrites. */
|
|
858
|
+
function stripUndefined(obj: Record<string, unknown>): Record<string, unknown> {
|
|
859
|
+
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const configGetDef: ToolDef = {
|
|
863
|
+
name: "config_get",
|
|
864
|
+
description:
|
|
865
|
+
"Read thatch's user config, optionally restricted to one section. " +
|
|
866
|
+
"Values are annotated with their defaults; unset fields show <unset>. " +
|
|
867
|
+
"The config file (~/.config/thatch/config.json, beside thatch.db) is " +
|
|
868
|
+
"also hand-editable. Call this before config_set.",
|
|
869
|
+
args: {
|
|
870
|
+
section: z.enum(["notifications"]).optional().describe(
|
|
871
|
+
"Restrict output to one section. Omit to read all sections.",
|
|
872
|
+
),
|
|
873
|
+
},
|
|
874
|
+
async execute(args) {
|
|
875
|
+
const loaded = loadConfig();
|
|
876
|
+
const lines: string[] = [];
|
|
877
|
+
if (loaded.warning) lines.push(`[warning] ${loaded.warning}`, "");
|
|
878
|
+
const sections: ConfigSection[] = args.section
|
|
879
|
+
? [args.section as ConfigSection]
|
|
880
|
+
: [...CONFIG_SECTIONS];
|
|
881
|
+
for (const name of sections) {
|
|
882
|
+
if (name === "notifications") {
|
|
883
|
+
lines.push(renderNotificationSection(loaded.config.notifications));
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
lines.push("", `file: ${loaded.path}`);
|
|
887
|
+
return lines.join("\n");
|
|
888
|
+
},
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
const configSetDef: ToolDef = {
|
|
892
|
+
name: "config_set",
|
|
893
|
+
description:
|
|
894
|
+
"Update thatch's user config. Field-level merge: only fields you pass " +
|
|
895
|
+
"change; omitted fields and omitted sections keep their current values. " +
|
|
896
|
+
"Returns the resulting section so you can verify the write. Call " +
|
|
897
|
+
"config_get first to see current values and defaults.",
|
|
898
|
+
args: {
|
|
899
|
+
notifications: z.strictObject({
|
|
900
|
+
mode: z.enum(["both", "banner", "voice", "none"]).optional().describe(
|
|
901
|
+
"Default channels for notify_user: both, banner only, voice only, " +
|
|
902
|
+
"or none (disable notifications entirely).",
|
|
903
|
+
),
|
|
904
|
+
voice: z.string().optional().describe(
|
|
905
|
+
"Spoken voice name (macOS default: Zarvox). Omit to use the " +
|
|
906
|
+
"platform default.",
|
|
907
|
+
),
|
|
908
|
+
sound: z.string().optional().describe(
|
|
909
|
+
"Banner alert sound (macOS, from /System/Library/Sounds; default: " +
|
|
910
|
+
"Submarine). Omit to use the platform default.",
|
|
911
|
+
),
|
|
912
|
+
}).optional().describe(
|
|
913
|
+
"Notification preferences to update. Fields you omit keep their " +
|
|
914
|
+
"current values.",
|
|
915
|
+
),
|
|
916
|
+
},
|
|
917
|
+
async execute(args) {
|
|
918
|
+
const loaded = loadConfig();
|
|
919
|
+
const config: Config = { ...loaded.config };
|
|
920
|
+
const patch = args.notifications as Record<string, unknown> | undefined;
|
|
921
|
+
if (!patch || Object.keys(stripUndefined(patch)).length === 0) {
|
|
922
|
+
return (
|
|
923
|
+
"Nothing to update: pass a section with fields to change.\n" +
|
|
924
|
+
renderNotificationSection(config.notifications)
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
config.notifications = notificationPrefsSchema.parse(
|
|
928
|
+
mergeNotificationPrefs(config.notifications, stripUndefined(patch)),
|
|
929
|
+
);
|
|
930
|
+
const path = saveConfig(config);
|
|
931
|
+
return (
|
|
932
|
+
`[saved] ${path}\n` +
|
|
933
|
+
renderNotificationSection(config.notifications)
|
|
934
|
+
);
|
|
935
|
+
},
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
const notifyUserDef: ToolDef = {
|
|
939
|
+
name: "notify_user",
|
|
940
|
+
description:
|
|
941
|
+
"Notify the user out-of-band: desktop banner and/or spoken voice " +
|
|
942
|
+
"(macOS: Notification Center + say; Linux: notify-send + spd-say/espeak). " +
|
|
943
|
+
"Use for long-running terminal-event outcomes worth interrupting for: " +
|
|
944
|
+
"CI results, merge or deploy completion, watcher events. ALWAYS include " +
|
|
945
|
+
"source: a word or two identifying the session or work item (ticket " +
|
|
946
|
+
"number or feature name) so the user knows which session spoke. Honors " +
|
|
947
|
+
"the user's configured preferences; pass channel to override for this " +
|
|
948
|
+
"call only. If the configured mode is none, the tool no-ops and says " +
|
|
949
|
+
"so. The result reports command success only - OS focus modes can " +
|
|
950
|
+
"silently suppress banners.",
|
|
951
|
+
args: {
|
|
952
|
+
message: z.string().describe(
|
|
953
|
+
"The notification text. Spell out letter sequences so " +
|
|
954
|
+
"text-to-speech pronounces them (\"C I green\", not \"CI green\").",
|
|
955
|
+
),
|
|
956
|
+
title: z.string().optional().describe(
|
|
957
|
+
"Banner title. Defaults to source, then \"thatch\".",
|
|
958
|
+
),
|
|
959
|
+
source: z.string().optional().describe(
|
|
960
|
+
"Short label identifying the session or work item (ticket number or " +
|
|
961
|
+
"feature name), e.g. \"PLAT-280\". Prepended to the spoken message.",
|
|
962
|
+
),
|
|
963
|
+
channel: z.enum(["both", "banner", "voice"]).optional().describe(
|
|
964
|
+
"Override the configured mode for this call only.",
|
|
965
|
+
),
|
|
966
|
+
voice: z.string().optional().describe(
|
|
967
|
+
"Override the configured voice for this call.",
|
|
968
|
+
),
|
|
969
|
+
},
|
|
970
|
+
async execute(args, ctx) {
|
|
971
|
+
const loaded = loadConfig();
|
|
972
|
+
const prefs = loaded.config.notifications ?? {};
|
|
973
|
+
const mode = prefs.mode ?? "both";
|
|
974
|
+
if (mode === "none") {
|
|
975
|
+
return "[skipped] notifications are disabled (notifications.mode: none). " +
|
|
976
|
+
"Use thatch config_set to re-enable if the user asks.";
|
|
977
|
+
}
|
|
978
|
+
const channel = (args.channel as NotifyChannel | undefined) ?? (mode as NotifyChannel);
|
|
979
|
+
return sendNotification({
|
|
980
|
+
message: args.message as string,
|
|
981
|
+
title: args.title as string | undefined,
|
|
982
|
+
source: args.source as string | undefined,
|
|
983
|
+
channel,
|
|
984
|
+
voice: (args.voice as string | undefined) ?? prefs.voice,
|
|
985
|
+
sound: prefs.sound,
|
|
986
|
+
}, ctx.spawner ?? defaultSpawner);
|
|
987
|
+
},
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
|
|
821
991
|
/**
|
|
822
992
|
* Reports the calling session's identity. opencode does not surface its
|
|
823
993
|
* session ID to the model, so the model cannot learn it any other way -
|
|
@@ -1148,6 +1318,9 @@ export const TOOL_DEFS: ToolDef[] = [
|
|
|
1148
1318
|
behaviorFeedbackDef,
|
|
1149
1319
|
behaviorListDef,
|
|
1150
1320
|
behaviorDeleteDef,
|
|
1321
|
+
configGetDef,
|
|
1322
|
+
configSetDef,
|
|
1323
|
+
notifyUserDef,
|
|
1151
1324
|
getSessionInfoDef,
|
|
1152
1325
|
sessionSearchDef,
|
|
1153
1326
|
sessionGetDef,
|
package/src/watchers.ts
CHANGED
|
@@ -97,6 +97,9 @@ export interface WorkflowRunRef {
|
|
|
97
97
|
url: string;
|
|
98
98
|
/** What triggered the run: push, pull_request, schedule, workflow_run, ... */
|
|
99
99
|
event: string;
|
|
100
|
+
/** The branch the run's head commit is on (a tag-triggered run puts the TAG name here). */
|
|
101
|
+
headBranch: string;
|
|
102
|
+
headSha: string;
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
// ---------------------------------------------------------------------------
|
|
@@ -303,6 +306,8 @@ function parseWorkflowRuns(raw: unknown): Record<string, WorkflowRunRef> {
|
|
|
303
306
|
conclusion?: string | null;
|
|
304
307
|
html_url?: string;
|
|
305
308
|
event?: string;
|
|
309
|
+
head_branch?: string;
|
|
310
|
+
head_sha?: string;
|
|
306
311
|
}> }).workflow_runs ?? [];
|
|
307
312
|
for (const run of rawRuns) {
|
|
308
313
|
if (run.id === undefined) continue;
|
|
@@ -312,6 +317,8 @@ function parseWorkflowRuns(raw: unknown): Record<string, WorkflowRunRef> {
|
|
|
312
317
|
conclusion: run.conclusion ?? null,
|
|
313
318
|
url: run.html_url ?? "",
|
|
314
319
|
event: run.event ?? "unknown",
|
|
320
|
+
headBranch: run.head_branch ?? "",
|
|
321
|
+
headSha: run.head_sha ?? "",
|
|
315
322
|
};
|
|
316
323
|
}
|
|
317
324
|
return runs;
|
|
@@ -375,20 +382,30 @@ export async function fetchPrState(gh: GhRunner, repo: string, pr: number): Prom
|
|
|
375
382
|
|
|
376
383
|
/**
|
|
377
384
|
* Fetches the current state of a branch: the head commit, check runs on that
|
|
378
|
-
* head, and
|
|
385
|
+
* head, and recent workflow runs relevant to the branch. The branch and
|
|
379
386
|
* workflow-run calls run in parallel; check runs need the head SHA first.
|
|
387
|
+
*
|
|
388
|
+
* Workflow runs are fetched WITHOUT the API's branch filter and filtered
|
|
389
|
+
* client-side: keep runs whose head_branch matches the branch (push runs)
|
|
390
|
+
* or whose head_sha is the branch's current head (tag-triggered runs like a
|
|
391
|
+
* release Publish run - their head_branch is the tag name, so the API's
|
|
392
|
+
* branch filter would hide exactly the runs a release watch exists for).
|
|
380
393
|
*/
|
|
381
394
|
export async function fetchBranchState(gh: GhRunner, repo: string, branch: string): Promise<BranchState> {
|
|
382
395
|
const [branchInfo, runs] = await Promise.all([
|
|
383
396
|
gh([`/repos/${repo}/branches/${branch}`]),
|
|
384
|
-
gh([`/repos/${repo}/actions/runs?
|
|
397
|
+
gh([`/repos/${repo}/actions/runs?per_page=30`]),
|
|
385
398
|
]);
|
|
386
399
|
const headSha = (branchInfo as { commit?: { sha?: string } }).commit?.sha ?? "";
|
|
400
|
+
const allRuns = parseWorkflowRuns(runs);
|
|
401
|
+
const relevant = Object.fromEntries(
|
|
402
|
+
Object.entries(allRuns).filter(([, run]) => run.headBranch === branch || run.headSha === headSha),
|
|
403
|
+
);
|
|
387
404
|
const checkRuns = await gh([`/repos/${repo}/commits/${headSha}/check-runs?per_page=100`]);
|
|
388
405
|
return {
|
|
389
406
|
headSha,
|
|
390
407
|
checkRuns: parseCheckRuns(checkRuns),
|
|
391
|
-
workflowRuns:
|
|
408
|
+
workflowRuns: relevant,
|
|
392
409
|
};
|
|
393
410
|
}
|
|
394
411
|
|