ultimate-pi 0.4.0 → 0.4.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 (76) hide show
  1. package/.pi/PACKAGING.md +3 -2
  2. package/.pi/extensions/lib/harness-vcc-settings.ts +50 -0
  3. package/.pi/extensions/ultimate-pi-vcc.ts +17 -0
  4. package/.pi/harness/docs/adrs/0030-inhouse-vcc-compaction.md +40 -0
  5. package/.pi/harness/docs/adrs/README.md +1 -0
  6. package/.pi/prompts/harness-setup.md +2 -2
  7. package/.pi/scripts/vendor-pi-vcc-settings.stub.ts +8 -0
  8. package/.pi/scripts/vendor-sync-pi-vcc.sh +40 -0
  9. package/.pi/settings.example.json +1 -6
  10. package/CHANGELOG.md +9 -7
  11. package/THIRD_PARTY_NOTICES.md +8 -22
  12. package/package.json +7 -6
  13. package/vendor/pi-vcc/README.md +215 -0
  14. package/vendor/pi-vcc/UPSTREAM_PIN.md +12 -0
  15. package/vendor/pi-vcc/demo.gif +0 -0
  16. package/vendor/pi-vcc/index.ts +12 -0
  17. package/vendor/pi-vcc/package.json +26 -0
  18. package/vendor/pi-vcc/scripts/audit-sessions.ts +88 -0
  19. package/vendor/pi-vcc/scripts/benchmark-real-sessions.ts +25 -0
  20. package/vendor/pi-vcc/scripts/compare-before-after.ts +36 -0
  21. package/vendor/pi-vcc/scripts/dump-branch-output.ts +20 -0
  22. package/vendor/pi-vcc/src/commands/pi-vcc.ts +36 -0
  23. package/vendor/pi-vcc/src/commands/vcc-recall.ts +65 -0
  24. package/vendor/pi-vcc/src/core/brief.ts +381 -0
  25. package/vendor/pi-vcc/src/core/build-sections.ts +79 -0
  26. package/vendor/pi-vcc/src/core/content.ts +60 -0
  27. package/vendor/pi-vcc/src/core/filter-noise.ts +42 -0
  28. package/vendor/pi-vcc/src/core/format-recall.ts +27 -0
  29. package/vendor/pi-vcc/src/core/format.ts +49 -0
  30. package/vendor/pi-vcc/src/core/lineage.ts +26 -0
  31. package/vendor/pi-vcc/src/core/load-messages.ts +41 -0
  32. package/vendor/pi-vcc/src/core/normalize.ts +66 -0
  33. package/vendor/pi-vcc/src/core/recall-scope.ts +14 -0
  34. package/vendor/pi-vcc/src/core/render-entries.ts +55 -0
  35. package/vendor/pi-vcc/src/core/report.ts +237 -0
  36. package/vendor/pi-vcc/src/core/sanitize.ts +5 -0
  37. package/vendor/pi-vcc/src/core/search-entries.ts +221 -0
  38. package/vendor/pi-vcc/src/core/settings.ts +8 -0
  39. package/vendor/pi-vcc/src/core/skill-collapse.ts +35 -0
  40. package/vendor/pi-vcc/src/core/summarize.ts +157 -0
  41. package/vendor/pi-vcc/src/core/tool-args.ts +14 -0
  42. package/vendor/pi-vcc/src/details.ts +7 -0
  43. package/vendor/pi-vcc/src/extract/commits.ts +69 -0
  44. package/vendor/pi-vcc/src/extract/files.ts +80 -0
  45. package/vendor/pi-vcc/src/extract/goals.ts +79 -0
  46. package/vendor/pi-vcc/src/extract/preferences.ts +55 -0
  47. package/vendor/pi-vcc/src/hooks/before-compact.ts +314 -0
  48. package/vendor/pi-vcc/src/sections.ts +12 -0
  49. package/vendor/pi-vcc/src/tools/recall.ts +109 -0
  50. package/vendor/pi-vcc/src/types.ts +14 -0
  51. package/vendor/pi-vcc/tests/before-compact-hook.test.ts +204 -0
  52. package/vendor/pi-vcc/tests/before-compact.test.ts +145 -0
  53. package/vendor/pi-vcc/tests/brief.test.ts +206 -0
  54. package/vendor/pi-vcc/tests/build-sections.test.ts +59 -0
  55. package/vendor/pi-vcc/tests/compile.test.ts +80 -0
  56. package/vendor/pi-vcc/tests/content.test.ts +31 -0
  57. package/vendor/pi-vcc/tests/extract-goals.test.ts +86 -0
  58. package/vendor/pi-vcc/tests/extract-preferences.test.ts +30 -0
  59. package/vendor/pi-vcc/tests/filter-noise.test.ts +61 -0
  60. package/vendor/pi-vcc/tests/fixtures.ts +61 -0
  61. package/vendor/pi-vcc/tests/format-recall.test.ts +30 -0
  62. package/vendor/pi-vcc/tests/format.test.ts +62 -0
  63. package/vendor/pi-vcc/tests/lineage.test.ts +33 -0
  64. package/vendor/pi-vcc/tests/load-messages.test.ts +51 -0
  65. package/vendor/pi-vcc/tests/normalize.test.ts +97 -0
  66. package/vendor/pi-vcc/tests/real-sessions.test.ts +38 -0
  67. package/vendor/pi-vcc/tests/recall-expand.test.ts +15 -0
  68. package/vendor/pi-vcc/tests/recall-scope.test.ts +32 -0
  69. package/vendor/pi-vcc/tests/recall-tool-scope.test.ts +67 -0
  70. package/vendor/pi-vcc/tests/render-entries.test.ts +62 -0
  71. package/vendor/pi-vcc/tests/report.test.ts +44 -0
  72. package/vendor/pi-vcc/tests/sanitize.test.ts +24 -0
  73. package/vendor/pi-vcc/tests/search-entries.test.ts +144 -0
  74. package/vendor/pi-vcc/tests/support/load-session.ts +23 -0
  75. package/vendor/pi-vcc/tests/support/real-sessions.ts +51 -0
  76. package/.pi/pi-vcc-config.json +0 -4
