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.
- package/LICENSE +35 -0
- package/LICENSE.vendor +21 -0
- package/README.md +377 -2
- package/dist/server.js +5025 -0
- package/index.ts +1 -0
- package/package.json +57 -4
- package/src/adapter/opencode-messages.ts +270 -0
- package/src/compaction/build.ts +264 -0
- package/src/compaction/handoff.ts +421 -0
- package/src/config.ts +316 -0
- package/src/index.ts +714 -0
- package/src/memory/ledger.ts +254 -0
- package/src/memory/manager.ts +659 -0
- package/src/memory/models.ts +172 -0
- package/src/memory/prompts.ts +145 -0
- package/src/recall/recall.ts +327 -0
- package/src/state/paths.ts +42 -0
- package/src/store/capture.ts +84 -0
- package/src/store/transcript.ts +279 -0
- package/src/vcc/README.md +51 -0
- package/src/vcc/core/brief.ts +407 -0
- package/src/vcc/core/build-sections.ts +81 -0
- package/src/vcc/core/compact-args.ts +45 -0
- package/src/vcc/core/content.ts +156 -0
- package/src/vcc/core/drill-down.ts +302 -0
- package/src/vcc/core/filter-noise.ts +41 -0
- package/src/vcc/core/format-recall.ts +100 -0
- package/src/vcc/core/format.ts +104 -0
- package/src/vcc/core/lineage.ts +26 -0
- package/src/vcc/core/normalize.ts +76 -0
- package/src/vcc/core/rank.ts +283 -0
- package/src/vcc/core/recall-request.ts +160 -0
- package/src/vcc/core/recall-scope.ts +30 -0
- package/src/vcc/core/render-entries.ts +55 -0
- package/src/vcc/core/sanitize.ts +5 -0
- package/src/vcc/core/search-entries.ts +594 -0
- package/src/vcc/core/skill-collapse.ts +35 -0
- package/src/vcc/core/summarize.ts +289 -0
- package/src/vcc/core/token-estimate.ts +100 -0
- package/src/vcc/core/tool-args.ts +16 -0
- package/src/vcc/details.ts +11 -0
- package/src/vcc/extract/commits.ts +96 -0
- package/src/vcc/extract/files.ts +87 -0
- package/src/vcc/extract/goals.ts +79 -0
- package/src/vcc/extract/preferences.ts +55 -0
- package/src/vcc/extract/tracked-commands.ts +232 -0
- package/src/vcc/load-messages.ts +42 -0
- package/src/vcc/pi-types.ts +53 -0
- package/src/vcc/sections.ts +12 -0
- 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
|
+
}
|