@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.
- package/bin/synapse-setup.cjs +3 -7
- package/commands/synapse-rebuild-full.md +1 -1
- package/harness/codex/skills/synapse-rebuild-full/SKILL.md +1 -1
- package/harness/opencode/plugin/synapse.js +148 -53
- package/lib/resolve-binaries.cjs +2 -2
- package/package.json +4 -4
- package/skills/synapse-node-authoring/SKILL.md +2 -2
- package/skills/synapse-orientation/SKILL.md +2 -2
- package/synapse.conf.template +1 -1
package/bin/synapse-setup.cjs
CHANGED
|
@@ -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
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
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 `
|
|
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 `
|
|
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/
|
|
5
|
+
// `src/hooks/synapse-hooks-dispatch.ads`'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/lib/resolve-binaries.cjs
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
27
|
-
"@imunitic/synapse-linux-x64": "0.
|
|
28
|
-
"@imunitic/synapse-linux-arm64": "0.
|
|
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
|
-
|
|
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 (`
|
|
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
|
-
`
|
|
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 `
|
|
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
|
package/synapse.conf.template
CHANGED
|
@@ -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
|
|
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
|
|