@imunitic/synapse 0.13.15 → 0.13.17

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.
@@ -4,33 +4,37 @@
4
4
  // binary already reads and writes. No engine change -- see
5
5
  // `src/apps/hook/main.zig`'s own doc comment for that contract.
6
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.
7
+ // Ported to the OpenCode v2 plugin API (2.0.x). The v2 loader rejects this
8
+ // file's old shape outright at load time -- it requires a default export
9
+ // carrying an `id` plus a `setup(ctx)` function, and it never runs a bare
10
+ // named-export factory ("Plugin must export a default definition with an id
11
+ // and an effect or setup function"). The v1 hook map is kept as `server()`
12
+ // for OpenCode 1.18.29+ hosts, which read exactly that key off the default
13
+ // export; the two implementations share all state and helpers below, and
14
+ // only the wiring differs.
17
15
  //
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`. Fired on
22
- // write/edit tools for `staleness` -- the vault's own version control
23
- // (`SYNAPSE_VAULT_INTEGRATIONS=git`) commits from inside `synapse`'s own CLI
24
- // (`vault-write`/`vault-patch`) itself now, needing no `PostToolUse`-style
25
- // hook here or on any other harness.
16
+ // v1 `chat.message` -> v2 `ctx.session.hook("prompt", ...)`. The v2 prompt
17
+ // hook runs once at prompt admission, before the durable inbox write, and an
18
+ // edit to `event.prompt.text` becomes the canonical persisted user input --
19
+ // so appending the synapse blocks there lands them where v1's synthetic parts
20
+ // did: delivered to the model call answering this prompt, and persisted in
21
+ // session history. That persistence is what makes the one-per-session gate
22
+ // correct across separate `opencode run` invocations -- each CLI call is its
23
+ // own short-lived process with an empty in-memory Set, so the gate re-checks
24
+ // real session history (`ctx.session.context`), not just memory.
26
25
  //
27
- // `stop-nudge`'s Claude Code trigger (`Stop`, once per turn) maps to
28
- // `session.idle` -- live-verified as firing exactly once, after every tool
29
- // call and the final response for a turn. Its own `additionalContext` (the
30
- // periodic "worth capturing" nudge) has nowhere to land at that moment --
31
- // `session.idle` is a pure notification, no message `output` to push a part
32
- // onto the way `chat.message` has -- so it's queued and delivered as a
33
- // synthetic part on the *next* `chat.message` instead of dropped.
26
+ // v1 `tool.execute.after` -> v2 `ctx.tool.hook("execute.after", ...)`, same
27
+ // event fields under different names (`event.tool`, `event.input` instead of
28
+ // `input.tool`, `input.args`).
29
+ //
30
+ // v1 `event` -> v2 `ctx.event.subscribe()`, filtered to `session.idle` --
31
+ // live-verified in v1 as firing exactly once, after every tool call and the
32
+ // final response for a turn. `stop-nudge`'s `additionalContext` (the periodic
33
+ // "worth capturing" nudge) still has nowhere to land at that moment -- idle
34
+ // is a pure notification -- so it stays queued and delivered as a block on
35
+ // the *next* admitted prompt instead of dropped. Same for `staleness`
36
+ // warnings: `tool.execute.after` is a post-hoc notification with no way to
37
+ // reach the current turn, so its output queues the same way.
34
38
  //
35
39
  // `synapse-hook`'s own binary path isn't resolved here the usual way --
36
40
  // OpenCode plugins get no `${CLAUDE_PLUGIN_ROOT}`-equivalent "where am I
@@ -86,54 +90,85 @@ const injected = new Set()
86
90
  // correct across separate CLI invocations, not just within one, confirmed
87
91
  // live: without this check, a second `opencode run --continue` call
88
92
  // re-injected the full vault index every time.
89
- async function alreadyInjected(client, sessionID) {
93
+ //
94
+ // The Set claim happens synchronously *before* the awaited history read, so
95
+ // two prompts admitted concurrently in one process cannot both pass the
96
+ // check (v1 marked only after the await, leaving that race open).
97
+ async function alreadyInjected(fetchHistory, sessionID) {
90
98
  if (injected.has(sessionID)) return true
99
+ injected.add(sessionID)
91
100
  try {
92
- const res = await client.session.messages({ path: { id: sessionID } })
93
- const history = res?.data ?? []
94
- const found = history.some((m) => m.parts.some((p) => p.type === "text" && p.text.startsWith(SESSION_START_MARKER)))
95
- if (found) injected.add(sessionID)
96
- return found
101
+ const history = await fetchHistory(sessionID)
102
+ return history.some(messageHasMarker)
97
103
  } catch {
98
104
  return false
99
105
  }
100
106
  }
101
107
 
108
+ // v1 messages carry text in `parts`; v2 session-context messages (user and
109
+ // synthetic alike) carry it flat on `text`.
110
+ function messageHasMarker(m) {
111
+ if (typeof m?.text === "string") return m.text.startsWith(SESSION_START_MARKER)
112
+ return (m?.parts ?? []).some((p) => p.type === "text" && p.text.startsWith(SESSION_START_MARKER))
113
+ }
114
+
102
115
  const EDIT_TOOLS = new Set(["write", "edit"])
103
116
 
104
117
  // sessionID -> nudge text from a `stop-nudge` call that had nowhere to land
105
- // yet -- delivered on that session's next `chat.message`.
118
+ // yet -- delivered on that session's next admitted prompt.
106
119
  const pendingNudge = new Map()
107
120
 
108
121
  // sessionID -> queued `staleness` output, same reason and same delivery as
109
- // `pendingNudge` above: `tool.execute.after` has no message `output` to push
110
- // a part onto, so a drift/grounding warning from it would otherwise be
122
+ // `pendingNudge` above: `tool.execute.after` has no channel into the turn
123
+ // that just ran, so a drift/grounding warning from it would otherwise be
111
124
  // silently discarded instead of just reaching the next turn late.
112
125
  const pendingStaleness = new Map()
113
126
 
127
+ // Blocks delivered alongside a prompt, in v1's part order: session-start,
128
+ // queued nudge, queued staleness, prompt-context.
129
+ async function collectBlocks(sessionID, opts) {
130
+ const blocks = []
131
+
132
+ if (!(await opts.injected())) {
133
+ const ctx = runHook("session-start", { cwd: opts.directory })
134
+ if (ctx) blocks.push(`${SESSION_START_MARKER}\n${ctx}`)
135
+ }
136
+
137
+ const queuedNudge = pendingNudge.get(sessionID)
138
+ if (queuedNudge) {
139
+ pendingNudge.delete(sessionID)
140
+ blocks.push(`[SYNAPSE-STOP-NUDGE]\n${queuedNudge}`)
141
+ }
142
+
143
+ const queuedStaleness = pendingStaleness.get(sessionID)
144
+ if (queuedStaleness) {
145
+ pendingStaleness.delete(sessionID)
146
+ blocks.push(`[SYNAPSE-STALENESS]\n${queuedStaleness}`)
147
+ }
148
+
149
+ return blocks
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // v1 wiring (OpenCode 1.x): named factory plus `server()` on the default
154
+ // export, per the v1->v2 migration guide's dual-entrypoint shape.
155
+ // ---------------------------------------------------------------------------
156
+
114
157
  export const Synapse = async ({ directory, client }) => {
115
158
  return {
116
159
  "chat.message": async (input, output) => {
117
160
  const sessionID = input.sessionID
118
161
  const newParts = []
119
162
 
120
- if (!(await alreadyInjected(client, sessionID))) {
121
- injected.add(sessionID)
122
- const ctx = runHook("session-start", { cwd: directory })
123
- if (ctx) newParts.push(textPart(sessionID, output, `${SESSION_START_MARKER}\n${ctx}`))
124
- }
125
-
126
- const queuedNudge = pendingNudge.get(sessionID)
127
- if (queuedNudge) {
128
- pendingNudge.delete(sessionID)
129
- newParts.push(textPart(sessionID, output, `[SYNAPSE-STOP-NUDGE]\n${queuedNudge}`))
130
- }
131
-
132
- const queuedStaleness = pendingStaleness.get(sessionID)
133
- if (queuedStaleness) {
134
- pendingStaleness.delete(sessionID)
135
- newParts.push(textPart(sessionID, output, `[SYNAPSE-STALENESS]\n${queuedStaleness}`))
163
+ const history = async (id) => {
164
+ const res = await client.session.messages({ path: { id } })
165
+ return res?.data ?? []
136
166
  }
167
+ const blocks = await collectBlocks(sessionID, {
168
+ directory,
169
+ injected: () => alreadyInjected(history, sessionID),
170
+ })
171
+ for (const block of blocks) newParts.push(textPart(sessionID, output, block))
137
172
 
138
173
  const promptText = (output.parts || [])
139
174
  .filter((p) => p.type === "text")
@@ -158,9 +193,69 @@ export const Synapse = async ({ directory, client }) => {
158
193
 
159
194
  event: async ({ event }) => {
160
195
  if (event.type !== "session.idle") return
161
- const sessionID = event.properties.sessionID
196
+ const sessionID = event.properties?.sessionID ?? event.data?.sessionID
162
197
  const text = runHook("stop-nudge", { session_id: sessionID })
163
- if (text) pendingNudge.set(sessionID, text)
198
+ if (text && sessionID) pendingNudge.set(sessionID, text)
164
199
  },
165
200
  }
166
201
  }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // v2 wiring (OpenCode 2.x): registrations through the plugin context.
205
+ // ---------------------------------------------------------------------------
206
+
207
+ async function setup(ctx) {
208
+ const directory = ctx.location.directory
209
+
210
+ await ctx.session.hook("prompt", async (event) => {
211
+ const sessionID = event.sessionID
212
+
213
+ const history = async (id) => {
214
+ const res = await ctx.session.context({ sessionID: id })
215
+ return Array.isArray(res) ? res : res?.data ?? []
216
+ }
217
+ const blocks = await collectBlocks(sessionID, {
218
+ directory,
219
+ injected: () => alreadyInjected(history, sessionID),
220
+ })
221
+
222
+ const promptText = event.prompt?.text ?? ""
223
+ const nudge = runHook("prompt-context", { cwd: directory, prompt: promptText || "x" })
224
+ if (nudge) blocks.push(`[SYNAPSE-PROMPT-CONTEXT]\n${nudge}`)
225
+
226
+ if (blocks.length) {
227
+ event.prompt.text = promptText ? `${promptText}\n\n${blocks.join("\n\n")}` : blocks.join("\n\n")
228
+ }
229
+ })
230
+
231
+ await ctx.tool.hook("execute.after", async (event) => {
232
+ const filePath = event.input?.filePath
233
+ if (EDIT_TOOLS.has(event.tool) && filePath) {
234
+ const text = runHook("staleness", {
235
+ session_id: event.sessionID,
236
+ tool_input: { file_path: filePath },
237
+ })
238
+ if (text) pendingStaleness.set(event.sessionID, text)
239
+ }
240
+ })
241
+
242
+ const controller = new AbortController()
243
+ void (async () => {
244
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
245
+ if (event?.type !== "session.idle") continue
246
+ const sessionID = event.data?.sessionID ?? event.properties?.sessionID
247
+ if (!sessionID) continue
248
+ const text = runHook("stop-nudge", { session_id: sessionID })
249
+ if (text) pendingNudge.set(sessionID, text)
250
+ }
251
+ })().catch(() => {})
252
+
253
+ return () => controller.abort()
254
+ }
255
+
256
+ export default {
257
+ id: "synapse",
258
+ setup,
259
+ // v1 hook factory, read by OpenCode 1.18.29+ loaders; v2 hosts ignore it.
260
+ server: Synapse,
261
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imunitic/synapse",
3
- "version": "0.13.15",
3
+ "version": "0.13.17",
4
4
  "description": "Memory for Claude Code, Codex CLI, and OpenCode: a durable markdown vault plus a per-repo code graph.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,9 +23,9 @@
23
23
  "*.conf.template"
24
24
  ],
25
25
  "optionalDependencies": {
26
- "@imunitic/synapse-darwin-arm64": "0.13.15",
27
- "@imunitic/synapse-linux-x64": "0.13.15",
28
- "@imunitic/synapse-linux-arm64": "0.13.15"
26
+ "@imunitic/synapse-darwin-arm64": "0.13.17",
27
+ "@imunitic/synapse-linux-x64": "0.13.17",
28
+ "@imunitic/synapse-linux-arm64": "0.13.17"
29
29
  },
30
30
  "license": "SEE LICENSE IN LICENSE"
31
31
  }
@@ -47,14 +47,15 @@ required:
47
47
 
48
48
  | Need | Reach for | Why |
49
49
  |---|---|---|
50
- | "Where does X live?" (ranked, natural-language) | `synapse vault-search-text <words> --namespace <repo>@<branch>` (this checkout's graph included), plus a first read of `synapse/{project}/Index.md` | Full-text, relevance-ranked; each row is `node ⇥ score ⇥ line ranges`, no text. Without `--namespace` the search covers the whole vault and task/design/research notes outrank graph nodes. Read the hit with `query body "{Node}"` (brief) or `query body "{Node}" --lines <ranges>`. Not semantic ranking, but genuinely comparable for locating a concept. |
50
+ | "Where does X live?" (ranked, natural-language) | `synapse vault-search-text "<words>" --namespace <repo>@<branch>` (this checkout's graph included; quote the words, the query is one argument), plus a first read of `synapse/{project}/Index.md` | Full-text, relevance-ranked; each row is `node ⇥ score ⇥ line ranges`, no text. Without `--namespace` the search covers the whole vault and task/design/research notes outrank graph nodes. Read the hit with `query body "{Node}"` (brief) or `query body "{Node}" --lines <ranges>`. Not semantic ranking, but genuinely comparable for locating a concept. |
51
51
  | Every occurrence of a pattern | native `grep`/`rg`, **scoped to a file Synapse already named** | Not a repo-wide first move — the deterred, last-resort case. See "Why this exists" above. |
52
52
  | A file's API surface | read the file directly | Claude already has direct, cheap filesystem access — no separate view needed. |
53
53
  | Who depends on a subsystem, or what it depends on | `synapse query links "{Node}" --inbound` / `--closure` | Real transitive-closure traversal over the typed relations, at node granularity. |
54
54
  | **Who calls this method/class, repo-wide** | `synapse callers <name>` | Every call site as `path:line ⇥ calling expression`, off the flat index `synapse build-refs` projects from the tags cache. Well under a second even against a multi-gigabyte index. Needs **no node and no graph** — it works in a repo `/synapse-init` has never touched, as long as the cache is filled. Still name-based rather than type-resolved, so hits are candidates with evidence: the calling expression is on the line, which usually settles the receiver without opening the file. |
55
55
  | **Where a symbol is defined, or every definition and reference of a name** | `synapse query symbol <name> "{Node}"`, or repo-wide `synapse callers <name> --all` | `symbol` gives `path:line` plus the defining expression inside one node's files. `callers --all` needs no node: every def and ref repo-wide from the Code Cache, the first move when you don't know the owning node yet. |
56
56
  | One frontmatter scalar (`stale`, `built_at`, `commit`, ...) | `synapse query field "{Node}" <key>` | Cheap, targeted extraction — never reads the rest of the node. |
57
- | A node's brief, or its prose, without its (possibly huge) `sources` list | `synapse query body "{Node}"` (brief), `--full` (the whole prose), `--lines <a-b>[,<c-d>]` (those lines of the node file) | Disk read, never the API; the brief and `--full` skip frontmatter and `## Notes`. Start with the brief; go to `--full` or `--lines` only when it doesn't answer. See the cost note above. |
57
+ | What a node is and what it links to | `synapse query body "{Node}"` | A brief: the node's summary, a `path:lines` pointer to its crux and its Links, about 600 characters. This is the first read for "how does X work" too; it says which files to open next. Disk read, never the API. See the cost note above. |
58
+ | A node's whole prose and crux code, or specific lines of it | `synapse query body "{Node}" --full`, or `--lines <a-b>[,<c-d>]` | Only when the brief did not answer: `--full` is about 8k characters, which every later turn re-reads. `--lines` takes the ranges a `vault-search-text` hit gave. |
58
59
  | Every file a node covers | `synapse query sources "{Node}" [--count\|--modules\|--filter <p>]` | Filtered/counted/grouped, never the raw megabyte-scale list. |
59
60
  | Is this node's understanding still accurate? | `synapse query stale` / `drift` / `grounding` | Hand off to the `synapse-node` skill's procedure — this skill doesn't re-explain that. |
60
61
  | A node in a *different* checkout's graph, from a repo with no namespace of its own (or the wrong one) | `synapse query --namespace <repo>@<branch> body "{Node}"` (any subcommand takes it) | Names the target namespace directly instead of deriving it from cwd. `stale`/`drift`/`grounding`/`symbol` still need that checkout's real files on disk and refuse without `SYNAPSE_REPO_ROOT` set to it — `body`/`sources`/`field`/`links` don't. |