obsidian-wikilinks 0.2.1 → 0.2.3-dev.pr5.34775183233.1

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
@@ -49,12 +49,20 @@ claude plugin install obsidian-wikilinks@depickeresven-obsidian-wikilinks
49
49
 
50
50
  ### From npm (recommended)
51
51
 
52
- If you want it local in one project
52
+ The `opencode plugin` command installs the package and adds it to your config.
53
+ For every project:
54
+
55
+ ```bash
56
+ opencode plugin obsidian-wikilinks --global
57
+ ```
58
+
59
+ For one project only, run it without the flag inside that repo:
60
+
53
61
  ```bash
54
62
  opencode plugin obsidian-wikilinks
55
63
  ```
56
64
 
57
- That installs the package and adds it to your config. Or add it by hand:
65
+ You can also add it by hand:
58
66
 
59
67
  ```json
60
68
  {
@@ -66,12 +74,6 @@ That installs the package and adds it to your config. Or add it by hand:
66
74
  Use `~/.config/opencode/opencode.json` for every project, or `opencode.json` in
67
75
  a repo for that project only.
68
76
 
69
- Or you want it installed global:
70
- ```bash
71
- opencode plugin obsidian-wikilinks --global
72
- ```
73
-
74
-
75
77
  ### From a local checkout
76
78
 
77
79
  OpenCode loads any `.js` / `.ts` file in a plugin directory, and follows
@@ -86,7 +88,7 @@ ln -s ~/.config/opencode/obsidian-wikilinks/plugin/obsidian-wikilinks.js ~/.conf
86
88
  Use `.opencode/plugin/` instead of `~/.config/opencode/plugin/` to enable it for
87
89
  a single project only.
88
90
 
89
- Alternatively, reference the checkout from your config instead of symlinking
91
+ Alternatively, reference the checkout from your config instead of symlinking.
90
92
  `plugin` entries accept `file://` URLs and paths relative to the config file:
91
93
 
92
94
  ```json
@@ -131,6 +133,73 @@ Review the notes in [[Meetings]] and prepare a weekly summary.
131
133
  When a name is ambiguous, the plugin provides up to three candidate paths. If
132
134
  there is no match, it says so instead of guessing.
133
135
 
136
+ ### What the agent receives
137
+
138
+ The injected context is plain text, one line per distinct wikilink:
139
+
140
+ ```text
141
+ Obsidian wikilink resolution (vault: /Users/you/Documents/Obsidian):
142
+ [[Website Redesign]] -> /Users/you/Documents/Obsidian/Projects/Website Redesign.md
143
+ [[Meetings]] -> /Users/you/Documents/Obsidian/Meetings/
144
+ [[Notes]] -> ambiguous, candidates: /path/Notes.md, /path/Old/Notes.md
145
+ [[Nope]] -> no match found in vault /Users/you/Documents/Obsidian
146
+ Read the resolved file(s) when their content is relevant to the request.
147
+ ```
148
+
149
+ Folder matches end in a path separator. Repeated wikilinks are resolved once.
150
+
151
+ ### What gets indexed
152
+
153
+ The whole vault is walked, notes and folders alike, with two exclusions:
154
+
155
+ - Anything whose name starts with a dot.
156
+ - The folders `.obsidian`, `.trash`, `.git`, and `.vault-meta`.
157
+
158
+ A note inside a hidden folder will therefore never resolve.
159
+
160
+ ## OpenCode sidebar: did the agent read my notes?
161
+
162
+ Resolving a link only tells the agent where a note is. It does not prove the
163
+ note was read. On OpenCode the plugin adds an **Obsidian notes** section to the
164
+ session sidebar that answers that for every note you linked:
165
+
166
+ ```text
167
+ ▼ Obsidian notes ✓ 2 ○ 1
168
+ ✓ Website Redesign
169
+ ○ Weekly
170
+ ✓ Meetings/ (2 read)
171
+ ✗ Nope (no match)
172
+ also read
173
+ · Research/AI Agents.md
174
+ ```
175
+
176
+ - `✓` the agent read the note (for a folder link: at least one note inside it)
177
+ - `○` linked, but not read yet
178
+ - `✗` the link matched nothing in the vault
179
+ - *also read*: vault files the agent read without you linking them
180
+
181
+ Reads are taken from OpenCode's `read` tool calls. A note the agent only reaches
182
+ through `bash` (`cat`, `grep`) or an MCP server is not detected. A read also
183
+ proves the agent opened the note, not that it used it.
184
+
185
+ OpenCode loads sidebar plugins from `tui.json`, separately from the `plugin`
186
+ list in `opencode.json`. Add the package there too
187
+ (`~/.config/opencode/tui.json`):
188
+
189
+ ```json
190
+ {
191
+ "plugin": ["obsidian-wikilinks"]
192
+ }
193
+ ```
194
+
195
+ From a local checkout, list the checkout directory instead:
196
+ `"file:/Users/you/src/obsidian-wikilinks"`.
197
+
198
+ The section appears once a prompt in the session contains a wikilink. Each
199
+ session's links and reads are logged to
200
+ `~/.local/state/obsidian-wikilinks/sessions/<session>.ndjson`, which the sidebar
201
+ watches.
202
+
134
203
  ## Vault selection
135
204
 
136
205
  Vault path resolution order:
@@ -142,8 +211,8 @@ Vault path resolution order:
142
211
  (or `$OPENCODE_CONFIG_DIR` / `$XDG_CONFIG_HOME` when set)
143
212
  2. The other hosts' config files, as a compatibility fallback
144
213
  3. `$OBSIDIAN_VAULT` environment variable
145
- 4. Obsidian's vault registry auto-detected (prefers the open vault, else most
146
- recently opened). Cross-platform (macOS / Windows / Linux).
214
+ 4. Obsidian's vault registry, auto-detected (prefers the open vault, else the
215
+ most recently opened). Cross-platform (macOS / Windows / Linux).
147
216
  5. `~/Documents/Obsidian` (default fallback)
148
217
 
149
218
  You only need an explicit override if you have **multiple vaults** and want to
@@ -153,19 +222,54 @@ pin a specific one. Create the config file for your host with:
153
222
  { "vault": "/Users/you/Documents/Obsidian" }
154
223
  ```
