opencode-deja 0.19.2 → 0.19.3

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.
Files changed (4) hide show
  1. package/README.md +3 -2
  2. package/index.js +86 -12
  3. package/lib.js +20 -3
  4. package/package.json +3 -3
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # opencode-deja
2
2
 
3
3
  opencode remembers its own sessions. This plugin answers the other question:
4
- what you did in Claude Code, Codex, Cursor, Gemini, Zed and fifteen more agents
5
- on this machine — including the months before you installed anything.
4
+ what you did in the twenty-one other coding agents on this machine — Claude Code,
5
+ Codex, Cursor, Gemini and Zed among them — including the months before you
6
+ installed anything.
6
7
 
7
8
  It runs [deja](https://github.com/vshulcz/deja-vu), a local Go binary that
8
9
  indexes the transcripts those agents already wrote to disk. No LLM, no
package/index.js CHANGED
@@ -14,6 +14,7 @@ import { join } from "node:path"
14
14
  import { accessSync, constants, existsSync, readFileSync } from "node:fs"
15
15
 
16
16
  import {
17
+ argv,
17
18
  clampLimit,
18
19
  cliPluginPath,
19
20
  configPaths,
@@ -30,6 +31,11 @@ const WINDOWS = process.platform === "win32"
30
31
  const PLATFORM = WINDOWS ? "windows" : process.platform
31
32
  const ARCH = process.arch === "x64" ? "amd64" : process.arch
32
33
  const EXE = WINDOWS ? "deja.exe" : "deja"
34
+ // How many turns of a session will ask again after being answered with
35
+ // nothing. Three covers the moments that pass — a locked index, a call that
36
+ // timed out, an upgrade replacing the binary — without turning a store that
37
+ // genuinely holds nothing into a shell-out on every turn.
38
+ const emptyRetries = 3
33
39
 
34
40
  // The tool helper is a peer of the host, not of us: it is identity plus a zod
35
41
  // re-export. Without it the hooks still run and only the model-facing tools are
@@ -143,6 +149,15 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
143
149
 
144
150
  const digests = new Map()
145
151
  const told = new Set()
152
+ // How many times this session has been answered with nothing. An empty
153
+ // answer is cached so the plugin does not shell out every turn, but the
154
+ // reasons for it are not alike: no history is permanent, while a locked
155
+ // index, a timed-out call or a binary being replaced mid-`deja update` are
156
+ // over by the next turn. Cached alike, one bad moment cost the session all
157
+ // of its memory — every later turn read the emptiness rather than asking
158
+ // again. Counted, so a store that really is empty is still only asked a few
159
+ // times.
160
+ const empties = new Map()
146
161
 
147
162
  const hooks = {}
148
163
 
@@ -166,7 +181,7 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
166
181
  },
167
182
  async execute(args) {
168
183
  const limit = String(clampLimit(args.limit))
169
- return answer(await ask(["search", "--json", "--limit", limit, String(args.query)]))
184
+ return answer(await ask(argv("search", ["--json", "--limit", limit], args.query)))
170
185
  },
171
186
  }),
172
187
  deja_session: tool({
@@ -176,7 +191,7 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
176
191
  query: schema.string().describe("A query, or a session id prefix returned by deja_recall."),
177
192
  },
178
193
  async execute(args) {
179
- return answer(await ask(["ctx", String(args.query)]))
194
+ return answer(await ask(argv("ctx", [], args.query)))
180
195
  },
181
196
  }),
182
197
  deja_blame: tool({
@@ -186,7 +201,7 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
186
201
  path: schema.string().describe("Path to the file, absolute or relative to the project."),
187
202
  },
188
203
  async execute(args) {
189
- return answer(await ask(["blame", String(args.path), "--json"]))
204
+ return answer(await ask(argv("blame", ["--json"], args.path)))
190
205
  },
191
206
  }),
192
207
  deja_fix: tool({
@@ -196,7 +211,7 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
196
211
  error: schema.string().describe("The failing output, copied as it was printed."),
197
212
  },
198
213
  async execute(args) {
199
- return answer(await ask(["fix", String(args.error)]))
214
+ return answer(await ask(argv("fix", [], args.error)))
200
215
  },
201
216
  }),
