@soffits/opencode-rummage 0.0.0-stage → 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/index.js ADDED
@@ -0,0 +1,78 @@
1
+ // OpenCode plugin for rummage: recall from the user's files on each prompt, plus the rummage skill.
2
+ //
3
+ // The prompt hook runs `rummage recall` once per prompt and keeps the block in the message's metadata, so it is
4
+ // stored with the message and never re-run. The context hook adds that block to its user message in every model
5
+ // request, so the system prompt stays the same and the request prefix stays cacheable. A recall that fails or
6
+ // takes longer than `timeout` adds nothing.
7
+
8
+ import { execFile } from "node:child_process"
9
+ import { readFileSync } from "node:fs"
10
+
11
+ const KEY = "rummage.recall"
12
+
13
+ function recall(command, args, prompt, sessionID, cwd, timeout) {
14
+ return new Promise((resolve) => {
15
+ const child = execFile(
16
+ command,
17
+ ["recall", "--hook", ...args],
18
+ { timeout, maxBuffer: 1 << 20, windowsHide: true },
19
+ (error, stdout) => {
20
+ if (error || !stdout.trim()) return resolve("")
21
+ try {
22
+ resolve(JSON.parse(stdout).hookSpecificOutput?.additionalContext ?? "")
23
+ } catch {
24
+ resolve("")
25
+ }
26
+ },
27
+ )
28
+ child.stdin.on("error", () => {})
29
+ child.stdin.end(JSON.stringify({ prompt, session_id: sessionID, cwd, harness: "opencode" }))
30
+ })
31
+ }
32
+
33
+ function bundledSkill() {
34
+ try {
35
+ const path = new URL("./skills/rummage/SKILL.md", import.meta.url)
36
+ const text = readFileSync(path, "utf8")
37
+ const [, front = "", body = text] = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/) ?? []
38
+ const field = (name) => front.match(new RegExp(`^${name}:\\s*(.*)$`, "m"))?.[1].trim()
39
+ return { id: field("name") ?? "rummage", description: field("description"), path: path.pathname, content: body }
40
+ } catch {
41
+ return undefined
42
+ }
43
+ }
44
+
45
+ export default {
46
+ id: "rummage",
47
+ async setup(ctx) {
48
+ const options = ctx.options ?? {}
49
+ const command = options.command ?? "rummage"
50
+ const args = options.args ?? []
51
+ const timeout = options.timeout ?? 5000
52
+
53
+ await ctx.session.hook("prompt", async (event) => {
54
+ const text = event.prompt.text ?? ""
55
+ if (!text.trim()) return
56
+ const cwd = ctx.location?.directory ?? process.cwd()
57
+ const block = await recall(command, args, text, String(event.sessionID), cwd, timeout)
58
+ if (block) event.metadata = { ...event.metadata, [KEY]: block }
59
+ })
60
+
61
+ await ctx.session.hook("context", (event) => {
62
+ event.messages.forEach((message, index) => {
63
+ const block = message.role === "user" && message.metadata?.[KEY]
64
+ if (typeof block !== "string") return
65
+ event.messages[index] = Object.assign(Object.create(Object.getPrototypeOf(message)), message, {
66
+ content: [...message.content, { type: "text", text: block }],
67
+ })
68
+ })
69
+ })
70
+
71
+ const skill = options.skill === false ? undefined : bundledSkill()
72
+ if (skill) {
73
+ await ctx.skill.transform((editor) => {
74
+ if (!editor.get(skill.id)) editor.add({ ...skill, name: skill.id })
75
+ })
76
+ }
77
+ },
78
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@soffits/opencode-rummage",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "OpenCode plugin for rummage: recall from your own files on each prompt, a skill for searching them, and indexing progress in the footer.",
5
+ "license": "AGPL-3.0-or-later",
6
+ "author": "Sakina",
7
+ "homepage": "https://github.com/soffits/rummage/blob/main/docs/integrations.md#opencode",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/soffits/rummage.git",
11
+ "directory": "integrations/opencode"
12
+ },
13
+ "keywords": [
14
+ "opencode",
15
+ "opencode-plugin",
16
+ "memory",
17
+ "recall",
18
+ "search"
19
+ ],
20
+ "type": "module",
21
+ "exports": {
22
+ ".": "./index.js",
23
+ "./tui": "./tui.js"
24
+ },
25
+ "files": [
26
+ "index.js",
27
+ "tui.js",
28
+ "skills"
29
+ ],
30
+ "scripts": {
31
+ "prepack": "node -e \"require('fs').cpSync('../../skills/rummage', 'skills/rummage', { recursive: true })\""
32
+ },
33
+ "peerDependencies": {
34
+ "@opentui/solid": ">=0.6.6",
35
+ "solid-js": ">=1.9.0"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "@opentui/solid": {
39
+ "optional": true
40
+ },
41
+ "solid-js": {
42
+ "optional": true
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: rummage
3
+ description: Search the user's own local files (notes, documents, PDFs, pictures, chats) by meaning or exact words with the rummage CLI, then open the passages it finds. Use when the answer may be in the user's files and you don't know where, or the exact words.
4
+ ---
5
+
6
+ # rummage
7
+
8
+ `rummage` keeps a local index of the user's files and finds things by what they mean and by the exact words in
9
+ them. It runs on the user's machine; nothing is sent anywhere.
10
+
11
+ ## Search
12
+
13
+ ```bash
14
+ rummage search "what the user is looking for, in their own words" --json -n 10
15
+ rummage search "INV-20931" --words-only --json # names, IDs, numbers: exact words, no model, instant
16
+ rummage search "..." --json --path notes/work --glob '*.md' -s notes # narrow by folder, pattern, source
17
+ rummage search "..." --json --since 2026-10-01 --until 2026-10-31 # a time window (dates you work out)
18
+ rummage search "Ana Souza" --json --sort newest -n 30 # the newest matches first
19
+ rummage search "..." --json --budget-tokens 1500 # shorter output
20
+ ```
21
+
22
+ The JSON has `status`:
23
+
24
+ - `ok`: `results` are ranked. `summary.strong` says how many score near the top; the rest are weak.
25
+ - `no_results`: nothing matched. Rephrase by meaning, try exact words with `--words-only`, or drop a scope
26
+ filter.
27
+ - `empty_index` or an error with status `index_missing`: nothing is indexed yet (`rummage index`).
28
+
29
+ Each result has `id`, `source`, `title`, `ref` (where it is), `lines` for text files, `score`, `snippet`.
30
+ `next_actions` holds the commands that show what the output left out (masked credentials, the rest of a partially
31
+ indexed file); the next query is yours to choose.
32
+
33
+ ## Open what you found
34
+
35
+ ```bash
36
+ rummage get '#3f2a9c01be' # the whole item's text
37
+ rummage get '#3f2a9c01be' --loc 1800 # only the matched passage (a result's `loc`)
38
+ rummage get 'notes/lease.md' --head 40
39
+ ```
40
+
41
+ For a chat result, `rummage get '#id' --around 3` shows the turns before and after it; for a file passage it shows
42
+ the lines around it. `rummage recent --minutes 30 --json` lists what was said or changed lately, newest first.
43
+
44
+ Credentials in results print as `[secret]`. Add `--show-secrets` only when the user asks for a value.
45
+
46
+ Quote the file and lines you relied on. A miss doesn't prove the thing isn't there: try other words, exact
47
+ names, or the folder directly before saying so.
package/tui.js ADDED
@@ -0,0 +1,108 @@
1
+ // OpenCode TUI part of the rummage plugin: shows rummage's index state in the footer while it is building, paused
2
+ // or broken, and a toast when indexing starts, finishes or fails.
3
+ //
4
+ // All state comes from `rummage status --line --json`, which reads one small progress file. Between runs this part
5
+ // only checks that file's modification time, so an idle index costs a stat every few seconds. Written as plain JS
6
+ // (no JSX): OpenCode does not transpile JSX in packages installed under node_modules.
7
+
8
+ import { execFile } from "node:child_process"
9
+ import { stat } from "node:fs/promises"
10
+ import { createElement, effect, insert, setProp } from "@opentui/solid"
11
+ import { createSignal } from "solid-js"
12
+
13
+ const CHECK_MS = 2000
14
+ const RECHECK_BUSY_MS = 30000
15
+ const FOOTER_CHARS = 60
16
+ const KEY = Symbol.for("rummage.opencode.tui")
17
+
18
+ function run(command, args, timeout) {
19
+ return new Promise((resolve) => {
20
+ execFile(command, args, { timeout, maxBuffer: 1 << 20, windowsHide: true }, (error, stdout, stderr) =>
21
+ resolve({ ok: !error, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") }),
22
+ )
23
+ })
24
+ }
25
+
26
+ export default {
27
+ id: "rummage.tui",
28
+ setup(context) {
29
+ // The TUI may set a plugin up again while an older instance lives; only the newest one polls and renders.
30
+ globalThis[KEY]?.()
31
+ const options = context.options ?? {}
32
+ const command = options.command ?? "rummage"
33
+ const [info, setInfo] = createSignal({ state: "complete", line: "" })
34
+ const started = Date.now()
35
+ let file, mtime = 0, checked = 0, timer, stopped = false
36
+
37
+ async function refresh() {
38
+ const result = await run(command, ["status", "--line", "--json"], 10000)
39
+ checked = Date.now()
40
+ if (!result.ok) return
41
+ let next
42
+ try {
43
+ next = JSON.parse(result.stdout)
44
+ } catch {
45
+ return
46
+ }
47
+ const before = info()
48
+ file = next.file ?? file
49
+ setInfo(next)
50
+ if (before.state !== next.state && checked > started + 1000) {
51
+ if (next.state === "complete" && before.state === "building")
52
+ context.ui.toast.show({ title: "rummage", message: "index complete", variant: "success" })
53
+ else if (next.state === "error" || next.state === "stopped")
54
+ context.ui.toast.show({ title: "rummage", message: next.note || next.line, variant: "warning" })
55
+ else if (next.state === "building" && before.state === "complete")
56
+ context.ui.toast.show({ title: "rummage", message: next.line, variant: "info" })
57
+ }
58
+ }
59
+
60
+ async function tick() {
61
+ if (stopped) return
62
+ try {
63
+ const now = Date.now()
64
+ let changed = false
65
+ if (file) {
66
+ const seen = await stat(file).then((s) => s.mtimeMs, () => 0)
67
+ changed = seen !== mtime
68
+ mtime = seen
69
+ }
70
+ const busy = info().state !== "complete" || info().line
71
+ if (!file || changed || (busy && now - checked > RECHECK_BUSY_MS)) await refresh()
72
+ } finally {
73
+ if (!stopped) timer = setTimeout(tick, CHECK_MS)
74
+ }
75
+ }
76
+
77
+ void tick()
78
+
79
+ function label() {
80
+ const el = createElement("text")
81
+ insert(el, () => {
82
+ const line = info().line || ""
83
+ return line.length > FOOTER_CHARS ? line.slice(0, FOOTER_CHARS - 3) + "..." : line
84
+ })
85
+ effect((prev) => {
86
+ const bad = ["error", "stopped", "paused"].includes(info().state)
87
+ const theme = context.theme.text
88
+ return setProp(el, "fg", bad ? (theme.feedback?.warning?.base ?? theme.base) : theme.muted, prev)
89
+ })
90
+ return el
91
+ }
92
+
93
+ const unslot = [
94
+ context.ui.slot({ append: "prompt.footer.status", render: label }),
95
+ context.ui.slot({ append: "home.footer.status", render: label }),
96
+ ]
97
+
98
+ const dispose = () => {
99
+ if (stopped) return
100
+ stopped = true
101
+ clearTimeout(timer)
102
+ for (const off of unslot) off?.()
103
+ if (globalThis[KEY] === dispose) delete globalThis[KEY]
104
+ }
105
+ globalThis[KEY] = dispose
106
+ return dispose
107
+ },
108
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.