@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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
// `
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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
|
-
// `
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// `
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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
|
-
|
|
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
|
|
93
|
-
|
|
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
|
|
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
|
|
110
|
-
//
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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.
|
|
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.
|
|
27
|
-
"@imunitic/synapse-linux-x64": "0.13.
|
|
28
|
-
"@imunitic/synapse-linux-arm64": "0.13.
|
|
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
|
-
|
|
|
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. |
|