opencode-deja 0.21.4 → 0.21.5
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/README.md +18 -1
- package/index.js +139 -72
- package/lib.js +85 -3
- package/package.json +4 -2
- package/server.js +122 -0
package/README.md
CHANGED
|
@@ -20,6 +20,14 @@ embeddings, no network path unless you ask for one.
|
|
|
20
20
|
}
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
That is opencode 1.x. On 2.x the list is `plugins`:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"plugins": ["opencode-deja"]
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
23
31
|
opencode installs the package on start. The deja binary comes with it, but a
|
|
24
32
|
deja you installed yourself always wins — see [Which binary](#which-binary).
|
|
25
33
|
|
|
@@ -45,7 +53,8 @@ Six tools the model can call:
|
|
|
45
53
|
And recall nobody has to ask for:
|
|
46
54
|
|
|
47
55
|
- the project's recent sessions are pushed onto the system prompt once per
|
|
48
|
-
session, with a one-time toast so you know memory arrived
|
|
56
|
+
session, with a one-time toast so you know memory arrived (1.x only: a
|
|
57
|
+
2.x server plugin has no channel to the TUI);
|
|
49
58
|
- each prompt gets a relevance pass of its own — silent when nothing matches;
|
|
50
59
|
- a spawned agent gets recall in its own instructions, since it never sees the
|
|
51
60
|
system prompt or a user prompt;
|
|
@@ -63,6 +72,14 @@ And recall nobody has to ask for:
|
|
|
63
72
|
}
|
|
64
73
|
```
|
|
65
74
|
|
|
75
|
+
On 2.x:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"plugins": [{ "package": "opencode-deja", "options": { "autoRecall": false, "tools": true } }]
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
66
83
|
- `autoRecall` (default `true`) — the hooks above. Turn off to keep only
|
|
67
84
|
the tools.
|
|
68
85
|
- `tools` (default `true`) — the six tools. They are skipped on their own when
|
package/index.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
// this machine, including months before deja existed. The index is deja's; this
|
|
6
6
|
// file is the seam: six tools the model can call, plus recall that arrives
|
|
7
7
|
// without being asked for.
|
|
8
|
+
//
|
|
9
|
+
// This is the 1.x plugin. opencode 2.x loads server.js, which runs this same
|
|
10
|
+
// function and wires its hooks to 2.x's seams.
|
|
8
11
|
|
|
9
12
|
import { createRequire } from "node:module"
|
|
10
13
|
import { execFile } from "node:child_process"
|
|
@@ -22,6 +25,8 @@ import {
|
|
|
22
25
|
contributions,
|
|
23
26
|
lastUserText,
|
|
24
27
|
mcpWired,
|
|
28
|
+
TOOL_SPECS,
|
|
29
|
+
zodTools,
|
|
25
30
|
} from "./lib.js"
|
|
26
31
|
|
|
27
32
|
const require = createRequire(import.meta.url)
|
|
@@ -99,6 +104,9 @@ async function run(bin, args, input, cwd, timeout = 20000) {
|
|
|
99
104
|
maxBuffer: 8 * 1024 * 1024,
|
|
100
105
|
})
|
|
101
106
|
if (input !== undefined) {
|
|
107
|
+
// deja can exit before it reads stdin; unhandled, the EPIPE that
|
|
108
|
+
// follows would take opencode down. The generated plugin does the same.
|
|
109
|
+
child.child.stdin.on("error", () => {})
|
|
102
110
|
child.child.stdin.end(input)
|
|
103
111
|
}
|
|
104
112
|
const { stdout } = await child
|
|
@@ -158,6 +166,27 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
|
|
|
158
166
|
// again. Counted, so a store that really is empty is still only asked a few
|
|
159
167
|
// times.
|
|
160
168
|
const empties = new Map()
|
|
169
|
+
// Every session the per-prompt hook stamped live. opencode never says a
|
|
170
|
+
// session ended, so without ending them here the stamp sat out its whole
|
|
171
|
+
// window and the next session's MCP recall left a finished one out (#4546).
|
|
172
|
+
const live = new Set()
|
|
173
|
+
const endSession = (id) => ask(["hook-session-end"], JSON.stringify({ session_id: id }), 5000)
|
|
174
|
+
// The session that spawned each one, asked of opencode once. A task
|
|
175
|
+
// sub-agent's digest and recall led with its parent, which is live and
|
|
176
|
+
// asking through it (#4548).
|
|
177
|
+
const parents = new Map()
|
|
178
|
+
const parentOf = async (id) => {
|
|
179
|
+
if (!id) return ""
|
|
180
|
+
if (!parents.has(id)) {
|
|
181
|
+
let parent = ""
|
|
182
|
+
try {
|
|
183
|
+
const res = await client?.session?.get?.({ path: { id } })
|
|
184
|
+
parent = res?.data?.parentID || ""
|
|
185
|
+
} catch {}
|
|
186
|
+
parents.set(id, parent)
|
|
187
|
+
}
|
|
188
|
+
return parents.get(id)
|
|
189
|
+
}
|
|
161
190
|
|
|
162
191
|
const hooks = {}
|
|
163
192
|
|
|
@@ -169,74 +198,80 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
|
|
|
169
198
|
// copy `deja install` keeps current; this package fills the gaps.
|
|
170
199
|
const adds = contributions(installerWiring(), config)
|
|
171
200
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
},
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
},
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
},
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
},
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
},
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
},
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
}
|
|
201
|
+
// The six tools, described once. opencode 1.x takes them through its zod
|
|
202
|
+
// helper and 2.x as JSON Schema (server.js), so the arguments are plain
|
|
203
|
+
// data and each side builds its own.
|
|
204
|
+
const specs = {
|
|
205
|
+
deja_recall: {
|
|
206
|
+
description:
|
|
207
|
+
"Search this machine's own past AI coding sessions — every agent used on it, including months before deja was installed. Use before debugging an error or re-implementing anything that may already exist. Match on the most specific token available: an exact error string, function name, file path or flag.",
|
|
208
|
+
args: {
|
|
209
|
+
query: { type: "string", description: "Specific tokens to match. Several words are ANDed." },
|
|
210
|
+
limit: { type: "number", optional: true, description: "How many sessions to return. Default 5." },
|
|
211
|
+
},
|
|
212
|
+
async execute(args) {
|
|
213
|
+
const limit = String(clampLimit(args.limit))
|
|
214
|
+
return answer(await ask(argv("search", ["--json", "--limit", limit], args.query)))
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
deja_session: {
|
|
218
|
+
description:
|
|
219
|
+
"A full digest of the single best-matching past session — what was tried, what was decided, what it cost. Use after deja_recall when the reasoning behind an earlier decision matters, not just that it happened.",
|
|
220
|
+
args: {
|
|
221
|
+
query: { type: "string", description: "A query, or a session id prefix returned by deja_recall." },
|
|
222
|
+
},
|
|
223
|
+
async execute(args) {
|
|
224
|
+
return answer(await ask(argv("ctx", [], args.query)))
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
deja_blame: {
|
|
228
|
+
description:
|
|
229
|
+
"The past sessions that discussed a file, so you know why it is shaped the way it is before editing, refactoring or deleting it. Session history, not git authorship.",
|
|
230
|
+
args: {
|
|
231
|
+
path: { type: "string", description: "Path to the file, absolute or relative to the project." },
|
|
232
|
+
},
|
|
233
|
+
async execute(args) {
|
|
234
|
+
return answer(await ask(argv("blame", ["--json"], args.path)))
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
deja_fix: {
|
|
238
|
+
description:
|
|
239
|
+
"What this machine ran after that same error before, in the sessions where the error did not come back. Paste the failing output verbatim rather than a paraphrase — the match is on the error's own words.",
|
|
240
|
+
args: {
|
|
241
|
+
error: { type: "string", description: "The failing output, copied as it was printed." },
|
|
242
|
+
},
|
|
243
|
+
async execute(args) {
|
|
244
|
+
return answer(await ask(argv("fix", [], args.error)))
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
deja_how: {
|
|
248
|
+
description:
|
|
249
|
+
"The real invocation this machine uses for a build, test, deploy or script, with the flags it actually ran, ordered by how many sessions ran it. A guessed command is plausible and fails on this setup.",
|
|
250
|
+
args: {
|
|
251
|
+
what: { type: "string", description: "The thing to run: a tool, a task, a script name." },
|
|
252
|
+
},
|
|
253
|
+
async execute(args) {
|
|
254
|
+
return answer(await ask(argv("how", [], args.what)))
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
deja_remember: {
|
|
258
|
+
description:
|
|
259
|
+
"Store one durable decision once it is settled, as a single self-contained fact that will make sense months later. Not transcripts, not a summary of the conversation, and not anything already obvious from the code.",
|
|
260
|
+
args: {
|
|
261
|
+
text: { type: "string", description: "The decision, in one or two sentences, with the reason it was taken." },
|
|
262
|
+
},
|
|
263
|
+
async execute(args) {
|
|
264
|
+
const written = await ask(argv("remember", [], args.text))
|
|
265
|
+
if (!installed) return MISSING
|
|
266
|
+
return written || "deja did not record that."
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (adds.tools) {
|
|
272
|
+
// Under a symbol, so 1.x, which reads its hooks by name, never sees it.
|
|
273
|
+
hooks[TOOL_SPECS] = specs
|
|
274
|
+
if (tool) hooks.tool = zodTools(tool, specs)
|
|
240
275
|
}
|
|
241
276
|
|
|
242
277
|
if (!adds.recall) return hooks
|
|
@@ -248,7 +283,12 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
|
|
|
248
283
|
try {
|
|
249
284
|
const key = input?.sessionID || "default"
|
|
250
285
|
if (!digests.has(key)) {
|
|
251
|
-
|
|
286
|
+
// The session id, as the plugin `deja install opencode-auto` writes
|
|
287
|
+
// sends it: without it deja hands the session its own work back and
|
|
288
|
+
// never reads the digest cached for it (#4273). The parent rides
|
|
289
|
+
// along so the digest leaves it out too.
|
|
290
|
+
const payload = { session_id: input?.sessionID || "", parent_session_id: await parentOf(input?.sessionID), cwd }
|
|
291
|
+
const { context, receipt } = contextText(await ask(["hook-context"], JSON.stringify(payload), 30000))
|
|
252
292
|
digests.set(key, context)
|
|
253
293
|
// The receipt is the only sign the user gets that memory arrived. Once
|
|
254
294
|
// per session: repeating it every turn is wallpaper. The hook's own
|
|
@@ -296,13 +336,18 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
|
|
|
296
336
|
hooks["experimental.chat.messages.transform"] = async (input, output) => {
|
|
297
337
|
try {
|
|
298
338
|
const { parts, prompt, sessionID } = lastUserText(output?.messages)
|
|
299
|
-
|
|
339
|
+
// A turn with no text, an image alone, still goes to hook-prompt: that
|
|
340
|
+
// call is what stamps the session live again after session.idle ended
|
|
341
|
+
// it, and without it the turn's MCP recall could return the session to
|
|
342
|
+
// itself (#4573). sessionID is empty when there is no user message.
|
|
343
|
+
if (!prompt && !sessionID) return
|
|
300
344
|
// The session id lets recall skip what it already showed this session.
|
|
301
345
|
// Without it every message re-injects the same block — measured on a real
|
|
302
346
|
// store, half of all injections were a word-for-word repeat.
|
|
303
347
|
const key = input?.sessionID || sessionID || ""
|
|
304
|
-
|
|
305
|
-
|
|
348
|
+
if (key) live.add(key)
|
|
349
|
+
const raw = await ask(["hook-prompt"], JSON.stringify({ prompt, session_id: key, parent_session_id: await parentOf(key), cwd }))
|
|
350
|
+
if (!prompt || !raw) return
|
|
306
351
|
const extra = JSON.parse(raw)?.hookSpecificOutput?.additionalContext
|
|
307
352
|
if (!extra) return
|
|
308
353
|
parts[parts.length - 1].text += "\n\n" + extra
|
|
@@ -399,5 +444,27 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
|
|
|
399
444
|
}
|
|
400
445
|
}
|
|
401
446
|
|
|
447
|
+
// A turn is over when opencode publishes session.idle, and the next prompt
|
|
448
|
+
// stamps the session again before the model can ask anything, so the session
|
|
449
|
+
// is history to everyone else in between.
|
|
450
|
+
hooks.event = async ({ event }) => {
|
|
451
|
+
try {
|
|
452
|
+
const id = event?.type === "session.idle" ? event.properties?.sessionID : ""
|
|
453
|
+
if (!id) return
|
|
454
|
+
await endSession(id)
|
|
455
|
+
// Ended, so dispose does not spawn deja for it again on the way out.
|
|
456
|
+
live.delete(id)
|
|
457
|
+
} catch {
|
|
458
|
+
// memory is optional: never break the session over it
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// `opencode run` exits without waiting for the event above, and it does wait
|
|
463
|
+
// for dispose: a one-shot run ends what it started here.
|
|
464
|
+
hooks.dispose = async () => {
|
|
465
|
+
for (const id of live) await endSession(id)
|
|
466
|
+
live.clear()
|
|
467
|
+
}
|
|
468
|
+
|
|
402
469
|
return hooks
|
|
403
470
|
}
|
package/lib.js
CHANGED
|
@@ -65,10 +65,13 @@ export function configPaths(env, home) {
|
|
|
65
65
|
export function mcpWired(text) {
|
|
66
66
|
try {
|
|
67
67
|
const config = JSON.parse(stripJSONComments(String(text || "")))
|
|
68
|
-
|
|
68
|
+
// 1.x keeps servers directly under `mcp`, 2.x under `mcp.servers`.
|
|
69
|
+
const mcp = (config && config.mcp) || {}
|
|
70
|
+
const entry = mcp.deja || (mcp.servers && mcp.servers.deja)
|
|
69
71
|
// A server kept in the file but switched off offers nothing, so the
|
|
70
|
-
// tools here are not a second copy of it.
|
|
71
|
-
|
|
72
|
+
// tools here are not a second copy of it. 1.x switches it off with
|
|
73
|
+
// `enabled: false`, 2.x with `disabled: true`.
|
|
74
|
+
return Boolean(entry) && entry.enabled !== false && entry.disabled !== true
|
|
72
75
|
} catch {
|
|
73
76
|
return false
|
|
74
77
|
}
|
|
@@ -145,3 +148,82 @@ export function clampLimit(value, fallback = 5) {
|
|
|
145
148
|
if (!Number.isFinite(asked)) return fallback
|
|
146
149
|
return Math.min(20, Math.max(1, Math.trunc(asked)))
|
|
147
150
|
}
|
|
151
|
+
|
|
152
|
+
// TOOL_SPECS is where the plugin leaves its tool list on the hooks it returns,
|
|
153
|
+
// for the 2.x entry to register. A symbol, so 1.x never reads it as a hook.
|
|
154
|
+
export const TOOL_SPECS = Symbol.for("opencode-deja.tools")
|
|
155
|
+
|
|
156
|
+
// zodTools builds the 1.x tool table from the specs, through opencode's own
|
|
157
|
+
// helper and the zod it re-exports.
|
|
158
|
+
export function zodTools(tool, specs) {
|
|
159
|
+
const schema = tool.schema
|
|
160
|
+
const out = {}
|
|
161
|
+
for (const [name, spec] of Object.entries(specs)) {
|
|
162
|
+
const args = {}
|
|
163
|
+
for (const [key, arg] of Object.entries(spec.args)) {
|
|
164
|
+
let field = arg.type === "number" ? schema.number() : schema.string()
|
|
165
|
+
if (arg.optional) field = field.optional()
|
|
166
|
+
args[key] = field.describe(arg.description)
|
|
167
|
+
}
|
|
168
|
+
out[name] = tool({ description: spec.description, args, execute: spec.execute })
|
|
169
|
+
}
|
|
170
|
+
return out
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// jsonSchema is a spec's arguments in the form 2.x takes a tool's input.
|
|
174
|
+
export function jsonSchema(args) {
|
|
175
|
+
const properties = {}
|
|
176
|
+
const required = []
|
|
177
|
+
for (const [key, arg] of Object.entries(args)) {
|
|
178
|
+
properties[key] = { type: arg.type, description: arg.description }
|
|
179
|
+
if (!arg.optional) required.push(key)
|
|
180
|
+
}
|
|
181
|
+
return { type: "object", properties, required }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// turnEnded is the session whose turn a 2.x event says is over, or "". 2.0.22
|
|
185
|
+
// ends a turn with session.execution.succeeded, .failed or .interrupted and
|
|
186
|
+
// never publishes 1.x's session.idle, which its schema still carries (#4571).
|
|
187
|
+
export function turnEnded(event) {
|
|
188
|
+
switch (event?.type) {
|
|
189
|
+
case "session.execution.succeeded":
|
|
190
|
+
case "session.execution.failed":
|
|
191
|
+
case "session.execution.interrupted":
|
|
192
|
+
case "session.idle":
|
|
193
|
+
return event.data?.sessionID || ""
|
|
194
|
+
}
|
|
195
|
+
return ""
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// v1Messages shows 2.x's request messages in the 1.x shape lastUserText reads.
|
|
199
|
+
// The parts are 2.x's own content objects, so text appended to one lands in
|
|
200
|
+
// the request.
|
|
201
|
+
export function v1Messages(messages, sessionID) {
|
|
202
|
+
const list = Array.isArray(messages) ? messages : []
|
|
203
|
+
return list.map((m) => ({
|
|
204
|
+
info: { role: m?.role, sessionID: sessionID || "" },
|
|
205
|
+
parts: Array.isArray(m?.content) ? m.content : [],
|
|
206
|
+
}))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// resultText is the text a finished 2.x tool call returned: `content` is a
|
|
210
|
+
// string or a list of blocks, and a tool may leave only `output`.
|
|
211
|
+
export function resultText(result) {
|
|
212
|
+
const r = result || {}
|
|
213
|
+
if (typeof r.content === "string") return r.content
|
|
214
|
+
if (Array.isArray(r.content)) {
|
|
215
|
+
return r.content
|
|
216
|
+
.filter((c) => c?.type === "text")
|
|
217
|
+
.map((c) => c.text)
|
|
218
|
+
.join("\n")
|
|
219
|
+
}
|
|
220
|
+
return typeof r.output === "string" ? r.output : ""
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// v1ToolName is the 1.x name for a 2.x tool, where the two differ in a way the
|
|
224
|
+
// hooks look at: the spawn tool and the shell.
|
|
225
|
+
export function v1ToolName(name) {
|
|
226
|
+
if (name === "subagent") return "task"
|
|
227
|
+
if (name === "shell") return "bash"
|
|
228
|
+
return name
|
|
229
|
+
}
|
package/package.json
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-deja",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.5",
|
|
4
4
|
"description": "deja-vu memory for opencode: the session history of thirty-four other coding agents, searchable and recalled on every request — a local index, no LLM.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./index.js",
|
|
9
|
+
"./server": "./server.js",
|
|
9
10
|
"./package.json": "./package.json"
|
|
10
11
|
},
|
|
11
12
|
"files": [
|
|
12
13
|
"index.js",
|
|
14
|
+
"server.js",
|
|
13
15
|
"lib.js",
|
|
14
16
|
"README.md",
|
|
15
17
|
"LICENSE"
|
|
@@ -50,7 +52,7 @@
|
|
|
50
52
|
},
|
|
51
53
|
"dependencies": {
|
|
52
54
|
"@opencode-ai/plugin": ">=1.0.0",
|
|
53
|
-
"@vshulcz/deja-vu": "^0.21.
|
|
55
|
+
"@vshulcz/deja-vu": "^0.21.5"
|
|
54
56
|
},
|
|
55
57
|
"scripts": {
|
|
56
58
|
"test": "node --test test/*.test.js"
|
package/server.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The entry opencode loads as `opencode-deja/server`.
|
|
2
|
+
//
|
|
3
|
+
// opencode 2.x imports only a default export and requires `{ id, setup }` (or
|
|
4
|
+
// `effect`) on it; the named export in index.js fails its check with "Plugin
|
|
5
|
+
// must export a default definition with an id and an effect or setup
|
|
6
|
+
// function" (#4151). 1.x from 1.3.4 resolves the same `./server` subpath and
|
|
7
|
+
// wants `{ server }` on the default export, so this object carries both. 1.x
|
|
8
|
+
// before 1.3.4 never looks here and keeps loading index.js, which calls every
|
|
9
|
+
// export as a plugin and would choke on an object.
|
|
10
|
+
//
|
|
11
|
+
// setup runs the 1.x plugin and wires its hooks to 2.x's seams, so both majors
|
|
12
|
+
// run the same code: the session digest and the per-prompt recall go in on
|
|
13
|
+
// `session` "context", the pre-compaction index on "compaction", the spawn and
|
|
14
|
+
// after-tool lines on `tool` "execute.before" and "execute.after", the end of
|
|
15
|
+
// a turn on `event` "subscribe" and dispose on the cleanup setup returns.
|
|
16
|
+
|
|
17
|
+
import { DejaPlugin } from "./index.js"
|
|
18
|
+
import { jsonSchema, resultText, TOOL_SPECS, turnEnded, v1Messages, v1ToolName } from "./lib.js"
|
|
19
|
+
|
|
20
|
+
// A 2.x server plugin has no channel to the TUI, so the toasts go nowhere.
|
|
21
|
+
const noToast = { tui: { showToast: async () => {} } }
|
|
22
|
+
|
|
23
|
+
async function setup(ctx) {
|
|
24
|
+
const directory = ctx.location?.directory || process.cwd()
|
|
25
|
+
const hooks = await DejaPlugin({ client: noToast, directory }, ctx.options || {})
|
|
26
|
+
|
|
27
|
+
const specs = hooks[TOOL_SPECS]
|
|
28
|
+
if (specs) {
|
|
29
|
+
await ctx.tool.transform((tools) => {
|
|
30
|
+
for (const [name, spec] of Object.entries(specs)) {
|
|
31
|
+
tools.add({
|
|
32
|
+
name,
|
|
33
|
+
// Only a codemode: false tool is offered to the model directly; the
|
|
34
|
+
// rest sit behind the code-mode `execute` tool, as built-ins do not.
|
|
35
|
+
options: { codemode: false },
|
|
36
|
+
description: spec.description,
|
|
37
|
+
input: jsonSchema(spec.args),
|
|
38
|
+
execute: async (input) => ({ content: await spec.execute(input || {}) }),
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const system = hooks["experimental.chat.system.transform"]
|
|
45
|
+
const prompt = hooks["experimental.chat.messages.transform"]
|
|
46
|
+
if (system || prompt) {
|
|
47
|
+
await ctx.session.hook("context", async (event) => {
|
|
48
|
+
try {
|
|
49
|
+
if (system) {
|
|
50
|
+
const out = { system: event.system.map((p) => p.text) }
|
|
51
|
+
await system({ sessionID: event.sessionID }, out)
|
|
52
|
+
if (event.system.length) {
|
|
53
|
+
if (out.system[0] !== event.system[0].text) event.system[0] = { ...event.system[0], text: out.system[0] }
|
|
54
|
+
} else if (out.system.length) {
|
|
55
|
+
event.system.push({ type: "text", text: out.system[0] })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (prompt) {
|
|
59
|
+
await prompt({ sessionID: event.sessionID }, { messages: v1Messages(event.messages, event.sessionID) })
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// memory is optional: never break the session over it
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const compacting = hooks["experimental.session.compacting"]
|
|
68
|
+
if (compacting) {
|
|
69
|
+
await ctx.session.hook("compaction", async () => {
|
|
70
|
+
await compacting()
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const before = hooks["tool.execute.before"]
|
|
75
|
+
if (before) {
|
|
76
|
+
await ctx.tool.hook("execute.before", async (event) => {
|
|
77
|
+
const args = { ...(event.input || {}) }
|
|
78
|
+
await before({ tool: v1ToolName(event.tool), sessionID: event.sessionID }, { args })
|
|
79
|
+
if (args.prompt !== event.input?.prompt) event.input = args
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const after = hooks["tool.execute.after"]
|
|
84
|
+
if (after) {
|
|
85
|
+
await ctx.tool.hook("execute.after", async (event) => {
|
|
86
|
+
if (event.status !== "completed") return
|
|
87
|
+
const text = resultText(event.result)
|
|
88
|
+
const output = { output: text }
|
|
89
|
+
await after({ tool: v1ToolName(event.tool), sessionID: event.sessionID, args: event.input || {} }, output)
|
|
90
|
+
if (output.output === text || !output.output.startsWith(text)) return
|
|
91
|
+
const extra = output.output.slice(text.length).trim()
|
|
92
|
+
const result = event.result || {}
|
|
93
|
+
const content = Array.isArray(result.content)
|
|
94
|
+
? result.content
|
|
95
|
+
: typeof result.content === "string"
|
|
96
|
+
? [{ type: "text", text: result.content }]
|
|
97
|
+
: text
|
|
98
|
+
? [{ type: "text", text }]
|
|
99
|
+
: []
|
|
100
|
+
event.result = { ...result, content: [...content, { type: "text", text: extra }] }
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2.x has no event hook or dispose in a plugin's table: the events come from
|
|
105
|
+
// ctx.event and the exit is the cleanup setup returns, which `opencode run`
|
|
106
|
+
// awaits. Without them a session stamped live was never ended (#4571).
|
|
107
|
+
const stop = new AbortController()
|
|
108
|
+
if (hooks.event && typeof ctx.event?.subscribe === "function") {
|
|
109
|
+
;(async () => {
|
|
110
|
+
for await (const event of ctx.event.subscribe({ signal: stop.signal })) {
|
|
111
|
+
const id = turnEnded(event)
|
|
112
|
+
if (id) await hooks.event({ event: { type: "session.idle", properties: { sessionID: id } } })
|
|
113
|
+
}
|
|
114
|
+
})().catch(() => {})
|
|
115
|
+
}
|
|
116
|
+
return async () => {
|
|
117
|
+
stop.abort()
|
|
118
|
+
await hooks.dispose?.()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export default { id: "opencode-deja", setup, server: DejaPlugin }
|