omp-vcc 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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/commands/omp-vcc.md +19 -0
  4. package/commands/vcc-recall.md +21 -0
  5. package/extensions/main.ts +319 -0
  6. package/extensions/vcc-core/commands/vcc-recall.ts +2 -0
  7. package/extensions/vcc-core/core/brief.ts +404 -0
  8. package/extensions/vcc-core/core/build-sections.ts +77 -0
  9. package/extensions/vcc-core/core/compact-args.ts +46 -0
  10. package/extensions/vcc-core/core/content.ts +157 -0
  11. package/extensions/vcc-core/core/drill-down.ts +299 -0
  12. package/extensions/vcc-core/core/filter-noise.ts +42 -0
  13. package/extensions/vcc-core/core/format-recall.ts +101 -0
  14. package/extensions/vcc-core/core/format.ts +82 -0
  15. package/extensions/vcc-core/core/lineage.ts +27 -0
  16. package/extensions/vcc-core/core/load-messages.ts +44 -0
  17. package/extensions/vcc-core/core/normalize.ts +66 -0
  18. package/extensions/vcc-core/core/rank.ts +284 -0
  19. package/extensions/vcc-core/core/recall-scope.ts +31 -0
  20. package/extensions/vcc-core/core/render-entries.ts +55 -0
  21. package/extensions/vcc-core/core/report.ts +233 -0
  22. package/extensions/vcc-core/core/sanitize.ts +6 -0
  23. package/extensions/vcc-core/core/search-entries.ts +576 -0
  24. package/extensions/vcc-core/core/settings.ts +151 -0
  25. package/extensions/vcc-core/core/skill-collapse.ts +36 -0
  26. package/extensions/vcc-core/core/summarize.ts +208 -0
  27. package/extensions/vcc-core/core/token-estimate.ts +101 -0
  28. package/extensions/vcc-core/core/tool-args.ts +17 -0
  29. package/extensions/vcc-core/details.ts +12 -0
  30. package/extensions/vcc-core/extract/commits.ts +70 -0
  31. package/extensions/vcc-core/extract/files.ts +88 -0
  32. package/extensions/vcc-core/extract/goals.ts +80 -0
  33. package/extensions/vcc-core/extract/preferences.ts +56 -0
  34. package/extensions/vcc-core/hook.ts +1017 -0
  35. package/extensions/vcc-core/sections.ts +9 -0
  36. package/extensions/vcc-core/types.ts +17 -0
  37. package/package.json +104 -0
  38. package/scripts/smoke.ts +116 -0
  39. package/scripts/uninstall-reset.js +73 -0
  40. package/skills/omp-vcc/SKILL.md +35 -0
  41. package/types.d.ts +114 -0