202
217
  deja_how: tool({
@@ -206,7 +221,7 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
206
221
  what: schema.string().describe("The thing to run: a tool, a task, a script name."),
207
222
  },
208
223
  async execute(args) {
209
- return answer(await ask(["how", String(args.what)]))
224
+ return answer(await ask(argv("how", [], args.what)))
210
225
  },
211
226
  }),
212
227
  deja_remember: tool({
@@ -216,7 +231,7 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
216
231
  text: schema.string().describe("The decision, in one or two sentences, with the reason it was taken."),
217
232
  },
218
233
  async execute(args) {
219
- const written = await ask(["remember", String(args.text)])
234
+ const written = await ask(argv("remember", [], args.text))
220
235
  if (!installed) return MISSING
221
236
  return written || "deja did not record that."
222
237
  },
@@ -250,9 +265,13 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
250
265
  output.system.push(context)
251
266
  return
252
267
  }
253
- // Nothing recalled: either this machine has no history yet, or the first
254
- // index is still building. Only the second is worth saying, and saying it
255
- // drops the empty answer so the next turn asks again.
268
+ // Nothing recalled: this machine has no history yet, the first index is
269
+ // still building, or the call did not get through. The build is the only
270
+ // one worth saying out loud, and saying it drops the empty answer so the
271
+ // next turn asks again.
272
+ const asks = (empties.get(key) || 0) + 1
273
+ empties.set(key, asks)
274
+ if (asks < emptyRetries) digests.delete(key)
256
275
  if (told.has(key)) return
257
276
  const status = await ask(["warmup-status"], undefined, 5000)
258
277
  if (!status) return
@@ -269,11 +288,15 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
269
288
  // Per-prompt recall, the relevance pass Claude Code gets on
270
289
  // UserPromptSubmit: the digest above is ranked by the project, this is ranked
271
290
  // by what the user just asked. Silent when nothing matches.
272
- hooks["experimental.chat.messages.transform"] = async (_input, output) => {
291
+ hooks["experimental.chat.messages.transform"] = async (input, output) => {
273
292
  try {
274
- const { parts, prompt } = lastUserText(output?.messages)
293
+ const { parts, prompt, sessionID } = lastUserText(output?.messages)
275
294
  if (!prompt) return
276
- const raw = await ask(["hook-prompt"], JSON.stringify({ prompt, cwd }))
295
+ // The session id lets recall skip what it already showed this session.
296
+ // Without it every message re-injects the same block — measured on a real
297
+ // store, half of all injections were a word-for-word repeat.
298
+ const key = input?.sessionID || sessionID || ""
299
+ const raw = await ask(["hook-prompt"], JSON.stringify({ prompt, session_id: key, cwd }))
277
300
  if (!raw) return
278
301
  const extra = JSON.parse(raw)?.hookSpecificOutput?.additionalContext
279
302
  if (!extra) return
@@ -293,5 +316,56 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
293
316
  }
294
317
  }
295
318
 
319
+ // A spawned agent gets none of the above: the system prompt was built for the
320
+ // session that spawned it, and the per-prompt pass fires on what the user
321
+ // typed, which a subagent never does. Its instructions are the one thing that
322
+ // reaches it, so recall goes in there.
323
+ hooks["tool.execute.before"] = async (input, output) => {
324
+ try {
325
+ if (input?.tool !== "task") return
326
+ const args = output?.args
327
+ if (!args?.prompt) return
328
+ const payload = {
329
+ hook_event_name: "PreToolUse",
330
+ tool_name: "Task",
331
+ tool_input: { prompt: args.prompt },
332
+ session_id: input.sessionID || "",
333
+ cwd,
334
+ }
335
+ const raw = await ask(["hook-tool"], JSON.stringify(payload))
336
+ if (!raw) return
337
+ const next = JSON.parse(raw)?.hookSpecificOutput?.updatedInput?.prompt
338
+ if (next) args.prompt = next
339
+ } catch {
340
+ // memory is optional: never break a spawn over it
341
+ }
342
+ }
343
+
344
+ // The moment a command fails is the one an agent never thinks to ask about,
345
+ // and tool.execute.after is the only seam opencode gives for it. The hook
346
+ // returns nothing to inject, so the line is folded into the tool output,
347
+ // which the next request carries as the tool result.
348
+ hooks["tool.execute.after"] = async (input, output) => {
349
+ try {
350
+ if (input?.tool !== "bash") return
351
+ const text = output?.output
352
+ if (!text) return
353
+ const payload = {
354
+ hook_event_name: "PostToolUse",
355
+ tool_name: "bash",
356
+ tool_input: { command: input?.args?.command || "" },
357
+ tool_response: { output: text },
358
+ session_id: input.sessionID || "",
359
+ cwd,
360
+ }
361
+ const raw = await ask(["hook-tool-after"], JSON.stringify(payload))
362
+ if (!raw) return
363
+ const extra = JSON.parse(raw)?.hookSpecificOutput?.additionalContext
364
+ if (extra) output.output = text + "\n\n" + extra
365
+ } catch {
366
+ // memory is optional: never break a tool call over it
367
+ }
368
+ }
369
+
296
370
  return hooks
297
371
  }
