obsidian-wikilinks 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sven Depickere
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,207 @@
1
+ # obsidian-wikilinks
2
+
3
+ Resolve `[[wikilinks]]` typed in a Codex, Claude Code, or OpenCode prompt to
4
+ absolute paths in your Obsidian vault, and inject them as context so the agent
5
+ reads the right notes.
6
+
7
+ This is a **coding-agent** plugin (it teaches Codex / Claude Code / OpenCode
8
+ about `[[ ]]`), not an Obsidian-app plugin.
9
+
10
+ ## How it works
11
+
12
+ The prompt is scanned for `[[...]]`, each target is fuzzy-matched against note
13
+ and folder names in your vault, and the resolved absolute paths are added to the
14
+ prompt as extra context. The agent can then read the referenced notes when they
15
+ are relevant to your request.
16
+
17
+ All hosts share one resolver, `hooks/wikilink-resolver.py`:
18
+
19
+ - Codex and Claude Code run it as a `UserPromptSubmit` hook.
20
+ - OpenCode runs it from `plugin/obsidian-wikilinks.js` on the `chat.message`
21
+ hook, which appends the resolution as a synthetic text part.
22
+
23
+ ## Install in Codex
24
+
25
+ ```bash
26
+ codex plugin marketplace add DepickereSven/obsidian-wikilinks
27
+ codex plugin add obsidian-wikilinks@depickeresven-obsidian-wikilinks
28
+ ```
29
+
30
+ Start a new thread, run `/hooks`, and review and trust the plugin's
31
+ `UserPromptSubmit` hook when Codex asks. Installed command hooks do not run until
32
+ they have been trusted.
33
+
34
+ ## Install in Claude Code
35
+
36
+ ```bash
37
+ claude plugin marketplace add DepickereSven/obsidian-wikilinks
38
+ claude plugin install obsidian-wikilinks@depickeresven-obsidian-wikilinks
39
+ ```
40
+
41
+ ## Install in OpenCode
42
+
43
+ ### From npm (recommended)
44
+
45
+ ```bash
46
+ opencode plugin obsidian-wikilinks
47
+ ```
48
+
49
+ That installs the package and adds it to your config. Or add it by hand:
50
+
51
+ ```json
52
+ {
53
+ "$schema": "https://opencode.ai/config.json",
54
+ "plugin": ["obsidian-wikilinks"]
55
+ }
56
+ ```
57
+
58
+ Use `~/.config/opencode/opencode.json` for every project, or `opencode.json` in
59
+ a repo for that project only.
60
+
61
+ > The package is not on npm yet. Until it is published, use one of the local
62
+ > options below, or publish it yourself from a checkout with `npm publish`.
63
+
64
+ ### From a local checkout
65
+
66
+ OpenCode loads any `.js` / `.ts` file in a plugin directory, and follows
67
+ symlinks. Clone the repo once, then link the plugin:
68
+
69
+ ```bash
70
+ git clone https://github.com/DepickereSven/obsidian-wikilinks.git ~/.config/opencode/obsidian-wikilinks
71
+ mkdir -p ~/.config/opencode/plugin
72
+ ln -s ~/.config/opencode/obsidian-wikilinks/plugin/obsidian-wikilinks.js ~/.config/opencode/plugin/
73
+ ```
74
+
75
+ Use `.opencode/plugin/` instead of `~/.config/opencode/plugin/` to enable it for
76
+ a single project only.
77
+
78
+ Alternatively, reference the checkout from your config instead of symlinking —
79
+ `plugin` entries accept `file://` URLs and paths relative to the config file:
80
+
81
+ ```json
82
+ {
83
+ "$schema": "https://opencode.ai/config.json",
84
+ "plugin": ["file:///Users/you/src/obsidian-wikilinks/plugin/obsidian-wikilinks.js"]
85
+ }
86
+ ```
87
+
88
+ The plugin finds `hooks/wikilink-resolver.py` next to itself, in the checkout or
89
+ in `node_modules`. If you copy the `.js` file somewhere on its own, point it at
90
+ the resolver with
91
+ `OBSIDIAN_WIKILINKS_RESOLVER=/path/to/hooks/wikilink-resolver.py`.
92
+
93
+ In the common case, no configuration is needed on any host. The plugin reads
94
+ Obsidian's own vault registry and uses your active vault automatically.
95
+
96
+ ## Examples
97
+
98
+ If your vault contains `Projects/Website Redesign.md`, this prompt:
99
+
100
+ ```text
101
+ Summarize [[Website Redesign]] and list the next actions.
102
+ ```
103
+
104
+ injects the note's absolute path so the agent can read and summarize it.
105
+
106
+ You can reference several notes, headings, and aliases in one prompt:
107
+
108
+ ```text
109
+ Compare [[Research/AI Agents#Open questions]] with
110
+ [[Projects/Website Redesign|the project brief]].
111
+ ```
112
+
113
+ The heading and alias are ignored during path lookup. Both note paths are added
114
+ to the prompt context. Folder wikilinks work too:
115
+
116
+ ```text
117
+ Review the notes in [[Meetings]] and prepare a weekly summary.
118
+ ```
119
+
120
+ When a name is ambiguous, the plugin provides up to three candidate paths. If
121
+ there is no match, it says so instead of guessing.
122
+
123
+ ## Vault selection
124
+
125
+ Vault path resolution order:
126
+
127
+ 1. The current host's explicit override:
128
+ - Codex: `~/.codex/obsidian-wikilinks.json`
129
+ - Claude Code: `~/.claude/obsidian-wikilinks.json`
130
+ - OpenCode: `~/.config/opencode/obsidian-wikilinks.json`
131
+ (or `$OPENCODE_CONFIG_DIR` / `$XDG_CONFIG_HOME` when set)
132
+ 2. The other hosts' config files, as a compatibility fallback
133
+ 3. `$OBSIDIAN_VAULT` environment variable
134
+ 4. Obsidian's vault registry — auto-detected (prefers the open vault, else most
135
+ recently opened). Cross-platform (macOS / Windows / Linux).
136
+ 5. `~/Documents/Obsidian` (default fallback)
137
+
138
+ You only need an explicit override if you have **multiple vaults** and want to
139
+ pin a specific one. Create the config file for your host with:
140
+
141
+ ```json
142
+ { "vault": "/Users/you/Documents/Obsidian" }
143
+ ```
144
+
145
+ The plugin itself is identical on every device. Update Claude Code with
146
+ `claude plugin update obsidian-wikilinks`. Re-run the Codex `plugin add` command
147
+ to install an updated version there. For OpenCode, re-run `opencode plugin
148
+ obsidian-wikilinks`, or `git pull` in the checkout.
149
+
150
+ ## Environment variables
151
+
152
+ | Variable | Purpose |
153
+ |---------------------------------|--------------------------------------------------------------|
154
+ | `OBSIDIAN_VAULT` | Vault path, used when no host config file sets one |
155
+ | `OBSIDIAN_WIKILINKS_RESOLVER` | Path to `wikilink-resolver.py` (OpenCode only) |
156
+ | `OBSIDIAN_WIKILINKS_PYTHON` | Python interpreter to use (default `python3`, OpenCode only) |
157
+ | `OBSIDIAN_WIKILINKS_TIMEOUT_MS` | Resolver timeout in ms (default `10000`, OpenCode only) |
158
+
159
+ ## Development
160
+
161
+ ```bash
162
+ npm test # smoke tests on Node
163
+ npm run test:bun # same tests on Bun, the runtime OpenCode uses
164
+ ```
165
+
166
+ No dependencies to install: the plugin uses Node/Bun built-ins and the resolver
167
+ is standard-library Python.
168
+
169
+ ## Releasing
170
+
171
+ CI runs the test suite on Node 20/22/24 and on Bun for every push and pull
172
+ request, and checks that the packed tarball ships both `plugin/` and `hooks/`.
173
+
174
+ To cut a release:
175
+
176
+ ```bash
177
+ npm version patch # or minor / major
178
+ git push --follow-tags
179
+ gh release create "v$(node -p 'require("./package.json").version')" --generate-notes
180
+ ```
181
+
182
+ `npm version` also rewrites the Claude Code and Codex manifests through
183
+ `scripts/sync-versions.mjs`, so all three stay on one version. Publishing the
184
+ GitHub release triggers `.github/workflows/publish.yml`, which re-runs the
185
+ tests, verifies the tag matches `package.json`, refuses to overwrite a version
186
+ already on npm, and publishes with provenance.
187
+
188
+ The workflow authenticates through **npm trusted publishing** (OIDC), so no
189
+ token is stored in the repository. One-time setup: on npmjs.com, open the
190
+ package's *Settings -> Trusted publishers*, and add this repository with
191
+ workflow `publish.yml`.
192
+
193
+ Trusted-publisher settings live on the package, so they can only be configured
194
+ once the package exists. Do the **first** publish by hand from a checkout with
195
+ `npm publish --access public`, then wire up the trusted publisher and let the
196
+ workflow handle every release after that.
197
+
198
+ To use a granular access token instead, save it as the `NPM_TOKEN` secret and
199
+ uncomment the `NODE_AUTH_TOKEN` block in the workflow.
200
+
201
+ Use the workflow's manual trigger (`workflow_dispatch`) with *dry run* enabled
202
+ to rehearse a publish without releasing anything.
203
+
204
+ ## Requirements
205
+
206
+ - `python3` on PATH (standard-library only; no pip installs).
207
+ - OpenCode only: no extra dependencies — the plugin uses Node/Bun built-ins.
@@ -0,0 +1,14 @@
1
+ {
2
+ "hooks": {
3
+ "UserPromptSubmit": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/wikilink-resolver.py"
9
+ }
10
+ ]
11
+ }
12
+ ]
13
+ }
14
+ }
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env python3
2
+ """Prompt hook: resolve [[wikilinks]] in the prompt to Obsidian vault files.
3
+
4
+ Reads the hook JSON on stdin, scans the prompt for [[...]] references,
5
+ fuzzy-matches them against note names in the vault, and emits the resolved
6
+ absolute paths as additionalContext so the agent can read the right files.
7
+
8
+ Hosts: Claude Code and Codex run this as a UserPromptSubmit hook. OpenCode runs
9
+ it through plugin/obsidian-wikilinks.js, which feeds the same stdin payload and
10
+ reads the same stdout JSON.
11
+
12
+ Vault path resolution order:
13
+ 1. The current host's config file (~/.claude, ~/.codex or ~/.config/opencode)
14
+ 2. The other hosts' config files (compatibility fallback)
15
+ 3. $OBSIDIAN_VAULT environment variable
16
+ 4. Obsidian's own vault registry (auto-detected, cross-platform)
17
+ 5. ~/Documents/Obsidian (default)
18
+ """
19
+ import difflib
20
+ import json
21
+ import os
22
+ import re
23
+ import sys
24
+
25
+ CONFIG_NAME = "obsidian-wikilinks.json"
26
+
27
+
28
+ def opencode_config_dir():
29
+ explicit = os.environ.get("OPENCODE_CONFIG_DIR")
30
+ if explicit:
31
+ return os.path.expanduser(explicit)
32
+ xdg = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
33
+ return os.path.join(xdg, "opencode")
34
+
35
+
36
+ CONFIG_FILES = {
37
+ "claude": os.path.expanduser("~/.claude/" + CONFIG_NAME),
38
+ "codex": os.path.expanduser("~/.codex/" + CONFIG_NAME),
39
+ "opencode": os.path.join(opencode_config_dir(), CONFIG_NAME),
40
+ }
41
+ SKIP_DIRS = {".obsidian", ".trash", ".git", ".vault-meta"}
42
+ WIKILINK = re.compile(r"\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]")
43
+
44
+
45
+ def obsidian_registry_paths():
46
+ """Candidate locations of Obsidian's obsidian.json across platforms."""
47
+ home = os.path.expanduser("~")
48
+ candidates = [
49
+ os.path.join(home, "Library", "Application Support", "obsidian", "obsidian.json"), # macOS
50
+ os.path.join(os.environ.get("APPDATA", ""), "obsidian", "obsidian.json"), # Windows
51
+ os.path.join(home, ".config", "obsidian", "obsidian.json"), # Linux
52
+ ]
53
+ return [p for p in candidates if p and os.path.isfile(p)]
54
+
55
+
56
+ def autodetect_vault():
57
+ """Read Obsidian's vault registry; prefer the open vault, else most recent."""
58
+ for path in obsidian_registry_paths():
59
+ try:
60
+ with open(path) as f:
61
+ vaults = json.load(f).get("vaults", {})
62
+ except (json.JSONDecodeError, OSError):
63
+ continue
64
+ valid = [v for v in vaults.values() if os.path.isdir(v.get("path", ""))]
65
+ if not valid:
66
+ continue
67
+ valid.sort(key=lambda v: (bool(v.get("open")), v.get("ts", 0)), reverse=True)
68
+ return valid[0]["path"]
69
+ return None
70
+
71
+
72
+ def detect_host():
73
+ """Which agent invoked us; decides whose config file wins."""
74
+ host = os.environ.get("OBSIDIAN_WIKILINKS_HOST", "").strip().lower()
75
+ if host in CONFIG_FILES:
76
+ return host
77
+ if os.environ.get("OPENCODE") or os.environ.get("OPENCODE_CONFIG_DIR"):
78
+ return "opencode"
79
+ if os.environ.get("PLUGIN_ROOT"):
80
+ return "codex"
81
+ return "claude"
82
+
83
+
84
+ def config_file_order():
85
+ """Current host's config first, the other hosts' files as fallbacks."""
86
+ host = detect_host()
87
+ return [CONFIG_FILES[host]] + [v for k, v in CONFIG_FILES.items() if k != host]
88
+
89
+
90
+ def get_vault():
91
+ for config_file in config_file_order():
92
+ if not os.path.isfile(config_file):
93
+ continue
94
+ try:
95
+ with open(config_file) as f:
96
+ cfg = json.load(f)
97
+ vault = cfg.get("vault", "")
98
+ if vault:
99
+ return os.path.expanduser(vault)
100
+ except (json.JSONDecodeError, OSError):
101
+ pass
102
+ env = os.environ.get("OBSIDIAN_VAULT", "")
103
+ if env:
104
+ return os.path.expanduser(env)
105
+ detected = autodetect_vault()
106
+ if detected:
107
+ return detected
108
+ return os.path.expanduser("~/Documents/Obsidian")
109
+
110
+
111
+ def build_index(vault):
112
+ index = [] # (stem, relpath, abspath)
113
+ for root, dirs, files in os.walk(vault):
114
+ dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
115
+ for d in dirs:
116
+ abspath = os.path.join(root, d)
117
+ relpath = os.path.relpath(abspath, vault)
118
+ index.append((d, relpath, abspath + os.sep))
119
+ for f in files:
120
+ if f.startswith("."):
121
+ continue
122
+ abspath = os.path.join(root, f)
123
+ relpath = os.path.relpath(abspath, vault)
124
+ stem = os.path.splitext(f)[0]
125
+ index.append((stem, relpath, abspath))
126
+ return index
127
+
128
+
129
+ def normalize(s):
130
+ return re.sub(r"[^a-z0-9]", "", s.lower())
131
+
132
+
133
+ def resolve(target, index):
134
+ t = target.strip()
135
+ t_lower = t.lower()
136
+ t_md = t_lower if t_lower.endswith(".md") else t_lower + ".md"
137
+ t_norm = normalize(t)
138
+
139
+ # 1. exact stem match (case-insensitive)
140
+ hits = [e for e in index if e[0].lower() == t_lower]
141
+ # 2. exact relative path match (with or without .md)
142
+ if not hits:
143
+ hits = [e for e in index if e[1].lower() in (t_lower, t_md)]
144
+ # 3. substring match on stem
145
+ if not hits:
146
+ hits = [e for e in index if t_lower in e[0].lower()]
147
+ # 4. normalized containment ("Backend search" ~ "Back-end search vs ...")
148
+ if not hits and t_norm:
149
+ hits = [e for e in index if t_norm in normalize(e[0])]
150
+ # 5. fuzzy match: full stems, then stem prefixes of target length
151
+ if not hits:
152
+ stems = {e[0]: e for e in index}
153
+ close = difflib.get_close_matches(t, stems.keys(), n=3, cutoff=0.6)
154
+ if not close:
155
+ prefixes = {}
156
+ for stem in stems:
157
+ prefixes.setdefault(stem[: len(t)], stem)
158
+ close = [prefixes[p] for p in
159
+ difflib.get_close_matches(t, prefixes.keys(), n=3, cutoff=0.6)]
160
+ hits = [stems[c] for c in close]
161
+ return hits[:3]
162
+
163
+
164
+ def main():
165
+ try:
166
+ payload = json.load(sys.stdin)
167
+ except (json.JSONDecodeError, ValueError):
168
+ sys.exit(0)
169
+
170
+ prompt = payload.get("prompt", "")
171
+ targets = WIKILINK.findall(prompt)
172
+ if not targets:
173
+ sys.exit(0)
174
+
175
+ vault = get_vault()
176
+ if not os.path.isdir(vault):
177
+ sys.exit(0)
178
+
179
+ index = build_index(vault)
180
+ lines = []
181
+ for target in dict.fromkeys(targets): # dedupe, keep order
182
+ hits = resolve(target, index)
183
+ if not hits:
184
+ lines.append(f"[[{target}]] -> no match found in vault {vault}")
185
+ elif len(hits) == 1:
186
+ lines.append(f"[[{target}]] -> {hits[0][2]}")
187
+ else:
188
+ opts = ", ".join(h[2] for h in hits)
189
+ lines.append(f"[[{target}]] -> ambiguous, candidates: {opts}")
190
+
191
+ context = (
192
+ "Obsidian wikilink resolution (vault: " + vault + "):\n"
193
+ + "\n".join(lines)
194
+ + "\nRead the resolved file(s) when their content is relevant to the request."
195
+ )
196
+ print(json.dumps({
197
+ "hookSpecificOutput": {
198
+ "hookEventName": "UserPromptSubmit",
199
+ "additionalContext": context,
200
+ }
201
+ }))
202
+ sys.exit(0)
203
+
204
+
205
+ if __name__ == "__main__":
206
+ main()
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "obsidian-wikilinks",
3
+ "version": "0.2.0",
4
+ "description": "Resolve Obsidian [[wikilinks]] in coding-agent prompts to local vault paths and inject them as context.",
5
+ "type": "module",
6
+ "main": "plugin/obsidian-wikilinks.js",
7
+ "exports": {
8
+ ".": "./plugin/obsidian-wikilinks.js",
9
+ "./server": "./plugin/obsidian-wikilinks.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "scripts": {
13
+ "test": "node test/smoke.mjs",
14
+ "test:bun": "bun test/smoke.mjs",
15
+ "check:versions": "node scripts/sync-versions.mjs --check",
16
+ "sync:versions": "node scripts/sync-versions.mjs",
17
+ "version": "node scripts/sync-versions.mjs && git add .claude-plugin/plugin.json .codex-plugin/plugin.json",
18
+ "prepublishOnly": "npm run check:versions && npm test"
19
+ },
20
+ "files": [
21
+ "plugin",
22
+ "hooks",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "keywords": [
27
+ "obsidian",
28
+ "wikilinks",
29
+ "notes",
30
+ "context",
31
+ "opencode",
32
+ "opencode-plugin"
33
+ ],
34
+ "author": {
35
+ "name": "Sven Depickere",
36
+ "url": "https://github.com/DepickereSven"
37
+ },
38
+ "homepage": "https://github.com/DepickereSven/obsidian-wikilinks",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/DepickereSven/obsidian-wikilinks.git"
42
+ },
43
+ "license": "MIT",
44
+ "bugs": {
45
+ "url": "https://github.com/DepickereSven/obsidian-wikilinks/issues"
46
+ }
47
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * OpenCode plugin: resolve [[wikilinks]] in a prompt to Obsidian vault paths.
3
+ *
4
+ * Thin wrapper around hooks/wikilink-resolver.py, the same resolver used by the
5
+ * Claude Code and Codex UserPromptSubmit hooks. The Python script owns all
6
+ * vault detection and matching logic; this file only bridges OpenCode's
7
+ * `chat.message` hook to it.
8
+ */
9
+ import {spawn} from "node:child_process"
10
+ import {existsSync} from "node:fs"
11
+ import {homedir} from "node:os"
12
+ import path from "node:path"
13
+ import {fileURLToPath} from "node:url"
14
+
15
+ const WIKILINK = /\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]/
16
+ const PYTHON = process.env.OBSIDIAN_WIKILINKS_PYTHON || "python3"
17
+ const TIMEOUT_MS = Number(process.env.OBSIDIAN_WIKILINKS_TIMEOUT_MS || 10000)
18
+
19
+ /** Locate wikilink-resolver.py, whether the plugin is symlinked, copied, or installed by another host. */
20
+ function findResolver() {
21
+ const explicit = process.env.OBSIDIAN_WIKILINKS_RESOLVER
22
+ if (explicit) return existsSync(explicit) ? explicit : null
23
+
24
+ const candidates = []
25
+ const here = path.dirname(fileURLToPath(import.meta.url))
26
+ // Repo layout: <root>/plugin/obsidian-wikilinks.js -> <root>/hooks/wikilink-resolver.py
27
+ for (let dir = here, i = 0; i < 5; i++, dir = path.dirname(dir)) {
28
+ candidates.push(path.join(dir, "hooks", "wikilink-resolver.py"))
29
+ if (dir === path.dirname(dir)) break
30
+ }
31
+ // Copied next to the plugin file
32
+ candidates.push(path.join(here, "wikilink-resolver.py"))
33
+ // Installed by another host on the same machine
34
+ const home = homedir()
35
+ for (const base of [
36
+ path.join(home, ".config", "opencode", "obsidian-wikilinks"),
37
+ path.join(home, ".claude", "plugins", "obsidian-wikilinks"),
38
+ path.join(home, ".codex", "plugins", "obsidian-wikilinks"),
39
+ ]) {
40
+ candidates.push(path.join(base, "hooks", "wikilink-resolver.py"))
41
+ }
42
+ return candidates.find(existsSync) || null
43
+ }
44
+
45
+ /** Run the resolver with the hook payload on stdin; resolve to its additionalContext, or null. */
46
+ function resolveWikilinks(script, prompt) {
47
+ return new Promise((resolve) => {
48
+ let child
49
+ try {
50
+ child = spawn(PYTHON, [script], {
51
+ stdio: ["pipe", "pipe", "ignore"],
52
+ env: {...process.env, OBSIDIAN_WIKILINKS_HOST: "opencode"},
53
+ })
54
+ } catch {
55
+ return resolve(null)
56
+ }
57
+
58
+ let out = ""
59
+ const done = (value) => {
60
+ clearTimeout(timer)
61
+ resolve(value)
62
+ }
63
+ const timer = setTimeout(() => {
64
+ child.kill()
65
+ done(null)
66
+ }, TIMEOUT_MS)
67
+
68
+ child.stdout.on("data", (chunk) => (out += chunk))
69
+ child.on("error", () => done(null))
70
+ child.on("close", () => {
71
+ try {
72
+ const context = JSON.parse(out).hookSpecificOutput?.additionalContext
73
+ done(typeof context === "string" && context ? context : null)
74
+ } catch {
75
+ done(null)
76
+ }
77
+ })
78
+
79
+ child.stdin.on("error", () => {
80
+ })
81
+ child.stdin.end(JSON.stringify({prompt}))
82
+ })
83
+ }
84
+
85
+ let partCounter = 0
86
+
87
+ function partID() {
88
+ partCounter += 1
89
+ const stamp = Date.now().toString(36)
90
+ const rand = Math.random().toString(36).slice(2, 10)
91
+ return `prt_wikilink_${stamp}${partCounter.toString(36)}${rand}`
92
+ }
93
+
94
+ /** @type {import("@opencode-ai/plugin").Plugin} */
95
+ export const ObsidianWikilinksPlugin = async () => {
96
+ return {
97
+ "chat.message": async (_input, output) => {
98
+ const prompt = output.parts
99
+ .filter((part) => part.type === "text" && !part.synthetic && part.text)
100
+ .map((part) => part.text)
101
+ .join("\n")
102
+ if (!prompt || !WIKILINK.test(prompt)) return
103
+
104
+ const script = findResolver()
105
+ if (!script) return
106
+
107
+ const context = await resolveWikilinks(script, prompt)
108
+ if (!context) return
109
+
110
+ output.parts.push({
111
+ id: partID(),
112
+ sessionID: output.message.sessionID,
113
+ messageID: output.message.id,
114
+ type: "text",
115
+ text: context,
116
+ synthetic: true,
117
+ })
118
+ },
119
+ }
120
+ }
121
+
122
+ // Default export shape required by opencode for npm and file:// plugin specs
123
+ // (`{ id, server }`); the named export above keeps plugin-directory loading
124
+ // working on older opencode versions that scan named exports.
125
+ export default {
126
+ id: "obsidian-wikilinks",
127
+ server: ObsidianWikilinksPlugin,
128
+ }