155
224
 
156
- The plugin itself is identical on every device. Update Claude Code with
157
- `claude plugin update obsidian-wikilinks`. Re-run the Codex `plugin add` command
158
- to install an updated version there. For OpenCode, re-run `opencode plugin
159
- obsidian-wikilinks`, or `git pull` in the checkout.
225
+ ## Updating
226
+
227
+ The plugin itself is identical on every device.
228
+
229
+ - Claude Code: `claude plugin update obsidian-wikilinks`
230
+ - Codex: re-run the `codex plugin add` command to install a newer version.
231
+ - OpenCode: re-run `opencode plugin obsidian-wikilinks`, or `git pull` in the
232
+ checkout.
160
233
 
161
234
  ## Environment variables
162
235
 
163
- | Variable | Purpose |
164
- |---------------------------------|--------------------------------------------------------------|
165
- | `OBSIDIAN_VAULT` | Vault path, used when no host config file sets one |
166
- | `OBSIDIAN_WIKILINKS_RESOLVER` | Path to `wikilink-resolver.py` (OpenCode only) |
167
- | `OBSIDIAN_WIKILINKS_PYTHON` | Python interpreter to use (default `python3`, OpenCode only) |
168
- | `OBSIDIAN_WIKILINKS_TIMEOUT_MS` | Resolver timeout in ms (default `10000`, OpenCode only) |
236
+ | Variable | Purpose |
237
+ |---------------------------------|-------------------------------------------------------------------------------------------------------------------------------------|
238
+ | `OBSIDIAN_VAULT` | Vault path, used when no host config file sets one |
239
+ | `OBSIDIAN_WIKILINKS_HOST` | Force the host the resolver assumes: `codex`, `claude`, `opencode`. Normally auto-detected, and the OpenCode plugin sets it for you |
240
+ | `OBSIDIAN_WIKILINKS_RESOLVER` | Path to `wikilink-resolver.py` (OpenCode only) |
241
+ | `OBSIDIAN_WIKILINKS_PYTHON` | Python interpreter to use (default `python3`, OpenCode only) |
242
+ | `OBSIDIAN_WIKILINKS_TIMEOUT_MS` | Resolver timeout in ms (default `10000`, OpenCode only) |
243
+ | `OBSIDIAN_WIKILINKS_STATE_DIR` | Where the sidebar's per-session logs live (default `$XDG_STATE_HOME/obsidian-wikilinks/sessions`, OpenCode only) |
244
+
245
+ ## Troubleshooting
246
+
247
+ The resolver is deliberately silent: on any problem it exits without output and
248
+ your prompt is passed through untouched. That makes the failure modes below look
249
+ identical from the outside, so work through them in order.
250
+
251
+ **Nothing is injected at all.**
252
+
253
+ - Check `python3 --version` in the same shell the agent runs in. No `python3` on
254
+ PATH means no resolution. On OpenCode, point `OBSIDIAN_WIKILINKS_PYTHON` at
255
+ your interpreter.
256
+ - On Codex, run `/hooks` in a new thread and confirm the plugin's
257
+ `UserPromptSubmit` hook is trusted. Untrusted hooks never execute.
258
+ - Confirm the vault directory actually exists. The resolver exits early when the
259
+ resolved path is not a directory, including for the `~/Documents/Obsidian`
260
+ fallback.
261
+
262
+ **The wrong vault is used.** Run through the resolution order above. A stale
263
+ config file for *another* host wins over `$OBSIDIAN_VAULT`, because step 2 comes
264
+ first. Delete it, or set an explicit override for your own host.
265
+
266
+ **A note resolves to `no match found`.** Check that it is not inside a hidden
267
+ folder (see [What gets indexed](#what-gets-indexed)), and that the vault in the
268
+ output line is the one you expect.
269
+
270
+ **Resolution is slow or times out on OpenCode.** The vault is walked on every
271
+ prompt containing a wikilink. On a very large vault, raise
272
+ `OBSIDIAN_WIKILINKS_TIMEOUT_MS` above its `10000` default.
169
273
 
170
274
  ## Development
171
275
 
@@ -183,16 +287,16 @@ Two workflows, chained.
183
287
 
184
288
  `.github/workflows/ci.yml` runs the smoke tests on Node 20/22/24 and on Bun, and
185
289
  checks the packed tarball ships both `plugin/` and `hooks/`. It runs on pull
186
- requests and on pushes to `main` not on every branch, so a pull request is
290
+ requests and on pushes to `main`, not on every branch, so a pull request is
187
291
  never tested twice.
188
292
 
189
293
  `.github/workflows/publish.yml` starts only when a CI run on `main` finishes
190
294
  successfully, checks out that exact commit, and asks npm whether the version in
191
295
  `package.json` already exists:
192
296
 
193
- - **Already on npm** nothing is released. This is what an ordinary push to
297
+ - **Already on npm**: nothing is released. This is what an ordinary push to
194
298
  `main` does.
195
- - **Not on npm** it publishes with provenance, tags the commit, and opens a
299
+ - **Not on npm**: it publishes with provenance, tags the commit, and opens a
196
300
  GitHub release with notes generated from the merged commits.
197
301
 
198
302
  So a version bump landing on `main` *is* the release, and it can only happen
@@ -208,13 +312,17 @@ git push
208
312
 
209
313
  `--no-git-tag-version` matters: the workflow creates the tag, so creating one
210
314
  locally would collide. The bump still runs `scripts/sync-versions.mjs`, keeping
211
- the Claude Code and Codex manifests on the same version as `package.json` CI
315
+ the Claude Code and Codex manifests on the same version as `package.json`. CI
212
316
  fails the build if they ever drift.
213
317
 
214
318
  Use the publish workflow's manual trigger (`workflow_dispatch`) with *dry run*
215
- enabled to rehearse a publish without releasing anything.
319
+ enabled to rehearse a publication without releasing anything.
320
+
321
+ ## Requirements
322
+
323
+ - `python3` on PATH. Standard library only, no pip installs.
324
+ - OpenCode only: no extra dependencies, the plugin uses Node/Bun built-ins.
216
325
 
217
- ### Requirements
326
+ ## License
218
327
 
219
- - `python3` on PATH (standard-library only; no pip installs).
220
- - OpenCode only: no extra dependencies — the plugin uses Node/Bun built-ins.
328
+ [MIT](LICENSE) © Sven Depickere
@@ -178,27 +178,38 @@ def main():
178
178
 
179
179
  index = build_index(vault)
180
180
  lines = []
181
+ links = []
181
182
  for target in dict.fromkeys(targets): # dedupe, keep order
182
183
  hits = resolve(target, index)
184
+ paths = [h[2] for h in hits]
183
185
  if not hits:
186
+ status = "missing"
184
187
  lines.append(f"[[{target}]] -> no match found in vault {vault}")
185
188
  elif len(hits) == 1:
189
+ status = "resolved"
186
190
  lines.append(f"[[{target}]] -> {hits[0][2]}")
187
191
  else:
188
- opts = ", ".join(h[2] for h in hits)
192
+ status = "ambiguous"
193
+ opts = ", ".join(paths)
189
194
  lines.append(f"[[{target}]] -> ambiguous, candidates: {opts}")
195
+ links.append({"target": target.strip(), "status": status, "paths": paths})
190
196
 
191
197
  context = (
192
198
  "Obsidian wikilink resolution (vault: " + vault + "):\n"
193
199
  + "\n".join(lines)
194
200
  + "\nRead the resolved file(s) when their content is relevant to the request."
195
201
  )
196
- print(json.dumps({
202
+ output = {
197
203
  "hookSpecificOutput": {
198
204
  "hookEventName": "UserPromptSubmit",
199
205
  "additionalContext": context,
200
206
  }
201
- }))
207
+ }
208
+ # Structured links for the OpenCode sidebar. Opt-in, so the Claude Code and
209
+ # Codex hook output stays exactly the documented shape.
210
+ if os.environ.get("OBSIDIAN_WIKILINKS_EMIT_LINKS"):
211
+ output["obsidianWikilinks"] = {"vault": vault, "links": links}
212
+ print(json.dumps(output))
202
213
  sys.exit(0)
203
214
 
204
215
 
package/package.json CHANGED
@@ -1,14 +1,19 @@
1
1
  {
2
2
  "name": "obsidian-wikilinks",
3
- "version": "0.2.1",
3
+ "version": "0.2.3-dev.pr5.34775183233.1",
4
4
  "description": "Resolve Obsidian [[wikilinks]] in coding-agent prompts to local vault paths and inject them as context.",
5
5
  "type": "module",
6
6
  "main": "plugin/obsidian-wikilinks.js",
7
7
  "exports": {
8
8
  ".": "./plugin/obsidian-wikilinks.js",
9
9
  "./server": "./plugin/obsidian-wikilinks.js",
10
+ "./tui": "./plugin/tui.js",
10
11
  "./package.json": "./package.json"
11
12
  },
13
+ "oc-plugin": [
14
+ "server",
15
+ "tui"
16
+ ],
12
17
  "scripts": {
13
18
  "test": "node test/smoke.mjs",
14
19
  "test:bun": "bun test/smoke.mjs",
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Per-session log of the notes a prompt linked and the vault files the agent
3
+ * read, shared by the OpenCode server plugin (writer) and sidebar (reader).
4
+ *
5
+ * One NDJSON file per session, because the TUI and the server run the plugin
6
+ * in separate module instances and a file is the one thing both can see.
7
+ *
8
+ * Events:
9
+ * {kind: "link", ts, vault, target, status: "resolved"|"ambiguous"|"missing", paths}
10
+ * {kind: "read", ts, path}
11
+ *
12
+ * Only Node built-ins, so the server side stays install-free.
13
+ */
14
+ import {appendFileSync, mkdirSync, readFileSync} from "node:fs"
15
+ import {homedir} from "node:os"
16
+ import path from "node:path"
17
+
18
+ export function stateDir() {
19
+ const explicit = process.env.OBSIDIAN_WIKILINKS_STATE_DIR
20
+ if (explicit) {
21
+ return explicit
22
+ }
23
+ const base = process.env.XDG_STATE_HOME || path.join(homedir(), ".local", "state")
24
+ return path.join(base, "obsidian-wikilinks", "sessions")
25
+ }
26
+
27
+ export function logPath(sessionID) {
28
+ return path.join(stateDir(), `${String(sessionID).replace(/[^\w.-]/g, "_")}.ndjson`)
29
+ }
30
+
31
+ /** Append one event. Never throws: a broken log must not disturb the session. */
32
+ export function append(sessionID, event) {
33
+ try {
34
+ mkdirSync(stateDir(), {recursive: true})
35
+ appendFileSync(logPath(sessionID), JSON.stringify(event) + "\n")
36
+ } catch {
37
+ // ignore
38
+ }
39
+ }
40
+
41
+ export function parse(text) {
42
+ const events = []
43
+ for (const line of text.split("\n")) {
44
+ if (!line.trim()) continue
45
+ try {
46
+ const event = JSON.parse(line)
47
+ if (event && (event.kind === "link" || event.kind === "read")) {
48
+ events.push(event)
49
+ }
50
+ } catch {
51
+ // skip a torn or foreign line
52
+ }
53
+ }
54
+ return events
55
+ }
56
+
57
+ export function readEvents(sessionID) {
58
+ try {
59
+ return parse(readFileSync(logPath(sessionID), "utf8"))
60
+ } catch {
61
+ return []
62
+ }
63
+ }
64
+
65
+ /** Folder links end in a separator and cover every file below them. */
66
+ export function covers(linked, read) {
67
+ if (linked.endsWith(path.sep) || linked.endsWith("/")) {
68
+ return read.startsWith(linked)
69
+ }
70
+ return read === linked
71
+ }
72
+
73
+ export function insideVault(vault, file) {
74
+ const rel = path.relative(vault, file)
75
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
76
+ }
77
+
78
+ /**
79
+ * Fold a session's events into what the sidebar shows: every linked note with
80
+ * the reads that satisfy it, plus vault files read without being linked.
81
+ */
82
+ export function buildView(events) {
83
+ const links = new Map()
84
+ const vaults = new Set()
85
+ const reads = []
86
+ for (const event of events) {
87
+ if (event.kind === "link") {
88
+ vaults.add(event.vault)
89
+ const paths = Array.isArray(event.paths) ? event.paths : []
90
+ const existing = links.get(event.target)
91
+ // Re-linking a note keeps its first position but takes the latest resolution.
92
+ links.set(event.target, {target: event.target, status: event.status, paths, vault: event.vault, ts: existing?.ts ?? event.ts})
93
+ } else if (typeof event.path === "string" && !reads.includes(event.path)) {
94
+ reads.push(event.path)
95
+ }
96
+ }
97
+
98
+ const claimed = new Set()
99
+ const items = [...links.values()].map((link) => {
100
+ const read = reads.filter((file) => link.paths.some((linked) => covers(linked, file)))
101
+ read.forEach((file) => claimed.add(file))
102
+ const state = link.status === "missing" ? "missing" : read.length ? "read" : "unread"
103
+ return {...link, read, state}
104
+ })
105
+ const other = reads.filter((file) => !claimed.has(file) && [...vaults].some((vault) => insideVault(vault, file)))
106
+ return {links: items, other, vaults: [...vaults]}
107
+ }
108
+
109
+ const ICON = {read: "✓", unread: "○", missing: "✗"}
110
+
111
+ function fit(text, width) {
112
+ return text.length > width ? text.slice(0, Math.max(1, width - 1)) + "…" : text
113
+ }
114
+
115
+ function relative(vaults, file) {
116
+ const vault = vaults.find((v) => insideVault(v, file))
117
+ return vault ? path.relative(vault, file) : file
118
+ }
119
+
120
+ /**
121
+ * Sidebar rows for a view. Empty when the session linked nothing, so the
122
+ * section stays out of the way in sessions that never used a wikilink.
123
+ * Each row: {text, tone: "title"|"read"|"unread"|"missing"|"muted", toggle?}
124
+ */
125
+ export function sidebarLines(view, {open = true, width = 30} = {}) {
126
+ if (!view.links.length) {
127
+ return []
128
+ }
129
+ const readCount = view.links.filter((l) => l.state === "read").length
130
+ const unreadCount = view.links.length - readCount
131
+ const rows = [{text: fit(`${open ? "▼" : "▶"} Obsidian notes ✓ ${readCount} ○ ${unreadCount}`, width), tone: "title", toggle: true}]
132
+ if (!open) {
133
+ return rows
134
+ }
135
+
136
+ for (const link of view.links) {
137
+ const label = link.paths.length === 1 && link.paths[0].endsWith(path.sep)
138
+ ? `${link.target}/`
139
+ : link.target
140
+ let suffix = ""
141
+ if (link.state === "missing") {
142
+ suffix = " (no match)"
143
+ }
144
+ else if (link.paths.length > 1 || label.endsWith("/")) {
145
+ suffix = link.read.length ? ` (${link.read.length} read)` : ""
146
+ }
147
+ if (link.status === "ambiguous" && !link.read.length) {
148
+ suffix = ` (${link.paths.length} candidates)`
149
+ }
150
+ rows.push({text: fit(` ${ICON[link.state]} ${label}${suffix}`, width), tone: link.state})
151
+ }
152
+ if (view.other.length) {
153
+ rows.push({text: fit(" also read", width), tone: "muted"})
154
+ for (const file of view.other) {
155
+ rows.push({text: fit(` · ${relative(view.vaults, file)}`, width), tone: "muted"})
156
+ }
157
+ }
158
+ return rows
159
+ }
@@ -12,6 +12,8 @@ import {homedir} from "node:os"
12
12
  import path from "node:path"
13
13
  import {fileURLToPath} from "node:url"
14
14
 
15
+ import {append, insideVault, readEvents} from "./lib/read-log.js"
16
+
15
17
  const WIKILINK = /\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]/
16
18
  const PYTHON = process.env.OBSIDIAN_WIKILINKS_PYTHON || "python3"
17
19
  const TIMEOUT_MS = Number(process.env.OBSIDIAN_WIKILINKS_TIMEOUT_MS || 10000)
@@ -19,14 +21,18 @@ const TIMEOUT_MS = Number(process.env.OBSIDIAN_WIKILINKS_TIMEOUT_MS || 10000)
19
21
  /** Locate wikilink-resolver.py, whether the plugin is symlinked, copied, or installed by another host. */
20
22
  function findResolver() {
21
23
  const explicit = process.env.OBSIDIAN_WIKILINKS_RESOLVER
22
- if (explicit) return existsSync(explicit) ? explicit : null
24
+ if (explicit) {
25
+ return existsSync(explicit) ? explicit : null
26
+ }
23
27
 
24
28
  const candidates = []
25
29
  const here = path.dirname(fileURLToPath(import.meta.url))
26
30
  // Repo layout: <root>/plugin/obsidian-wikilinks.js -> <root>/hooks/wikilink-resolver.py
27
31
  for (let dir = here, i = 0; i < 5; i++, dir = path.dirname(dir)) {
28
32
  candidates.push(path.join(dir, "hooks", "wikilink-resolver.py"))
29
- if (dir === path.dirname(dir)) break
33
+ if (dir === path.dirname(dir)) {
34
+ break
35
+ }
30
36
  }
31
37
  // Copied next to the plugin file
32
38
  candidates.push(path.join(here, "wikilink-resolver.py"))
@@ -42,14 +48,17 @@ function findResolver() {
42
48
  return candidates.find(existsSync) || null
43
49
  }
44
50
 
45
- /** Run the resolver with the hook payload on stdin; resolve to its additionalContext, or null. */
51
+ /**
52
+ * Run the resolver with the hook payload on stdin; resolve to
53
+ * `{context, vault, links}`, or null.
54
+ */
46
55
  function resolveWikilinks(script, prompt) {
47
56
  return new Promise((resolve) => {
48
57
  let child
49
58
  try {
50
59
  child = spawn(PYTHON, [script], {
51
60
  stdio: ["pipe", "pipe", "ignore"],
52
- env: {...process.env, OBSIDIAN_WIKILINKS_HOST: "opencode"},
61
+ env: {...process.env, OBSIDIAN_WIKILINKS_HOST: "opencode", OBSIDIAN_WIKILINKS_EMIT_LINKS: "1"},
53
62
  })
54
63
  } catch {
55
64
  return resolve(null)
@@ -69,8 +78,13 @@ function resolveWikilinks(script, prompt) {
69
78
  child.on("error", () => done(null))
70
79
  child.on("close", () => {
71
80
  try {
72
- const context = JSON.parse(out).hookSpecificOutput?.additionalContext
73
- done(typeof context === "string" && context ? context : null)
81
+ const payload = JSON.parse(out)
82
+ const context = payload.hookSpecificOutput?.additionalContext
83
+ if (typeof context !== "string" || !context) {
84
+ return done(null)
85
+ }
86
+ const {vault = null, links = []} = payload.obsidianWikilinks || {}
87
+ done({context, vault, links})
74
88
  } catch {
75
89
  done(null)
76
90
  }
@@ -91,30 +105,78 @@ function partID() {
91
105
  return `prt_wikilink_${stamp}${partCounter.toString(36)}${rand}`
92
106
  }
93
107
 
108
+ /** Vaults each session has linked into; only reads inside them are logged. */
109
+ const sessionVaults = new Map()
110
+
111
+ function vaultsFor(sessionID) {
112
+ let vaults = sessionVaults.get(sessionID)
113
+ if (!vaults) {
114
+ // Rebuilt from the log so a restarted opencode keeps tracking old sessions.
115
+ vaults = new Set(readEvents(sessionID).filter((e) => e.kind === "link").map((e) => e.vault))
116
+ sessionVaults.set(sessionID, vaults)
117
+ }
118
+ return vaults
119
+ }
120
+
94
121
  /** @type {import("@opencode-ai/plugin").Plugin} */
95
- export const ObsidianWikilinksPlugin = async () => {
122
+ export const ObsidianWikilinksPlugin = async ({directory} = {}) => {
96
123
  return {
97
124
  "chat.message": async (_input, output) => {
98
125
  const prompt = output.parts
99
126
  .filter((part) => part.type === "text" && !part.synthetic && part.text)
100
127
  .map((part) => part.text)
101
128
  .join("\n")
102
- if (!prompt || !WIKILINK.test(prompt)) return
129
+ if (!prompt || !WIKILINK.test(prompt)) {
130
+ return
131
+ }
103
132
 
104
133
  const script = findResolver()
105
- if (!script) return
134
+ if (!script) {
135
+ return
136
+ }
106
137
 
107
- const context = await resolveWikilinks(script, prompt)
108
- if (!context) return
138
+ const result = await resolveWikilinks(script, prompt)
139
+ if (!result) {
140
+ return
141
+ }
109
142
 
143
+ const sessionID = output.message.sessionID
110
144
  output.parts.push({
111
145
  id: partID(),
112
- sessionID: output.message.sessionID,
146
+ sessionID,
113
147
  messageID: output.message.id,
114
148
  type: "text",
115
- text: context,
149
+ text: result.context,
116
150
  synthetic: true,
117
151
  })
152
+
153
+ if (!result.vault || !result.links.length) {
154
+ return
155
+ }
156
+ vaultsFor(sessionID).add(result.vault)
157
+ const ts = new Date().toISOString()
158
+ for (const link of result.links) {
159
+ append(sessionID, {kind: "link", ts, vault: result.vault, ...link})
160
+ }
161
+ },
162
+
163
+ "tool.execute.after": async ({tool, sessionID, args}) => {
164
+ if (tool !== "read") {
165
+ return
166
+ }
167
+ const file = args?.filePath
168
+ if (typeof file !== "string" || !file) {
169
+ return
170
+ }
171
+ const vaults = vaultsFor(sessionID)
172
+ if (!vaults.size) {
173
+ return
174
+ }
175
+ const abs = path.resolve(directory || process.cwd(), file)
176
+ if (![...vaults].some((vault) => insideVault(vault, abs))) {
177
+ return
178
+ }
179
+ append(sessionID, {kind: "read", ts: new Date().toISOString(), path: abs})
118
180
  },
119
181
  }
120
182
  }
package/plugin/tui.js ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * OpenCode TUI plugin: an "Obsidian notes" sidebar section listing every note
3
+ * the session's prompts linked with [[wikilinks]], marked ✓ read or ○ not read.
4
+ *
5
+ * Reads the per-session log the server plugin writes (lib/read-log.js).
6
+ * `@opentui/solid` and `solid-js` are provided by opencode at runtime.
7
+ */
8
+ import {mkdirSync, statSync, watch} from "node:fs"
9
+
10
+ import {createElement, insert, setProp} from "@opentui/solid"
11
+ import {createSignal, onCleanup} from "solid-js"
12
+
13
+ import {buildView, logPath, readEvents, sidebarLines, stateDir} from "./lib/read-log.js"
14
+
15
+ /** Below opencode's own sidebar content. */
16
+ const ORDER = 810
17
+ const DEFAULT_WIDTH = 30
18
+
19
+ function element(tag, props, children = []) {
20
+ const node = createElement(tag)
21
+ for (const [key, value] of Object.entries(props)) {
22
+ if (value !== undefined) {
23
+ setProp(node, key, value)
24
+ }
25
+ }
26
+ for (const child of children) {
27
+ insert(node, child)
28
+ }
29
+ return node
30
+ }
31
+
32
+ function color(theme, tone) {
33
+ if (tone === "title") {
34
+ return theme.text
35
+ }
36
+ if (tone === "read") {
37
+ return theme.success
38
+ }
39
+ if (tone === "unread") {
40
+ return theme.warning
41
+ }
42
+ if (tone === "missing") {
43
+ return theme.error
44
+ }
45
+ return theme.textMuted
46
+ }
47
+
48
+ function mtime(file) {
49
+ try {
50
+ return statSync(file).mtimeMs
51
+ } catch {
52
+ return 0
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Call `onChange` when the session's log changes. Watches the directory, since
58
+ * the file only appears with the first link; polling covers filesystems where
59
+ * fs.watch is unreliable.
60
+ */
61
+ function watchLog(sessionID, onChange) {
62
+ const file = logPath(sessionID)
63
+ let last = mtime(file)
64
+ const check = () => {
65
+ const current = mtime(file)
66
+ if (current === last) {
67
+ return
68
+ }
69
+ last = current
70
+ onChange()
71
+ }
72
+ let watcher
73
+ try {
74
+ mkdirSync(stateDir(), {recursive: true})
75
+ watcher = watch(stateDir(), check)
76
+ } catch {
77
+ watcher = undefined
78
+ }
79
+ const poll = setInterval(check, 2000)
80
+ return () => {
81
+ clearInterval(poll)
82
+ watcher?.close()
83
+ }
84
+ }
85
+
86
+ function Section(api, sessionID, width) {
87
+ const [view, setView] = createSignal(buildView(readEvents(sessionID)))
88
+ const [open, setOpen] = createSignal(true)
89
+ const redraw = () => api.renderer.requestRender()
90
+
91
+ onCleanup(
92
+ watchLog(sessionID, () => {
93
+ setView(buildView(readEvents(sessionID)))
94
+ redraw()
95
+ }),
96
+ )
97
+
98
+ const rows = () =>
99
+ sidebarLines(view(), {open: open(), width}).map((row) =>
100
+ element(
101
+ "text",
102
+ {
103
+ fg: color(api.theme.current, row.tone),
104
+ onMouseDown: row.toggle
105
+ ? () => {
106
+ setOpen((value) => !value)
107
+ redraw()
108
+ }
109
+ : undefined,
110
+ },
111
+ [row.text],
112
+ ),
113
+ )
114
+
115
+ return element("box", {width: "100%", flexDirection: "column"}, [rows])
116
+ }
117
+
118
+ /** @type {import("@opencode-ai/plugin/tui").TuiPlugin} */
119
+ export const tui = async (api, options) => {
120
+ const width = typeof options?.width === "number" ? options.width : DEFAULT_WIDTH
121
+ api.slots.register({
122
+ order: ORDER,
123
+ slots: {sidebar_content: (_ctx, props) => Section(api, props.session_id, width)},
124
+ })
125
+ }
126
+
127
+ export default {
128
+ id: "obsidian-wikilinks",
129
+ tui,
130
+ }