opencode-blackhole 0.0.0-stage → 0.1.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 (50) hide show
  1. package/LICENSE +35 -0
  2. package/LICENSE.vendor +21 -0
  3. package/README.md +377 -2
  4. package/dist/server.js +5025 -0
  5. package/index.ts +1 -0
  6. package/package.json +57 -4
  7. package/src/adapter/opencode-messages.ts +270 -0
  8. package/src/compaction/build.ts +264 -0
  9. package/src/compaction/handoff.ts +421 -0
  10. package/src/config.ts +316 -0
  11. package/src/index.ts +714 -0
  12. package/src/memory/ledger.ts +254 -0
  13. package/src/memory/manager.ts +659 -0
  14. package/src/memory/models.ts +172 -0
  15. package/src/memory/prompts.ts +145 -0
  16. package/src/recall/recall.ts +327 -0
  17. package/src/state/paths.ts +42 -0
  18. package/src/store/capture.ts +84 -0
  19. package/src/store/transcript.ts +279 -0
  20. package/src/vcc/README.md +51 -0
  21. package/src/vcc/core/brief.ts +407 -0
  22. package/src/vcc/core/build-sections.ts +81 -0
  23. package/src/vcc/core/compact-args.ts +45 -0
  24. package/src/vcc/core/content.ts +156 -0
  25. package/src/vcc/core/drill-down.ts +302 -0
  26. package/src/vcc/core/filter-noise.ts +41 -0
  27. package/src/vcc/core/format-recall.ts +100 -0
  28. package/src/vcc/core/format.ts +104 -0
  29. package/src/vcc/core/lineage.ts +26 -0
  30. package/src/vcc/core/normalize.ts +76 -0
  31. package/src/vcc/core/rank.ts +283 -0
  32. package/src/vcc/core/recall-request.ts +160 -0
  33. package/src/vcc/core/recall-scope.ts +30 -0
  34. package/src/vcc/core/render-entries.ts +55 -0
  35. package/src/vcc/core/sanitize.ts +5 -0
  36. package/src/vcc/core/search-entries.ts +594 -0
  37. package/src/vcc/core/skill-collapse.ts +35 -0
  38. package/src/vcc/core/summarize.ts +289 -0
  39. package/src/vcc/core/token-estimate.ts +100 -0
  40. package/src/vcc/core/tool-args.ts +16 -0
  41. package/src/vcc/details.ts +11 -0
  42. package/src/vcc/extract/commits.ts +96 -0
  43. package/src/vcc/extract/files.ts +87 -0
  44. package/src/vcc/extract/goals.ts +79 -0
  45. package/src/vcc/extract/preferences.ts +55 -0
  46. package/src/vcc/extract/tracked-commands.ts +232 -0
  47. package/src/vcc/load-messages.ts +42 -0
  48. package/src/vcc/pi-types.ts +53 -0
  49. package/src/vcc/sections.ts +12 -0
  50. package/src/vcc/types.ts +16 -0
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Worker model selection: fallback chains, per-model cooldowns and a retry
3
+ * gate.
4
+ *
5
+ * The rules mirror pi-blackhole:
6
+ * - each worker has its own chain: primary → fallbacks → (session model, only
7
+ * when `sessionFallback` is on);
8
+ * - a model that fails gets a cooldown and is skipped on later attempts;
9
+ * - a transient failure (rate limit, timeout, an upstream error under a
10
+ * router) is retried once and cooled down briefly, because it says nothing
11
+ * about the model a minute later;
12
+ * - a failed stage is skipped, never escalated to a paid model by accident;
13
+ * - the coverage cursor never advances past what a worker actually saw.
14
+ */
15
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
16
+ import { join } from "node:path"
17
+ import type { ModelRef } from "../config"
18
+
19
+ export interface CooldownEntry {
20
+ until: number
21
+ reason: string
22
+ }
23
+
24
+ export class CooldownStore {
25
+ private readonly path: string
26
+ private state: Record<string, CooldownEntry> = {}
27
+
28
+ constructor(dir: string) {
29
+ this.path = join(dir, "cooldowns.json")
30
+ try {
31
+ if (existsSync(this.path)) this.state = JSON.parse(readFileSync(this.path, "utf8"))
32
+ } catch {
33
+ this.state = {}
34
+ }
35
+ }
36
+
37
+ static key(model: ModelRef): string {
38
+ return `${model.providerID}/${model.id}`
39
+ }
40
+
41
+ active(model: ModelRef, now = Date.now()): CooldownEntry | undefined {
42
+ const entry = this.state[CooldownStore.key(model)]
43
+ if (!entry) return undefined
44
+ if (entry.until <= now) {
45
+ delete this.state[CooldownStore.key(model)]
46
+ return undefined
47
+ }
48
+ return entry
49
+ }
50
+
51
+ cool(model: ModelRef, reason: string, seconds: number, now = Date.now()) {
52
+ this.state[CooldownStore.key(model)] = { until: now + seconds * 1000, reason }
53
+ this.persist()
54
+ }
55
+
56
+ private persist() {
57
+ try {
58
+ writeFileSync(this.path, JSON.stringify(this.state, null, 2))
59
+ } catch {
60
+ // Cooldowns are an optimization; failing to persist them is not fatal.
61
+ }
62
+ }
63
+ }
64
+
65
+ /** Errors worth retrying on another model rather than skipping the stage. */
66
+ const RETRYABLE =
67
+ /rate.?limit|429|too many requests|timeout|timed out|overload|503|502|500|bad gateway|service unavailable|fetch failed|econn|network|stream (?:error|closed)|aborted|finish_reason:?\s*error|provider reported an error/i
68
+
69
+ /**
70
+ * How long a transient failure is cooled down for.
71
+ *
72
+ * A rate limit, a timeout or an upstream error under a router says nothing
73
+ * about whether the model will work in a minute. Cooling it for the full hour
74
+ * turns one bad request into an hour of missing memory.
75
+ */
76
+ export const TRANSIENT_COOLDOWN_SECONDS = 180
77
+
78
+ /**
79
+ * A model the catalog does not know.
80
+ *
81
+ * Cooling such a model down would only clutter the store: it will still be
82
+ * missing next time. Upstream skips it for the same reason.
83
+ */
84
+ const UNKNOWN_MODEL = /model .*(not found|does not exist|not available|unavailable|unknown)|no such model|model_not_found|modelselectionerror/i
85
+
86
+ export const isRetryable = (error: unknown): boolean => {
87
+ const text = error instanceof Error ? `${error.message} ${error.stack ?? ""}` : String(error)
88
+ return RETRYABLE.test(text)
89
+ }
90
+
91
+ /** True when the model itself is unknown to the provider catalog. */
92
+ export const isUnknownModel = (error: unknown): boolean => {
93
+ const text = error instanceof Error ? `${error.message} ${error.stack ?? ""}` : String(error)
94
+ return UNKNOWN_MODEL.test(text)
95
+ }
96
+
97
+ export interface RunResult {
98
+ text: string
99
+ model: ModelRef
100
+ /** Models that were tried and failed before this one. */
101
+ skipped: ModelRef[]
102
+ }
103
+
104
+ export interface RunOptions {
105
+ models: ModelRef[]
106
+ prompt: string
107
+ /** Called for each model attempt. */
108
+ call: (model: ModelRef, prompt: string) => Promise<string>
109
+ /** The session's model, used only when `sessionFallback` is on. */
110
+ sessionModel?: ModelRef
111
+ sessionFallback?: boolean
112
+ cooldowns: CooldownStore
113
+ /** How long a failed model stays in cooldown. */
114
+ cooldownSeconds: number
115
+ onAttempt?: (model: ModelRef) => void
116
+ onFailure?: (model: ModelRef, error: unknown, retryable: boolean) => void
117
+ }
118
+
119
+ /**
120
+ * Try each model in the chain until one answers.
121
+ *
122
+ * A model in cooldown is skipped. Every failure moves on to the next
123
+ * candidate - including a deterministic one (bad credentials, an unknown
124
+ * model, a rejected payload), which upstream also cools down rather than
125
+ * burning every cycle on a model that cannot work. That is what makes the
126
+ * session model a real last resort: a dead worker credential falls through to
127
+ * it instead of ending the stage.
128
+ */
129
+ export const runWithFallbacks = async (options: RunOptions): Promise<RunResult | undefined> => {
130
+ const chain = [...options.models]
131
+ if (options.sessionFallback && options.sessionModel) chain.push(options.sessionModel)
132
+
133
+ const skipped: ModelRef[] = []
134
+ for (const model of chain) {
135
+ const cooldown = options.cooldowns.active(model)
136
+ if (cooldown) {
137
+ skipped.push(model)
138
+ options.onFailure?.(model, new Error(`in cooldown: ${cooldown.reason}`), true)
139
+ continue
140
+ }
141
+ // One immediate retry for a transient failure. A router such as
142
+ // openrouter/free picks a provider per request, so the second call often
143
+ // lands somewhere else - cheaper than losing the whole stage.
144
+ for (let attempt = 0; ; attempt++) {
145
+ options.onAttempt?.(model)
146
+ try {
147
+ const text = await options.call(model, options.prompt)
148
+ return { text, model, skipped }
149
+ } catch (error) {
150
+ const retryable = isRetryable(error)
151
+ options.onFailure?.(model, error, retryable)
152
+ if (isUnknownModel(error)) {
153
+ // Not in the catalog: skip it, but do not cool it down - it will
154
+ // still be missing next time.
155
+ skipped.push(model)
156
+ break
157
+ }
158
+ if (retryable && attempt === 0) continue
159
+ // Deterministic failures cool for the full window; transient ones
160
+ // come back quickly instead of costing an hour of memory.
161
+ options.cooldowns.cool(
162
+ model,
163
+ String(error).slice(0, 200),
164
+ retryable ? Math.min(options.cooldownSeconds, TRANSIENT_COOLDOWN_SECONDS) : options.cooldownSeconds,
165
+ )
166
+ skipped.push(model)
167
+ break
168
+ }
169
+ }
170
+ }
171
+ return undefined
172
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Worker prompts.
3
+ *
4
+ * The observer extracts timestamped facts from a slice of the conversation;
5
+ * the reflector distils those into durable insights. Both are asked for JSON
6
+ * so a small model can be parsed reliably — the models in play here are
7
+ * usually free ones.
8
+ */
9
+
10
+ export const OBSERVER_PROMPT = `You extract durable facts from a coding session transcript.
11
+
12
+ Read the transcript and list only the facts that will still matter later:
13
+ - decisions the user made, and why ("chose Postgres over SQLite because of concurrent writes")
14
+ - stated preferences and constraints ("never log request bodies", "always use pnpm")
15
+ - work that was completed, and where it stands ("rate limiter added in src/ratelimit.ts")
16
+ - problems hit and how they were resolved
17
+ - things the user explicitly asked to remember
18
+
19
+ Do NOT list:
20
+ - the assistant's plans, questions or speculation
21
+ - transient chatter, greetings, or restatements of the request
22
+ - anything the transcript does not actually say
23
+
24
+ Rules:
25
+ - one fact per line, each a complete sentence
26
+ - name files, commands and identifiers exactly as the transcript does
27
+ - no numbering, no markdown, no preamble
28
+ - if there is nothing durable, output nothing
29
+
30
+ Reply with JSON only: {"observations": ["...", "..."]}`
31
+
32
+ /**
33
+ * The observer's second pass.
34
+ *
35
+ * Upstream's observer runs an agent loop: it records a batch, reads the tool
36
+ * result back, and keeps working until the chunk is covered. OpenCode's
37
+ * plugin API has no tool loop, so the review happens as a second call
38
+ * instead: the draft comes back for correction, and the corrected list
39
+ * replaces it.
40
+ */
41
+ export const OBSERVER_REFINE_PROMPT = `You review a draft of durable facts extracted from a coding session transcript.
42
+
43
+ The draft was written in one pass and may be wrong. Check it against the
44
+ transcript and return the corrected list.
45
+
46
+ Fix these, in priority order:
47
+ - facts the transcript states that the draft missed
48
+ - facts the draft got wrong, or attributed to the wrong person
49
+ - facts that are not durable: plans, speculation, transient chatter
50
+ - wording that does not match the transcript (file names, commands, identifiers)
51
+
52
+ Keep a draft fact when the transcript supports it. Do not invent facts, and
53
+ do not drop a fact merely because it is not in the draft - the draft may
54
+ simply have missed it.
55
+
56
+ Rules:
57
+ - one fact per line, each a complete sentence
58
+ - name files, commands and identifiers exactly as the transcript does
59
+ - no numbering, no markdown, no preamble
60
+ - return the complete corrected list, not just the changes
61
+
62
+ Reply with JSON only: {"observations": ["...", "..."]}`
63
+
64
+ /**
65
+ * The dropper's proposal.
66
+ *
67
+ * Upstream's dropper asks a model which observations are safe to remove, then
68
+ * applies a deterministic ranker and cap to whatever the model proposed. This
69
+ * port does the same, minus the agent loop: one call returns the ids, and the
70
+ * deterministic rule still decides which of them actually go.
71
+ */
72
+ export const DROPPER_PROMPT = `You decide which observations to remove from a session's compacted memory.
73
+
74
+ These records are the ONLY information the assistant will have about past
75
+ interactions once the raw conversation is compacted out of context. Dropping
76
+ the wrong observation can make future work repeat, contradict, or misremember
77
+ the user. Take this seriously.
78
+
79
+ Your job is to identify only the safest active observations to remove. The
80
+ default action is KEEP. When uncertain, keep the observation.
81
+
82
+ What to drop, in priority order:
83
+ - Paraphrase duplicates: two or more observations stating the same fact in
84
+ different words. Keep the newest or most detailed one; the pool should
85
+ carry each fact once.
86
+ - Redundant observations whose meaning is already captured by a current
87
+ reflection.
88
+ - Superseded observations where a later observation clearly replaces the older.
89
+ - Repeated routine acknowledgements or low-signal progress updates that carry
90
+ no decisions, constraints, exact errors, or user-specific facts.
91
+ - Older observations that no longer carry working context and are covered by a
92
+ reflection or a newer observation.
93
+
94
+ Reflection coverage is evidence, not an automatic decision:
95
+ - none: no current reflection cites this observation.
96
+ - partial: one current reflection cites it.
97
+ - strong: two or more current reflections cite it.
98
+
99
+ Recent observations carry working context the assistant may still need. Age
100
+ alone is not enough to drop an important or uniquely load-bearing observation.
101
+
102
+ Return the ids of the observations you would drop. Return an empty list if
103
+ nothing is safe to drop.
104
+
105
+ Reply with JSON only: {"drop": ["...", "..."]}`
106
+
107
+ export const REFLECTOR_PROMPT = `You distil a list of observations into durable reflections.
108
+
109
+ A reflection is a stable insight that stays true across the session: a constraint,
110
+ a preference, a pattern in how the user wants to work, a decision that shapes
111
+ future work. It is not a restatement of one observation.
112
+
113
+ Rules:
114
+ - one reflection per line, a single complete sentence
115
+ - keep the original wording of identifiers and file paths
116
+ - no numbering, no markdown, no preamble
117
+ - if nothing rises above the individual observations, output nothing
118
+
119
+ Reply with JSON only: {"reflections": ["...", "..."]}`
120
+
121
+ /** Parse a JSON object out of a model reply that may be wrapped in prose. */
122
+ export const parseJson = <T>(text: string): T | undefined => {
123
+ if (!text) return undefined
124
+ const trimmed = text.trim()
125
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(trimmed)
126
+ const candidates = [fenced?.[1], trimmed]
127
+ for (const candidate of candidates) {
128
+ if (!candidate) continue
129
+ try {
130
+ return JSON.parse(candidate) as T
131
+ } catch {
132
+ // fall through to the brace-slice attempt
133
+ }
134
+ const start = candidate.indexOf("{")
135
+ const end = candidate.lastIndexOf("}")
136
+ if (start >= 0 && end > start) {
137
+ try {
138
+ return JSON.parse(candidate.slice(start, end + 1)) as T
139
+ } catch {
140
+ // try the next candidate
141
+ }
142
+ }
143
+ }
144
+ return undefined
145
+ }
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Recall for OpenCode.
3
+ *
4
+ * The vcc core ships the search, ranking and formatting; this module wires
5
+ * them to OpenCode's session transcript. OpenCode has no branch/lineage model
6
+ * that a plugin can see, so scope handling is simplified: the transcript is
7
+ * the whole session, and `scope:'all'` is accepted for compatibility.
8
+ */
9
+ import { expandEntryFile } from "../vcc/core/drill-down"
10
+ import { formatRecallOutput, formatTouchedOutput, TOUCHED_PAGE_SIZE } from "../vcc/core/format-recall"
11
+ import { loadMessages, type LoadedMessages } from "../vcc/load-messages"
12
+ import {
13
+ parseCommandRequest,
14
+ parseToolRequest,
15
+ type RecallRequest,
16
+ type RecallToolParams,
17
+ } from "../vcc/core/recall-request"
18
+ import { getTouchedFiles, searchEntriesDetailed, type SearchHit } from "../vcc/core/search-entries"
19
+ import type { TranscriptStore } from "../store/transcript"
20
+
21
+ const SEARCH_PAGE_SIZE = 5
22
+ const RANGE_PAGE_SIZE = 20
23
+ const RECENT_COUNT = 25
24
+ const PAIRED_RESULT_CHARS = 4000
25
+ const WRITES_FILE = /write|edit|patch/i
26
+
27
+ /**
28
+ * A recall response has a character budget.
29
+ *
30
+ * One stored message can be enormous (a file read, a long command output), and
31
+ * a recall that returns it verbatim would flood the context it was meant to
32
+ * help with. The budget is shared across the entries of a response, and each
33
+ * entry's lines share their slice of it — so a page of twenty small hits stays
34
+ * readable and one giant hit cannot swallow the page.
35
+ */
36
+ export interface RecallOptions {
37
+ /** Maximum characters for the whole response. 0 disables the cap. */
38
+ maxChars: number
39
+ }
40
+
41
+ const clipText = (text: string, limit: number): string => {
42
+ if (limit <= 0 || text.length <= limit) return text
43
+ return `${text.slice(0, limit)}…[clipped ${text.length - limit} chars]`
44
+ }
45
+
46
+ /** Share the response budget across the entries, and each entry's lines. */
47
+ const clipEntries = <T extends { summary: string; snippet?: string }>(
48
+ entries: T[],
49
+ budget: number,
50
+ ): T[] => {
51
+ if (budget <= 0 || entries.length === 0) return entries
52
+ const perEntry = Math.max(200, Math.floor(budget / entries.length))
53
+ return entries.map((entry) => {
54
+ const body = entry.snippet ?? entry.summary
55
+ if (body.length <= perEntry) return entry
56
+ // Give every line of the entry a slice, so one long line cannot take it
57
+ // all and the rest of the entry stays visible.
58
+ const lines = body.split("\n")
59
+ const perLine = Math.max(80, Math.floor(perEntry / Math.max(1, lines.length)))
60
+ const clipped = lines.map((line) => clipText(line, perLine)).join("\n")
61
+ const next = { ...entry, summary: clipText(clipped, perEntry) }
62
+ if (entry.snippet !== undefined) next.snippet = clipText(clipped, perEntry)
63
+ return next
64
+ })
65
+ }
66
+
67
+ /** The last line of defence: the formatted response itself never exceeds the budget. */
68
+ const clipResponse = (text: string, budget: number): string => {
69
+ if (budget <= 0 || text.length <= budget) return text
70
+ return `${text.slice(0, budget)}\n…[response clipped at ${budget} of ${text.length} chars]`
71
+ }
72
+
73
+ /**
74
+ * Rendered views are cached per session.
75
+ *
76
+ * Rendering the transcript means summarizing every message, which is wasted
77
+ * work when the same session is recalled several times and nothing was
78
+ * captured in between. The store's version counter tells us when to rebuild.
79
+ * The cache is capped, so it cannot become a second copy of every session.
80
+ */
81
+ const VIEW_CACHE_SIZE = 4
82
+ const viewCache = new Map<string, { version: number; full: boolean; view: LoadedMessages }>()
83
+
84
+ const view = (store: TranscriptStore, sessionID: string, full: boolean): LoadedMessages => {
85
+ const transcript = store.read(sessionID)
86
+ const cached = viewCache.get(sessionID)
87
+ if (cached && cached.version === transcript.version && cached.full === full) {
88
+ viewCache.delete(sessionID)
89
+ viewCache.set(sessionID, cached)
90
+ return cached.view
91
+ }
92
+ const result = loadMessages(
93
+ transcript.messages.map((entry) => entry.message),
94
+ full,
95
+ )
96
+ viewCache.delete(sessionID)
97
+ viewCache.set(sessionID, { version: transcript.version, full, view: result })
98
+ while (viewCache.size > VIEW_CACHE_SIZE) {
99
+ const oldest = viewCache.keys().next().value
100
+ if (oldest === undefined) break
101
+ viewCache.delete(oldest)
102
+ }
103
+ return result
104
+ }
105
+
106
+ const ignoredNote = (request: RecallRequest): string => {
107
+ const reasons: Record<string, string> = {
108
+ drill: "a #N:path query runs alone",
109
+ touched: "mode:'touched' runs alone",
110
+ expand: "expand runs alone; send the rest in a separate call",
111
+ range: "range runs alone; send the rest in a separate call",
112
+ search: "",
113
+ recent: "page applies to query or range results",
114
+ }
115
+ return (request.dropped?.length ? `Ignored: ${request.dropped.join(", ")}.\n\n` : "") +
116
+ (request.ignored?.length ? `Ignored: ${request.ignored.join(", ")} (${reasons[request.action.kind]}).\n\n` : "")
117
+ }
118
+
119
+ const runExpand = (
120
+ indices: number[],
121
+ store: TranscriptStore,
122
+ sessionID: string,
123
+ options: RecallOptions,
124
+ ): string => {
125
+ const { rendered, rawMessages } = view(store, sessionID, true)
126
+ const invalid = indices.filter((i) => !rendered.some((m) => m.index === i))
127
+ if (invalid.length > 0) return `Cannot expand indices outside session history: ${invalid.join(", ")}`
128
+
129
+ const out: SearchHit[] = []
130
+ for (const index of indices) {
131
+ const pos = rendered.findIndex((m) => m.index === index)
132
+ if (pos < 0) continue
133
+ const entry = rendered[pos]
134
+ out.push(entry)
135
+
136
+ // Bring the tool results of an expanded call along, so the agent does not
137
+ // have to know the result sits at a later #N.
138
+ const raw = rawMessages[pos] as any
139
+ const ids = new Set<string>(
140
+ Array.isArray(raw?.content)
141
+ ? raw.content
142
+ .filter((part: any) => part?.type === "toolCall" && typeof part.id === "string")
143
+ .map((part: any) => part.id)
144
+ : [],
145
+ )
146
+ if (ids.size === 0) continue
147
+ for (let j = pos + 1; j < rendered.length && ids.size > 0; j++) {
148
+ const result = rawMessages[j] as any
149
+ if (result?.role === "assistant") break
150
+ if (result?.role !== "toolResult" || !ids.has(result.toolCallId)) continue
151
+ ids.delete(result.toolCallId)
152
+ const res = rendered[j]
153
+ if (indices.includes(res.index)) continue
154
+ out.push({
155
+ ...res,
156
+ summary:
157
+ res.summary.length > PAIRED_RESULT_CHARS
158
+ ? `${res.summary.slice(0, PAIRED_RESULT_CHARS)}\n...[result of #${entry.index} clipped at ${PAIRED_RESULT_CHARS} of ${res.summary.length} chars; expand:[${res.index}] for all of it]`
159
+ : res.summary,
160
+ })
161
+ }
162
+ }
163
+
164
+ const header = `Expanded ${indices.map((i) => `#${i}`).join(", ")}`
165
+ return clipResponse(
166
+ formatRecallOutput(clipEntries(out, options.maxChars), undefined, header),
167
+ options.maxChars,
168
+ )
169
+ }
170
+
171
+ const runRange = (
172
+ range: unknown[],
173
+ page: number,
174
+ store: TranscriptStore,
175
+ sessionID: string,
176
+ options: RecallOptions,
177
+ ): string => {
178
+ const [from, to] = range
179
+ if (
180
+ range.length !== 2 ||
181
+ !Number.isInteger(from) ||
182
+ !Number.isInteger(to) ||
183
+ (from as number) < 0 ||
184
+ (from as number) > (to as number)
185
+ ) {
186
+ return `Invalid range ${JSON.stringify(range)}: use range:[from, to] with two #N indices, from <= to.`
187
+ }
188
+
189
+ const lo = from as number
190
+ const { rendered } = view(store, sessionID, false)
191
+ const entries = rendered.filter((m) => m.index >= lo && m.index <= (to as number))
192
+ if (entries.length === 0) {
193
+ const last = rendered[rendered.length - 1]
194
+ return `No messages #${lo}..#${to} in session history${last ? ` (last entry is #${last.index})` : ""}.`
195
+ }
196
+
197
+ const lastIndex = rendered[rendered.length - 1].index
198
+ const clamped = (to as number) > lastIndex
199
+ const hi = clamped ? lastIndex : (to as number)
200
+ const totalPages = Math.ceil(entries.length / RANGE_PAGE_SIZE)
201
+ const counts = `${entries.length} messages${clamped ? `, #${hi} is the last entry` : ""}`
202
+ if (page > totalPages) {
203
+ return `Page ${page} is outside the available range 1-${totalPages} (#${lo}..#${hi}: ${counts}). Use a page between 1 and ${totalPages}.`
204
+ }
205
+ const start = (page - 1) * RANGE_PAGE_SIZE
206
+ const header =
207
+ totalPages > 1 ? `Range #${lo}..#${hi}, page ${page}/${totalPages} (${counts})` : `Range #${lo}..#${hi} (${counts})`
208
+ const footer =
209
+ page < totalPages ? `\n--- Use range:[${lo}, ${hi}] page:${page + 1} for the next ${RANGE_PAGE_SIZE} ---` : ""
210
+ return (
211
+ clipResponse(
212
+ formatRecallOutput(
213
+ clipEntries(entries.slice(start, start + RANGE_PAGE_SIZE), options.maxChars),
214
+ undefined,
215
+ header,
216
+ ),
217
+ options.maxChars,
218
+ ) + footer
219
+ )
220
+ }
221
+
222
+ const runTouched = (
223
+ page: number | undefined,
224
+ store: TranscriptStore,
225
+ sessionID: string,
226
+ options: RecallOptions,
227
+ ): string => {
228
+ const { rendered, rawMessages } = view(store, sessionID, false)
229
+ const touched = getTouchedFiles(rawMessages, rendered)
230
+ const text = formatTouchedOutput(touched, page)
231
+ const start = (Math.max(1, page ?? 1) - 1) * TOUCHED_PAGE_SIZE
232
+ for (const file of touched.slice(start, start + TOUCHED_PAGE_SIZE)) {
233
+ const write = [...file.entries].reverse().find((entry) => WRITES_FILE.test(entry.toolName))
234
+ if (!write) continue
235
+ const name = file.path.replace(/\\/g, "/").split("/").pop()
236
+ return clipResponse(`${text}\n--- File content at an entry: query:'#${write.index}:${name}' ---`, options.maxChars)
237
+ }
238
+ return text
239
+ }
240
+
241
+ const runSearch = (
242
+ query: string,
243
+ page: number,
244
+ store: TranscriptStore,
245
+ sessionID: string,
246
+ options: RecallOptions,
247
+ ): string => {
248
+ const { rendered, rawMessages } = view(store, sessionID, false)
249
+ const { hits, totalBeforeCap, truncated } = searchEntriesDetailed(rendered, rawMessages, query)
250
+ const totalPages = Math.ceil(hits.length / SEARCH_PAGE_SIZE)
251
+ const truncationNote = truncated
252
+ ? ` — showing ${hits.length} of ${totalBeforeCap} matches, refine your query for more precise results`
253
+ : ""
254
+ if (hits.length > 0 && page > totalPages) {
255
+ return `Page ${page} is outside the available range 1-${totalPages} (${hits.length} matches${truncationNote}). Use a page between 1 and ${totalPages}.`
256
+ }
257
+ const start = (page - 1) * SEARCH_PAGE_SIZE
258
+ const pageHits = hits.slice(start, start + SEARCH_PAGE_SIZE)
259
+ const header =
260
+ totalPages > 1
261
+ ? `Page ${page}/${totalPages} (${hits.length} total matches${truncationNote})`
262
+ : `${hits.length} matches${truncationNote}`
263
+ const footer = page < totalPages ? `\n--- Next page: page:${page + 1} ---` : ""
264
+ return (
265
+ clipResponse(formatRecallOutput(clipEntries(pageHits, options.maxChars), query, header), options.maxChars) + footer
266
+ )
267
+ }
268
+
269
+ const runAction = (
270
+ request: RecallRequest,
271
+ store: TranscriptStore,
272
+ sessionID: string,
273
+ options: RecallOptions,
274
+ ): string => {
275
+ const { action } = request
276
+ switch (action.kind) {
277
+ case "drill": {
278
+ const { rendered } = view(store, sessionID, false)
279
+ const target = action.target
280
+ const offPath = !rendered.some((m) => m.index === target.index)
281
+ const messages = store.read(sessionID).messages.map((entry) => entry.message)
282
+ return clipResponse(
283
+ (offPath ? "Entry is not in the recorded session history.\n\n" : "") +
284
+ expandEntryFile(messages, target.index, target.pathPattern, target.full, target.offset, target.limit),
285
+ options.maxChars,
286
+ )
287
+ }
288
+ case "touched":
289
+ return runTouched(action.page, store, sessionID, options)
290
+ case "expand":
291
+ return runExpand(action.indices, store, sessionID, options)
292
+ case "range":
293
+ return runRange(action.range, action.page, store, sessionID, options)
294
+ case "search":
295
+ return runSearch(action.query, action.page, store, sessionID, options)
296
+ case "recent": {
297
+ const { rendered } = view(store, sessionID, false)
298
+ return clipResponse(
299
+ formatRecallOutput(clipEntries(rendered.slice(-RECENT_COUNT), options.maxChars), action.query),
300
+ options.maxChars,
301
+ )
302
+ }
303
+ }
304
+ }
305
+
306
+ export const runRecall = (
307
+ store: TranscriptStore,
308
+ sessionID: string,
309
+ request: RecallRequest,
310
+ options: RecallOptions = { maxChars: 0 },
311
+ ): string => ignoredNote(request) + runAction(request, store, sessionID, options)
312
+
313
+ /** Run a recall from tool parameters. */
314
+ export const runRecallTool = (
315
+ store: TranscriptStore,
316
+ sessionID: string,
317
+ params: RecallToolParams,
318
+ options: RecallOptions = { maxChars: 0 },
319
+ ): string => runRecall(store, sessionID, parseToolRequest(params), options)
320
+
321
+ /** Run a recall from a command's argument string. */
322
+ export const runRecallCommand = (
323
+ store: TranscriptStore,
324
+ sessionID: string,
325
+ args: string,
326
+ options: RecallOptions = { maxChars: 0 },
327
+ ): string => runRecall(store, sessionID, parseCommandRequest(args), options)
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Where the plugin keeps its state.
3
+ *
4
+ * The state lives next to OpenCode's own data, in the user's home directory,
5
+ * not inside the project — a project's transcript is derived data (OpenCode
6
+ * already stores the same messages in the session record), and it has no
7
+ * business in a repository.
8
+ *
9
+ * ~/.local/share/opencode/blackhole/<projectID>/
10
+ * transcripts/<sessionID>.jsonl
11
+ * memory/<sessionID>.jsonl
12
+ *
13
+ * The per-project folder is keyed by OpenCode's `projectID`, not by the
14
+ * directory path: the id survives moving or renaming a project, the path does
15
+ * not.
16
+ */
17
+ import { createHash } from "node:crypto"
18
+ import { existsSync, readdirSync } from "node:fs"
19
+ import { homedir } from "node:os"
20
+ import { join } from "node:path"
21
+
22
+ /** The root of the plugin's state, next to OpenCode's own data. */
23
+ export const stateBase = (): string => join(homedir(), ".local", "share", "opencode", "blackhole")
24
+
25
+ /** The state folder of one project. */
26
+ export const projectStateDir = (base: string, projectID: string): string => join(base, projectID)
27
+
28
+ /** A stable short id for a directory, used when a project id is unavailable. */
29
+ export const directoryKey = (directory: string): string =>
30
+ `dir-${createHash("sha1").update(directory).digest("hex").slice(0, 12)}`
31
+
32
+ /** The project folders that exist under the base. */
33
+ export const listProjectDirs = (base: string): string[] => {
34
+ try {
35
+ if (!existsSync(base)) return []
36
+ return readdirSync(base, { withFileTypes: true })
37
+ .filter((entry) => entry.isDirectory())
38
+ .map((entry) => join(base, entry.name))
39
+ } catch {
40
+ return []
41
+ }
42
+ }