@imunitic/synapse 0.0.1-test.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/Index.md.template +23 -0
  2. package/bin/synapse-hook.cjs +19 -0
  3. package/bin/synapse-setup.cjs +420 -0
  4. package/bin/synapse.cjs +20 -0
  5. package/commands/synapse-design-note.md +229 -0
  6. package/commands/synapse-init.md +354 -0
  7. package/commands/synapse-note.md +196 -0
  8. package/commands/synapse-rebuild-diff.md +314 -0
  9. package/commands/synapse-rebuild-full.md +152 -0
  10. package/commands/synapse-status.md +144 -0
  11. package/commands/synapse-task-note.md +133 -0
  12. package/commands/synapse-vault-tidy.md +187 -0
  13. package/harness/claude/hooks.json +54 -0
  14. package/harness/codex/hooks.json +54 -0
  15. package/harness/codex/skills/synapse-design-note/SKILL.md +236 -0
  16. package/harness/codex/skills/synapse-init/SKILL.md +354 -0
  17. package/harness/codex/skills/synapse-note/SKILL.md +212 -0
  18. package/harness/codex/skills/synapse-rebuild-diff/SKILL.md +315 -0
  19. package/harness/codex/skills/synapse-rebuild-full/SKILL.md +149 -0
  20. package/harness/codex/skills/synapse-status/SKILL.md +146 -0
  21. package/harness/codex/skills/synapse-task-note/SKILL.md +133 -0
  22. package/harness/codex/skills/synapse-vault-tidy/SKILL.md +187 -0
  23. package/harness/opencode/plugin/synapse.js +164 -0
  24. package/lib/obsidian-mcp-refresh.cjs +303 -0
  25. package/lib/resolve-binaries.cjs +54 -0
  26. package/package.json +26 -0
  27. package/skills/synapse-node/SKILL.md +211 -0
  28. package/skills/synapse-node-authoring/SKILL.md +188 -0
  29. package/skills/synapse-node-format/SKILL.md +205 -0
  30. package/skills/synapse-orientation/SKILL.md +468 -0
  31. package/skills/synapse-query/SKILL.md +99 -0
  32. package/skills/synapse-task/SKILL.md +261 -0
  33. package/skills/synapse-vault/SKILL.md +107 -0
  34. package/synapse-claude.md +220 -0
  35. package/synapse-fence-languages.conf.template +24 -0
  36. package/synapse-ignore-files.conf.template +45 -0
  37. package/synapse-module-boilerplate.conf.template +24 -0
  38. package/synapse-projects.conf.template +14 -0
  39. package/synapse-prompt-stopwords.conf.template +594 -0
  40. package/synapse.conf.template +23 -0
