opencode-blackhole 0.0.0-stage → 0.1.1

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,55 @@
1
+ import type { NormalizedBlock } from "../types";
2
+ import { clip, nonEmptyLines } from "../core/content";
3
+
4
+ // Tightened patterns: require a clear preference construction, not bare keywords.
5
+ const PREF_PATTERNS = [
6
+ /\bprefer(?:s|red|ring)?\s+\w/i,
7
+ /\bdon'?t want\b/i,
8
+ /\balways (?:use|do|run|prefer|keep|make|format|write|add|set|put|prefix|start|include|append)\b/i,
9
+ /\bnever (?:use|do|run|push|commit|write|ignore|add|set|put|remove|delete|include|deploy)\b/i,
10
+ /\bplease (?:use|avoid|keep|make|don'?t|do not|format|write)\b/i,
11
+ /\b(?:style|format|language|naming)\s*[:=]\s*\S/i,
12
+ ];
13
+
14
+ export const extractPreferences = (blocks: NormalizedBlock[]): string[] => {
15
+ const prefs: string[] = [];
16
+ const seen = new Set<string>();
17
+
18
+ for (const b of blocks) {
19
+ if (b.kind !== "user") continue;
20
+
21
+ let perBlock = 0;
22
+ for (const line of nonEmptyLines(b.text)) {
23
+ const trimmed = line.trim();
24
+ if (!trimmed || trimmed.length < 5) continue;
25
+ if (trimmed.length > 200) continue;
26
+ // Reject questions.
27
+ if (trimmed.endsWith("?") || trimmed.includes("?...")) continue;
28
+ if (!PREF_PATTERNS.some((p) => p.test(trimmed))) continue;
29
+
30
+ const clipped = clip(trimmed, 200);
31
+ const key = clipped.toLowerCase();
32
+ if (seen.has(key)) continue;
33
+ seen.add(key);
34
+ prefs.push(clipped);
35
+
36
+ // Cap per user block to avoid pasting long rule lists as many prefs.
37
+ if (++perBlock >= 1) break;
38
+ }
39
+ }
40
+
41
+ return prefs.slice(0, 10);
42
+ };
43
+
44
+ /**
45
+ * Remove preferences that duplicate goals (case-insensitive, trimmed).
46
+ * Called by `buildSections` so that the two sections do not overlap.
47
+ */
48
+ export const dedupPreferencesAgainstGoals = (
49
+ prefs: string[],
50
+ goals: string[],
51
+ ): string[] => {
52
+ const norm = (s: string) => s.trim().toLowerCase();
53
+ const goalSet = new Set(goals.map(norm));
54
+ return prefs.filter((p) => !goalSet.has(norm(p)));
55
+ };
@@ -0,0 +1,232 @@
1
+ import type { NormalizedBlock } from "../types";
2
+ import { capItems } from "../core/format";
3
+
4
+ /** Maximum characters kept per captured entry (a truncated one-liner, not a
5
+ * parsed structure -- deliberately shallow, see module docstring below). */
6
+ const MAX_ENTRY_CHARS = 80;
7
+
8
+ /** Single-pass quote-aware scan: returns the index of every UNQUOTED
9
+ * shell separator (`;`, `&`, `|`, newline). Quote state tracks ' and "
10
+ * plus backslash escapes; separators inside quotes are not boundaries.
11
+ * Deliberately stops there -- $( ) and backticks are not handled, keeping
12
+ * this a 20-line tokenizer rather than a shell parser. */
13
+ const unquotedSeparators = (cmd: string): number[] => {
14
+ const seps: number[] = [];
15
+ let quote: string | null = null;
16
+ for (let i = 0; i < cmd.length; i++) {
17
+ const c = cmd[i];
18
+ if (quote) {
19
+ // Backslash escapes inside "..." only; inside '...' it is literal.
20
+ if (c === "\\" && quote === '"') i++;
21
+ else if (c === quote) quote = null;
22
+ continue;
23
+ }
24
+ if (c === "\\") i++; // `it\'s` is not an opening quote
25
+ else if (c === "'" || c === '"') quote = c;
26
+ // A comment runs to the newline; its apostrophes (`# don't`) are not quotes.
27
+ else if (c === "#" && (i === 0 || /[\s;&|(]/.test(cmd[i - 1]))) {
28
+ while (i + 1 < cmd.length && cmd[i + 1] !== "\n") i++;
29
+ }
30
+ // `2>&1`, `<&3`, `&>file` are redirections, not separators.
31
+ else if (c === "&" && (cmd[i - 1] === ">" || cmd[i - 1] === "<" || cmd[i + 1] === ">")) continue;
32
+ else if (c === ";" || c === "&" || c === "|" || c === "\n") seps.push(i);
33
+ }
34
+ return seps;
35
+ };
36
+
37
+ /** Cut `text` at the next UNQUOTED separator. Quotes are kept as written:
38
+ * the cut never lands inside a quote, so they stay balanced. No truncation
39
+ * here -- dedup must see the full string (two distinct invocations sharing
40
+ * an 80-char prefix must not collapse); render-time truncation lives in
41
+ * formatTrackedCommands. */
42
+ const captureEntry = (text: string): string => {
43
+ const seps = unquotedSeparators(text);
44
+ const cut = seps.length ? text.slice(0, seps[0]) : text;
45
+ return cut.trim();
46
+ };
47
+
48
+ /** Prefixes that hide the real command name: `VAR=val` assignments and a
49
+ * tiny, decades-stable wrapper list (sudo/env/nohup/time/command) — NOT a
50
+ * growing per-CLI taxonomy. They are skipped only to find the name; the
51
+ * entry keeps them (see findTopLevelInvocations). */
52
+ const WRAPPERS = new Set(["sudo", "env", "nohup", "time", "command"]);
53
+ const WRAPPER_VALUE_FLAGS: Record<string, RegExp> = {
54
+ sudo: /^-[ugpCDRT]$/, // -u user -g group -p prompt -C fd -D dir -R? -T
55
+ env: /^-[uS]$/, // -u name, -S split-string
56
+ };
57
+
58
+ /** Next token from pos on the same line: a newline is a separator, so a
59
+ * lone `VAR=x` line must not borrow the command on the next one. */
60
+ const tokenAt = (cmd: string, pos: number): [string, number] => {
61
+ while (pos < cmd.length && (cmd[pos] === " " || cmd[pos] === "\t")) pos++;
62
+ const t = cmd.slice(pos).match(/^\S+/)?.[0] ?? "";
63
+ return [t, pos];
64
+ };
65
+
66
+ /** Skip VAR=val prefixes and wrapper-command invocations starting at pos;
67
+ * returns the position where the real command begins. */
68
+ const skipToCommand = (cmd: string, pos: number): number => {
69
+ for (;;) {
70
+ const [tok, at] = tokenAt(cmd, pos);
71
+ if (!tok) return at;
72
+ if (/^[A-Za-z_]\w*=/.test(tok)) { pos = at + tok.length; continue; }
73
+ if (!WRAPPERS.has(tok)) return at;
74
+ pos = at + tok.length;
75
+ for (;;) {
76
+ const [flag, fat] = tokenAt(cmd, pos);
77
+ if (!flag.startsWith("-")) break;
78
+ pos = fat + flag.length;
79
+ if (WRAPPER_VALUE_FLAGS[tok]?.test(flag)) {
80
+ const [value, vat] = tokenAt(cmd, pos); // consume the flag's value
81
+ pos = vat + value.length;
82
+ }
83
+ }
84
+ }
85
+ };
86
+
87
+ /** Positions where an invocation of `name` begins: position 0 or right
88
+ * after an UNQUOTED separator, so `echo "a; docker restart"` is not an
89
+ * invocation. Requires whitespace (or end of string) right after `name`, so
90
+ * `ssh-keygen`/`docker-compose` don't match `ssh`/`docker`. Returns the
91
+ * START of the segment (before any VAR=/wrapper prefixes), not the name
92
+ * itself, so the recorded entry stays faithful to what actually ran
93
+ * (`sudo -u root kubectl get pods` is recorded whole). */
94
+ const findTopLevelInvocations = (cmd: string, name: string): number[] => {
95
+ const bounds = [0, ...unquotedSeparators(cmd).map((i) => i + 1)];
96
+ const starts: number[] = [];
97
+ for (const bound of bounds) {
98
+ const pos = skipToCommand(cmd, bound);
99
+ if (!cmd.startsWith(name, pos)) continue;
100
+ const after = cmd[pos + name.length];
101
+ if (after === undefined || /\s/.test(after)) starts.push(bound);
102
+ }
103
+ return starts;
104
+ };
105
+
106
+ /** ssh flags that take NO argument; every other leading `-flag` consumes
107
+ * the next token. Without this, `ssh -T host 'cmd'` misreads `host` as
108
+ * -T's value and the remote command is never found. Clusters (`-tt`, `-NT`)
109
+ * count as boolean when every letter is one. */
110
+ const SSH_BOOL_FLAG = /^-[46ACfGgKkMNTtVvXxYynaq]+$/;
111
+
112
+ /** Locate the SSH target by TOKEN POSITION (via matchAll's own `.index`,
113
+ * never a substring re-search, which would misfire on e.g.
114
+ * `ssh -i key-prod prod ...` finding "prod" inside "key-prod") and return
115
+ * everything after it, trimmed. */
116
+ const sshRemoteCommand = (afterSsh: string): string | undefined => {
117
+ const tokens = [...afterSsh.matchAll(/\S+/g)];
118
+ let i = 0;
119
+ while (i < tokens.length && tokens[i][0].startsWith("-")) {
120
+ i += SSH_BOOL_FLAG.test(tokens[i][0]) ? 1 : 2;
121
+ }
122
+ const target = tokens[i];
123
+ if (!target) return undefined;
124
+ const rest = afterSsh.slice(target.index! + target[0].length).trim();
125
+ return rest || undefined;
126
+ };
127
+
128
+ export interface TrackedCommandActivity {
129
+ /** Command name (as configured in settings.trackCommands) -> entries seen. */
130
+ byCommand: Map<string, Set<string>>;
131
+ }
132
+
133
+ /**
134
+ * Scans bash tool-call commands for invocations of any command name in
135
+ * `trackCommands`, capturing a one-line snapshot per match --
136
+ * deliberately shallow (no per-command argument parsing) so this never
137
+ * needs updating as any given CLI's flags evolve, unlike a design that
138
+ * tries to extract structured fields (e.g. "the kubectl namespace" or "the
139
+ * docker container name") for each tool separately.
140
+ *
141
+ * When "ssh" is one of the tracked names, also scans inside its own
142
+ * remote-command argument (quoted or not) for other tracked names --
143
+ * running a command over SSH is as common as running it locally, and a
144
+ * top-level-only scan would otherwise miss it.
145
+ *
146
+ * Only matches literal command text, never tool_result output.
147
+ */
148
+ export const extractTrackedCommands = (
149
+ blocks: NormalizedBlock[],
150
+ trackCommands: readonly string[],
151
+ ): TrackedCommandActivity => {
152
+ const byCommand = new Map<string, Set<string>>();
153
+ if (trackCommands.length === 0) return { byCommand };
154
+ for (const name of trackCommands) byCommand.set(name, new Set());
155
+
156
+ const trackSsh = trackCommands.includes("ssh");
157
+
158
+ for (const b of blocks) {
159
+ if (b.kind !== "tool_call" || b.name !== "bash") continue;
160
+ const cmd = typeof b.args.command === "string" ? b.args.command : "";
161
+ if (!cmd) continue;
162
+
163
+ for (const name of trackCommands) {
164
+ for (const startAt of findTopLevelInvocations(cmd, name)) {
165
+ const item = captureEntry(cmd.slice(startAt).trim());
166
+ if (!item) continue;
167
+ const set = byCommand.get(name)!;
168
+ // re-running the same command refreshes it to the tail — the cap
169
+ // then keeps the N most-recently-USED, not first-seen.
170
+ if (set.has(item)) set.delete(item);
171
+ set.add(item);
172
+ }
173
+ }
174
+
175
+ if (trackSsh) {
176
+ for (const bound of findTopLevelInvocations(cmd, "ssh")) {
177
+ // bounds are segment starts (incl. wrappers); the ssh token itself
178
+ // sits at skipToCommand — slice past it for the remote parser.
179
+ const seg = cmd.slice(bound);
180
+ const remote = sshRemoteCommand(seg.slice(skipToCommand(seg, 0) + 3));
181
+ if (!remote) continue;
182
+ // The remote string's quotes were the LOCAL shell's syntax, not
183
+ // the remote command's — a leftover stray ' would open a phantom
184
+ // quote in the rescanner and swallow the rest of the line. Strip
185
+ // them all; boundary detection stays shallow regardless.
186
+ const unquoted = remote.replace(/['"]/g, "");
187
+ for (const name of trackCommands) {
188
+ if (name === "ssh") continue;
189
+ for (const startAt2 of findTopLevelInvocations(unquoted, name)) {
190
+ const item = captureEntry(unquoted.slice(startAt2).trim());
191
+ if (!item) continue;
192
+ const set = byCommand.get(name)!;
193
+ if (set.has(item)) set.delete(item);
194
+ set.add(item);
195
+ }
196
+ }
197
+ }
198
+ }
199
+ }
200
+
201
+ return { byCommand };
202
+ };
203
+
204
+ /** Line format of `[Tracked Commands]`, shared with the merge in
205
+ * core/summarize.ts: `- <name>: <entry> | <entry> ...`, newest
206
+ * COMMANDS_PER_NAME kept. " | " rather than "," since an entry (e.g.
207
+ * `kubectl get pods,svc`) can legitimately contain a comma. */
208
+ export const COMMAND_SEPARATOR = " | ";
209
+ export const COMMANDS_PER_NAME = 10;
210
+
211
+ /** Render form: one line (a quoted multi-line argument such as
212
+ * `python3 -c '...'` would otherwise break the line format), with an inner
213
+ * separator escaped as ` \| ` so the merge, which splits on
214
+ * COMMAND_SEPARATOR, keeps the entry whole. Then truncated. */
215
+ const renderEntry = (entry: string): string => {
216
+ const line = entry.replace(/\s+/g, " ").replaceAll(COMMAND_SEPARATOR, " \\| ");
217
+ return line.length > MAX_ENTRY_CHARS ? `${line.slice(0, MAX_ENTRY_CHARS)}…` : line;
218
+ };
219
+
220
+ /** Formats TrackedCommandActivity into `[Tracked Commands]` body lines, one per
221
+ * tracked command name that had at least one match. Keeps the NEWEST
222
+ * entries: a ledger of what was run recently must not freeze at its first
223
+ * ten while everything later drowns in a permanent "(+N more)". */
224
+ export const formatTrackedCommands = (act: TrackedCommandActivity): string[] => {
225
+ const lines: string[] = [];
226
+ for (const [name, entries] of act.byCommand) {
227
+ if (entries.size > 0) {
228
+ lines.push(`${name}: ${capItems([...entries].map(renderEntry), COMMANDS_PER_NAME, COMMAND_SEPARATOR, "tail")}`);
229
+ }
230
+ }
231
+ return lines;
232
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * In-memory session history loader.
3
+ *
4
+ * Upstream pi-vcc reads the session JSONL file from disk. OpenCode owns its
5
+ * history in the server database and exposes it over the API, so the loader
6
+ * here takes an already-materialized message list instead of a path.
7
+ *
8
+ * Upstream: https://github.com/sting8k/pi-vcc (MIT) — Copyright (c) 2026
9
+ * sting8k. See LICENSE.vendor in this package for the full text.
10
+ */
11
+ import type { Message } from "./pi-types"
12
+ import { renderMessage, type RenderedEntry } from "./core/render-entries"
13
+
14
+ export interface LoadedMessages {
15
+ rendered: RenderedEntry[]
16
+ rawMessages: Message[]
17
+ }
18
+
19
+ /**
20
+ * Render every message into the entry shape the summary and recall pipeline
21
+ * consumes. `full` keeps the untruncated text (recall needs it); the summary
22
+ * path uses the clipped variant.
23
+ */
24
+ export const loadMessages = (messages: Message[], full = false): LoadedMessages => {
25
+ const rendered: RenderedEntry[] = []
26
+ const rawMessages: Message[] = []
27
+ let messageIndex = 0
28
+
29
+ for (const message of messages) {
30
+ // System messages occupy an index but are not conversation content.
31
+ if (message.role !== "system") {
32
+ // A message that already carries a session-global index keeps it, so
33
+ // recall's `#N` refs line up with the ones in a summary.
34
+ const index = message.index ?? messageIndex
35
+ rendered.push(renderMessage(message, index, full))
36
+ rawMessages.push(message)
37
+ }
38
+ messageIndex++
39
+ }
40
+
41
+ return { rendered, rawMessages }
42
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Minimal mirror of the `@earendil-works/pi-ai` message shape that the ported
3
+ * vcc core depends on.
4
+ *
5
+ * The vcc core was written against Pi's message model. Rather than rewrite
6
+ * its algorithms, the OpenCode adapter normalizes OpenCode messages into this
7
+ * shape and the core runs unchanged.
8
+ *
9
+ * Upstream: https://github.com/sting8k/pi-vcc (MIT) — Copyright (c) 2026
10
+ * sting8k. See LICENSE.vendor in this package for the full text.
11
+ */
12
+
13
+ export interface ToolCall {
14
+ type: "toolCall"
15
+ id: string
16
+ name: string
17
+ arguments: Record<string, unknown>
18
+ }
19
+
20
+ export interface TextPart {
21
+ type: "text"
22
+ text: string
23
+ }
24
+
25
+ export interface ImagePart {
26
+ type: "image"
27
+ mimeType: string
28
+ }
29
+
30
+ export type ContentPart = TextPart | ToolCall | ImagePart
31
+
32
+ export type MessageRole = "user" | "assistant" | "toolResult" | "bashExecution" | "system"
33
+
34
+ export interface Message {
35
+ role: MessageRole
36
+ /** OpenCode's message id, when the message came from a session. */
37
+ id?: string
38
+ /** Session-global index, matching the `#N` references in summaries. */
39
+ index?: number
40
+ content?: string | ContentPart[]
41
+ /** Present on tool results. */
42
+ toolName?: string
43
+ /** Present on bash execution entries. */
44
+ command?: string
45
+ output?: string
46
+ exitCode?: number
47
+ }
48
+
49
+ /** Pi persists `{ id, message }` entries; only the inner message is used here. */
50
+ export interface SessionMessageEntry {
51
+ id: string
52
+ message: Message
53
+ }
@@ -0,0 +1,12 @@
1
+ export interface SectionData {
2
+ sessionGoal: string[];
3
+ outstandingContext: string[];
4
+ filesAndChanges: string[];
5
+ commits: string[];
6
+ userPreferences: string[];
7
+ /** Populated only when settings.trackCommands is non-empty -- see
8
+ * core/settings.ts. Empty array otherwise, same as any other section
9
+ * with nothing to report. */
10
+ trackedCommands: string[];
11
+ briefTranscript: string;
12
+ }
@@ -0,0 +1,16 @@
1
+ import type { Message } from "./pi-types";
2
+
3
+ export type CompactionReason = "manual" | "threshold" | "overflow";
4
+
5
+ export interface FileOps {
6
+ readFiles?: string[];
7
+ modifiedFiles?: string[];
8
+ createdFiles?: string[];
9
+ }
10
+
11
+ export type NormalizedBlock =
12
+ | { kind: "user"; text: string; sourceIndex?: number }
13
+ | { kind: "assistant"; text: string; sourceIndex?: number }
14
+ | { kind: "tool_call"; name: string; args: Record<string, unknown>; sourceIndex?: number }
15
+ | { kind: "tool_result"; name: string; text: string; sourceIndex?: number }
16
+ | { kind: "bash"; command: string; output: string; exitCode: number | undefined; sourceIndex?: number };