package/lib.js CHANGED
@@ -29,11 +29,12 @@ export function lastUserText(messages) {
29
29
  const list = Array.isArray(messages) ? messages : []
30
30
  for (let i = list.length - 1; i >= 0; i--) {
31
31
  if (list[i]?.info?.role !== "user") continue
32
+ const sessionID = list[i].info?.sessionID || ""
32
33
  const parts = (list[i].parts || []).filter((p) => p?.type === "text" && p.text)
33
- if (!parts.length) return { parts: [], prompt: "" }
34
- return { parts, prompt: parts.map((p) => p.text).join("\n").trim() }
34
+ if (!parts.length) return { parts: [], prompt: "", sessionID }
35
+ return { parts, prompt: parts.map((p) => p.text).join("\n").trim(), sessionID }
35
36
  }
36
- return { parts: [], prompt: "" }
37
+ return { parts: [], prompt: "", sessionID: "" }
37
38
  }
38
39
 
39
40
  // cliPluginPath is where `deja install opencode-auto` writes its own plugin.
@@ -118,6 +119,22 @@ export function contributions(wiring, config = {}) {
118
119
  }
119
120
  }
120
121
 
122
+ // argv builds the call for a query somebody typed, rather than handing the text
123
+ // to deja as it stands.
124
+ //
125
+ // A query that starts with a dash is read as a flag: `deja search --no-verify`
126
+ // exits on an unknown flag, run() turns a failed call into "", and the model is
127
+ // told the history holds nothing about --no-verify while the sessions that
128
+ // discuss it sit right there. A wrong answer, where an error would at least be
129
+ // visible. `--` ends the flags.
130
+ //
131
+ // Sent only when the query needs it, so a deja too old to know the terminator
132
+ // on this subcommand still answers every ordinary query.
133
+ export function argv(cmd, flags, text) {
134
+ const arg = String(text)
135
+ return arg.startsWith("-") ? [cmd, ...flags, "--", arg] : [cmd, ...flags, arg]
136
+ }
137
+
121
138
  // clampLimit keeps a model that asks for a hundred sessions from spending the
122
139
  // window on a tail nobody reads.
123
140
  export function clampLimit(value, fallback = 5) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-deja",
3
- "version": "0.19.2",
4
- "description": "Brings the session history of nineteen other coding agents into opencode: recall, session digest and per-file history tools over a local index, plus automatic recall on every request.",
3
+ "version": "0.19.3",
4
+ "description": "deja-vu memory for opencode: the session history of twenty-one 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": {
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@opencode-ai/plugin": ">=1.0.0",
40
- "@vshulcz/deja-vu": "^0.19.2"
40
+ "@vshulcz/deja-vu": "^0.19.3"
41
41
  },
42
42
  "scripts": {
43
43
  "test": "node --test test/*.test.js"