@@ -0,0 +1,133 @@
1
+ ---
2
+ name: synapse-task-note
3
+ description: Compile a Ready design note into a single tracked, checklist-based task note in Synapse Vault — delegates the actual note creation to the synapse-note skill's task mode under the hood. Use whenever the user wants to turn a settled design discussion into actionable, tracked work ("let's compile a task note", "let's turn this into a task", "make this a task now") — not for starting or continuing the design discussion itself (that's the synapse-design-note skill).
4
+ ---
5
+
6
+ # Synapse Task Note: Compile a Design into a Tracked Checklist
7
+
8
+ Compiles a `Ready` design note (from the synapse-design-note skill) into a single tracked task, using
9
+ the vault's existing task-tracking machinery instead of a bespoke format: creation goes through the
10
+ synapse-note skill's task mode, and status transitions from then on belong entirely to the
11
+ synapse-task skill. This skill's only job is the compile step — turning a design into an ordered
12
+ checklist — not tracking progress itself.
13
+
14
+ One design compiles into **one** task note, one `task_id`, and the checklist items *are* the
15
+ steps — matching how every other task in the vault already works (see the synapse-task skill's
16
+ "Task file structure").
17
+
18
+ ## When this runs
19
+
20
+ Invoked whenever the user wants to compile a settled design into a tracked task ("let's compile a
21
+ task note", "let's turn this into a task", "make this a task now") — the design the user means is
22
+ whatever they're currently discussing or naming, not a positional argument to parse.
23
+
24
+ Once created, the task note's own progress (checked items, `status:` frontmatter) is what the
25
+ synapse-note skill's list mode and the synapse-task skill already track. Use those instead of
26
+ reinventing a parallel view — this skill has no separate list/continue mode of its own.
27
+
28
+ ## Prerequisites
29
+
30
+ - Requires a matching Obsidian design note (`designs/`) with `Status: Ready`.
31
+ - No matching note → "No Ready design note found for '{topic}'. Start or continue that design note
32
+ first." Never generate a checklist from scratch.
33
+ - Matching note but `Status: Discussing` → "Design note for '{topic}' is still in Discussing. Finish
34
+ it first."
35
+ - Matching note but `Status: Reference` → "Design note for '{topic}' concluded as Reference —
36
+ nothing to compile. Reopen the design discussion and mark it Ready if that's changed."
37
+ - A task note already exists for this design (check the design note's `> Compiled task:` annotation,
38
+ or `search_query` for a `tasks/` note linking to it) → show its current state (title, `status:`,
39
+ checked/total) and ask: view it, or recompile (only on explicit confirmation — recompiling rewrites
40
+ the checklist, so any progress on items that no longer exist is lost).
41
+
42
+ **A recompile updates the existing note in place. It never creates a second one and never bumps
43
+ the `task_id`.** One design has exactly one task note for its whole life; a design that gets
44
+ revised mid-implementation is the normal case, not a new task. Preserve `task_id`, `created`,
45
+ `status`, the filename, and any `## Notes` content the human added; replace the checklist and the
46
+ pre-implementation notes. Carry forward `- [x]` marks for items that survive the recompile
47
+ unchanged — work already done doesn't become undone because the plan around it grew.
48
+
49
+ Record *why* in the note itself, in one line at the top of the body: what the old plan got wrong
50
+ and what changed. A recompiled task with no explanation reads like a plan that was always this
51
+ shape, which hides the fact that implementation found a gap.
52
+
53
+ ## Compiling the checklist
54
+
55
+ 1. Read the design note in full.
56
+ 2. Break the approach into an ordered list of small, sequential, independently-completable steps.
57
+ 3. For each step, write it the way the synapse-task skill's own checklist convention expects: a short
58
+ `- [ ] {Do}` line; for substantive steps (type definitions, API surfaces, interface signatures)
59
+ add a one-line nested description plus a fenced code block showing the exact interface, per that
60
+ skill's "Inline code examples in checklist items". Don't invent separate Files/Tests/AC fields —
61
+ fold what matters into the item's own description instead (e.g. "...; test: X returns Y").
62
+ 4. Note explicit exclusions — things a reasonable implementer might also attempt that are out of
63
+ scope — for the `## Notes` section below, not a separate heading.
64
+
65
+ ## Creating the note
66
+
67
+ Follow the synapse-note skill's task-mode procedure exactly (its "Creating the note" section) —
68
+ don't duplicate that scaffolding here, just supply its inputs:
69
+
70
+ - **Title:** a short, plain description of the compiled plan (e.g. "Rollup direct storage
71
+ implementation") — the synapse-note skill's task mode resolves the project prefix and `task_id`
72
+ and prepends them itself.
73
+ - **Project:** derive from the source design note's `project:` frontmatter — that prefix is already
74
+ resolved (the design note went through the synapse-design-note skill's resolution when it was
75
+ created, which reads/appends the resolved `synapse-projects.conf`, per the synapse-note skill's
76
+ tiered lookup), so supply it directly instead of re-deriving or re-asking. This is the *prefix*
77
+ (`ecs`, `sb`, ...), not the `tasks/{project}/` folder name — the synapse-note skill's task mode's
78
+ own "Resolving the project folder" step turns it into the folder name. Never hardcode a specific
79
+ project/prefix pair in this skill's own instructions — the conf file is machine-local and
80
+ deliberately outside the portable Synapse package, so projects from unrelated contexts (e.g.
81
+ personal vs. work) must never end up in the same place.
82
+ - **Body:** the checklist from "Compiling the checklist" above, under the single top-level heading —
83
+ exactly the structure the synapse-task skill's "Task file structure" requires (no `## Step`
84
+ sub-headings).
85
+ - **`## Notes` (pre-implementation):** populate per the synapse-task skill's own convention —
86
+ - Design reference: `[[{design note title}]]`
87
+ - Key constraints the implementor must not miss
88
+ - Deliberate exclusions (from step 4 above) and why
89
+
90
+ ## Linking back
91
+
92
+ After creation, patch the design note: add `> Compiled task: [[{task note title}]]` near the top
93
+ (via `mcp__obsidian__vault_patch`) — a small annotation line, same idea as any other cross-reference
94
+ you'd drop near a note's title. This is a one-time link; the design note's `Status` itself does not
95
+ change and does not need to track the task's progress — Obsidian's own backlinks panel already
96
+ surfaces the live task note from the design note, and the task's `status:` frontmatter is the
97
+ authoritative progress signal from here on.
98
+
99
+ ## Confirm
100
+
101
+ Report the created path and title, and remind the user that `status:` transitions (TODO →
102
+ IN-PROGRESS → REVIEW) happen automatically via the synapse-task skill once work starts — this
103
+ skill doesn't set or watch that field itself.
104
+
105
+ ## Integration
106
+
107
+ - Reads the synapse-design-note skill's `Ready` output only — never invents a design.
108
+ - Delegates note creation to the synapse-note skill's task mode and status tracking to the
109
+ synapse-task skill — this skill owns only the design → checklist compile step.
110
+
111
+ ---
112
+
113
+ ## Example
114
+
115
+ ```
116
+ User: let's compile a task note for "rollup direct storage"
117
+
118
+ Codex: [reads designs/{PROJECT} — Rollup direct storage.md, Status: Ready]
119
+ [no existing linked task note]
120
+
121
+ Compiled checklist:
122
+ - [ ] Add ReplacingMergeTree target table for the daily rollup
123
+ - [ ] Point the rollup MV at the new table instead of AggregatingMergeTree
124
+ - [ ] Migrate existing daily/monthly rollups (drop+recreate, sort-key change)
125
+ - [ ] Update the 5 retrofitted dashboards' rollup queries to drop argMaxMerge
126
+
127
+ Project already known: {PROJECT} → {prefix}-005.
128
+
129
+ Created: tasks/{PROJECT}/{prefix}-005 — Rollup direct storage implementation.md
130
+ Linked back from designs/{PROJECT} — Rollup direct storage.md.
131
+
132
+ Status transitions happen automatically via the synapse-task skill once you start work.
133
+ ```
@@ -0,0 +1,187 @@
1
+ ---
2
+ name: synapse-vault-tidy
3
+ description: Run one on-demand vault-tidy pass over Synapse Vault -- surface recategorization candidates (notes that no longer fit their folder, clusters that deserve a new top-level category, categories that have gone stale) into a single inbox/ proposal, silently fill in the one class of missing-frontmatter default that has an unambiguous fix, and fold the rest of note health (broken links, orphaned notes, duplicate titles) into that same proposal since none of them has a safe mechanical repair either. Use whenever the user wants the vault's own organization checked or tidied ("tidy the vault", "check vault health", "any notes drifted out of their folder", "find orphaned/duplicate notes"). Never invoked automatically -- no session-start wiring, no autonomous scheduling; run it only when asked, or on a recurring cadence the user sets up themselves. Scoped to everything except designs/, tasks/, and synapse/ (the code graph's own generated namespace), which stay entirely the synapse-status skill's (the first two) or the synapse-init skill's (the third) territory.
4
+ ---
5
+
6
+ # Synapse Vault Tidy: Recategorization Proposals and Note-Health Fixes
7
+
8
+ A single on-demand pass over Synapse Vault's free-form taxonomy (`inbox/`/`research/`/`scratchpad/`/
9
+ whatever a given install's index note currently lists) that a person would otherwise have to notice
10
+ and fix by hand: a note's folder fit drifting since it was filed, several notes sharing enough in
11
+ common to deserve a category of their own, a category that stopped earning its keep, a link that
12
+ broke when its target moved, a note nothing points to or from, two notes that are really the same
13
+ thing under slightly different titles. Nothing here runs unprompted — it exists because nothing else
14
+ in the vault periodically re-checks any of this, not because it's meant to run behind the user's
15
+ back.
16
+
17
+ Never touches `designs/`, `tasks/`, or `synapse/` — the first two are the design-note and
18
+ task-note-creation skills' own interface for directing project work (any gap found there is the
19
+ synapse-status skill's territory, not this skill's); `synapse/` is the code graph's own generated
20
+ namespace, a different note kind entirely, owned and kept fresh by the synapse-init skill and the
21
+ synapse-node/synapse-node-format skills. The vault's index note and any vault-root `README.md` are
22
+ also out of scope — foundational files, not taxonomy notes.
23
+
24
+ ## When this runs
25
+
26
+ Invoked whenever the user wants the vault's own organization checked or tidied ("tidy the vault",
27
+ "check vault health", "any notes drifted out of their folder", "find orphaned/duplicate notes") --
28
+ there is no argument to parse, every run produces the same one-pass sweep.
29
+
30
+ ## Prerequisites
31
+
32
+ Requires the `obsidian` MCP server (`mcp__obsidian__*` tools). If unreachable, say so and stop —
33
+ there is no local-file fallback.
34
+
35
+ No compiled code anywhere in this skill. `adapters/obsidian/store.zig`'s `ObsidianStore` does now
36
+ implement `read`/`list`/`search`, but for `synapse frontmatter get`/`set`'s narrow one-field-at-a-time
37
+ use — a full-vault tidy sweep, reading every note's whole body to judge category, tags and broken
38
+ links, is a different shape of work entirely, and stays on these same MCP tools rather than the
39
+ compiled binary, the same way the synapse-status and synapse-rebuild-diff skills' vault-side checks
40
+ already work. The one exception is Step 3's broken-link history check, a plain `git log` call (via
41
+ the shell, not a compiled tool) against the vault's own local repo when one exists — best-effort,
42
+ never a hard requirement.
43
+
44
+ ## Step 1: Inventory sweep
45
+
46
+ Enumerate every note in scope and read each one exactly once — every later step reuses this same
47
+ sweep rather than re-reading anything.
48
+
49
+ ```
50
+ {"and": [
51
+ {"!": [{"glob": ["designs/*", {"var": "path"}]}]},
52
+ {"!": [{"glob": ["tasks/*", {"var": "path"}]}]},
53
+ {"!": [{"glob": ["synapse/*", {"var": "path"}]}]},
54
+ {"!=": [{"var": "path"}, "Index.md"]},
55
+ {"!=": [{"var": "path"}, "README.md"]}
56
+ ]}
57
+ ```
58
+
59
+ `mcp__obsidian__search_query` with that filter, then `mcp__obsidian__vault_read` each match. Keep
60
+ the full result (`path`, `frontmatter`, `tags`, `links`, `backlinks`, `unresolvedLinks`, `stat`,
61
+ `content`) per note — Steps 2–4 below only ever reason over the structured fields, never `content`
62
+ itself; the judgment layer in Step 5 is the first point anything actually reads note *bodies*, and
63
+ only for the subset flagged by then. `content` is fetched for free in this same call regardless, so
64
+ there's no separate "light" read to bother with.
65
+
66
+ ## Step 2: Frontmatter defaults (fixed directly)
67
+
68
+ The only note-health fix applied silently, because it's the only one with exactly one correct
69
+ answer per note. Design and task notes are out of scope entirely; the plain/free-form schema is
70
+ just two fields (`title`, `created` — see the synapse-note skill's bare-mode format), both
71
+ derivable without guessing:
72
+
73
+ - Missing `title` → the filename with its `.md` extension stripped.
74
+ - Missing `created` → `stat.ctime`, formatted `YYYY-MM-DD HH:MM` to match every other note's
75
+ convention.
76
+
77
+ Apply via read-modify-write on the whole file (`vault_read` → edit the one frontmatter line in the
78
+ returned content → `vault_write` the whole file back) — never `vault_patch` with
79
+ `targetType: frontmatter`, which re-serializes the entire YAML block and silently reformats
80
+ unrelated fields, the same hazard the vault and task-status skills already document.
81
+
82
+ ## Step 3: Note-health findings (reported, not fixed)
83
+
84
+ From the same inventory, no additional vault reads (the broken-link history check below reads the
85
+ vault's local git log, not more notes). Each of these three has no safe mechanical repair —
86
+ fixing any of them means guessing at intent — so they become findings for the Step 6 proposal
87
+ instead of a silent edit:
88
+
89
+ - **Broken links** — `unresolvedLinks` non-empty. Before writing the finding, check the vault's own
90
+ local git history to say *why* it's broken instead of leaving that to guesswork — the vault is
91
+ usually a git repo (a db-sync hook auto-commits every agent-driven edit into it, opt-in per vault,
92
+ same precondition as that hook). Resolve the vault's filesystem path the same way its own conf
93
+ file already does (`OBSIDIAN_VAULT_DIR`), skip this sub-step entirely if `{vault}/.git` doesn't
94
+ exist, and never let a missing/unreachable git repo block the rest of the finding — worst case
95
+ it's reported with no history context, same as today.
96
+ - `git -C {vault} log --all --diff-filter=A --name-only --pretty=format: -- "**/{target}.md"` — a
97
+ hit means a note by that exact title was created at some point (even if later renamed or
98
+ deleted): report it as *"used to be a note — find what it's called now, or was deleted"*.
99
+ - No hit → `git -C {vault} log --all -p -S"[[{target}]]" -- .` and look at the earliest match's
100
+ diff: if the line first appears inside a `new file mode` addition (the note was created with the
101
+ link already broken), report it as *"born broken — never pointed at a real note; probably meant
102
+ as plain text or an external reference, not a vault link"* rather than implying anything was
103
+ lost.
104
+ - Neither check resolves anything more specific → report the target plainly, same as before.
105
+ - **Orphaned notes** — `links` empty *and* `backlinks` empty.
106
+ - **Duplicate/near-duplicate titles** — group notes by title normalized (lowercased, trimmed,
107
+ internal whitespace collapsed); any group with 2+ members is a finding. This is a mechanical
108
+ string-normalization match, not fuzzy similarity — genuinely fuzzy "these might be the same
109
+ note" calls belong to the judgment layer in Step 5, not here.
110
+
111
+ ## Step 4: Recategorization signal layer
112
+
113
+ Mechanical, from the same inventory, still no note-body reasoning:
114
+
115
+ - **Weak folder fit** — a note (not already an orphan from Step 3) that shares no tag with any
116
+ other note in its own folder, and where fewer than half of its combined `links`+`backlinks` point
117
+ to notes within that same folder.
118
+ - **Uncovered clusters** — a tag or keyword held by 3+ notes spanning 2+ different folders, where no
119
+ existing top-level folder name (from the index note's current list) already matches it.
120
+ - **Stale categories** — a scope-eligible top-level folder whose newest note's `stat.mtime` is more
121
+ than 90 days old (no new note gained in that window) is flagged as a merge-back candidate, the
122
+ same way a newly proposed split is flagged — one signal, checked at a different point in a
123
+ category's life.
124
+
125
+ ## Step 5: Recategorization judgment layer
126
+
127
+ For each Step 4 candidate — a weak-fit note, a cluster, or a stale folder — read its full content
128
+ (and, for a cluster, every member's content) and decide with real judgment, not the mechanical
129
+ signal alone:
130
+
131
+ - Weak-fit note: does it genuinely belong in a different existing category, or was the signal a
132
+ false positive (a note that's fine where it is, just thin on tags/links)?
133
+ - Cluster: given what these notes actually say, is this a real emerging category, or just a
134
+ coincidental shared keyword?
135
+ - Stale folder: has it truly stopped being useful, or is low volume expected and fine (a narrow but
136
+ still-active category)?
137
+
138
+ Never moves a note or creates a folder here — every conclusion becomes one line in the Step 6
139
+ proposal, for the vault owner to act on.
140
+
141
+ ## Step 6: Compose the proposal
142
+
143
+ One note, `inbox/Vault tidy — {YYYY-MM-DD}.md` (fetch machine local time, never infer it), written
144
+ via `vault_write` (creates `inbox/` automatically if it doesn't exist yet; if `inbox/` isn't already
145
+ in the index note's folder list, add it there in the same pass, matching the folder-layout rule every
146
+ other skill that can create a top-level folder already follows). Two sections:
147
+
148
+ ```
149
+ ## Recategorization
150
+ - {note or cluster}: {proposed move/new category/merge}, because {reasoning}
151
+
152
+ ## Note health
153
+ - Broken link in {note}: → {target text that doesn't resolve} ({history classification, if the
154
+ vault's local git repo yielded one — "used to be a note", "born broken", or omitted entirely if
155
+ neither check resolved anything})
156
+ - Orphaned: {note}
157
+ - Possible duplicate: {note A} / {note B}
158
+ ```
159
+
160
+ Omit either section if Steps 3–5 found nothing for it. If both are empty, don't write a proposal at
161
+ all — say so directly instead of creating an empty note.
162
+
163
+ This skill never edits the index note itself beyond the `inbox/` bootstrap case above — creating or
164
+ renaming a category is the vault owner's call, made by hand using the vault's own already-shipped
165
+ folder-taxonomy-customization mechanism, not something this skill does on its own.
166
+
167
+ ## Step 7: Report
168
+
169
+ Print a short summary directly in the response, not left only in tool-call output:
170
+
171
+ - Count of frontmatter defaults silently filled, with filenames.
172
+ - Count of recategorization candidates and note-health findings written to the proposal, with a
173
+ link to `inbox/Vault tidy — {date}.md`.
174
+ - If nothing was found anywhere: "Vault's in good shape — nothing to fix or propose."
175
+
176
+ ## Constraints
177
+
178
+ - Never touches — moves, edits, or reorganizes — anything inside `designs/`, `tasks/`, or
179
+ `synapse/`. The index note and a vault-root `README.md` are also out of scope.
180
+ - Recategorization never auto-moves a note or auto-creates a folder — proposal only, to `inbox/`.
181
+ - Only the frontmatter-default class of note-health fix (Step 2) is applied directly and silently.
182
+ Broken links, orphaned notes, and duplicate titles are proposal findings, never auto-repaired.
183
+ - Invoked on demand only — no session-start wiring, no autonomous scheduling. Run it only when
184
+ asked, or on a recurring cadence the user sets up themselves.
185
+ - No compiled code — every step above is a plain `mcp__obsidian__*` call, or (Step 3's broken-link
186
+ history check only) a plain `git log` via the shell against the vault's own local repo; this skill
187
+ itself never calls into `ObsidianStore`.
@@ -0,0 +1,164 @@
1
+ // Synapse for OpenCode: shells out to the same `synapse-hook` binary Claude
2
+ // Code's hooks.json already calls, translating OpenCode's plugin callbacks
3
+ // into the same stdin JSON payload / stdout `hookSpecificOutput` shape the
4
+ // binary already reads and writes. No engine change -- see
5
+ // `src/apps/hook/main.zig`'s own doc comment for that contract.
6
+ //
7
+ // Session lifecycle isn't a named `Hooks` key in OpenCode's plugin API --
8
+ // confirmed against the real `@opencode-ai/plugin`/`@opencode-ai/sdk` type
9
+ // packages, not assumed from prose docs. `session.created` is delivered
10
+ // only through the generic catch-all `event` hook as an `Event` union
11
+ // member (`event.type === "session.created"`), so this plugin instead
12
+ // piggybacks the equivalent one-per-session injection on `chat.message`,
13
+ // gated on whether this session has already received it -- checked against
14
+ // real session history (`client.session.messages`), not just in-memory
15
+ // state, since each `opencode run` CLI invocation is its own short-lived
16
+ // process and an in-memory Set alone re-injects on every `--continue` call.
17
+ //
18
+ // `tool.execute.after`'s real tool names/args, live-verified against a real
19
+ // edit: `write` (`args.filePath`, `args.content`) and `edit` (`args.filePath`,
20
+ // `args.oldString`, `args.newString`) -- both share `filePath`, matching
21
+ // Claude Code's `Write`/`Edit` sharing `tool_input.file_path`. `db_sync.zig`
22
+ // itself reads no payload at all (a blind `git add -A && commit` against the
23
+ // vault's own repo), so it only needs a trigger, not a translated payload --
24
+ // fired on the same write/edit tools, plus the two vault-mutating MCP tools
25
+ // (`obsidian_vault_write`/`obsidian_vault_patch`, the same OpenCode
26
+ // tool-naming transform `mcp__obsidian__vault_write`/`vault_patch` go
27
+ // through elsewhere in this plugin's own skill/command prose).
28
+ //
29
+ // `stop-nudge`'s Claude Code trigger (`Stop`, once per turn) maps to
30
+ // `session.idle` -- live-verified as firing exactly once, after every tool
31
+ // call and the final response for a turn. Its own `additionalContext` (the
32
+ // periodic "worth capturing" nudge) has nowhere to land at that moment --
33
+ // `session.idle` is a pure notification, no message `output` to push a part
34
+ // onto the way `chat.message` has -- so it's queued and delivered as a
35
+ // synthetic part on the *next* `chat.message` instead of dropped.
36
+ //
37
+ // `synapse-hook`'s own binary path isn't resolved here the usual way --
38
+ // OpenCode plugins get no `${CLAUDE_PLUGIN_ROOT}`-equivalent "where am I
39
+ // installed" value, unlike Claude Code/Codex. Resolves via `SYNAPSE_HOOK_BIN`
40
+ // if set (an override, not a requirement), else the fixed shared install
41
+ // location every harness's setup fetches into (`~/.cache/synapse/bin/
42
+ // synapse-hook`, `XDG_CACHE_HOME`-aware) -- a real, predictable path now that
43
+ // the binary is shared and unsuffixed across harnesses, not a bare name that
44
+ // needed PATH resolution.
45
+
46
+ import { spawnSync } from "child_process"
47
+ import { homedir } from "os"
48
+ import { join } from "path"
49
+
50
+ function defaultHookBin() {
51
+ const cacheHome = process.env.XDG_CACHE_HOME || join(homedir(), ".cache")
52
+ return join(cacheHome, "synapse", "bin", "synapse-hook")
53
+ }
54
+
55
+ const HOOK_BIN = process.env.SYNAPSE_HOOK_BIN || defaultHookBin()
56
+ const SESSION_START_MARKER = "[SYNAPSE-SESSION-START]"
57
+
58
+ function runHook(subcommand, payload) {
59
+ const res = spawnSync(HOOK_BIN, [subcommand], {
60
+ input: JSON.stringify(payload),
61
+ encoding: "utf8",
62
+ })
63
+ if (res.status !== 0 || !res.stdout) return null
64
+ try {
65
+ const parsed = JSON.parse(res.stdout)
66
+ return parsed?.hookSpecificOutput?.additionalContext ?? null
67
+ } catch {
68
+ return null
69
+ }
70
+ }
71
+
72
+ function partID() {
73
+ return "prt_" + Math.random().toString(36).slice(2) + Date.now().toString(36)
74
+ }
75
+
76
+ function textPart(sessionID, output, text) {
77
+ return {
78
+ id: partID(),
79
+ sessionID,
80
+ messageID: output.message.id,
81
+ type: "text",
82
+ text,
83
+ synthetic: true,
84
+ }
85
+ }
86
+
87
+ const injected = new Set()
88
+
89
+ // In-memory `injected` is a fast path only, good within one long-lived
90
+ // process (the interactive TUI). Each `opencode run` invocation is its own
91
+ // process, so `--continue`/`--session` against an existing session starts
92
+ // with an empty Set -- checking real session history is what makes this
93
+ // correct across separate CLI invocations, not just within one, confirmed
94
+ // live: without this check, a second `opencode run --continue` call
95
+ // re-injected the full vault index every time.
96
+ async function alreadyInjected(client, sessionID) {
97
+ if (injected.has(sessionID)) return true
98
+ try {
99
+ const res = await client.session.messages({ path: { id: sessionID } })
100
+ const history = res?.data ?? []
101
+ const found = history.some((m) => m.parts.some((p) => p.type === "text" && p.text.startsWith(SESSION_START_MARKER)))
102
+ if (found) injected.add(sessionID)
103
+ return found
104
+ } catch {
105
+ return false
106
+ }
107
+ }
108
+
109
+ const EDIT_TOOLS = new Set(["write", "edit"])
110
+ const VAULT_WRITE_TOOLS = new Set(["obsidian_vault_write", "obsidian_vault_patch"])
111
+
112
+ // sessionID -> nudge text from a `stop-nudge` call that had nowhere to land
113
+ // yet -- delivered on that session's next `chat.message`.
114
+ const pendingNudge = new Map()
115
+
116
+ export const Synapse = async ({ directory, client }) => {
117
+ return {
118
+ "chat.message": async (input, output) => {
119
+ const sessionID = input.sessionID
120
+ const newParts = []
121
+
122
+ if (!(await alreadyInjected(client, sessionID))) {
123
+ injected.add(sessionID)
124
+ const ctx = runHook("session-start", { cwd: directory })
125
+ if (ctx) newParts.push(textPart(sessionID, output, `${SESSION_START_MARKER}\n${ctx}`))
126
+ }
127
+
128
+ const queuedNudge = pendingNudge.get(sessionID)
129
+ if (queuedNudge) {
130
+ pendingNudge.delete(sessionID)
131
+ newParts.push(textPart(sessionID, output, `[SYNAPSE-STOP-NUDGE]\n${queuedNudge}`))
132
+ }
133
+
134
+ const promptText = (output.parts || [])
135
+ .filter((p) => p.type === "text")
136
+ .map((p) => p.text)
137
+ .join("\n")
138
+ const nudge = runHook("prompt-context", { cwd: directory, prompt: promptText || "x" })
139
+ if (nudge) newParts.push(textPart(sessionID, output, `[SYNAPSE-PROMPT-CONTEXT]\n${nudge}`))
140
+
141
+ if (newParts.length) output.parts.push(...newParts)
142
+ },
143
+
144
+ "tool.execute.after": async (input) => {
145
+ const filePath = input.args?.filePath
146
+ if (EDIT_TOOLS.has(input.tool) && filePath) {
147
+ runHook("staleness", {
148
+ session_id: input.sessionID,
149
+ tool_input: { file_path: filePath },
150
+ })
151
+ runHook("db-sync", {});
152
+ } else if (VAULT_WRITE_TOOLS.has(input.tool)) {
153
+ runHook("db-sync", {});
154
+ }
155
+ },
156
+
157
+ event: async ({ event }) => {
158
+ if (event.type !== "session.idle") return
159
+ const sessionID = event.properties.sessionID
160
+ const text = runHook("stop-nudge", { session_id: sessionID })
161
+ if (text) pendingNudge.set(sessionID, text)
162
+ },
163
+ }
164
+ }