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 +137 -29
- package/hooks/wikilink-resolver.py +14 -3
- package/package.json +6 -1
- package/plugin/lib/read-log.js +159 -0
- package/plugin/obsidian-wikilinks.js +75 -13
- package/plugin/tui.js +130 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
| `
|
|
167
|
-
| `
|
|
168
|
-
| `
|
|
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
|
|
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
|
|
297
|
+
- **Already on npm**: nothing is released. This is what an ordinary push to
|
|
194
298
|
`main` does.
|
|
195
|
-
- **Not on npm
|
|
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
|
|
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
|
|
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
|
-
|
|
326
|
+
## License
|
|
218
327
|
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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))
|
|
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
|
-
/**
|
|
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
|
|
73
|
-
|
|
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))
|
|
129
|
+
if (!prompt || !WIKILINK.test(prompt)) {
|
|
130
|
+
return
|
|
131
|
+
}
|
|
103
132
|
|
|
104
133
|
const script = findResolver()
|
|
105
|
-
if (!script)
|
|
134
|
+
if (!script) {
|
|
135
|
+
return
|
|
136
|
+
}
|
|
106
137
|
|
|
107
|
-
const
|
|
108
|
-
if (!
|
|
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
|
|
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
|
+
}
|