@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 +4 -3
- package/package.json +35 -6
- package/skills/rummage/SKILL.md +10 -0
- package/tui.js +168 -0
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
|
|
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.
|
|
4
|
-
"description": "OpenCode plugin for rummage: recall from your own files on each prompt,
|
|
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": {
|
|
9
|
-
|
|
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": {
|
|
12
|
-
|
|
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
|
}
|
package/skills/rummage/SKILL.md
CHANGED
|
@@ -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
|
+
}
|