@@ -0,0 +1,69 @@
1
+ import type { NormalizedBlock } from "../types";
2
+
3
+ interface CommitInfo {
4
+ hash?: string;
5
+ message: string;
6
+ }
7
+
8
+ const COMMIT_MSG_RE = /git\s+commit[^\n]*?-m\s+(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|\$?'((?:[^'\\]|\\.)*)')/;
9
+ // Match short hash from git output: "[branch hash]" or "main hash" or 7-12 hex
10
+ const HASH_RE = /\b([0-9a-f]{7,12})\b/;
11
+
12
+ const firstLineOf = (text: string): string => {
13
+ const line = text.split(/\\n|\n/)[0] ?? "";
14
+ return line.trim();
15
+ };
16
+
17
+ const cleanMessage = (msg: string): string =>
18
+ msg.replace(/\\"/g, '"').replace(/\\'/g, "'").trim();
19
+
20
+ /**
21
+ * Extract git commits from bash tool calls (`git commit -m "..."`) and pair
22
+ * with hash from the immediately following tool_result.
23
+ */
24
+ export const extractCommits = (blocks: NormalizedBlock[]): CommitInfo[] => {
25
+ const commits: CommitInfo[] = [];
26
+
27
+ for (let i = 0; i < blocks.length; i++) {
28
+ const b = blocks[i];
29
+ if (b.kind !== "tool_call" || b.name !== "bash") continue;
30
+ const cmd = typeof b.args.command === "string" ? b.args.command : "";
31
+ if (!/\bgit\s+commit\b/.test(cmd)) continue;
32
+ const m = cmd.match(COMMIT_MSG_RE);
33
+ if (!m) continue;
34
+ const message = firstLineOf(cleanMessage(m[1] ?? m[2] ?? m[3] ?? ""));
35
+ if (!message) continue;
36
+
37
+ let hash: string | undefined;
38
+ // Look at next tool_result for hash
39
+ for (let j = i + 1; j < Math.min(blocks.length, i + 3); j++) {
40
+ const r = blocks[j];
41
+ if (r.kind !== "tool_result") continue;
42
+ // Common git commit output: `[branch <hash>] message` or `<branch> <hash>..<hash>`
43
+ const bracket = r.text.match(/\[\S+\s+([0-9a-f]{7,12})\]/);
44
+ if (bracket) { hash = bracket[1]; break; }
45
+ const range = r.text.match(/\b([0-9a-f]{7,12})\.\.([0-9a-f]{7,12})\b/);
46
+ if (range) { hash = range[2]; break; }
47
+ const plain = r.text.match(HASH_RE);
48
+ if (plain) { hash = plain[1]; break; }
49
+ }
50
+
51
+ // Dedup by message+hash
52
+ const key = `${hash ?? ""}::${message}`;
53
+ if (!commits.some((c) => `${c.hash ?? ""}::${c.message}` === key)) {
54
+ commits.push({ hash, message });
55
+ }
56
+ }
57
+
58
+ return commits;
59
+ };
60
+
61
+ export const formatCommits = (commits: CommitInfo[], limit = 8): string[] => {
62
+ const lines: string[] = [];
63
+ const items = commits.slice(-limit); // keep most recent
64
+ for (const c of items) {
65
+ const prefix = c.hash ? `${c.hash}: ` : "";
66
+ lines.push(`${prefix}${c.message}`);
67
+ }
68
+ return lines;
69
+ };
@@ -0,0 +1,80 @@
1
+ import type { FileOps, NormalizedBlock } from "../types";
2
+ import { extractPath } from "../core/tool-args";
3
+
4
+ interface FileActivity {
5
+ read: Set<string>;
6
+ modified: Set<string>;
7
+ created: Set<string>;
8
+ }
9
+
10
+ const FILE_READ_TOOLS = new Set([
11
+ "Read", "read_file", "View",
12
+ ]);
13
+
14
+ const FILE_WRITE_TOOLS = new Set([
15
+ "Edit", "Write", "edit", "write", "edit_file", "write_file",
16
+ "MultiEdit",
17
+ ]);
18
+
19
+ const FILE_CREATE_TOOLS = new Set([
20
+ "Write", "write", "write_file",
21
+ ]);
22
+
23
+ /**
24
+ * Find the longest common directory prefix among absolute paths.
25
+ * Returns "" if fewer than 2 absolute paths or no meaningful common prefix.
26
+ */
27
+ const longestCommonDirPrefix = (paths: string[]): string => {
28
+ const abs = paths.filter((p) => p.startsWith("/"));
29
+ if (abs.length < 2) return "";
30
+ const split = abs.map((p) => p.split("/"));
31
+ const min = Math.min(...split.map((s) => s.length));
32
+ let i = 0;
33
+ while (i < min - 1) {
34
+ const seg = split[0][i];
35
+ if (!split.every((s) => s[i] === seg)) break;
36
+ i++;
37
+ }
38
+ if (i < 2) return ""; // require at least /a/b common
39
+ return split[0].slice(0, i).join("/") + "/";
40
+ };
41
+
42
+ const trimPaths = (set: Set<string>, prefix: string): Set<string> => {
43
+ if (!prefix) return set;
44
+ const out = new Set<string>();
45
+ for (const p of set) {
46
+ out.add(p.startsWith(prefix) ? p.slice(prefix.length) : p);
47
+ }
48
+ return out;
49
+ };
50
+
51
+ export const extractFiles = (
52
+ blocks: NormalizedBlock[],
53
+ fileOps?: FileOps,
54
+ ): FileActivity => {
55
+ const act: FileActivity = {
56
+ read: new Set(fileOps?.readFiles ?? []),
57
+ modified: new Set(fileOps?.modifiedFiles ?? []),
58
+ created: new Set(fileOps?.createdFiles ?? []),
59
+ };
60
+
61
+ for (const b of blocks) {
62
+ if (b.kind !== "tool_call") continue;
63
+ const p = extractPath(b.args);
64
+ if (!p) continue;
65
+
66
+ if (FILE_READ_TOOLS.has(b.name)) act.read.add(p);
67
+ if (FILE_WRITE_TOOLS.has(b.name)) act.modified.add(p);
68
+ if (FILE_CREATE_TOOLS.has(b.name)) act.created.add(p);
69
+ }
70
+
71
+ const all = [...act.read, ...act.modified, ...act.created];
72
+ const prefix = longestCommonDirPrefix(all);
73
+ if (prefix) {
74
+ act.read = trimPaths(act.read, prefix);
75
+ act.modified = trimPaths(act.modified, prefix);
76
+ act.created = trimPaths(act.created, prefix);
77
+ }
78
+
79
+ return act;
80
+ };
@@ -0,0 +1,79 @@
1
+ import type { NormalizedBlock } from "../types";
2
+ import { nonEmptyLines, clip } from "../core/content";
3
+ import { collapseSkillLines } from "../core/skill-collapse";
4
+
5
+ const SCOPE_CHANGE_RE =
6
+ /\b(instead|actually|change of plan|forget that|new task|switch to|now I want|pivot|let'?s do|stop .* and)\b/i;
7
+
8
+ const TASK_RE =
9
+ /\b(fix|implement|add|create|build|refactor|debug|investigate|update|remove|delete|migrate|deploy|test|write|set up)\b/i;
10
+
11
+ const NOISE_SHORT_RE = /^(ok|yes|no|sure|yeah|yep|go|hi|hey|thx|thanks|ok\b.*|y|n|k)\s*[.!?]*$/i;
12
+
13
+ // Reject lines that are clearly not user goals (pasted output, code, paths, tool dumps)
14
+ // or meta-prompt boilerplate (command templates like `/issues` that start with "For each issue:"
15
+ // followed by numbered "Read the issue in full..." steps).
16
+ const NON_GOAL_RE =
17
+ /^\s*[\[│├└─╭╰]|```|^\s*(=[A-Z]+\(|function |const |let |var |import |export |class )|^(https?:|file:|\/[A-Za-z])|\\n|^\s*For each\b|\bin full\b[^\n]*\b(comments|issue|issues|PRs?|linked)\b/;
18
+
19
+ // Signals that the rest of the user message is a command template (e.g. /issues),
20
+ // in which case we should stop collecting goals at the signal line.
21
+ const TEMPLATE_SIGNAL_RE =
22
+ /^\s*(For each\b|Do NOT implement\b|Analyze and propose\b|If Task\/context\b|Output:\s*$)/i;
23
+
24
+ const truncateAtTemplate = (lines: string[]): string[] => {
25
+ const idx = lines.findIndex((l) => TEMPLATE_SIGNAL_RE.test(l));
26
+ return idx >= 0 ? lines.slice(0, idx) : lines;
27
+ };
28
+
29
+ const stripLeadingBullet = (line: string): string =>
30
+ line.replace(/^\s*(?:[-*+]|\d+\.)\s+/, "").trim();
31
+
32
+ const MAX_GOAL_CHARS = 200;
33
+
34
+ const isSubstantiveGoal = (text: string): boolean => {
35
+ const t = text.trim();
36
+ if (t.length <= 5) return false;
37
+ if (t.length > MAX_GOAL_CHARS) return false;
38
+ if (NOISE_SHORT_RE.test(t)) return false;
39
+ if (NON_GOAL_RE.test(t)) return false;
40
+ return true;
41
+ };
42
+
43
+ // Test scope-change / task intent only on the leading portion of a user block
44
+ // so that pasted outputs below the actual instruction do not trigger matches.
45
+ const LEADING_CHARS = 200;
46
+
47
+ export const extractGoals = (blocks: NormalizedBlock[]): string[] => {
48
+ const goals: string[] = [];
49
+ let latestScopeChange: string[] | null = null;
50
+
51
+ for (const b of blocks) {
52
+ if (b.kind !== "user") continue;
53
+ const rawLines = nonEmptyLines(b.text);
54
+ const truncated = truncateAtTemplate(rawLines);
55
+ const lines = collapseSkillLines(truncated.filter(isSubstantiveGoal))
56
+ .map(stripLeadingBullet)
57
+ .filter((l) => l.length > 5);
58
+ if (lines.length === 0) continue;
59
+
60
+ if (goals.length === 0) {
61
+ goals.push(...lines.slice(0, 6));
62
+ continue;
63
+ }
64
+
65
+ const leading = b.text.slice(0, LEADING_CHARS);
66
+ if (SCOPE_CHANGE_RE.test(leading)) {
67
+ latestScopeChange = lines.slice(0, 3).map((l) => clip(l, MAX_GOAL_CHARS));
68
+ } else if (TASK_RE.test(leading) && lines[0].length > 15) {
69
+ latestScopeChange = lines.slice(0, 2).map((l) => clip(l, MAX_GOAL_CHARS));
70
+ }
71
+ }
72
+
73
+ // Only emit the [Scope change] marker when we actually captured bullets.
74
+ if (latestScopeChange && latestScopeChange.length > 0) {
75
+ goals.push("[Scope change]", ...latestScopeChange);
76
+ }
77
+
78
+ return goals.slice(0, 8);
79
+ };
@@ -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,314 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { convertToLlm } from "@mariozechner/pi-coding-agent";
3
+ import { writeFileSync } from "fs";
4
+ import { compile } from "../core/summarize";
5
+ import { loadSettings, type PiVccSettings } from "../core/settings";
6
+ import type { PiVccCompactionDetails } from "../details";
7
+
8
+ export const PI_VCC_COMPACT_INSTRUCTION = "__pi_vcc__";
9
+
10
+ export interface CompactionStats {
11
+ summarized: number;
12
+ kept: number;
13
+ keptTokensEst: number;
14
+ }
15
+
16
+ let lastStats: CompactionStats | null = null;
17
+ let lastCompactWasPiVcc = false;
18
+ export const getLastCompactionStats = () => lastStats;
19
+
20
+ const formatTokens = (n: number): string => {
21
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
22
+ return String(n);
23
+ };
24
+
25
+ const dbg = (settings: PiVccSettings, data: Record<string, unknown>) => {
26
+ if (!settings.debug) return;
27
+ try { writeFileSync("/tmp/pi-vcc-debug.json", JSON.stringify(data, null, 2)); } catch {}
28
+ };
29
+
30
+ const previewContent = (content: unknown): string => {
31
+ if (typeof content === "string") return content.slice(0, 300);
32
+ if (Array.isArray(content)) {
33
+ return content
34
+ .map((c: any) => {
35
+ if (c?.type === "text") return c.text ?? "";
36
+ if (c?.type === "toolCall") return `[toolCall:${c.name}]`;
37
+ if (c?.type === "thinking") return `[thinking]`;
38
+ if (c?.type === "image") return `[image:${c.mimeType}]`;
39
+ return `[${c?.type ?? "unknown"}]`;
40
+ })
41
+ .join("\n")
42
+ .slice(0, 300);
43
+ }
44
+ return "";
45
+ };
46
+
47
+ interface EntryWithMessage {
48
+ entry: { id: string; type: string };
49
+ message: { role: string; content: unknown };
50
+ }
51
+
52
+ export type OwnCutCancelReason =
53
+ | "no_live_messages"
54
+ | "too_few_live_messages";
55
+
56
+ export type OwnCutResult =
57
+ | { ok: true; messages: any[]; firstKeptEntryId: string; compactAll: boolean }
58
+ | { ok: false; reason: OwnCutCancelReason };
59
+
60
+ export function buildOwnCut(branchEntries: any[]): OwnCutResult {
61
+ // Find the last compaction entry and its firstKeptEntryId
62
+ let lastCompactionIdx = -1;
63
+ let lastKeptId: string | undefined;
64
+ for (let i = branchEntries.length - 1; i >= 0; i--) {
65
+ if (branchEntries[i].type === "compaction") {
66
+ lastCompactionIdx = i;
67
+ lastKeptId = branchEntries[i].firstKeptEntryId;
68
+ break;
69
+ }
70
+ }
71
+
72
+ // Orphan recovery: triggers when lastKeptId is set to "" (sentinel from prior
73
+ // compact-all) OR set to an id that no longer exists in the branch. In both cases,
74
+ // start collecting from right after the last compaction entry.
75
+ const hasPriorCompaction = lastCompactionIdx >= 0;
76
+ const hasValidKeptId = !!lastKeptId && branchEntries.some((e: any) => e.id === lastKeptId);
77
+ const orphanRecovery = hasPriorCompaction && !hasValidKeptId;
78
+
79
+ // Collect live messages
80
+ const liveMessages: EntryWithMessage[] = [];
81
+ if (orphanRecovery) {
82
+ for (let i = lastCompactionIdx + 1; i < branchEntries.length; i++) {
83
+ const e = branchEntries[i];
84
+ if (e.type === "compaction") continue;
85
+ if (e.type === "message" && e.message) {
86
+ liveMessages.push({ entry: e, message: e.message });
87
+ }
88
+ }
89
+ } else {
90
+ let foundKept = !lastKeptId; // if no prior compaction, start collecting immediately
91
+ for (const e of branchEntries) {
92
+ if (!foundKept && e.id === lastKeptId) foundKept = true;
93
+ if (!foundKept) continue;
94
+ if (e.type === "compaction") continue;
95
+ if (e.type === "message" && e.message) {
96
+ liveMessages.push({ entry: e, message: e.message });
97
+ }
98
+ }
99
+ }
100
+
101
+ if (liveMessages.length === 0) return { ok: false, reason: "no_live_messages" };
102
+ if (liveMessages.length <= 2) return { ok: false, reason: "too_few_live_messages" };
103
+
104
+ // Summarize all messages, keep only the last user message as context
105
+ let cutIdx = liveMessages.length - 1;
106
+ while (cutIdx > 0 && liveMessages[cutIdx].message.role !== "user") {
107
+ cutIdx--;
108
+ }
109
+
110
+ if (cutIdx <= 0) {
111
+ // Single user prompt scenario (or no user at all).
112
+ // Compact EVERYTHING and keep no tail. This handles both:
113
+ // - Single user prompt at index 0: compact all, fresh start after summary
114
+ // - No user message at all (e.g., long assistant/tool chain): still compact
115
+ // to recover from context overflow rather than cancelling and leaving
116
+ // the session unrecoverable.
117
+ // firstKeptEntryId="" is a sentinel: pi-core's buildSessionContext won't match it
118
+ // (so 0 kept from pre-compaction), and next buildOwnCut triggers orphan recovery.
119
+ return {
120
+ ok: true,
121
+ messages: liveMessages.map((e) => e.message),
122
+ firstKeptEntryId: "",
123
+ compactAll: true,
124
+ };
125
+ }
126
+
127
+ return {
128
+ ok: true,
129
+ messages: liveMessages.slice(0, cutIdx).map((e) => e.message),
130
+ firstKeptEntryId: liveMessages[cutIdx].entry.id,
131
+ compactAll: false,
132
+ };
133
+ }
134
+
135
+ const REASON_MESSAGES: Record<OwnCutCancelReason, string> = {
136
+ no_live_messages: "pi-vcc: Nothing to compact (no live messages)",
137
+ too_few_live_messages: "pi-vcc: Too few messages to compact",
138
+ };
139
+
140
+ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
141
+ pi.on("session_before_compact", (event, ctx) => {
142
+ const { preparation, branchEntries, customInstructions } = event;
143
+ const settings = loadSettings();
144
+
145
+ // Always handle explicit /pi-vcc marker.
146
+ // Otherwise, only handle when user opted in via settings.
147
+ const isPiVcc = customInstructions === PI_VCC_COMPACT_INSTRUCTION;
148
+ if (!isPiVcc && !settings.overrideDefaultCompaction) return;
149
+
150
+ const ownCut = buildOwnCut(branchEntries as any[]);
151
+ if (!ownCut.ok) {
152
+ const lastComp = [...branchEntries].reverse().find((e: any) => e.type === "compaction");
153
+ const lastCompIdx = lastComp ? (branchEntries as any[]).indexOf(lastComp) : -1;
154
+
155
+ // Recompute liveMessages view (same logic as buildOwnCut) for diagnostic
156
+ const lastKeptId: string | undefined = lastComp?.firstKeptEntryId;
157
+ const hasPriorCompaction = lastCompIdx >= 0;
158
+ const hasValidKeptId = !!lastKeptId && (branchEntries as any[]).some((e: any) => e.id === lastKeptId);
159
+ const diagOrphan = hasPriorCompaction && !hasValidKeptId;
160
+ const liveRoles: string[] = [];
161
+ if (diagOrphan) {
162
+ for (let i = lastCompIdx + 1; i < branchEntries.length; i++) {
163
+ const e = (branchEntries as any[])[i];
164
+ if (e.type === "compaction") continue;
165
+ if (e.type === "message" && e.message) liveRoles.push(e.message.role);
166
+ }
167
+ } else {
168
+ let foundKept = !lastKeptId;
169
+ for (const e of branchEntries as any[]) {
170
+ if (!foundKept && e.id === lastKeptId) foundKept = true;
171
+ if (!foundKept) continue;
172
+ if (e.type === "compaction") continue;
173
+ if (e.type === "message" && e.message) liveRoles.push(e.message.role);
174
+ }
175
+ }
176
+ const userIndices = liveRoles.reduce<number[]>((acc, r, i) => (r === "user" ? (acc.push(i), acc) : acc), []);
177
+
178
+ dbg(settings, {
179
+ cancelled: true,
180
+ reason: ownCut.reason,
181
+ isPiVcc,
182
+ counts: {
183
+ total: branchEntries.length,
184
+ messages: (branchEntries as any[]).filter((e: any) => e.type === "message").length,
185
+ compactions: (branchEntries as any[]).filter((e: any) => e.type === "compaction").length,
186
+ entriesAfterLastCompaction: lastCompIdx >= 0 ? branchEntries.length - lastCompIdx - 1 : null,
187
+ },
188
+ liveMessages: {
189
+ count: liveRoles.length,
190
+ userCount: userIndices.length,
191
+ firstUserIdx: userIndices[0] ?? null,
192
+ lastUserIdx: userIndices[userIndices.length - 1] ?? null,
193
+ roleSequence: liveRoles.length <= 30
194
+ ? liveRoles
195
+ : [...liveRoles.slice(0, 10), "...", ...liveRoles.slice(-10)],
196
+ },
197
+ lastCompaction: lastComp ? {
198
+ hasFirstKeptEntryId: !!lastComp.firstKeptEntryId,
199
+ foundInBranch: lastComp.firstKeptEntryId
200
+ ? (branchEntries as any[]).some((e: any) => e.id === lastComp.firstKeptEntryId)
201
+ : null,
202
+ } : null,
203
+ tail: (branchEntries as any[]).slice(-5).map((e: any) => ({
204
+ type: e.type,
205
+ role: e.type === "message" ? e.message?.role : undefined,
206
+ hasContent: e.type === "message" ? e.message?.content != null : undefined,
207
+ })),
208
+ });
209
+
210
+ try {
211
+ ctx?.ui?.notify?.(REASON_MESSAGES[ownCut.reason], "warning");
212
+ } catch {}
213
+ return { cancel: true };
214
+ }
215
+
216
+ const agentMessages = ownCut.messages;
217
+ const firstKeptEntryId = ownCut.firstKeptEntryId;
218
+ const messages = convertToLlm(agentMessages);
219
+
220
+ // Count kept messages and estimate tokens
221
+ const keptIdx = (branchEntries as any[]).findIndex((e: any) => e.id === firstKeptEntryId);
222
+ const keptEntries = keptIdx >= 0
223
+ ? (branchEntries as any[]).slice(keptIdx).filter((e: any) => e.type === "message")
224
+ : [];
225
+ const keptChars = keptEntries.reduce((sum: number, e: any) => {
226
+ const c = e.message?.content;
227
+ if (typeof c === "string") return sum + c.length;
228
+ if (Array.isArray(c)) return sum + c.reduce((s: number, p: any) => {
229
+ if (p.text) return s + p.text.length;
230
+ if (p.type === "toolCall") return s + (p.name?.length ?? 0) + (typeof p.input === "string" ? p.input.length : JSON.stringify(p.input ?? "").length);
231
+ if (p.type === "toolResult") return s + (typeof p.content === "string" ? p.content.length : JSON.stringify(p.content ?? "").length);
232
+ return s;
233
+ }, 0);
234
+ return sum;
235
+ }, 0);
236
+ lastStats = {
237
+ summarized: agentMessages.length,
238
+ kept: keptEntries.length,
239
+ keptTokensEst: Math.round(keptChars / 4),
240
+ };
241
+
242
+ const config = settings;
243
+
244
+ const summary = compile({
245
+ messages,
246
+ previousSummary: preparation.previousSummary,
247
+ fileOps: {
248
+ readFiles: [...preparation.fileOps.read],
249
+ modifiedFiles: [...preparation.fileOps.written, ...preparation.fileOps.edited],
250
+ },
251
+ });
252
+
253
+ const branchIds = branchEntries.map((e: any) => e.id);
254
+ const cutIdx = branchIds.indexOf(firstKeptEntryId);
255
+ const cutWindow = cutIdx >= 0
256
+ ? branchEntries.slice(Math.max(0, cutIdx - 3), Math.min(branchEntries.length, cutIdx + 3)).map((e: any) => ({
257
+ id: e.id,
258
+ type: e.type,
259
+ role: e.type === "message" ? e.message?.role : undefined,
260
+ preview: e.type === "message" ? previewContent(e.message?.content) : undefined,
261
+ }))
262
+ : [];
263
+
264
+ dbg(config, {
265
+ usedOwnCut: true,
266
+ messagesToSummarize: agentMessages.length,
267
+ messagesPreviewHead: agentMessages.slice(0, 3).map((m: any) => ({ role: m.role, preview: previewContent(m.content) })),
268
+ messagesPreviewTail: agentMessages.slice(-3).map((m: any) => ({ role: m.role, preview: previewContent(m.content) })),
269
+ convertedMessages: messages.length,
270
+ firstKeptEntryId,
271
+ cutWindow,
272
+ tokensBefore: preparation.tokensBefore,
273
+ summaryLength: summary.length,
274
+ summaryPreview: summary.slice(0, 500),
275
+ sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
276
+ });
277
+
278
+ const details: PiVccCompactionDetails = {
279
+ compactor: "ultimate-pi-vcc",
280
+ version: 1,
281
+ sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
282
+ sourceMessageCount: agentMessages.length,
283
+ previousSummaryUsed: Boolean(preparation.previousSummary),
284
+ };
285
+
286
+ lastCompactWasPiVcc = isPiVcc;
287
+
288
+ return {
289
+ compaction: {
290
+ summary,
291
+ details,
292
+ tokensBefore: preparation.tokensBefore,
293
+ firstKeptEntryId,
294
+ },
295
+ };
296
+ });
297
+
298
+ // Fire success toast for /compact path only (delayed to let UI settle).
299
+ // /pi-vcc path uses its own onComplete callback in the command handler.
300
+ pi.on("session_compact", (event, ctx) => {
301
+ if (!event.fromExtension) return;
302
+ if (lastCompactWasPiVcc) return; // /pi-vcc handles its own toast via onComplete
303
+ const stats = lastStats;
304
+ if (!stats) return;
305
+ setTimeout(() => {
306
+ try {
307
+ ctx?.ui?.notify?.(
308
+ `pi-vcc: ${stats.summarized} source entries processed; tail kept ${stats.kept} (~${formatTokens(stats.keptTokensEst)} tok).`,
309
+ "info",
310
+ );
311
+ } catch {}
312
+ }, 500);
313
+ });
314
+ };
@@ -0,0 +1,12 @@
1
+ import type { TranscriptEntry } from "./core/brief";
2
+
3
+ export interface SectionData {
4
+ sessionGoal: string[];
5
+ outstandingContext: string[];
6
+ filesAndChanges: string[];
7
+ commits: string[];
8
+ userPreferences: string[];
9
+ briefTranscript: string;
10
+ /** Structured transcript entries (verbose object format) */
11
+ transcriptEntries: TranscriptEntry[];
12
+ }