@soffits/opencode-rummage 0.1.0 → 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.
Files changed (3) hide show
  1. package/index.js +4 -3
  2. package/package.json +35 -6
  3. package/tui.js +108 -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 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.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
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
  }
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
+ }