@@ -0,0 +1,299 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Drill-down: resolve #N:path syntax to tool call file content.
4
+ *
5
+ * Ported from pi-blackhole (https://github.com/k0valik/pi-blackhole, MIT) by
6
+ * k0valik — a pi-vcc derivative.
7
+ *
8
+ * Supports: #42:auth.ts (preview), #42:auth.ts:full (full content), #42:file (auto-select).
9
+ *
10
+ * Path matching notes:
11
+ * - The regex uses $ anchor so lazy .+? consumes the full path — spaces, dots, and
12
+ * Windows drive-letter colons (C:\) all work correctly.
13
+ * - One edge case: a file path literally ending in ":full" (e.g. C:\file:full)
14
+ * would be misinterpreted as the :full flag. This is extremely unlikely.
15
+ * - Inline queries like "check #42:auth.ts" are NOT drill-down — the ^ anchor
16
+ * requires the entire query to be the drill-down pattern.
17
+ */
18
+ import { isContentBearing } from "./content";
19
+ import { extractPath } from "./tool-args";
20
+ import { loadAllMessages } from "./load-messages";
21
+
22
+ // ── Types ─────────────────────────────────────────────────────────────────
23
+
24
+ interface ContentBearingCall {
25
+ name: string;
26
+ path: string;
27
+ content?: string;
28
+ oldText?: string;
29
+ newText?: string;
30
+ edits?: Array<{ oldText?: string; newText?: string }>;
31
+ }
32
+
33
+ // ── Helpers ───────────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Find content-bearing tool calls that have a `path` argument and at least
37
+ * one content field (content, edits, oldText, newText).
38
+ * Uses the shared isContentBearing() heuristic from content.ts.
39
+ */
40
+ function findContentBearingCalls(content: unknown[]): ContentBearingCall[] {
41
+ if (!Array.isArray(content)) return [];
42
+ const results: ContentBearingCall[] = [];
43
+ for (const part of content) {
44
+ if (!part || (part as any).type !== "toolCall") continue;
45
+ const args = (part as any).arguments ?? {};
46
+ if (!isContentBearing(args)) continue;
47
+ const path = extractPath(args);
48
+ if (!path) continue;
49
+ const entry: ContentBearingCall = { name: (part as any).name ?? "", path };
50
+ if (typeof args.content === "string") entry.content = args.content;
51
+ if (Array.isArray(args.edits)) {
52
+ entry.edits = args.edits.filter(
53
+ (e: unknown): e is { oldText?: string; newText?: string } =>
54
+ e !== null && typeof e === "object",
55
+ );
56
+ }
57
+ if (typeof args.oldText === "string" && !Array.isArray(args.edits))
58
+ entry.oldText = args.oldText;
59
+ if (typeof args.newText === "string" && !Array.isArray(args.edits))
60
+ entry.newText = args.newText;
61
+ results.push(entry);
62
+ }
63
+ return results;
64
+ }
65
+
66
+ /** Format content for display with optional offset/limit slicing.
67
+ *
68
+ * When full=true, shows everything ignoring offset/limit.
69
+ * When offset/limit given, shows a window with "Lines X-Y (of Z)" header.
70
+ * When neither, shows preview (first 30 lines) with truncation hint.
71
+ */
72
+ function formatToolCallContent(
73
+ tc: ContentBearingCall,
74
+ entryIndex: number,
75
+ options?: { full?: boolean; offset?: number; limit?: number },
76
+ ): string {
77
+ let body: string;
78
+ if (tc.content) {
79
+ body = tc.content;
80
+ } else if (tc.edits) {
81
+ body = tc.edits
82
+ .map(
83
+ (e, i) =>
84
+ `--- edit ${i + 1} ---\n${e.oldText ?? ""}\n--- becomes ---\n${e.newText ?? ""}`,
85
+ )
86
+ .join("\n\n");
87
+ } else if (tc.oldText && tc.newText) {
88
+ body = `--- old ---\n${tc.oldText}\n--- new ---\n${tc.newText}`;
89
+ } else {
90
+ body = "(no file content found in tool call arguments)";
91
+ }
92
+
93
+ const full = options?.full ?? false;
94
+ const offset = options?.offset;
95
+ const limit = options?.limit;
96
+ const allLines = body.split("\n");
97
+ const totalLines = allLines.length;
98
+ const previewLimit = 30;
99
+ const MAX_FULL_BYTES = 50 * 1024;
100
+
101
+ if (full) {
102
+ // Full content: capped at 50KB
103
+ if (Buffer.byteLength(body, "utf8") > MAX_FULL_BYTES) {
104
+ const truncated = body.slice(0, MAX_FULL_BYTES);
105
+ return `File: ${tc.path}
106
+ Tool: ${tc.name}
107
+
108
+ ${truncated}
109
+
110
+ ... (${Buffer.byteLength(body, "utf8") - MAX_FULL_BYTES} more bytes — file exceeds 50KB display limit. Use #${entryIndex}:${tc.path}:${previewLimit} for next page.)`;
111
+ }
112
+ return `File: ${tc.path}
113
+ Tool: ${tc.name}
114
+
115
+ ${body}`;
116
+ }
117
+
118
+ if (offset !== undefined) {
119
+ // Offset-based window: show slice
120
+ const startLine = Math.max(0, offset);
121
+ const maxLines = limit ?? 30;
122
+ const endLine = Math.min(startLine + maxLines, totalLines);
123
+ const visible = allLines.slice(startLine, endLine);
124
+ const displayStart = startLine + 1; // 1-indexed for user display
125
+
126
+ if (visible.length === 0) {
127
+ return `Offset ${startLine} is beyond file length ${totalLines}. Use #${entryIndex}:${tc.path} for the first ${previewLimit} lines.`;
128
+ }
129
+
130
+ let result = `File: ${tc.path}
131
+ Tool: ${tc.name}
132
+ Lines ${displayStart}-${endLine} (of ${totalLines}):
133
+
134
+ `;
135
+ result += visible.join("\n");
136
+
137
+ if (endLine < totalLines) {
138
+ result += `\n\n--- Use #${entryIndex}:${tc.path}:${endLine} or #${entryIndex}:${tc.path}:${endLine}:${maxLines} for next ${maxLines} lines, #${entryIndex}:${tc.path}:full for complete ---`;
139
+ } else if (offset > 0) {
140
+ result += `\n\n(End of file)`;
141
+ }
142
+
143
+ return result;
144
+ }
145
+
146
+ // Default preview mode: first ${previewLimit} lines
147
+ if (totalLines > previewLimit) {
148
+ const preview = allLines.slice(0, previewLimit).join("\n");
149
+ return `File: ${tc.path}
150
+ Tool: ${tc.name}
151
+
152
+ ${preview}
153
+
154
+ ...(${totalLines - previewLimit} more lines — use #${entryIndex}:${tc.path}:full for complete content, or #${entryIndex}:${tc.path}:${previewLimit} for next ${previewLimit} lines)`;
155
+ }
156
+
157
+ return `File: ${tc.path}
158
+ Tool: ${tc.name}
159
+
160
+ ${body}`;
161
+ }
162
+
163
+ // ── Parse drill-down query ────────────────────────────────────────────────
164
+
165
+ /**
166
+ * Pattern: #N:path, #N:path:full, #N:path:offset, or #N:path:offset:limit
167
+ * Group 1: index number
168
+ * Group 2: path (consumed lazily, expanded until suffix can match)
169
+ * Group 3: suffix — "full", a number (offset), or "offset:limit"
170
+ */
171
+ const DRILLDOWN_PATTERN = /^#(\d+):(.+?)(?::(full|\d+(?::\d+)?))?$/;
172
+
173
+ /**
174
+ * Parse a drill-down query like #42:auth.ts or #42:auth.ts:full.
175
+ * Returns null if the query doesn't match the drill-down pattern.
176
+ *
177
+ * Suffixes:
178
+ * :full → full content (no truncation)
179
+ * :30 → offset 30 lines, default limit (30)
180
+ * :30:20 → offset 30 lines, limit 20 lines
181
+ * (none) → preview first 30 lines
182
+ */
183
+ export function parseDrillDown(query: string): {
184
+ index: number;
185
+ pathPattern: string;
186
+ full: boolean;
187
+ offset?: number;
188
+ limit?: number;
189
+ } | null {
190
+ const match = query.match(DRILLDOWN_PATTERN);
191
+ if (!match) return null;
192
+ const index = parseInt(match[1], 10);
193
+ const pathPattern = match[2];
194
+ const suffix = match[3];
195
+
196
+ if (suffix === "full") {
197
+ return {
198
+ index,
199
+ pathPattern,
200
+ full: true,
201
+ offset: undefined,
202
+ limit: undefined,
203
+ };
204
+ }
205
+
206
+ if (suffix !== undefined) {
207
+ // Parse "offset" or "offset:limit"
208
+ const parts = suffix.split(":");
209
+ const offset = parseInt(parts[0], 10);
210
+ const limit = parts[1] !== undefined ? parseInt(parts[1], 10) : undefined;
211
+ if (!Number.isNaN(offset)) {
212
+ return { index, pathPattern, full: false, offset, limit };
213
+ }
214
+ }
215
+
216
+ return {
217
+ index,
218
+ pathPattern,
219
+ full: false,
220
+ offset: undefined,
221
+ limit: undefined,
222
+ };
223
+ }
224
+
225
+ // ── Main export ───────────────────────────────────────────────────────────
226
+
227
+ /**
228
+ * Expand a drill-down query (#N:path) to tool call content.
229
+ *
230
+ * Offset/limit let you page through file content incrementally (like pi's read tool):
231
+ * #42:auth.ts → first 30 lines (preview)
232
+ * #42:auth.ts:full → all content
233
+ * #42:auth.ts:30 → lines 31-60 (default limit 30)
234
+ * #42:auth.ts:30:20 → lines 31-50 (custom limit 20)
235
+ *
236
+ * @param sessionFile - Path to the JSONL session file
237
+ * @param entryIndex - The message index (#N)
238
+ * @param pathPattern - File path substring to match (or "file" keyword)
239
+ * @param full - If true, return complete content without truncation
240
+ * @param offset - Line offset (0-indexed) for windowed content
241
+ * @param limit - Max lines to show (default 30 for windowed, ignored if full=true)
242
+ * @returns Formatted content string
243
+ */
244
+ export function expandEntryFile(
245
+ sessionFile: string,
246
+ entryIndex: number,
247
+ pathPattern: string,
248
+ full = false,
249
+ offset?: number,
250
+ limit?: number,
251
+ ): string {
252
+ const { rawMessages } = loadAllMessages(sessionFile, true);
253
+
254
+ if (entryIndex < 0 || entryIndex >= rawMessages.length) {
255
+ return `Entry #${entryIndex} not found in session history.`;
256
+ }
257
+
258
+ const msg = rawMessages[entryIndex];
259
+ const content = msg.content as unknown[];
260
+ const calls = findContentBearingCalls(content);
261
+
262
+ // Special case: #42:file keyword
263
+ if (pathPattern === "file") {
264
+ if (calls.length === 0) {
265
+ return `No file content found in entry #${entryIndex}.`;
266
+ }
267
+ if (calls.length === 1) {
268
+ return formatToolCallContent(calls[0], entryIndex, {
269
+ full,
270
+ offset,
271
+ limit,
272
+ });
273
+ }
274
+ // Multiple content-bearing calls — list them
275
+ const items = calls.map(
276
+ (tc) => ` [#${entryIndex}:${tc.path}] ${tc.name}(${tc.path})`,
277
+ );
278
+ return `Entry #${entryIndex} has ${calls.length} file operations:\n${items.join("\n")}\n\nUse #${entryIndex}:path to drill into a specific file.`;
279
+ }
280
+
281
+ const matched = calls.filter((tc) => tc.path.includes(pathPattern));
282
+
283
+ if (matched.length === 0) {
284
+ return `No file content found in entry #${entryIndex} for "${pathPattern}".`;
285
+ }
286
+
287
+ if (matched.length > 1) {
288
+ // Ambiguous match — list options instead of silently picking the first
289
+ const items = matched.map(
290
+ (tc) => ` [#${entryIndex}:${tc.path}] ${tc.name}(${tc.path})`,
291
+ );
292
+ return `Entry #${entryIndex} has ${matched.length} file operations matching "${pathPattern}":
293
+ ${items.join("\n")}
294
+
295
+ Use #${entryIndex}:<more-specific-path> to drill into a specific file.`;
296
+ }
297
+
298
+ return formatToolCallContent(matched[0], entryIndex, { full, offset, limit });
299
+ }
@@ -0,0 +1,42 @@
1
+ // @ts-nocheck
2
+ import type { NormalizedBlock } from "../types";
3
+
4
+ const NOISE_TOOLS = new Set([
5
+ "TodoWrite", "TodoRead", "ToolSearch", "WebSearch",
6
+ "AskUser", "ExitSpecMode", "GenerateDroid",
7
+ ]);
8
+
9
+ const NOISE_STRINGS = [
10
+ "Continue from where you left off.",
11
+ "No response requested.",
12
+ "IMPORTANT: TodoWrite was not called yet.",
13
+ ];
14
+
15
+ const XML_WRAPPER_RE = /<(system-reminder|ide_opened_file|command-message|context-window-usage)[^>]*>[\s\S]*?<\/\1>/g;
16
+
17
+ const isNoiseUserBlock = (text: string): boolean => {
18
+ const trimmed = text.trim();
19
+ if (NOISE_STRINGS.some((s) => trimmed.includes(s))) return true;
20
+ const stripped = trimmed.replace(XML_WRAPPER_RE, "").trim();
21
+ return stripped.length === 0;
22
+ };
23
+
24
+ const cleanUserText = (text: string): string =>
25
+ text.replace(XML_WRAPPER_RE, "").trim();
26
+
27
+ export const filterNoise = (blocks: NormalizedBlock[]): NormalizedBlock[] => {
28
+ const out: NormalizedBlock[] = [];
29
+ for (const b of blocks) {
30
+ if (b.kind === "tool_call" && NOISE_TOOLS.has(b.name)) continue;
31
+ if (b.kind === "tool_result" && NOISE_TOOLS.has(b.name)) continue;
32
+ if (b.kind === "user") {
33
+ if (isNoiseUserBlock(b.text)) continue;
34
+ const cleaned = cleanUserText(b.text);
35
+ if (!cleaned) continue;
36
+ out.push({ kind: "user", text: cleaned });
37
+ continue;
38
+ }
39
+ out.push(b);
40
+ }
41
+ return out;
42
+ };
@@ -0,0 +1,101 @@
1
+ // @ts-nocheck
2
+ import type { SearchHit, TouchedFile } from "./search-entries";
3
+
4
+ // ── Path shortening ───────────────────────────────────────────────────────
5
+
6
+ const CWD = process.cwd();
7
+
8
+ /**
9
+ * Shorten an absolute file path for display:
10
+ * - If within cwd, return `./relative/path`
11
+ * - Otherwise, show last 3 path components with `.../` prefix
12
+ * - Short paths (≤3 components) returned as-is
13
+ *
14
+ * Ported from pi-blackhole (https://github.com/k0valik/pi-blackhole, MIT) by
15
+ * k0valik — a pi-vcc derivative.
16
+ */
17
+ export function shortPath(fullPath: string): string {
18
+ const normalized = fullPath.replace(/\\/g, "/");
19
+ const cwdNormalized = CWD.replace(/\\/g, "/");
20
+ if (normalized.startsWith(cwdNormalized + "/")) {
21
+ return "." + normalized.slice(cwdNormalized.length);
22
+ }
23
+ const parts = normalized.split("/");
24
+ if (parts.length > 3) {
25
+ return ".../" + parts.slice(-3).join("/");
26
+ }
27
+ return normalized;
28
+ }
29
+
30
+ // ── Touched file output ───────────────────────────────────────────────────
31
+
32
+ export const TOUCHED_PAGE_SIZE = 5;
33
+
34
+ /**
35
+ * Format aggregated "files touched" output.
36
+ *
37
+ * Ported from pi-blackhole (https://github.com/k0valik/pi-blackhole, MIT) by
38
+ * k0valik — a pi-vcc derivative.
39
+ */
40
+ export function formatTouchedOutput(
41
+ touched: TouchedFile[],
42
+ page?: number,
43
+ pageSize?: number,
44
+ ): string {
45
+ if (touched.length === 0) {
46
+ return "No file operations found in session history.";
47
+ }
48
+
49
+ const ps = pageSize ?? TOUCHED_PAGE_SIZE;
50
+ const totalPages = Math.ceil(touched.length / ps);
51
+ const currentPage = Math.max(1, page ?? 1);
52
+ const start = (currentPage - 1) * ps;
53
+ const pageFiles = touched.slice(start, start + ps);
54
+
55
+ const header =
56
+ totalPages > 1
57
+ ? `Page ${currentPage}/${totalPages} (${touched.length} total files)`
58
+ : `${touched.length} files touched`;
59
+
60
+ const lines = pageFiles.map((tf) => {
61
+ const displayPath = shortPath(tf.path);
62
+ const indices = tf.entries
63
+ .map((e) => `#${e.index} (${e.toolName})`)
64
+ .join(", ");
65
+ return ` ${displayPath} ${indices}`;
66
+ });
67
+
68
+ let result = `${header}:\n\n${lines.join("\n")}`;
69
+
70
+ if (currentPage < totalPages) {
71
+ result += `\n\n--- Use page:${currentPage + 1} for more results ---`;
72
+ }
73
+
74
+ return result;
75
+ }
76
+
77
+ export const formatRecallOutput = (
78
+ entries: SearchHit[],
79
+ query?: string,
80
+ headerOverride?: string,
81
+ ): string => {
82
+ if (entries.length === 0) {
83
+ return query
84
+ ? `No matches for "${query}" in session history.`
85
+ : "No entries in session history.";
86
+ }
87
+
88
+ const header = headerOverride
89
+ ? `${headerOverride} for "${query}":`
90
+ : query
91
+ ? `Found ${entries.length} matches for "${query}":`
92
+ : `Session history (${entries.length} entries):`;
93
+
94
+ const lines = entries.map((e) => {
95
+ const fileSuffix = e.files?.length ? ` files:[${e.files.join(", ")}]` : "";
96
+ const body = query && e.snippet ? e.snippet : e.summary;
97
+ return `#${e.index} [${e.role}]${fileSuffix} ${body}`;
98
+ });
99
+
100
+ return `${header}\n\n${lines.join("\n\n")}`;
101
+ };
@@ -0,0 +1,82 @@
1
+ // @ts-nocheck
2
+ import type { SectionData } from "../sections";
3
+
4
+ const section = (title: string, items: string[]): string => {
5
+ if (items.length === 0) return "";
6
+ const body = items.map((i) => `- ${i}`).join("\n");
7
+ return `[${title}]\n${body}`;
8
+ };
9
+
10
+ export const BRIEF_MAX_LINES = 120;
11
+ const TUI_SAFE_LINE_CHARS = 120;
12
+
13
+ const wrapLine = (line: string, maxChars: number): string[] => {
14
+ if (line.length <= maxChars) return [line];
15
+
16
+ const indent = line.match(/^\s*(?:[-*]\s+|\d+\.\s+)?/)?.[0] ?? "";
17
+ const continuationIndent = indent ? " ".repeat(Math.min(indent.length, 8)) : "";
18
+ const wrapped: string[] = [];
19
+ let remaining = line;
20
+ let prefix = "";
21
+
22
+ while (prefix.length + remaining.length > maxChars) {
23
+ const available = Math.max(20, maxChars - prefix.length);
24
+ let splitAt = remaining.lastIndexOf(" ", available);
25
+ if (splitAt < Math.floor(available * 0.5)) splitAt = available;
26
+
27
+ wrapped.push(prefix + remaining.slice(0, splitAt).trimEnd());
28
+ remaining = remaining.slice(splitAt).trimStart();
29
+ prefix = continuationIndent;
30
+ }
31
+
32
+ if (remaining) wrapped.push(prefix + remaining);
33
+ return wrapped;
34
+ };
35
+
36
+ export const wrapLongLines = (text: string, maxChars = TUI_SAFE_LINE_CHARS): string =>
37
+ text.split("\n").flatMap((line) => wrapLine(line, maxChars)).join("\n");
38
+
39
+ export const capBrief = (text: string): string => {
40
+ const lines = text.split("\n");
41
+ if (lines.length <= BRIEF_MAX_LINES) return text;
42
+ const omitted = lines.length - BRIEF_MAX_LINES;
43
+ const kept = lines.slice(-BRIEF_MAX_LINES);
44
+ // Find first section header to avoid cutting mid-section
45
+ const firstHeader = kept.findIndex((l) => /^\[.+\]/.test(l));
46
+ const clean = firstHeader > 0 ? kept.slice(firstHeader) : kept;
47
+ return `...(${omitted} earlier lines omitted)\n\n${clean.join("\n")}`;
48
+ };
49
+
50
+ export const RECALL_NOTE =
51
+ "Use `vcc_recall` to search for prior work, decisions, and context from before this summary. " +
52
+ "Do not redo work already completed.";
53
+
54
+ export interface FormatSummaryOptions {
55
+ capBriefTranscript?: boolean;
56
+ }
57
+
58
+ export const formatSummary = (data: SectionData, options: FormatSummaryOptions = {}): string => {
59
+ const capBriefTranscript = options.capBriefTranscript ?? true;
60
+ const headerParts = [
61
+ section("Session Goal", data.sessionGoal),
62
+ section("Files And Changes", data.filesAndChanges),
63
+ section("Commits", data.commits),
64
+ section("Outstanding Context", data.outstandingContext),
65
+ section("User Preferences", data.userPreferences),
66
+ ].filter(Boolean);
67
+
68
+ const parts: string[] = [];
69
+ if (headerParts.length > 0) {
70
+ parts.push(headerParts.join("\n\n"));
71
+ }
72
+ if (data.briefTranscript) {
73
+ parts.push(capBriefTranscript ? capBrief(data.briefTranscript) : data.briefTranscript);
74
+ }
75
+
76
+ if (parts.length === 0) return "";
77
+
78
+ // NOTE: RECALL_NOTE is intentionally NOT appended here.
79
+ // It is appended once by `compile()` at the very end, after merge-with-previous,
80
+ // to avoid the note compounding inside the brief transcript across compactions.
81
+ return wrapLongLines(parts.join("\n\n---\n\n"));
82
+ };
@@ -0,0 +1,27 @@
1
+ // @ts-nocheck
2
+ export interface LineageEntryLike {
3
+ id?: string;
4
+ }
5
+
6
+ export interface LineageSessionManagerLike {
7
+ getBranch: () => LineageEntryLike[];
8
+ getEntries?: () => LineageEntryLike[];
9
+ }
10
+
11
+ export const getActiveLineageEntryIds = (sessionManager: LineageSessionManagerLike): Set<string> => {
12
+ try {
13
+ const branch = sessionManager.getBranch() ?? [];
14
+ if (branch.length > 0) {
15
+ return new Set(branch.map((e) => e.id).filter((id): id is string => Boolean(id)));
16
+ }
17
+ } catch {
18
+ // fall through to defensive fallback
19
+ }
20
+
21
+ try {
22
+ const all = sessionManager.getEntries?.() ?? [];
23
+ return new Set(all.map((e) => e.id).filter((id): id is string => Boolean(id)));
24
+ } catch {
25
+ return new Set();
26
+ }
27
+ };
@@ -0,0 +1,44 @@
1
+ // @ts-nocheck
2
+ import { readFileSync } from "fs";
3
+ import type { Message } from "@oh-my-pi/pi-ai";
4
+ import { renderMessage, type RenderedEntry } from "./render-entries";
5
+
6
+ export interface LoadedMessages {
7
+ rendered: RenderedEntry[];
8
+ rawMessages: Message[];
9
+ }
10
+
11
+ export const loadAllMessages = (
12
+ sessionFile: string,
13
+ full: boolean,
14
+ allowedEntryIds?: Set<string>,
15
+ ): LoadedMessages => {
16
+ let content: string;
17
+ try {
18
+ content = readFileSync(sessionFile, "utf-8");
19
+ } catch {
20
+ return { rendered: [], rawMessages: [] };
21
+ }
22
+ const entries: any[] = [];
23
+ for (const line of content.split("\n")) {
24
+ if (!line.trim()) continue;
25
+ try { entries.push(JSON.parse(line)); } catch {}
26
+ }
27
+ const rendered: RenderedEntry[] = [];
28
+ const rawMessages: Message[] = [];
29
+
30
+ let messageIndex = 0;
31
+ for (const e of entries) {
32
+ const isMessage = e.type === "message" && e.message;
33
+ if (!isMessage) continue;
34
+
35
+ const allowed = !allowedEntryIds || allowedEntryIds.has(e.id);
36
+ if (allowed) {
37
+ rendered.push(renderMessage(e.message, messageIndex, full));
38
+ rawMessages.push(e.message);
39
+ }
40
+ messageIndex++;
41
+ }
42
+
43
+ return { rendered, rawMessages };
44
+ };
@@ -0,0 +1,66 @@
1
+ // @ts-nocheck
2
+ import type { Message } from "@oh-my-pi/pi-ai";
3
+ import type { NormalizedBlock } from "../types";
4
+ import { textOf } from "./content";
5
+ import { sanitize } from "./sanitize";
6
+
7
+ const normalizeOne = (msg: Message, msgIndex: number): NormalizedBlock[] => {
8
+ if (msg.role === "user") {
9
+ const blocks: NormalizedBlock[] = [];
10
+ const text = sanitize(textOf(msg.content));
11
+ if (text) blocks.push({ kind: "user", text, sourceIndex: msgIndex });
12
+ if (msg.content && typeof msg.content !== "string") {
13
+ for (const part of msg.content) {
14
+ if (part.type === "image") {
15
+ blocks.push({ kind: "user", text: `[image: ${part.mimeType}]`, sourceIndex: msgIndex });
16
+ }
17
+ }
18
+ }
19
+ return blocks.length > 0 ? blocks : [{ kind: "user", text: "", sourceIndex: msgIndex }];
20
+ }
21
+
22
+ if (msg.role === "bashExecution") {
23
+ const cmd = (msg as any).command ?? "";
24
+ const out = (msg as any).output ?? "";
25
+ const exit = (msg as any).exitCode;
26
+ return [{ kind: "bash", command: cmd, output: out, exitCode: exit, sourceIndex: msgIndex }];
27
+ }
28
+
29
+ if (msg.role === "toolResult") {
30
+ return [{
31
+ kind: "tool_result",
32
+ name: msg.toolName,
33
+ text: sanitize(textOf(msg.content)),
34
+ sourceIndex: msgIndex,
35
+ }];
36
+ }
37
+
38
+ if (msg.role === "assistant") {
39
+ if (!msg.content) return [];
40
+ if (typeof msg.content === "string") {
41
+ return [{ kind: "assistant", text: sanitize(msg.content), sourceIndex: msgIndex }];
42
+ }
43
+
44
+ const blocks: NormalizedBlock[] = [];
45
+ for (const part of msg.content) {
46
+ if (part.type === "text") {
47
+ blocks.push({ kind: "assistant", text: sanitize(part.text), sourceIndex: msgIndex });
48
+ } else if (part.type === "toolCall") {
49
+ blocks.push({
50
+ kind: "tool_call",
51
+ name: part.name,
52
+ args: part.arguments,
53
+ sourceIndex: msgIndex,
54
+ });
55
+ }
56
+ }
57
+ return blocks;
58
+ }
59
+
60
+ return [];
61
+ };
62
+
63
+ export const normalize = (messages: Message[]): NormalizedBlock[] =>
64
+ messages.flatMap((msg, i) => normalizeOne(msg, i));
65
+
66
+