@monotykamary/pi-vcc 0.3.16

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/README.md +688 -0
  2. package/__tests__/helpers.ts +135 -0
  3. package/__tests__/root-causes.test.ts +858 -0
  4. package/demo.gif +0 -0
  5. package/index.ts +21 -0
  6. package/knip.json +4 -0
  7. package/npm-shrinkwrap.json +3001 -0
  8. package/package.json +36 -0
  9. package/scripts/audit-sessions.ts +88 -0
  10. package/scripts/benchmark-real-sessions.ts +25 -0
  11. package/scripts/compare-before-after.ts +36 -0
  12. package/scripts/dump-branch-output.ts +20 -0
  13. package/src/commands/pi-vcc.ts +36 -0
  14. package/src/commands/vcc-recall.ts +65 -0
  15. package/src/core/brief.ts +497 -0
  16. package/src/core/build-sections.ts +431 -0
  17. package/src/core/content.ts +71 -0
  18. package/src/core/filter-noise.ts +42 -0
  19. package/src/core/format-recall.ts +177 -0
  20. package/src/core/format.ts +98 -0
  21. package/src/core/invisible-continue.ts +172 -0
  22. package/src/core/lineage.ts +26 -0
  23. package/src/core/load-messages.ts +40 -0
  24. package/src/core/normalize.ts +73 -0
  25. package/src/core/recall-scope.ts +49 -0
  26. package/src/core/render-entries.ts +58 -0
  27. package/src/core/report.ts +237 -0
  28. package/src/core/sanitize.ts +5 -0
  29. package/src/core/search-entries.ts +375 -0
  30. package/src/core/settings.ts +190 -0
  31. package/src/core/skill-collapse.ts +35 -0
  32. package/src/core/summarize.ts +179 -0
  33. package/src/core/tool-args.ts +14 -0
  34. package/src/details.ts +19 -0
  35. package/src/extract/commits.ts +69 -0
  36. package/src/extract/goals.ts +79 -0
  37. package/src/extract/preferences.ts +55 -0
  38. package/src/extract/shared-symbols.ts +357 -0
  39. package/src/hooks/before-compact.ts +564 -0
  40. package/src/hooks/proactive-threshold.ts +131 -0
  41. package/src/sections.ts +23 -0
  42. package/src/tools/recall.ts +204 -0
  43. package/src/types.ts +20 -0
  44. package/tests/australian-government.test.ts +479 -0
  45. package/tests/before-compact-hook.test.ts +185 -0
  46. package/tests/before-compact.test.ts +227 -0
  47. package/tests/brief.test.ts +215 -0
  48. package/tests/build-sections.test.ts +59 -0
  49. package/tests/compile.test.ts +89 -0
  50. package/tests/content.test.ts +66 -0
  51. package/tests/deep-extraction.test.ts +313 -0
  52. package/tests/extract-goals.test.ts +86 -0
  53. package/tests/extract-preferences.test.ts +30 -0
  54. package/tests/filter-noise.test.ts +61 -0
  55. package/tests/fixtures.ts +61 -0
  56. package/tests/format-recall.test.ts +87 -0
  57. package/tests/format.test.ts +73 -0
  58. package/tests/government-domain.test.ts +542 -0
  59. package/tests/lineage.test.ts +33 -0
  60. package/tests/load-messages.test.ts +49 -0
  61. package/tests/model-threshold-hook.test.ts +377 -0
  62. package/tests/model-threshold.test.ts +223 -0
  63. package/tests/normalize.test.ts +104 -0
  64. package/tests/proactive-threshold.test.ts +490 -0
  65. package/tests/real-sessions.test.ts +38 -0
  66. package/tests/recall-expand.test.ts +15 -0
  67. package/tests/recall-scope.test.ts +32 -0
  68. package/tests/recall-tool-scope.test.ts +67 -0
  69. package/tests/render-entries.test.ts +92 -0
  70. package/tests/report.test.ts +58 -0
  71. package/tests/sanitize.test.ts +24 -0
  72. package/tests/search-entries.test.ts +233 -0
  73. package/tests/support/load-session.ts +23 -0
  74. package/tests/support/real-sessions.ts +51 -0
  75. package/tests/threshold-integration.test.ts +359 -0
  76. package/tsconfig.json +20 -0
