@imunitic/synapse 0.13.16 → 0.14.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.
@@ -239,13 +239,9 @@ function configureClaude() {
239
239
  const rendered = renderHooksTemplate(path.join(PKG_ROOT, "harness", "claude", "hooks.json"), hookBin);
240
240
  settings.hooks = mergeHooksInto(settings.hooks, rendered);
241
241
 
242
- // The npm-install counterpart to $CLAUDE_PLUGIN_ROOT -- conf.zig's own
243
- // resolveConfPath and session_start.zig's synapse-claude.md/Index.md.template
244
- // lookups both check this env var now that there's no plugin marketplace
245
- // to set CLAUDE_PLUGIN_ROOT for them. Points at PKG_ROOT, the same
246
- // relative shape CLAUDE_PLUGIN_ROOT used to point at plugins/synapse/ --
247
- // synapse-claude.md, Index.md.template, and every *.conf.template sit
248
- // directly under it.
242
+ // SYNAPSE_CONTENT_ROOT points at PKG_ROOT: the config resolution in
243
+ // Conf_Files and the session-start hook look up synapse-claude.md,
244
+ // Index.md.template and every *.conf.template directly under it.
249
245
  settings.env = settings.env || {};
250
246
  settings.env.SYNAPSE_CONTENT_ROOT = PKG_ROOT;
251
247
 
@@ -148,5 +148,5 @@ note live containing only the leftovers, trimmed of everything that did get merg
148
148
  only the wipe-with-preservation step before it and the note-merge step after it.
149
149
  - The wipe itself is `synapse graph-wipe` (via `synapse graph-wipe`), mirroring
150
150
  `synapse graph-clean` as the only other destructive tool in the system.
151
- - Resolves the namespace the same way `/synapse-init` and `/synapse-rebuild-diff` do -- one chain, in `core/identity.zig`
151
+ - Resolves the namespace the same way `/synapse-init` and `/synapse-rebuild-diff` do -- one chain, in `Core.Identity`
152
152
  — never re-derives repo/branch/remote independently.
@@ -146,4 +146,4 @@ note live containing only the leftovers, trimmed of everything that did get merg
146
146
  - The wipe itself is `synapse graph-wipe` (via `synapse graph-wipe`), mirroring
147
147
  `synapse graph-clean` as the only other destructive tool in the system.
148
148
  - Resolves the namespace the same way the synapse-init and synapse-rebuild-diff skills do -- one
149
- chain, in `core/identity.zig` — never re-derives repo/branch/remote independently.
149
+ chain, in `Core.Identity` — never re-derives repo/branch/remote independently.
@@ -2,35 +2,39 @@
2
2
  // Code's hooks.json already calls, translating OpenCode's plugin callbacks
3
3
  // into the same stdin JSON payload / stdout `hookSpecificOutput` shape the
4
4
  // binary already reads and writes. No engine change -- see
5
- // `src/apps/hook/main.zig`'s own doc comment for that contract.
5
+ // `src/hooks/synapse-hooks-dispatch.ads`'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
+ }
@@ -15,9 +15,9 @@ const path = require("path");
15
15
  const CLI_NAME = "synapse";
16
16
  const HOOK_NAME = "synapse-hook";
17
17
 
18
- // Matches process.platform/process.arch directly (not a Zig target triple)
18
+ // Matches process.platform/process.arch directly (not a compiler target triple)
19
19
  // since that's what npm's own `os`/`cpu` fields select against -- the
20
- // release pipeline maps its Zig target names to this naming once, at
20
+ // release pipeline maps its target names to this naming once, at
21
21
  // publish time, not here.
22
22
  function platformPackageName() {
23
23
  return `@imunitic/synapse-${process.platform}-${process.arch}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imunitic/synapse",
3
- "version": "0.13.16",
3
+ "version": "0.14.0",
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.14.0",
27
+ "@imunitic/synapse-linux-x64": "0.14.0",
28
+ "@imunitic/synapse-linux-arm64": "0.14.0"
29
29
  },
30
30
  "license": "SEE LICENSE IN LICENSE"
31
31
  }
@@ -44,7 +44,7 @@ conf file actually lives"): the first tier with a file at all wins, whether or n
44
44
  sets `SYNAPSE_AUTHOR_POOL` — a `synapse.conf` at `~/.claude/` is not consulted when one already
45
45
  exists at the XDG tier, even if the XDG one is silent on this key. Absent, empty, or malformed
46
46
  all fall through to `0`. **This is read by you, the orchestrating agent, directly — not by any
47
- Zig binary.** Nothing compiled dispatches subagents, so there is nothing for a `synapse` flag to
47
+ compiled binary.** Nothing compiled dispatches subagents, so there is nothing for a `synapse` flag to
48
48
  feed. The conf file is the shared home for the setting; the reader is markdown, not code.
49
49
 
50
50
  `pool = 0` is not "a pool of zero workers" — go to §2. `pool >= 1` is a real worker pool — go
@@ -142,7 +142,7 @@ convention. State it here, every time, rather than assuming it travels for free.
142
142
  ### 3d. Verify on completion, retry once, then fall back
143
143
 
144
144
  When a completion notification arrives, read `b-NN.md` yourself — this is a check you read the
145
- file for, same as the section check next to it, not a shell one-liner (`core/query.zig` is this
145
+ file for, same as the section check next to it, not a shell one-liner (`Core.Frontmatter` is this
146
146
  codebase's own tested, compiled frontmatter reader — a fresh hand-rolled `grep`/`sed`/`awk`
147
147
  pattern here would drift from it the same way the gap this section exists to close first
148
148
  happened). Confirm the file exists, opens with
@@ -406,9 +406,9 @@ discipline — never assume a patch worked from reading it:
406
406
 
407
407
  A per-file "this file depends on library L" edge narrows an ambiguous reference (a name defined in
408
408
  more than one node) to the candidate the referencing file actually declares a dependency on —
409
- `core/links.zig`'s own real signal for that case, read from `synapse-dependency-rules.conf`
409
+ `Core.Links`' own real signal for that case, read from `synapse-dependency-rules.conf`
410
410
  (`SYNAPSE_DEPENDENCY_RULES_CONF` overrides the path), a second registry alongside
411
- `synapse-namespace-rules.conf` and reusing the exact same `core/namespace.Rule`/`Registry` shape
411
+ `synapse-namespace-rules.conf` and reusing the exact same `Core.Namespace.Rule`/`Registry` shape
412
412
  unchanged — a rule per extension, `kind` `in-file` or `build-file`, ordered `prefix`/`terminator`,
413
413
  first match wins, no per-language branching in code. A separate file, not a second key on the same
414
414
  one: an extension can need both a namespace rule (what a file *is*) and a dependency rule (what a
@@ -44,7 +44,7 @@ SYNAPSE_GRAMMARS_DIR="$HOME/.cache/synapse/grammars"
44
44
  # -- the safest choice and the only one that preserves cross-node authorial
45
45
  # memory. N>=1 fans out to a worker pool of that size instead, each node
46
46
  # written by an isolated subagent. Read directly by the orchestrating agent
47
- # when it runs /synapse-init, not by any Zig binary -- nothing compiled
47
+ # when it runs /synapse-init, not by any compiled binary -- nothing compiled
48
48
  # dispatches subagents.
49
49
  #SYNAPSE_AUTHOR_POOL=0
50
50