@imunitic/synapse 0.13.16 → 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.16",
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.16",
27
- "@imunitic/synapse-linux-x64": "0.13.16",
28
- "@imunitic/synapse-linux-arm64": "0.13.16"
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
  }