@@ -0,0 +1,190 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { dirname, join } from "path";
4
+
5
+ const SETTINGS_PATH_DEFAULT = join(homedir(), ".pi", "agent", "pi-vcc-config.json");
6
+ const settingsPath = (): string => process.env.PI_VCC_CONFIG_PATH ?? SETTINGS_PATH_DEFAULT;
7
+ /** Backwards-compat export. Resolves at access time, not import time. */
8
+ const SETTINGS_PATH = settingsPath();
9
+
10
+ /** Per-model or global compaction threshold. */
11
+ export interface ModelThreshold {
12
+ /**
13
+ * Tokens to reserve for LLM response. Overrides pi-core's
14
+ * compaction.reserveTokens for matching models.
15
+ *
16
+ * This controls *when* compaction triggers:
17
+ * contextTokens > contextWindow - reserveTokens
18
+ *
19
+ * A higher value compacts earlier (more conservative); a lower value
20
+ * lets context grow larger before compacting.
21
+ *
22
+ * Takes precedence over compactPercent when both are set.
23
+ */
24
+ reserveTokens?: number;
25
+ /**
26
+ * Compaction trigger as a percentage of context window (1–99).
27
+ * Compaction fires when: contextTokens > contextWindow × compactPercent / 100
28
+ *
29
+ * E.g. compactPercent: 65 means "compact when context is 65% full",
30
+ * equivalent to reserveTokens = 35% of contextWindow.
31
+ *
32
+ * Ignored when reserveTokens is also set.
33
+ */
34
+ compactPercent?: number;
35
+ /**
36
+ * Recent tokens to keep (not summarized) when pi-core handles compaction.
37
+ *
38
+ * Only affects pi-core's default compaction (when overrideDefaultCompaction
39
+ * is false). Pi-vcc's own buildOwnCut uses task-boundary heuristics instead
40
+ * of token budgets, so this value is advisory/forward-compat for now.
41
+ */
42
+ keepRecentTokens?: number;
43
+ }
44
+
45
+ export interface PiVccSettings {
46
+ /**
47
+ * When true, pi-vcc handles ALL compactions:
48
+ * - /compact (no args)
49
+ * - /compact <text>
50
+ * - auto threshold / overflow
51
+ * - /pi-vcc (always handled regardless)
52
+ *
53
+ * When false, pi-vcc only handles /pi-vcc; everything else
54
+ * falls back to pi core's default LLM-based compaction.
55
+ */
56
+ overrideDefaultCompaction: boolean;
57
+ /** Write debug snapshot to /tmp/pi-vcc-debug.json on each compaction. */
58
+ debug: boolean;
59
+ /**
60
+ * Per-model compaction thresholds. Keys are matched against
61
+ * "provider/modelId" (e.g., "neuralwatt/zai-org/GLM-5.1-FP8") or
62
+ * just "modelId" (e.g., "GLM-5.1-FP8").
63
+ *
64
+ * When a model matches, its reserveTokens/compactPercent overrides
65
+ * pi-core's global compaction.reserveTokens for the *when to compact*
66
+ * decision. This lets different models compact at different context
67
+ * fill levels.
68
+ */
69
+ modelThresholds?: Record<string, ModelThreshold>;
70
+ /**
71
+ * Global threshold applied to all models not matched by modelThresholds.
72
+ * Uses compactPercent or reserveTokens (compactPercent is easier — e.g.
73
+ * 65 means "compact at 65% full"). If omitted, pi-core's global
74
+ * compaction settings apply (no override).
75
+ */
76
+ globalThreshold?: ModelThreshold;
77
+ /**
78
+ * @deprecated Use globalThreshold instead.
79
+ */
80
+ defaultThreshold?: ModelThreshold;
81
+ }
82
+
83
+ const DEFAULT_SETTINGS: PiVccSettings = {
84
+ overrideDefaultCompaction: true,
85
+ debug: false,
86
+ };
87
+
88
+ /**
89
+ * Resolve the effective ModelThreshold for a given model.
90
+ *
91
+ * Lookup order:
92
+ * 1. Exact match on "provider/modelId" key
93
+ * 2. Exact match on "modelId" key
94
+ * 4. globalThreshold from settings
95
+ * 5. undefined (no override — pi-core's global settings apply)
96
+ */
97
+ export function getModelThreshold(
98
+ settings: PiVccSettings,
99
+ model: { id: string; provider?: string } | undefined,
100
+ ): ModelThreshold | undefined {
101
+ if (!model) return settings.globalThreshold ?? settings.defaultThreshold;
102
+
103
+ const providerModelId = model.provider ? `${model.provider}/${model.id}` : undefined;
104
+
105
+ // Exact match on provider/modelId
106
+ if (providerModelId && settings.modelThresholds?.[providerModelId]) {
107
+ return settings.modelThresholds[providerModelId];
108
+ }
109
+
110
+ // Exact match on just modelId
111
+ if (settings.modelThresholds?.[model.id]) {
112
+ return settings.modelThresholds[model.id];
113
+ }
114
+
115
+ return settings.globalThreshold ?? settings.defaultThreshold;
116
+ }
117
+
118
+ /**
119
+ * Resolve the effective reserveTokens for a threshold, handling both
120
+ * absolute (reserveTokens) and percentage (compactPercent) modes.
121
+ *
122
+ * Returns the number of tokens to reserve, or undefined if the
123
+ * threshold is not usable (no reserveTokens, no compactPercent,
124
+ * or compactPercent out of range).
125
+ */
126
+ export function resolveReserveTokens(
127
+ threshold: ModelThreshold,
128
+ contextWindow: number,
129
+ ): number | undefined {
130
+ if (threshold.reserveTokens != null) return threshold.reserveTokens;
131
+ if (threshold.compactPercent != null && contextWindow > 0) {
132
+ const pct = threshold.compactPercent;
133
+ if (pct < 1 || pct > 99) return undefined;
134
+ return Math.round(contextWindow * (1 - pct / 100));
135
+ }
136
+ return undefined;
137
+ }
138
+
139
+ const readJson = (path: string): Record<string, unknown> | null => {
140
+ try {
141
+ return JSON.parse(readFileSync(path, "utf-8"));
142
+ } catch {
143
+ return null;
144
+ }
145
+ };
146
+
147
+ export function loadSettings(): PiVccSettings {
148
+ const parsed = readJson(settingsPath());
149
+ if (!parsed || typeof parsed !== "object") return { ...DEFAULT_SETTINGS };
150
+ const loaded = { ...DEFAULT_SETTINGS, ...(parsed as Partial<PiVccSettings>) };
151
+ // Backward compat: defaultThreshold → globalThreshold
152
+ if (!loaded.globalThreshold && (parsed as any).defaultThreshold) {
153
+ loaded.globalThreshold = (parsed as any).defaultThreshold;
154
+ }
155
+ return loaded;
156
+ }
157
+
158
+ /**
159
+ * Ensure ~/.pi/agent/pi-vcc-config.json exists with default keys.
160
+ * - File missing → create with full default block.
161
+ * - File exists but invalid JSON → no-op (don't clobber user file).
162
+ * - File exists and valid → fill in missing default keys, preserve existing values.
163
+ */
164
+ export function scaffoldSettings(): void {
165
+ try {
166
+ const path = settingsPath();
167
+ const dir = dirname(path);
168
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
169
+
170
+ if (!existsSync(path)) {
171
+ writeFileSync(path, `${JSON.stringify(DEFAULT_SETTINGS, null, 2)}\n`);
172
+ return;
173
+ }
174
+
175
+ const parsed = readJson(path);
176
+ if (!parsed || typeof parsed !== "object") return; // don't clobber
177
+
178
+ let changed = false;
179
+ const next: Record<string, unknown> = { ...parsed };
180
+ for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
181
+ if (!(key in next)) {
182
+ next[key] = value;
183
+ changed = true;
184
+ }
185
+ }
186
+ if (changed) writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
187
+ } catch {
188
+ // best-effort; never crash extension load
189
+ }
190
+ }
@@ -0,0 +1,35 @@
1
+ /** Shared skill-tag collapse utilities */
2
+
3
+ const SKILL_TAG_RE = /^-?\s*<skill\s+name="([^"]+)"/;
4
+ const SKILL_CLOSE_RE = /^-?\s*<\/skill>/;
5
+
6
+ /** Collapse skill tags in an array of lines — dedup by name, drop all content inside block */
7
+ export const collapseSkillLines = (lines: string[]): string[] => {
8
+ const result: string[] = [];
9
+ const seenSkills = new Set<string>();
10
+ let insideSkill = false;
11
+
12
+ for (const line of lines) {
13
+ const skillMatch = line.match(SKILL_TAG_RE);
14
+ if (skillMatch) {
15
+ insideSkill = true;
16
+ const name = skillMatch[1];
17
+ if (!seenSkills.has(name)) {
18
+ seenSkills.add(name);
19
+ result.push(`[skill: ${name}]`);
20
+ }
21
+ continue;
22
+ }
23
+ if (insideSkill) {
24
+ if (SKILL_CLOSE_RE.test(line)) insideSkill = false;
25
+ continue;
26
+ }
27
+ result.push(line);
28
+ }
29
+ return result;
30
+ };
31
+
32
+ /** Collapse <skill name="X" ...>...</skill> blocks in raw text */
33
+ const SKILL_BLOCK_RE = /<skill\s+name="([^"]+)"[^>]*>[\s\S]*?(?:<\/skill>|$)/g;
34
+ export const collapseSkillText = (text: string): string =>
35
+ text.replace(SKILL_BLOCK_RE, (_, name) => `[skill: ${name}]`);
@@ -0,0 +1,179 @@
1
+ import type { Message } from "@earendil-works/pi-ai";
2
+ import type { FileOps } from "../types";
3
+ import { normalize } from "./normalize";
4
+ import { filterNoise } from "./filter-noise";
5
+ import { buildSections } from "./build-sections";
6
+ import { formatSummary, capBrief, RECALL_NOTE, wrapLongLines } from "./format";
7
+
8
+ export interface CompileInput {
9
+ messages: Message[];
10
+ previousSummary?: string;
11
+ fileOps?: FileOps;
12
+ }
13
+
14
+ // Cache-friendly order: stable sections first, volatile sections last
15
+ const HEADER_NAMES = [
16
+ "Session Goal",
17
+ "User Preferences",
18
+ "Files And Changes",
19
+ "Commits",
20
+ "Anchors",
21
+ "Type Catalog",
22
+ "Outstanding Context",
23
+ "Earlier Turns",
24
+ "Current Status",
25
+ ];
26
+
27
+ const SEPARATOR = "\n\n---\n\n";
28
+
29
+ /** Preamble prepended to every compaction summary. */
30
+ const HANDFOFF_PREAMBLE =
31
+ "This summary captures work done before the most recent messages in this session. " +
32
+ "Read it to pick up context — this is work already in progress. " +
33
+ "Do not recap what was done, do not ask what to do next. " +
34
+ "Continue directly where you left off.";
35
+
36
+ /** Extract a named section from summary text */
37
+ const sectionOf = (text: string, header: string): string => {
38
+ const tag = `[${header}]`;
39
+ const start = text.indexOf(tag);
40
+ if (start < 0) return "";
41
+ const after = text.slice(start);
42
+ // Find next section header or separator
43
+ const nextSection = HEADER_NAMES
44
+ .filter((h) => h !== header)
45
+ .map((h) => after.indexOf(`[${h}]`))
46
+ .filter((n) => n > 0);
47
+ const nextSep = after.indexOf("\n\n---\n\n");
48
+ const candidates = [...nextSection, ...(nextSep > 0 ? [nextSep] : [])].sort((a, b) => a - b);
49
+ const end = candidates[0];
50
+ return (end ? after.slice(0, end) : after).trim();
51
+ };
52
+
53
+ /** Extract the brief transcript part (everything after ---) */
54
+ const briefOf = (text: string): string => {
55
+ const idx = text.indexOf(SEPARATOR);
56
+ if (idx < 0) return "";
57
+ return text.slice(idx + SEPARATOR.length).trim();
58
+ };
59
+
60
+ /** Merge a header section */
61
+ const mergeHeaderSection = (header: string, prev: string, fresh: string): string => {
62
+ // Outstanding Context, Type Catalog, and Current Status are volatile -- always use fresh only
63
+ if (header === "Outstanding Context" || header === "Type Catalog" || header === "Current Status" || header === "Anchors") return fresh;
64
+ if (!prev) return fresh;
65
+ if (!fresh) return prev;
66
+
67
+ // Files And Changes: merge by category (Modified/Created/Read), dedup paths
68
+ if (header === "Files And Changes") {
69
+ return mergeFileLines(prev, fresh);
70
+ }
71
+
72
+ // Session Goal, User Preferences: line-level dedup, cap
73
+ const isClean = (l: string) => l.startsWith("- ") && !l.includes("<skill") && !l.includes("</skill");
74
+ const prevLines = prev.split("\n").filter(isClean);
75
+ const freshLines = fresh.split("\n").filter(isClean);
76
+ const combined = [...new Set([...prevLines, ...freshLines])];
77
+ const CAP = header === "Session Goal" ? 8 : header === "Commits" ? 8 : header === "Earlier Turns" ? 15 : 15;
78
+ const capped = combined.length > CAP ? combined.slice(-CAP) : combined;
79
+ if (capped.length === 0) return "";
80
+ return `[${header}]\n${capped.join("\n")}`;
81
+ };
82
+
83
+ /** Merge Files And Changes by category, dedup paths across compactions */
84
+ const mergeFileLines = (prev: string, fresh: string): string => {
85
+ const categories = ["Modified", "Created", "Read"] as const;
86
+ const merged: Record<string, Set<string>> = {};
87
+ for (const cat of categories) merged[cat] = new Set();
88
+
89
+ // Parse "- Modified: a, b, c (+N more)" lines from both prev and fresh
90
+ // Also handle symbol-annotated format: "- Modified: a (fn1, fn2), b"
91
+ for (const text of [prev, fresh]) {
92
+ for (const line of text.split("\n")) {
93
+ for (const cat of categories) {
94
+ const prefix = `- ${cat}: `;
95
+ if (!line.startsWith(prefix)) continue;
96
+ let rest = line.slice(prefix.length);
97
+ // Strip symbol annotations like " (fn1, fn2)" from each path
98
+ rest = rest.replace(/\s*\([^)]*\)/g, "");
99
+ // Strip "(+N more)" suffix
100
+ rest = rest.replace(/\s*\(\+\d+ more\)\s*$/, "");
101
+ for (const p of rest.split(",")) {
102
+ const trimmed = p.trim();
103
+ if (trimmed) merged[cat].add(trimmed);
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ // Dedup: if already in Modified, drop from Created (file existed before)
110
+ for (const p of merged.Modified) merged.Created.delete(p);
111
+
112
+ const cap = (set: Set<string>, limit: number) => {
113
+ const arr = [...set];
114
+ if (arr.length <= limit) return arr.join(", ");
115
+ return arr.slice(0, limit).join(", ") + ` (+${arr.length - limit} more)`;
116
+ };
117
+
118
+ const lines: string[] = [];
119
+ if (merged.Modified.size > 0) lines.push(`- Modified: ${cap(merged.Modified, 10)}`);
120
+ if (merged.Created.size > 0) lines.push(`- Created: ${cap(merged.Created, 10)}`);
121
+ if (merged.Read.size > 0) lines.push(`- Read: ${cap(merged.Read, 10)}`);
122
+ if (lines.length === 0) return "";
123
+ return `[Files And Changes]\n${lines.join("\n")}`;
124
+ };
125
+
126
+ const mergeBriefTranscript = (prev: string, fresh: string): string => {
127
+ if (!prev) return fresh;
128
+ if (!fresh) return prev;
129
+ return prev + "\n\n" + fresh;
130
+ };
131
+
132
+ const mergePrevious = (prev: string, fresh: string): string => {
133
+ // Merge header sections
134
+ const headers = HEADER_NAMES
135
+ .map((header) => {
136
+ const freshSec = sectionOf(fresh, header);
137
+ const prevSec = sectionOf(prev, header);
138
+ return mergeHeaderSection(header, prevSec, freshSec);
139
+ })
140
+ .filter(Boolean);
141
+
142
+ // Merge brief transcript
143
+ const prevBrief = briefOf(prev);
144
+ const freshBrief = briefOf(fresh);
145
+ const mergedBrief = mergeBriefTranscript(prevBrief, freshBrief);
146
+
147
+ const parts: string[] = [];
148
+ if (headers.length > 0) {
149
+ parts.push(headers.join("\n\n"));
150
+ }
151
+ if (mergedBrief) {
152
+ parts.push(capBrief(mergedBrief));
153
+ }
154
+
155
+ return parts.join(SEPARATOR);
156
+ };
157
+
158
+ export const compile = (input: CompileInput): string => {
159
+ const blocks = filterNoise(normalize(input.messages));
160
+ const data = buildSections({ blocks });
161
+ const fresh = formatSummary(data);
162
+ // Strip any legacy RECALL_NOTE baked into prev summary (pre-fix format)
163
+ // so merge doesn't re-stack it inside the brief.
164
+ const prev = input.previousSummary
165
+ ? stripRecallNote(input.previousSummary)
166
+ : undefined;
167
+ const merged = prev ? mergePrevious(prev, fresh) : fresh;
168
+ if (!merged) return "";
169
+ const body = merged + SEPARATOR + RECALL_NOTE;
170
+ return wrapLongLines(HANDFOFF_PREAMBLE + "\n\n" + body);
171
+ };
172
+
173
+ const stripRecallNote = (text: string): string => {
174
+ // Remove trailing RECALL_NOTE (and any separators surrounding it) if present.
175
+ // Handles both current format (---\n\nNOTE) and bare trailing NOTE.
176
+ const idx = text.lastIndexOf(RECALL_NOTE);
177
+ if (idx < 0) return text;
178
+ return text.slice(0, idx).replace(/\s*(?:\n\n---\n\n)?\s*$/, "").trimEnd();
179
+ };
@@ -0,0 +1,14 @@
1
+ export const extractPath = (args: Record<string, unknown>): string | null => {
2
+ for (const key of ["path", "file_path", "filePath", "file"]) {
3
+ if (typeof args[key] === "string") return args[key] as string;
4
+ }
5
+ return null;
6
+ };
7
+
8
+ export const summarizeToolArgs = (args: Record<string, unknown>): string => {
9
+ const path = extractPath(args);
10
+ if (path) return `path=${path}`;
11
+ if (typeof args.command === "string") return `command=${args.command}`;
12
+ if (typeof args.query === "string") return `query=${args.query}`;
13
+ return Object.keys(args).join(", ");
14
+ };
package/src/details.ts ADDED
@@ -0,0 +1,19 @@
1
+ export interface PiVccCompactionDetails {
2
+ compactor: "pi-vcc";
3
+ version: number;
4
+ sections: string[];
5
+ sourceMessageCount: number;
6
+ previousSummaryUsed: boolean;
7
+ /** Entry IDs [firstSummarizedId, lastSummarizedId] that this compaction summarized */
8
+ messageRange?: [string, string];
9
+ /** Summarized-to-summary token ratio (rounded) */
10
+ compressionRatio?: number;
11
+ /** ISO-8601 timestamp of when this compaction ran */
12
+ timestamp?: string;
13
+ /** Token count before compaction */
14
+ tokensBefore?: number;
15
+ /** Number of messages kept in tail */
16
+ keptCount?: number;
17
+ /** Estimated token count of kept tail */
18
+ keptTokensEst?: number;
19
+ }
@@ -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,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
+ };