@soffits/opencode-rummage 0.1.0 → 0.3.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 CHANGED
@@ -10,7 +10,7 @@ import { readFileSync } from "node:fs"
10
10
 
11
11
  const KEY = "rummage.recall"
12
12
 
13
- function recall(command, args, prompt, sessionID, timeout) {
13
+ function recall(command, args, prompt, sessionID, cwd, timeout) {
14
14
  return new Promise((resolve) => {
15
15
  const child = execFile(
16
16
  command,
@@ -26,7 +26,7 @@ function recall(command, args, prompt, sessionID, timeout) {
26
26
  },
27
27
  )
28
28
  child.stdin.on("error", () => {})
29
- child.stdin.end(JSON.stringify({ prompt, session_id: sessionID }))
29
+ child.stdin.end(JSON.stringify({ prompt, session_id: sessionID, cwd, harness: "opencode" }))
30
30
  })
31
31
  }
32
32
 
@@ -53,7 +53,8 @@ export default {
53
53
  await ctx.session.hook("prompt", async (event) => {
54
54
  const text = event.prompt.text ?? ""
55
55
  if (!text.trim()) return
56
- const block = await recall(command, args, text, String(event.sessionID), timeout)
56
+ const cwd = ctx.location?.directory ?? process.cwd()
57
+ const block = await recall(command, args, text, String(event.sessionID), cwd, timeout)
57
58
  if (block) event.metadata = { ...event.metadata, [KEY]: block }
58
59
  })
59
60
 
package/package.json CHANGED
@@ -1,16 +1,45 @@
1
1
  {
2
2
  "name": "@soffits/opencode-rummage",
3
- "version": "0.1.0",
4
- "description": "OpenCode plugin for rummage: recall from your own files on each prompt, and a skill for searching them.",
3
+ "version": "0.3.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
5
  "license": "AGPL-3.0-or-later",
6
6
  "author": "Sakina",
7
7
  "homepage": "https://github.com/soffits/rummage/blob/main/docs/integrations.md#opencode",
8
- "repository": { "type": "git", "url": "git+https://github.com/soffits/rummage.git", "directory": "integrations/opencode" },
9
- "keywords": ["opencode", "opencode-plugin", "memory", "recall", "search"],
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
+ ],
10
20
  "type": "module",
11
- "exports": { ".": "./index.js" },
12
- "files": ["index.js", "skills"],
21
+ "exports": {
22
+ ".": "./index.js",
23
+ "./tui": "./tui.js"
24
+ },
25
+ "files": [
26
+ "index.js",
27
+ "tui.js",
28
+ "skills"
29
+ ],
13
30
  "scripts": {
14
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
+ }
15
44
  }
16
45
  }
@@ -45,3 +45,13 @@ Credentials in results print as `[secret]`. Add `--show-secrets` only when the u
45
45
 
46
46
  Quote the file and lines you relied on. A miss doesn't prove the thing isn't there: try other words, exact
47
47
  names, or the folder directly before saying so.
