opencode-deja 0.21.6 → 0.22.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/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  English | [中文](https://github.com/vshulcz/deja-vu/blob/main/extensions/opencode/docs/zh.md)
4
4
 
5
5
  opencode remembers its own sessions. This plugin answers the other question:
6
- what you did in the thirty-four other coding agents on this machine — Claude Code,
6
+ what you did in the forty other coding agents on this machine — Claude Code,
7
7
  Codex, Cursor, Gemini and Zed among them — including the months before you
8
8
  installed anything.
9
9
 
10
10
  It runs [deja](https://github.com/vshulcz/deja-vu), a local Go binary that
11
11
  indexes the transcripts those agents already wrote to disk. No LLM, no
12
- embeddings, no network path unless you ask for one.
12
+ embeddings, no network path besides a daily release check, which DEJA_OFFLINE=1 turns off.
13
13
 
14
14
  ## Install
15
15
 
package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // opencode-deja — the sessions you already have, inside opencode.
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 and thirty-one more agents on
4
+ // what you did in Claude Code, Codex, Cursor, Gemini and thirty-seven more agents on
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.
@@ -23,9 +23,9 @@ import {
23
23
  configPaths,
24
24
  contextText,
25
25
  contributions,
26
- lastUserText,
27
26
  mcpWired,
28
27
  TOOL_SPECS,
28
+ userTurns,
29
29
  zodTools,
30
30
  } from "./lib.js"
31
31
 
@@ -172,6 +172,12 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
172
172
  // session ended, so without ending them here the stamp sat out its whole
173
173
  // window and the next session's MCP recall left a finished one out (#4546).
174
174
  const live = new Set()
175
+ // The per-prompt recall each user message was given, by message.
176
+ const recalled = new Map()
177
+ const remember = (key, extra) => {
178
+ recalled.set(key, extra)
179
+ if (recalled.size > 1000) recalled.delete(recalled.keys().next().value)
180
+ }
175
181
  const endSession = (id) => ask(["hook-session-end"], JSON.stringify({ session_id: id }), 5000)
176
182
  // The session that spawned each one, asked of opencode once. A task
177
183
  // sub-agent's digest and recall led with its parent, which is live and
@@ -206,7 +212,7 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
206
212
  const specs = {
207
213
  deja_recall: {
208
214
  description:
209
- "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.",
215
+ "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, and before the first edit in a task and before calling a change done or ready to merge. Match on the most specific token available: an exact error string, function name, file path or flag.",
210
216
  args: {
211
217
  query: { type: "string", description: "Specific tokens to match. Several words are ANDed." },
212
218
  limit: { type: "number", optional: true, description: "How many sessions to return. Default 5." },
@@ -348,22 +354,34 @@ export const DejaPlugin = async ({ client, directory }, options = {}) => {
348
354
  // the recovery packet would count as delivered to a turn that never saw it.
349
355
  const owner = (output?.messages || []).find((m) => m?.info?.sessionID)?.info?.sessionID
350
356
  if (owner && compacting.has(owner)) return
351
- const { parts, prompt, sessionID } = lastUserText(output?.messages)
352
- // A turn with no text, an image alone, still goes to hook-prompt: that
353
- // call is what stamps the session live again after session.idle ended
354
- // it, and without it the turn's MCP recall could return the session to
355
- // itself (#4573). sessionID is empty when there is no user message.
356
- if (!prompt && !sessionID) return
357
- // The session id lets recall skip what it already showed this session.
358
- // Without it every message re-injects the same block — measured on a real
359
- // store, half of all injections were a word-for-word repeat.
360
- const key = input?.sessionID || sessionID || ""
361
- if (key) live.add(key)
362
- const raw = await ask(["hook-prompt"], JSON.stringify({ prompt, session_id: key, parent_session_id: await parentOf(key), cwd }))
363
- if (!prompt || !raw) return
364
- const extra = JSON.parse(raw)?.hookSpecificOutput?.additionalContext
365
- if (!extra) return
366
- parts[parts.length - 1].text += "\n\n" + extra
357
+ const users = userTurns(output?.messages)
358
+ const last = users[users.length - 1]
359
+ if (!last) return
360
+ // Asked once per message. opencode builds every model call afresh from
361
+ // its store, so text added to a message here lasts one call: once the
362
+ // agent ran a tool the recall was gone, and deja, having shown it, would
363
+ // not send it again (#4786).
364
+ if (!recalled.has(last.key)) {
365
+ // A turn with no text, an image alone, still goes to hook-prompt: that
366
+ // call is what stamps the session live again after session.idle ended
367
+ // it, and without it the turn's MCP recall could return the session
368
+ // to itself (#4573).
369
+ // The session id lets recall skip what it already showed this
370
+ // session. Without it every message re-injects the same block —
371
+ // measured on a real store, half of all injections were a
372
+ // word-for-word repeat.
373
+ const key = input?.sessionID || last.sessionID || ""
374
+ if (key) live.add(key)
375
+ const raw = await ask(["hook-prompt"], JSON.stringify({ prompt: last.prompt, session_id: key, parent_session_id: await parentOf(key), cwd }))
376
+ remember(last.key, last.prompt && raw ? JSON.parse(raw)?.hookSpecificOutput?.additionalContext || "" : "")
377
+ }
378
+ // Every message gets back what it was given, in every call, the way
379
+ // Claude Code keeps a prompt hook's text in its transcript.
380
+ for (const u of users) {
381
+ const extra = recalled.get(u.key)
382
+ const tail = u.parts[u.parts.length - 1]
383
+ if (extra && tail && !tail.text.endsWith(extra)) tail.text += "\n\n" + extra
384
+ }
367
385
  } catch {
368
386
  // memory is optional: never break the session over it
369
387
  }
package/lib.js CHANGED
@@ -22,19 +22,21 @@ export function contextText(raw) {
22
22
  }
23
23
  }
24
24
 
25
- // lastUserText is what the person typed on this turn, which is what the
26
- // per-prompt recall is ranked against. Parts a plugin appended earlier are
27
- // part of the same message, so the text is joined and matched whole.
28
- export function lastUserText(messages) {
25
+ // userTurns is every user message in a request, oldest first, each with a key
26
+ // that stays the same from one request to the next: opencode's message id, or
27
+ // where a shape carries none, the message's place among the user messages and
28
+ // its text.
29
+ export function userTurns(messages) {
29
30
  const list = Array.isArray(messages) ? messages : []
30
- for (let i = list.length - 1; i >= 0; i--) {
31
- if (list[i]?.info?.role !== "user") continue
32
- const sessionID = list[i].info?.sessionID || ""
33
- const parts = (list[i].parts || []).filter((p) => p?.type === "text" && p.text)
34
- if (!parts.length) return { parts: [], prompt: "", sessionID }
35
- return { parts, prompt: parts.map((p) => p.text).join("\n").trim(), sessionID }
31
+ const out = []
32
+ for (const m of list) {
33
+ if (m?.info?.role !== "user") continue
34
+ const sessionID = m.info?.sessionID || ""
35
+ const parts = (m.parts || []).filter((p) => p?.type === "text" && p.text)
36
+ const prompt = parts.map((p) => p.text).join("\n").trim()
37
+ out.push({ key: m.info?.id || sessionID + "#" + out.length + "#" + prompt, parts, prompt, sessionID })
36
38
  }
37
- return { parts: [], prompt: "", sessionID: "" }
39
+ return out
38
40
  }
39
41
 
40
42
  // cliPluginPath is where `deja install opencode-auto` writes its own plugin.
@@ -195,13 +197,13 @@ export function turnEnded(event) {
195
197
  return ""
196
198
  }
197
199
 
198
- // v1Messages shows 2.x's request messages in the 1.x shape lastUserText reads.
200
+ // v1Messages shows 2.x's request messages in the 1.x shape userTurns reads.
199
201
  // The parts are 2.x's own content objects, so text appended to one lands in
200
202
  // the request.
201
203
  export function v1Messages(messages, sessionID) {
202
204
  const list = Array.isArray(messages) ? messages : []
203
205
  return list.map((m) => ({
204
- info: { role: m?.role, sessionID: sessionID || "" },
206
+ info: { id: m?.id, role: m?.role, sessionID: sessionID || "" },
205
207
  parts: Array.isArray(m?.content) ? m.content : [],
206
208
  }))
207
209
  }
@@ -220,6 +222,24 @@ export function resultText(result) {
220
222
  return typeof r.output === "string" ? r.output : ""
221
223
  }
222
224
 
225
+ // v1Client is the slice of the 1.x client the plugin reads, over 2.x's ctx. A
226
+ // 2.x server plugin has no channel to the TUI, so the toasts go nowhere. The
227
+ // session lookup is what names a sub-agent's parent: without it the
228
+ // sub-agent's digest and recall led with the parent that spawned it (#4548).
229
+ // 1.x answers through client.session.get, 2.x through ctx.session.get, whose
230
+ // SessionInfo carries parentID.
231
+ export function v1Client(ctx) {
232
+ return {
233
+ tui: { showToast: async () => {} },
234
+ session: {
235
+ get: async ({ path } = {}) => {
236
+ if (!path?.id || typeof ctx?.session?.get !== "function") return { data: {} }
237
+ return { data: (await ctx.session.get({ sessionID: path.id })) || {} }
238
+ },
239
+ },
240
+ }
241
+ }
242
+
223
243
  // v1ToolName is the 1.x name for a 2.x tool, where the two differ in a way the
224
244
  // hooks look at: the spawn tool and the shell.
225
245
  export function v1ToolName(name) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-deja",
3
- "version": "0.21.6",
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.",
3
+ "version": "0.22.0",
4
+ "description": "deja-vu memory for opencode: the session history of forty 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": {
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@opencode-ai/plugin": ">=1.0.0",
55
- "@vshulcz/deja-vu": "^0.21.6"
55
+ "@vshulcz/deja-vu": "^0.22.0"
56
56
  },
57
57
  "scripts": {
58
58
  "test": "node --test test/*.test.js"
package/server.js CHANGED
@@ -15,14 +15,11 @@
15
15
  // a turn on `event` "subscribe" and dispose on the cleanup setup returns.
16
16
 
17
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 () => {} } }
18
+ import { jsonSchema, resultText, TOOL_SPECS, turnEnded, v1Client, v1Messages, v1ToolName } from "./lib.js"
22
19
 
23
20
  async function setup(ctx) {
24
21
  const directory = ctx.location?.directory || process.cwd()
25
- const hooks = await DejaPlugin({ client: noToast, directory }, ctx.options || {})
22
+ const hooks = await DejaPlugin({ client: v1Client(ctx), directory }, ctx.options || {})
26
23
 
27
24
  const specs = hooks[TOOL_SPECS]
28
25
  if (specs) {
@@ -66,8 +63,10 @@ async function setup(ctx) {
66
63
 
67
64
  const compacting = hooks["experimental.session.compacting"]
68
65
  if (compacting) {
69
- await ctx.session.hook("compaction", async () => {
70
- await compacting()
66
+ // The 1.x hook reads the session from its input; called with none, 2.x
67
+ // sent hook-precompact an empty id and nothing was read or forgotten.
68
+ await ctx.session.hook("compaction", async (event) => {
69
+ await compacting({ sessionID: event?.sessionID || "" })
71
70
  })
72
71
  }
73
72