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,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture model requests into the session transcript.
|
|
3
|
+
*
|
|
4
|
+
* The plugin sees every request the agent loop makes, which is the cheapest
|
|
5
|
+
* reliable way to see the whole conversation: OpenCode's plugin API only
|
|
6
|
+
* exposes the compacted view of a session.
|
|
7
|
+
*
|
|
8
|
+
* Only the messages we have not seen before are adapted. A request carries
|
|
9
|
+
* the whole conversation, so re-adapting it every turn would re-extract every
|
|
10
|
+
* tool output's text again and again — quadratic in the size of the session.
|
|
11
|
+
* Skipping known ids keeps capture linear in the amount of *new* content.
|
|
12
|
+
*/
|
|
13
|
+
import { adaptMessages } from "../adapter/opencode-messages"
|
|
14
|
+
import type { StoredMessage, TranscriptStore } from "./transcript"
|
|
15
|
+
|
|
16
|
+
interface RawMessage {
|
|
17
|
+
id?: string
|
|
18
|
+
role: string
|
|
19
|
+
content?: unknown
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const CHECKPOINT = "<conversation-checkpoint>"
|
|
23
|
+
|
|
24
|
+
/** A compaction checkpoint is a summary, not conversation — we never store it. */
|
|
25
|
+
const isCheckpoint = (message: RawMessage): boolean => {
|
|
26
|
+
if (message.role !== "user") return false
|
|
27
|
+
const content = message.content
|
|
28
|
+
if (!Array.isArray(content)) return false
|
|
29
|
+
return content.some(
|
|
30
|
+
(part) =>
|
|
31
|
+
(part as { type?: string; text?: string })?.type === "text" &&
|
|
32
|
+
typeof (part as { text?: string }).text === "string" &&
|
|
33
|
+
(part as { text: string }).text.includes(CHECKPOINT),
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const captureRequest = (
|
|
38
|
+
store: TranscriptStore,
|
|
39
|
+
sessionID: string,
|
|
40
|
+
requestMessages: readonly unknown[],
|
|
41
|
+
): number => {
|
|
42
|
+
if (!Array.isArray(requestMessages) || requestMessages.length === 0) return 0
|
|
43
|
+
|
|
44
|
+
const transcript = store.read(sessionID)
|
|
45
|
+
|
|
46
|
+
// Keep only what we have not seen before. A request carries the whole
|
|
47
|
+
// conversation, so re-adapting it every turn would re-extract every tool
|
|
48
|
+
// output's text again and again — quadratic in the size of the session.
|
|
49
|
+
const fresh: RawMessage[] = []
|
|
50
|
+
for (const message of requestMessages) {
|
|
51
|
+
const raw = message as RawMessage
|
|
52
|
+
if (!raw?.id) continue
|
|
53
|
+
if (typeof raw.role !== "string") continue
|
|
54
|
+
if (transcript.indexById.has(raw.id)) continue
|
|
55
|
+
if (transcript.ignoredIds.has(raw.id)) continue
|
|
56
|
+
if (isCheckpoint(raw)) {
|
|
57
|
+
// Remember it so the next request does not rescan the summary.
|
|
58
|
+
transcript.ignoredIds.add(raw.id)
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
fresh.push(raw)
|
|
62
|
+
}
|
|
63
|
+
if (fresh.length === 0) return 0
|
|
64
|
+
|
|
65
|
+
// The call map persists across requests, so a tool result whose call was
|
|
66
|
+
// captured earlier still resolves to its tool name and arguments.
|
|
67
|
+
const adapted = adaptMessages(fresh, { calls: transcript.callsById })
|
|
68
|
+
|
|
69
|
+
const entries: StoredMessage[] = []
|
|
70
|
+
let index = transcript.messages.length + 1
|
|
71
|
+
for (const message of adapted.messages) {
|
|
72
|
+
if (!message.id) continue
|
|
73
|
+
if (transcript.indexById.has(message.id)) continue
|
|
74
|
+
const storedIndex = index++
|
|
75
|
+
// The index rides on the message itself: the recall loader reads it there,
|
|
76
|
+
// so the `#N` in a recall result is the same `#N` the summary refers to.
|
|
77
|
+
const stored = { ...store.clipMessage(message), index: storedIndex }
|
|
78
|
+
entries.push({ id: message.id, index: storedIndex, message: stored, tokens: store.estimateTokens(stored) })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (entries.length === 0) return 0
|
|
82
|
+
store.append(sessionID, entries)
|
|
83
|
+
return entries.length
|
|
84
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session transcript store.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode's plugin API exposes the *compacted* view of a session, so the
|
|
5
|
+
* messages that a compaction dropped are not reachable from a plugin. To give
|
|
6
|
+
* recall a real history — and to keep the `#N` references in a summary
|
|
7
|
+
* session-global rather than request-relative — the plugin records every
|
|
8
|
+
* model request it sees into its own append-only store.
|
|
9
|
+
*
|
|
10
|
+
* The store lives next to the project (`.opencode/blackhole/transcripts`) and
|
|
11
|
+
* is a JSONL file per session: one line per message, in first-seen order.
|
|
12
|
+
*
|
|
13
|
+
* Two things keep it from growing without bound:
|
|
14
|
+
* - the parsed transcripts are cached in memory with an LRU cap, so a long
|
|
15
|
+
* lived server does not hold every session it has ever seen;
|
|
16
|
+
* - `sweep()` deletes transcripts that have not been touched for a while.
|
|
17
|
+
*/
|
|
18
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"
|
|
19
|
+
import { join } from "node:path"
|
|
20
|
+
import type { Message } from "../vcc/pi-types"
|
|
21
|
+
|
|
22
|
+
export interface StoredMessage {
|
|
23
|
+
/** OpenCode's message id. */
|
|
24
|
+
id: string
|
|
25
|
+
/** The session-global index, 1-based, matching the `#N` refs in summaries. */
|
|
26
|
+
index: number
|
|
27
|
+
/** vcc-shaped message content. */
|
|
28
|
+
message: Message
|
|
29
|
+
/** Rough token cost, computed once when the message is captured. */
|
|
30
|
+
tokens?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A tool call, kept so a later tool result can be paired with its call. */
|
|
34
|
+
export interface ToolCallInfo {
|
|
35
|
+
name: string
|
|
36
|
+
input: Record<string, unknown>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SessionTranscript {
|
|
40
|
+
messages: StoredMessage[]
|
|
41
|
+
indexById: Map<string, number>
|
|
42
|
+
/** Tool calls seen in this session, by call id. */
|
|
43
|
+
callsById: Map<string, ToolCallInfo>
|
|
44
|
+
/**
|
|
45
|
+
* Ids of messages we deliberately do not store (the compaction checkpoint).
|
|
46
|
+
* They come back with every request, so remembering them keeps us from
|
|
47
|
+
* re-scanning a large summary each turn.
|
|
48
|
+
*/
|
|
49
|
+
ignoredIds: Set<string>
|
|
50
|
+
/** Bumped on every append, so callers can cache derived views. */
|
|
51
|
+
version: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TranscriptStoreOptions {
|
|
55
|
+
/** How many parsed sessions stay in memory at once. */
|
|
56
|
+
maxCachedSessions?: number
|
|
57
|
+
/** Hard cap for the content of a single stored message. */
|
|
58
|
+
maxMessageChars?: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Where a session's transcript lives.
|
|
63
|
+
*
|
|
64
|
+
* A plain string keeps one directory; a function resolves a directory per
|
|
65
|
+
* session, which is what the global per-project layout needs.
|
|
66
|
+
*/
|
|
67
|
+
export type DirResolver = string | ((sessionID: string) => string)
|
|
68
|
+
|
|
69
|
+
const CHARS_PER_TOKEN = 4
|
|
70
|
+
|
|
71
|
+
/** Collect the tool calls of an assistant message, for later result pairing. */
|
|
72
|
+
export const collectToolCalls = (message: Message): Array<ToolCallInfo & { id: string }> => {
|
|
73
|
+
if (!Array.isArray(message.content)) return []
|
|
74
|
+
const calls: Array<ToolCallInfo & { id: string }> = []
|
|
75
|
+
for (const part of message.content) {
|
|
76
|
+
if (part.type !== "toolCall") continue
|
|
77
|
+
const call = part as { id?: string; name?: string; arguments?: Record<string, unknown> }
|
|
78
|
+
if (!call.id) continue
|
|
79
|
+
calls.push({ id: call.id, name: call.name ?? "tool", input: call.arguments ?? {} })
|
|
80
|
+
}
|
|
81
|
+
return calls
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class TranscriptStore {
|
|
85
|
+
private readonly dirFor: (sessionID: string) => string
|
|
86
|
+
private readonly maxCachedSessions: number
|
|
87
|
+
private readonly maxMessageChars: number
|
|
88
|
+
/** Insertion order is the LRU order: the first key is the oldest. */
|
|
89
|
+
private readonly cache = new Map<string, SessionTranscript>()
|
|
90
|
+
|
|
91
|
+
constructor(dir: DirResolver, options: TranscriptStoreOptions = {}) {
|
|
92
|
+
this.dirFor = typeof dir === "function" ? dir : () => dir
|
|
93
|
+
this.maxCachedSessions = Math.max(1, options.maxCachedSessions ?? 8)
|
|
94
|
+
this.maxMessageChars = Math.max(4_000, options.maxMessageChars ?? 262_144)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private path(sessionID: string) {
|
|
98
|
+
return join(this.dirFor(sessionID), `${sessionID.replace(/[^a-zA-Z0-9_-]/g, "_")}.jsonl`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Mark a session as recently used and evict the oldest when over the cap. */
|
|
102
|
+
private touch(sessionID: string, transcript: SessionTranscript) {
|
|
103
|
+
this.cache.delete(sessionID)
|
|
104
|
+
this.cache.set(sessionID, transcript)
|
|
105
|
+
while (this.cache.size > this.maxCachedSessions) {
|
|
106
|
+
const oldest = this.cache.keys().next().value
|
|
107
|
+
if (oldest === undefined) break
|
|
108
|
+
this.cache.delete(oldest)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Record the messages of a model request. Messages already known are
|
|
114
|
+
* ignored, so repeated requests do not duplicate history. Returns the
|
|
115
|
+
* transcript after the append.
|
|
116
|
+
*/
|
|
117
|
+
append(sessionID: string, entries: StoredMessage[]): SessionTranscript {
|
|
118
|
+
const transcript = this.read(sessionID)
|
|
119
|
+
const fresh = entries.filter((entry) => !transcript.indexById.has(entry.id))
|
|
120
|
+
if (fresh.length === 0) return transcript
|
|
121
|
+
|
|
122
|
+
mkdirSync(this.dirFor(sessionID), { recursive: true })
|
|
123
|
+
const lines = fresh
|
|
124
|
+
.map((entry) => JSON.stringify(entry))
|
|
125
|
+
.join("\n")
|
|
126
|
+
appendFileSync(this.path(sessionID), lines + "\n")
|
|
127
|
+
|
|
128
|
+
for (const entry of fresh) {
|
|
129
|
+
transcript.messages.push(entry)
|
|
130
|
+
transcript.indexById.set(entry.id, entry.index)
|
|
131
|
+
for (const call of collectToolCalls(entry.message)) {
|
|
132
|
+
if (!transcript.callsById.has(call.id)) transcript.callsById.set(call.id, { name: call.name, input: call.input })
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
transcript.version++
|
|
136
|
+
this.touch(sessionID, transcript)
|
|
137
|
+
return transcript
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Read a session's transcript from disk (cached in memory). */
|
|
141
|
+
read(sessionID: string): SessionTranscript {
|
|
142
|
+
const cached = this.cache.get(sessionID)
|
|
143
|
+
if (cached) {
|
|
144
|
+
this.touch(sessionID, cached)
|
|
145
|
+
return cached
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const transcript: SessionTranscript = {
|
|
149
|
+
messages: [],
|
|
150
|
+
indexById: new Map(),
|
|
151
|
+
callsById: new Map(),
|
|
152
|
+
ignoredIds: new Set(),
|
|
153
|
+
version: 0,
|
|
154
|
+
}
|
|
155
|
+
const path = this.path(sessionID)
|
|
156
|
+
try {
|
|
157
|
+
if (existsSync(path)) {
|
|
158
|
+
const raw = readFileSync(path, "utf8")
|
|
159
|
+
for (const line of raw.split("\n")) {
|
|
160
|
+
if (!line.trim()) continue
|
|
161
|
+
try {
|
|
162
|
+
const entry = JSON.parse(line) as StoredMessage
|
|
163
|
+
if (!entry?.id) continue
|
|
164
|
+
transcript.messages.push(entry)
|
|
165
|
+
transcript.indexById.set(entry.id, entry.index)
|
|
166
|
+
for (const call of collectToolCalls(entry.message)) {
|
|
167
|
+
if (!transcript.callsById.has(call.id)) {
|
|
168
|
+
transcript.callsById.set(call.id, { name: call.name, input: call.input })
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
// Skip a corrupt line rather than losing the whole transcript.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// An unreadable transcript is an empty transcript.
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this.touch(sessionID, transcript)
|
|
181
|
+
return transcript
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The next session-global index for a session. */
|
|
185
|
+
nextIndex(sessionID: string): number {
|
|
186
|
+
const transcript = this.read(sessionID)
|
|
187
|
+
return transcript.messages.length + 1
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Rough token cost of a message, used for the memory budgets. */
|
|
191
|
+
estimateTokens(message: Message): number {
|
|
192
|
+
return Math.ceil(JSON.stringify(message.content ?? "").length / CHARS_PER_TOKEN)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Clip a stored message to the per-message size cap.
|
|
197
|
+
*
|
|
198
|
+
* A single tool result can be enormous (`cat` of a large file), and one of
|
|
199
|
+
* those would otherwise dominate the store.
|
|
200
|
+
*/
|
|
201
|
+
clipMessage(message: Message): Message {
|
|
202
|
+
const limit = this.maxMessageChars
|
|
203
|
+
const clipText = (text: string) =>
|
|
204
|
+
text.length <= limit ? text : `${text.slice(0, limit)}…[clipped ${text.length - limit} chars]`
|
|
205
|
+
|
|
206
|
+
// A shell execution carries its output beside the command.
|
|
207
|
+
if (message.role === "bashExecution") {
|
|
208
|
+
const output = (message as { output?: string }).output ?? ""
|
|
209
|
+
if (output.length <= limit) return message
|
|
210
|
+
return { ...message, output: clipText(output) }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// A tool result carries its output as the message content.
|
|
214
|
+
if (message.role === "toolResult") {
|
|
215
|
+
const text = (message as { content?: unknown }).content
|
|
216
|
+
if (typeof text !== "string" || text.length <= limit) return message
|
|
217
|
+
return { ...message, content: clipText(text) }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const content = message.content
|
|
221
|
+
if (typeof content === "string") {
|
|
222
|
+
if (content.length <= limit) return message
|
|
223
|
+
return { ...message, content: clipText(content) }
|
|
224
|
+
}
|
|
225
|
+
if (!Array.isArray(content)) return message
|
|
226
|
+
let size = 0
|
|
227
|
+
let clipped = false
|
|
228
|
+
const parts = content.map((part) => {
|
|
229
|
+
if (clipped) return part
|
|
230
|
+
const text = (part as { text?: unknown }).text
|
|
231
|
+
const length = typeof text === "string" ? text.length : 0
|
|
232
|
+
if (size + length <= limit) {
|
|
233
|
+
size += length
|
|
234
|
+
return part
|
|
235
|
+
}
|
|
236
|
+
clipped = true
|
|
237
|
+
if (length <= 0) return part
|
|
238
|
+
const room = Math.max(0, limit - size)
|
|
239
|
+
return { ...part, text: `${String(text).slice(0, room)}…[clipped ${length - room} chars]` }
|
|
240
|
+
})
|
|
241
|
+
if (!clipped) return message
|
|
242
|
+
return { ...message, content: parts }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Delete transcripts that have not been modified for `olderThanMs`.
|
|
247
|
+
*
|
|
248
|
+
* The transcript duplicates what OpenCode already stores in the session
|
|
249
|
+
* record, so expiring it costs only the searchable index — not the
|
|
250
|
+
* conversation itself.
|
|
251
|
+
*/
|
|
252
|
+
sweep(olderThanMs: number, now = Date.now()): number {
|
|
253
|
+
return TranscriptStore.sweepDir(this.dirFor(""), olderThanMs, now)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Sweep one directory of transcript files. */
|
|
257
|
+
static sweepDir(dir: string, olderThanMs: number, now = Date.now()): number {
|
|
258
|
+
let removed = 0
|
|
259
|
+
let files: string[] = []
|
|
260
|
+
try {
|
|
261
|
+
files = readdirSync(dir)
|
|
262
|
+
} catch {
|
|
263
|
+
return 0
|
|
264
|
+
}
|
|
265
|
+
for (const file of files) {
|
|
266
|
+
if (!file.endsWith(".jsonl")) continue
|
|
267
|
+
const path = join(dir, file)
|
|
268
|
+
try {
|
|
269
|
+
const stats = statSync(path)
|
|
270
|
+
if (now - stats.mtimeMs < olderThanMs) continue
|
|
271
|
+
rmSync(path, { force: true })
|
|
272
|
+
removed++
|
|
273
|
+
} catch {
|
|
274
|
+
// A file we cannot stat or remove is left alone.
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return removed
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# `src/vcc` — vendored port of pi-vcc
|
|
2
|
+
|
|
3
|
+
This directory is a port of **[pi-vcc](https://github.com/sting8k/pi-vcc)**
|
|
4
|
+
(MIT — Copyright (c) 2026 sting8k). The full licence text is in
|
|
5
|
+
[`LICENSE.vendor`](../../LICENSE.vendor) in the package root; it ships with the
|
|
6
|
+
published package, as the MIT licence requires.
|
|
7
|
+
|
|
8
|
+
pi-vcc is Pi-agnostic pure TypeScript: it turns a conversation into a
|
|
9
|
+
structured summary (goal, files, commits, preferences, outstanding context) and
|
|
10
|
+
a ranked brief transcript, and it provides the recall/search core. It is the
|
|
11
|
+
engine this plugin runs.
|
|
12
|
+
|
|
13
|
+
## Why it lives here
|
|
14
|
+
|
|
15
|
+
The port is kept as a **separate, close-to-upstream module** rather than
|
|
16
|
+
rewritten. pi-vcc is actively developed, and upstream fixes should be merged,
|
|
17
|
+
not re-implemented — which only works if the vendored code stays diffable
|
|
18
|
+
against its source.
|
|
19
|
+
|
|
20
|
+
**Rule for changes here:** keep the divergence from upstream as small as
|
|
21
|
+
possible, and never reformat, re-order or "tidy" a file you did not have to
|
|
22
|
+
touch. A large diff here costs a future merge.
|
|
23
|
+
|
|
24
|
+
## What actually changed
|
|
25
|
+
|
|
26
|
+
Of the 30 files, **21 are byte-identical to upstream** and 2 are new; 7 carry a
|
|
27
|
+
small edit, each one removing a Pi coupling:
|
|
28
|
+
|
|
29
|
+
| File | Change |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `types.ts`, `core/content.ts`, `core/render-entries.ts`, `core/search-entries.ts`, `core/summarize.ts` | `import type { Message } from "@earendil-works/pi-ai"` → `from "./pi-types"` / `"../pi-types"` (a local copy of Pi's message shape) |
|
|
32
|
+
| `core/normalize.ts` | the same import change, plus `msg.toolName ?? "tool"` for the optional field |
|
|
33
|
+
| `core/drill-down.ts` | `loadAllMessages(sessionFile)` → `loadMessages(messages)`: Pi reads the session from a JSONL file, OpenCode owns its history and passes the messages in |
|
|
34
|
+
|
|
35
|
+
New files, written for this port:
|
|
36
|
+
|
|
37
|
+
| File | Purpose |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `pi-types.ts` | the message shape pi-vcc expects, copied from Pi's types so the vendored files need no import of Pi's packages |
|
|
40
|
+
| `load-messages.ts` | renders a materialised message list instead of reading a session file |
|
|
41
|
+
|
|
42
|
+
## Re-syncing with upstream
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
git clone https://github.com/sting8k/pi-vcc vendor/pi-vcc # ignored by git
|
|
46
|
+
diff -r vendor/pi-vcc/src src/vcc
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Port any upstream change by hand, keeping the seven edits above intact. After
|
|
50
|
+
re-syncing, `bun run check` must stay green — the vendored code has no
|
|
51
|
+
dependency on Pi's packages, which is the whole point of the port.
|