48
+
49
+ While the index is still being built, output carries an `index` note saying how much is in; treat a miss then as
50
+ "maybe not indexed yet", and `rummage status --line` tells the user how far along it is.
51
+
52
+ ## When rummage itself is broken
53
+
54
+ If you've confirmed a problem is rummage's own (reproduced it, checked `rummage doctor` and the version, ruled out
55
+ the user's config), tell the user and offer a ready issue draft: steps to reproduce, rummage version, OS, the exact
56
+ error, with private content (file names, paths, text from their files) removed. Link
57
+ https://github.com/soffits/rummage/issues and let them decide whether to file it; never open issues or PRs yourself.
package/tui.js ADDED
@@ -0,0 +1,168 @@
1
+ // OpenCode TUI part of the rummage plugin: shows rummage's index state in the footer while it is building, paused
2
+ // or broken (or memory is paused), toasts when indexing starts, finishes or fails, a one-time note on what the plugin
3
+ // does, and /rummage-pause and /rummage-resume.
4
+ //
5
+ // All state comes from `rummage status --line --json`, which reads one small progress file. Between runs this part
6
+ // only checks that file's modification time, so an idle index costs a stat every few seconds. Written as plain JS
7
+ // (no JSX): OpenCode does not transpile JSX in packages installed under node_modules.
8
+
9
+ import { execFile } from "node:child_process"
10
+ import { stat } from "node:fs/promises"
11
+ import { createElement, effect, insert, setProp } from "@opentui/solid"
12
+ import { createSignal } from "solid-js"
13
+
14
+ const CHECK_MS = 2000
15
+ const RECHECK_BUSY_MS = 30000
16
+ const FOOTER_CHARS = 60
17
+ const KEY = Symbol.for("rummage.opencode.tui")
18
+
19
+ const INTRO =
20
+ "rummage remembers the project you open and OpenCode's own chats, and adds passages that clearly match to your " +
21
+ "prompts. /rummage-pause stops it for a while; removing the plugin turns it off."
22
+
23
+ function run(command, args, timeout, input) {
24
+ return new Promise((resolve) => {
25
+ const child = execFile(command, args, { timeout, maxBuffer: 1 << 20, windowsHide: true }, (error, stdout, stderr) =>
26
+ resolve({ ok: !error, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") }),
27
+ )
28
+ child.stdin.on("error", () => {})
29
+ child.stdin.end(input ?? "")
30
+ })
31
+ }
32
+
33
+ export default {
34
+ id: "rummage.tui",
35
+ setup(context) {
36
+ // The TUI may set a plugin up again while an older instance lives; only the newest one polls and renders.
37
+ globalThis[KEY]?.()
38
+ const options = context.options ?? {}
39
+ const command = options.command ?? "rummage"
40
+ const [info, setInfo] = createSignal({ state: "complete", line: "" })
41
+ const started = Date.now()
42
+ let file, mtime = 0, checked = 0, timer, stopped = false
43
+
44
+ async function refresh() {
45
+ const result = await run(command, ["status", "--line", "--json"], 10000)
46
+ checked = Date.now()
47
+ if (!result.ok) return
48
+ let next
49
+ try {
50
+ next = JSON.parse(result.stdout)
51
+ } catch {
52
+ return
53
+ }
54
+ const before = info()
55
+ file = next.file ?? file
56
+ setInfo(next)
57
+ if (before.state !== next.state && checked > started + 1000) {
58
+ if (next.state === "complete" && before.state === "building")
59
+ context.ui.toast.show({ title: "rummage", message: "index complete", variant: "success" })
60
+ else if (next.state === "error" || next.state === "stopped")
61
+ context.ui.toast.show({ title: "rummage", message: next.note || next.line, variant: "warning" })
62
+ else if (next.state === "building" && before.state === "complete")
63
+ context.ui.toast.show({ title: "rummage", message: next.line, variant: "info" })
64
+ }
65
+ }
66
+
67
+ async function tick() {
68
+ if (stopped) return
69
+ try {
70
+ const now = Date.now()
71
+ let changed = false
72
+ if (file) {
73
+ const seen = await stat(file).then((s) => s.mtimeMs, () => 0)
74
+ changed = seen !== mtime
75
+ mtime = seen
76
+ }
77
+ const busy = info().state !== "complete" || info().line
78
+ if (!file || changed || (busy && now - checked > RECHECK_BUSY_MS)) await refresh()
79
+ } finally {
80
+ if (!stopped) timer = setTimeout(tick, CHECK_MS)
81
+ }
82
+ }
83
+
84
+ void tick()
85
+
86
+ function label() {
87
+ const el = createElement("text")
88
+ insert(el, () => {
89
+ const line = info().line || ""
90
+ return line.length > FOOTER_CHARS ? line.slice(0, FOOTER_CHARS - 3) + "..." : line
91
+ })
92
+ effect((prev) => {
93
+ const bad = ["error", "stopped", "paused"].includes(info().state) || Boolean(info().paused_memory)
94
+ const theme = context.theme.text
95
+ return setProp(el, "fg", bad ? (theme.feedback?.warning?.base ?? theme.base) : theme.muted, prev)
96
+ })
97
+ return el
98
+ }
99
+
100
+ const unslot = [
101
+ context.ui.slot({ append: "prompt.footer.status", render: label }),
102
+ context.ui.slot({ append: "home.footer.status", render: label }),
103
+ ]
104
+
105
+ const directory = () => (context.location ?? context.data.location.default())?.directory
106
+ const sessionID = () => {
107
+ const route = context.ui.router.current()
108
+ return route.type === "session" ? route.sessionID : undefined
109
+ }
110
+
111
+ // The same words as Claude Code's /rummage:pause: recall|index|all, a time like 2h, here, session, private.
112
+ async function pauseCommand(verb, input) {
113
+ const event = { command_args: String(input ?? ""), cwd: directory(), session_id: sessionID() }
114
+ const result = await run(command, [verb, "--hook"], 10000, JSON.stringify(event))
115
+ let reason = ""
116
+ try {
117
+ reason = JSON.parse(result.stdout).reason ?? ""
118
+ } catch {}
119
+ context.ui.toast.show({
120
+ title: "rummage",
121
+ message: reason || result.stderr.trim() || `${verb} failed`,
122
+ variant: result.ok ? "info" : "error",
123
+ })
124
+ void refresh()
125
+ }
126
+
127
+ const unkeymap = context.keymap.layer(() => ({
128
+ mode: "global",
129
+ commands: [
130
+ {
131
+ id: "rummage.pause",
132
+ title: "Pause rummage (recall, index or all; a time like 2h; here or session)",
133
+ group: "rummage",
134
+ palette: true,
135
+ slash: { name: "rummage-pause", arguments: true },
136
+ run: (input) => pauseCommand("pause", input),
137
+ },
138
+ {
139
+ id: "rummage.resume",
140
+ title: "Resume rummage",
141
+ group: "rummage",
142
+ palette: true,
143
+ slash: { name: "rummage-resume", arguments: true },
144
+ run: (input) => pauseCommand("resume", input),
145
+ },
146
+ ],
147
+ }))
148
+
149
+ const [seen, setSeen] = context.storage.store("rummage.intro", { initial: { shown: false } })
150
+ if (!seen.shown) {
151
+ context.ui.toast.show({ title: "rummage", message: INTRO, variant: "info" })
152
+ void setSeen((draft) => {
153
+ draft.shown = true
154
+ })
155
+ }
156
+
157
+ const dispose = () => {
158
+ if (stopped) return
159
+ stopped = true
160
+ clearTimeout(timer)
161
+ for (const off of unslot) off?.()
162
+ unkeymap?.()
163
+ if (globalThis[KEY] === dispose) delete globalThis[KEY]
164
+ }
165
+ globalThis[KEY] = dispose
166
+ return dispose
167
+ },
168
+ }