opencode-deja 0.1.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.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/index.js +260 -0
  4. package/lib.js +43 -0
  5. package/package.json +45 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vladislav Shulcz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # opencode-deja
2
+
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.
6
+
7
+ It runs [deja](https://github.com/vshulcz/deja-vu), a local Go binary that
8
+ indexes the transcripts those agents already wrote to disk. No LLM, no
9
+ embeddings, nothing leaves the machine.
10
+
11
+ ## Install
12
+
13
+ ```json
14
+ {
15
+ "$schema": "https://opencode.ai/config.json",
16
+ "plugin": ["opencode-deja"]
17
+ }
18
+ ```
19
+
20
+ opencode installs the package on start. The deja binary comes with it, but a
21
+ deja you installed yourself always wins — see [Which binary](#which-binary).
22
+
23
+ ## What you get
24
+
25
+ Six tools the model can call:
26
+
27
+ | Tool | Answers |
28
+ |---|---|
29
+ | `deja_recall` | past sessions matching an error, function, file or flag |
30
+ | `deja_session` | the full digest of one past session — what was tried and decided |
31
+ | `deja_blame` | the sessions that discussed a file, before you edit it |
32
+ | `deja_fix` | what this machine ran after that same error, where it stayed fixed |
33
+ | `deja_how` | the real build/test/deploy invocation, with the flags actually used |
34
+ | `deja_remember` | store one durable decision |
35
+
36
+ And recall nobody has to ask for:
37
+
38
+ - the project's recent sessions are pushed onto the system prompt once per
39
+ session, with a one-time toast so you know memory arrived;
40
+ - each prompt gets a relevance pass of its own — silent when nothing matches;
41
+ - before compaction the working transcript is indexed, so the session survives
42
+ the window collapsing.
43
+
44
+ ## Options
45
+
46
+ ```json
47
+ {
48
+ "plugin": [["opencode-deja", { "autoRecall": false, "tools": true }]]
49
+ }
50
+ ```
51
+
52
+ - `autoRecall` (default `true`) — the three hooks above. Turn off to keep only
53
+ the tools.
54
+ - `tools` (default `true`) — the six tools. Turn off if you already reach deja
55
+ through its MCP server and do not want them twice.
56
+ - `bin` — path to a specific deja binary.
57
+
58
+ ## Which binary
59
+
60
+ In order: `bin` from the options, `DEJA_BIN`, a `deja` on `PATH`, the usual
61
+ install locations (`~/.local/bin`, `/usr/local/bin`, `/opt/homebrew/bin`), and
62
+ only then the copy npm installed with this package. Your own `deja update` or
63
+ `brew upgrade deja` wins over whatever version this package shipped with.
64
+
65
+ Without deja anywhere, the tools say so instead of reporting an empty history,
66
+ and the hooks stay quiet.
67
+
68
+ ## License
69
+
70
+ MIT
package/index.js ADDED
@@ -0,0 +1,260 @@
1
+ // opencode-deja — the sessions you already have, inside opencode.
2
+ //
3
+ // opencode remembers its own sessions. This plugin answers the other question:
4
+ // what you did in Claude Code, Codex, Cursor, Gemini and sixteen more agents on
5
+ // this machine, including months before deja existed. The index is deja's; this
6
+ // file is the seam: six tools the model can call, plus recall that arrives
7
+ // without being asked for.
8
+
9
+ import { createRequire } from "node:module"
10
+ import { execFile } from "node:child_process"
11
+ import { promisify } from "node:util"
12
+ import { homedir } from "node:os"
13
+ import { join } from "node:path"
14
+ import { accessSync, constants } from "node:fs"
15
+
16
+ import { clampLimit, contextText, lastUserText } from "./lib.js"
17
+
18
+ const require = createRequire(import.meta.url)
19
+ const run_ = promisify(execFile)
20
+
21
+ const WINDOWS = process.platform === "win32"
22
+ const PLATFORM = WINDOWS ? "windows" : process.platform
23
+ const ARCH = process.arch === "x64" ? "amd64" : process.arch
24
+ const EXE = WINDOWS ? "deja.exe" : "deja"
25
+
26
+ // The tool helper is a peer of the host, not of us: it is identity plus a zod
27
+ // re-export. Without it the hooks still run and only the model-facing tools are
28
+ // skipped — a missing peer must not take the plugin down.
29
+ let tool = null
30
+ try {
31
+ ;({ tool } = await import("@opencode-ai/plugin"))
32
+ } catch {}
33
+
34
+ // wellKnown lists the places a user's own install lands. PATH is the normal
35
+ // answer; these cover the case where opencode was started from a launcher with
36
+ // a PATH that never sourced a shell profile.
37
+ function wellKnown() {
38
+ const home = homedir()
39
+ if (WINDOWS) {
40
+ const local = process.env.LOCALAPPDATA || join(home, "AppData", "Local")
41
+ return [join(local, "deja", "bin", EXE), join(home, ".local", "bin", EXE)]
42
+ }
43
+ return [
44
+ join(home, ".local", "bin", EXE),
45
+ "/usr/local/bin/deja",
46
+ "/opt/homebrew/bin/deja",
47
+ "/usr/bin/deja",
48
+ ]
49
+ }
50
+
51
+ // resolveDeja picks the binary in the order a user would expect: what they
52
+ // pointed at, then the deja they installed themselves and keep current with
53
+ // `deja update` or brew, and only last the copy npm brought along with this
54
+ // plugin. Pinning them to our bundled copy would freeze their memory at
55
+ // whatever version this package was released against.
56
+ function resolveDeja(setting) {
57
+ const candidates = [setting, process.env.DEJA_BIN, EXE, ...wellKnown()]
58
+ try {
59
+ candidates.push(require.resolve(`@vshulcz/deja-vu-${PLATFORM}-${ARCH}/bin/${EXE}`))
60
+ } catch {}
61
+ for (const candidate of candidates) {
62
+ if (!candidate) continue
63
+ if (candidate.includes("/") || candidate.includes("\\")) {
64
+ try {
65
+ accessSync(candidate, constants.X_OK)
66
+ } catch {
67
+ continue
68
+ }
69
+ }
70
+ return candidate
71
+ }
72
+ // Nothing to check further; keep the plain name so a failure names the thing
73
+ // that is missing rather than a path nobody chose.
74
+ return EXE
75
+ }
76
+
77
+ // deja is asked for text, never for control flow: memory is optional
78
+ // everywhere, and a throw here would end someone's turn.
79
+ async function run(bin, args, input, cwd, timeout = 20000) {
80
+ try {
81
+ const child = run_(bin, args, {
82
+ encoding: "utf8",
83
+ timeout,
84
+ cwd,
85
+ maxBuffer: 8 * 1024 * 1024,
86
+ })
87
+ if (input !== undefined) {
88
+ child.child.stdin.end(input)
89
+ }
90
+ const { stdout } = await child
91
+ return stdout.trim()
92
+ } catch {
93
+ return ""
94
+ }
95
+ }
96
+
97
+ const NOTHING = "Nothing in this machine's history matches that."
98
+ const MISSING =
99
+ "deja is not installed on this machine, so there is no history to search. " +
100
+ "Install it with: curl -fsSL https://raw.githubusercontent.com/vshulcz/deja-vu/main/install.sh | sh"
101
+
102
+ export const DejaPlugin = async ({ client, directory }, options = {}) => {
103
+ const config = options || {}
104
+ const bin = resolveDeja(typeof config.bin === "string" ? config.bin : "")
105
+ const cwd = directory || process.cwd()
106
+ // Every call runs in the session's own directory: deja ranks by project.
107
+ const ask = (args, input, timeout) => run(bin, args, input, cwd, timeout)
108
+
109
+ // Whether the binary answers at all is asked once. Without it every tool
110
+ // would report an empty history to someone who simply never installed deja,
111
+ // which reads as "you have no past" rather than "nothing is here to read it".
112
+ const installed = (await ask(["version"], undefined, 5000)) !== ""
113
+ const answer = (text) => (installed ? text || NOTHING : MISSING)
114
+
115
+ const digests = new Map()
116
+ const told = new Set()
117
+
118
+ const hooks = {}
119
+
120
+ if (config.tools !== false && tool) {
121
+ const schema = tool.schema
122
+ hooks.tool = {
123
+ deja_recall: tool({
124
+ description:
125
+ "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.",
126
+ args: {
127
+ query: schema.string().describe("Specific tokens to match. Several words are ANDed."),
128
+ limit: schema.number().optional().describe("How many sessions to return. Default 5."),
129
+ },
130
+ async execute(args) {
131
+ const limit = String(clampLimit(args.limit))
132
+ return answer(await ask(["search", "--json", "--limit", limit, String(args.query)]))
133
+ },
134
+ }),
135
+ deja_session: tool({
136
+ description:
137
+ "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.",
138
+ args: {
139
+ query: schema.string().describe("A query, or a session id prefix returned by deja_recall."),
140
+ },
141
+ async execute(args) {
142
+ return answer(await ask(["ctx", String(args.query)]))
143
+ },
144
+ }),
145
+ deja_blame: tool({
146
+ description:
147
+ "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.",
148
+ args: {
149
+ path: schema.string().describe("Path to the file, absolute or relative to the project."),
150
+ },
151
+ async execute(args) {
152
+ return answer(await ask(["blame", String(args.path), "--json"]))
153
+ },
154
+ }),
155
+ deja_fix: tool({
156
+ description:
157
+ "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.",
158
+ args: {
159
+ error: schema.string().describe("The failing output, copied as it was printed."),
160
+ },
161
+ async execute(args) {
162
+ return answer(await ask(["fix", String(args.error)]))
163
+ },
164
+ }),
165
+ deja_how: tool({
166
+ description:
167
+ "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.",
168
+ args: {
169
+ what: schema.string().describe("The thing to run: a tool, a task, a script name."),
170
+ },
171
+ async execute(args) {
172
+ return answer(await ask(["how", String(args.what)]))
173
+ },
174
+ }),
175
+ deja_remember: tool({
176
+ description:
177
+ "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.",
178
+ args: {
179
+ text: schema.string().describe("The decision, in one or two sentences, with the reason it was taken."),
180
+ },
181
+ async execute(args) {
182
+ const written = await ask(["remember", String(args.text)])
183
+ if (!installed) return MISSING
184
+ return written || "deja did not record that."
185
+ },
186
+ }),
187
+ }
188
+ }
189
+
190
+ if (config.autoRecall === false) return hooks
191
+
192
+ // opencode has no session-start hook. The system prompt is assembled on
193
+ // every request, so the session digest is fetched once and pushed there —
194
+ // the same place Claude Code's SessionStart output lands.
195
+ hooks["experimental.chat.system.transform"] = async (input, output) => {
196
+ try {
197
+ const key = input?.sessionID || "default"
198
+ if (!digests.has(key)) {
199
+ const { context, receipt } = contextText(await ask(["hook-context"], undefined, 30000))
200
+ digests.set(key, context)
201
+ // The receipt is the only sign the user gets that memory arrived. Once
202
+ // per session: repeating it every turn is wallpaper. The hook's own
203
+ // output is model context, so the toast is the only channel for it.
204
+ if (receipt && !told.has(key)) {
205
+ told.add(key)
206
+ await client.tui.showToast({
207
+ body: { message: receipt, variant: "info", duration: 6000 },
208
+ })
209
+ }
210
+ }
211
+ const context = digests.get(key)
212
+ if (context) {
213
+ output.system.push(context)
214
+ return
215
+ }
216
+ // Nothing recalled: either this machine has no history yet, or the first
217
+ // index is still building. Only the second is worth saying, and saying it
218
+ // drops the empty answer so the next turn asks again.
219
+ if (told.has(key)) return
220
+ const status = await ask(["warmup-status"], undefined, 5000)
221
+ if (!status) return
222
+ told.add(key)
223
+ digests.delete(key)
224
+ await client.tui.showToast({
225
+ body: { message: status, variant: "info", duration: 6000 },
226
+ })
227
+ } catch {
228
+ // memory is optional: never break the session over it
229
+ }
230
+ }
231
+
232
+ // Per-prompt recall, the relevance pass Claude Code gets on
233
+ // UserPromptSubmit: the digest above is ranked by the project, this is ranked
234
+ // by what the user just asked. Silent when nothing matches.
235
+ hooks["experimental.chat.messages.transform"] = async (_input, output) => {
236
+ try {
237
+ const { parts, prompt } = lastUserText(output?.messages)
238
+ if (!prompt) return
239
+ const raw = await ask(["hook-prompt"], JSON.stringify({ prompt, cwd }))
240
+ if (!raw) return
241
+ const extra = JSON.parse(raw)?.hookSpecificOutput?.additionalContext
242
+ if (!extra) return
243
+ parts[parts.length - 1].text += "\n\n" + extra
244
+ } catch {
245
+ // memory is optional: never break the session over it
246
+ }
247
+ }
248
+
249
+ // Compaction is about to throw the working transcript away. Index what
250
+ // exists now, so the session survives in memory after the window collapses.
251
+ hooks["experimental.session.compacting"] = async () => {
252
+ try {
253
+ await ask(["hook-precompact"], undefined, 60000)
254
+ } catch {
255
+ // memory is optional: never break a compaction over it
256
+ }
257
+ }
258
+
259
+ return hooks
260
+ }
package/lib.js ADDED
@@ -0,0 +1,43 @@
1
+ // The pure half of the plugin: everything that reads deja's output or
2
+ // opencode's message shape, with no process and no host. Kept apart from
3
+ // index.js because opencode loads every function a plugin module exports —
4
+ // a helper exported next to the plugin gets called as one.
5
+
6
+ // contextText pulls the recall out of whatever hook-context printed. deja
7
+ // answers in the Claude Code hook shape; older builds and `--plain` print bare
8
+ // text, so both are accepted.
9
+ export function contextText(raw) {
10
+ const text = String(raw || "").trim()
11
+ if (!text) return { context: "", receipt: "" }
12
+ try {
13
+ const parsed = JSON.parse(text)
14
+ return {
15
+ context: parsed?.hookSpecificOutput?.additionalContext || "",
16
+ receipt: parsed?.systemMessage || "",
17
+ }
18
+ } catch {
19
+ return { context: text, receipt: "" }
20
+ }
21
+ }
22
+
23
+ // lastUserText is what the person typed on this turn, which is what the
24
+ // per-prompt recall is ranked against. Parts a plugin appended earlier are
25
+ // part of the same message, so the text is joined and matched whole.
26
+ export function lastUserText(messages) {
27
+ const list = Array.isArray(messages) ? messages : []
28
+ for (let i = list.length - 1; i >= 0; i--) {
29
+ if (list[i]?.info?.role !== "user") continue
30
+ const parts = (list[i].parts || []).filter((p) => p?.type === "text" && p.text)
31
+ if (!parts.length) return { parts: [], prompt: "" }
32
+ return { parts, prompt: parts.map((p) => p.text).join("\n").trim() }
33
+ }
34
+ return { parts: [], prompt: "" }
35
+ }
36
+
37
+ // clampLimit keeps a model that asks for a hundred sessions from spending the
38
+ // window on a tail nobody reads.
39
+ export function clampLimit(value, fallback = 5) {
40
+ const asked = Number(value)
41
+ if (!Number.isFinite(asked)) return fallback
42
+ return Math.min(20, Math.max(1, Math.trunc(asked)))
43
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "opencode-deja",
3
+ "version": "0.1.0",
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.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "lib.js",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/vshulcz/deja-vu.git",
21
+ "directory": "extensions/opencode"
22
+ },
23
+ "homepage": "https://github.com/vshulcz/deja-vu/tree/main/extensions/opencode",
24
+ "bugs": {
25
+ "url": "https://github.com/vshulcz/deja-vu/issues"
26
+ },
27
+ "keywords": [
28
+ "opencode",
29
+ "opencode-plugin",
30
+ "memory",
31
+ "recall",
32
+ "session-history",
33
+ "agent"
34
+ ],
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "dependencies": {
39
+ "@opencode-ai/plugin": ">=1.0.0",
40
+ "@vshulcz/deja-vu": "^0.18.0"
41
+ },
42
+ "scripts": {
43
+ "test": "node --test test/*.test.js"
44
+ }
45
+ }