@sting8k/pi-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 (49) hide show
  1. package/README.md +149 -0
  2. package/index.ts +10 -0
  3. package/package.json +36 -0
  4. package/scripts/audit-sessions.ts +88 -0
  5. package/scripts/benchmark-real-sessions.ts +25 -0
  6. package/scripts/compare-before-after.ts +36 -0
  7. package/src/commands/pi-vcc.ts +11 -0
  8. package/src/core/build-sections.ts +119 -0
  9. package/src/core/content.ts +20 -0
  10. package/src/core/filter-noise.ts +42 -0
  11. package/src/core/format-recall.ts +23 -0
  12. package/src/core/format.ts +34 -0
  13. package/src/core/normalize.ts +62 -0
  14. package/src/core/redact.ts +8 -0
  15. package/src/core/render-entries.ts +48 -0
  16. package/src/core/report.ts +225 -0
  17. package/src/core/sanitize.ts +5 -0
  18. package/src/core/search-entries.ts +14 -0
  19. package/src/core/summarize.ts +81 -0
  20. package/src/core/tool-args.ts +14 -0
  21. package/src/details.ts +7 -0
  22. package/src/extract/decisions.ts +32 -0
  23. package/src/extract/files.ts +46 -0
  24. package/src/extract/findings.ts +27 -0
  25. package/src/extract/goals.ts +41 -0
  26. package/src/extract/preferences.ts +30 -0
  27. package/src/hooks/before-compact.ts +141 -0
  28. package/src/sections.ts +11 -0
  29. package/src/tools/recall.ts +85 -0
  30. package/src/types.ts +14 -0
  31. package/tests/build-sections.test.ts +56 -0
  32. package/tests/compile.test.ts +50 -0
  33. package/tests/extract-decisions.test.ts +30 -0
  34. package/tests/extract-files.test.ts +62 -0
  35. package/tests/extract-findings.test.ts +39 -0
  36. package/tests/extract-goals.test.ts +86 -0
  37. package/tests/extract-preferences.test.ts +30 -0
  38. package/tests/filter-noise.test.ts +61 -0
  39. package/tests/fixtures.ts +61 -0
  40. package/tests/format-recall.test.ts +30 -0
  41. package/tests/format.test.ts +47 -0
  42. package/tests/normalize.test.ts +97 -0
  43. package/tests/real-sessions.test.ts +38 -0
  44. package/tests/render-entries.test.ts +40 -0
  45. package/tests/report.test.ts +54 -0
  46. package/tests/sanitize.test.ts +24 -0
  47. package/tests/search-entries.test.ts +33 -0
  48. package/tests/support/load-session.ts +23 -0
  49. package/tests/support/real-sessions.ts +51 -0
@@ -0,0 +1,48 @@
1
+ import type { Message } from "@mariozechner/pi-ai";
2
+ import { clip, textOf } from "./content";
3
+ import { summarizeToolArgs } from "./tool-args";
4
+ import { extractPath } from "./tool-args";
5
+
6
+ export interface RenderedEntry {
7
+ index: number;
8
+ role: string;
9
+ summary: string;
10
+ files?: string[];
11
+ }
12
+
13
+ const toolCalls = (content: Message["content"]): string => {
14
+ if (typeof content === "string") return "";
15
+ return content
16
+ .filter((c) => c.type === "toolCall")
17
+ .map((c) => `${c.name}(${summarizeToolArgs(c.arguments)})`)
18
+ .join(", ");
19
+ };
20
+
21
+ const extractFilesFromContent = (content: Message["content"]): string[] => {
22
+ if (typeof content === "string") return [];
23
+ return content
24
+ .filter((c) => c.type === "toolCall")
25
+ .map((c) => extractPath(c.arguments))
26
+ .filter((p): p is string => p !== null);
27
+ };
28
+
29
+ export const renderMessage = (msg: Message, index: number, full = false): RenderedEntry => {
30
+ if (msg.role === "user") {
31
+ return { index, role: "user", summary: full ? textOf(msg.content) : clip(textOf(msg.content), 300) };
32
+ }
33
+ if (msg.role === "toolResult") {
34
+ const prefix = msg.isError ? "ERROR " : "";
35
+ const text = full ? textOf(msg.content) : clip(textOf(msg.content), 200);
36
+ return {
37
+ index, role: "tool_result",
38
+ summary: `${prefix}[${msg.toolName}] ${text}`,
39
+ };
40
+ }
41
+ const text = full ? textOf(msg.content) : clip(textOf(msg.content), 300);
42
+ const tools = toolCalls(msg.content);
43
+ const files = extractFilesFromContent(msg.content);
44
+ const summary = tools ? `${tools}\n${text}` : text;
45
+ return { index, role: "assistant", summary, ...(files.length > 0 && { files }) };
46
+ };
47
+
48
+
@@ -0,0 +1,225 @@
1
+ import type { Message } from "@mariozechner/pi-ai";
2
+ import { buildSections } from "./build-sections";
3
+ import { clip } from "./content";
4
+ import { normalize } from "./normalize";
5
+ import { renderMessage } from "./render-entries";
6
+ import { searchEntries } from "./search-entries";
7
+ import { type CompileInput, compile } from "./summarize";
8
+
9
+ const headers = [
10
+ "Session Goal", "Current State", "What Was Done",
11
+ "Important Findings", "Files And Changes", "Open Problems",
12
+ "Decisions And Constraints", "User Preferences", "Next Best Steps",
13
+ ];
14
+
15
+ interface RoleCounts {
16
+ user: number;
17
+ assistant: number;
18
+ toolResult: number;
19
+ }
20
+
21
+ interface BlockCounts {
22
+ user: number;
23
+ assistant: number;
24
+ toolCalls: number;
25
+ toolResults: number;
26
+ thinking: number;
27
+ }
28
+
29
+ export interface RecallProbe {
30
+ label: string;
31
+ sourceText: string;
32
+ query: string;
33
+ summaryMentioned: boolean;
34
+ recallHits: number;
35
+ }
36
+
37
+ export interface CompactReport {
38
+ summary: string;
39
+ before: {
40
+ messageCount: number;
41
+ roleCounts: RoleCounts;
42
+ blockCounts: BlockCounts;
43
+ inputChars: number;
44
+ estimatedTokens: number;
45
+ topFiles: string[];
46
+ preview: string;
47
+ };
48
+ after: {
49
+ summaryLength: number;
50
+ estimatedTokens: number;
51
+ sectionCount: number;
52
+ summaryPreview: string;
53
+ goalsCount: number;
54
+ findingsCount: number;
55
+ filesCount: number;
56
+ blockersCount: number;
57
+ decisionsCount: number;
58
+ preferencesCount: number;
59
+ nextStepsCount: number;
60
+ };
61
+ compression: {
62
+ charsBefore: number;
63
+ charsAfter: number;
64
+ ratio: number;
65
+ messagesBefore: number;
66
+ };
67
+ recall: {
68
+ probes: RecallProbe[];
69
+ };
70
+ }
71
+
72
+ const estimateTokensFromChars = (chars: number): number =>
73
+ Math.ceil(chars / 4);
74
+
75
+ const countRoles = (messages: Message[]): RoleCounts => {
76
+ const counts: RoleCounts = { user: 0, assistant: 0, toolResult: 0 };
77
+ for (const msg of messages) {
78
+ if (msg.role === "user") counts.user += 1;
79
+ else if (msg.role === "assistant") counts.assistant += 1;
80
+ else if (msg.role === "toolResult") counts.toolResult += 1;
81
+ }
82
+ return counts;
83
+ };
84
+
85
+ const countBlocks = (messages: Message[]): BlockCounts => {
86
+ const counts: BlockCounts = {
87
+ user: 0,
88
+ assistant: 0,
89
+ toolCalls: 0,
90
+ toolResults: 0,
91
+ thinking: 0,
92
+ };
93
+
94
+ for (const block of normalize(messages)) {
95
+ if (block.kind === "user") counts.user += 1;
96
+ else if (block.kind === "assistant") counts.assistant += 1;
97
+ else if (block.kind === "tool_call") counts.toolCalls += 1;
98
+ else if (block.kind === "tool_result") counts.toolResults += 1;
99
+ else if (block.kind === "thinking") counts.thinking += 1;
100
+ }
101
+
102
+ return counts;
103
+ };
104
+
105
+ const inputCharsOf = (messages: Message[]): number =>
106
+ messages
107
+ .map((msg, index) => renderMessage(msg, index).summary.length)
108
+ .reduce((sum, len) => sum + len, 0);
109
+
110
+ const topFilesOf = (messages: Message[], fileOps?: CompileInput["fileOps"]): string[] => {
111
+ const data = buildSections({ blocks: normalize(messages), fileOps });
112
+ return [
113
+ ...data.filesModified,
114
+ ...data.filesCreated,
115
+ ...data.filesRead,
116
+ ].filter((file, index, all) => all.indexOf(file) === index).slice(0, 10);
117
+ };
118
+
119
+ const previewOf = (messages: Message[], edgeCount = 3): string => {
120
+ const rendered = messages.map((msg, index) => renderMessage(msg, index));
121
+ if (rendered.length === 0) return "(empty)";
122
+ if (rendered.length <= edgeCount * 2) {
123
+ return rendered
124
+ .map((entry) => `#${entry.index} [${entry.role}] ${clip(entry.summary, 220)}`)
125
+ .join("\n");
126
+ }
127
+
128
+ const first = rendered.slice(0, edgeCount);
129
+ const last = rendered.slice(-edgeCount);
130
+ return [
131
+ ...first.map((entry) => `#${entry.index} [${entry.role}] ${clip(entry.summary, 220)}`),
132
+ "...",
133
+ ...last.map((entry) => `#${entry.index} [${entry.role}] ${clip(entry.summary, 220)}`),
134
+ ].join("\n");
135
+ };
136
+
137
+ const sectionCountOf = (summary: string): number =>
138
+ headers.filter((header) => summary.includes(`[${header}]`)).length;
139
+
140
+ const queryTermsOf = (text: string): string[] =>
141
+ (text.match(/[\p{L}\p{N}_./-]{3,}/gu) ?? [])
142
+ .map((part) => part.trim())
143
+ .filter(Boolean);
144
+
145
+ const queryOf = (text: string): string => {
146
+ const terms = queryTermsOf(text);
147
+ return terms.slice(0, 6).join(" ");
148
+ };
149
+
150
+ const matchesQuery = (text: string, query: string): boolean => {
151
+ const hay = text.toLowerCase();
152
+ return query
153
+ .toLowerCase()
154
+ .split(/\s+/)
155
+ .filter(Boolean)
156
+ .every((term) => hay.includes(term));
157
+ };
158
+
159
+ const probesOf = (messages: Message[], summary: string, fileOps?: CompileInput["fileOps"]): RecallProbe[] => {
160
+ const data = buildSections({ blocks: normalize(messages), fileOps });
161
+ const rawProbes = [
162
+ { label: "goal", text: data.sessionGoal[0] ?? "" },
163
+ { label: "file", text: [...data.filesModified, ...data.filesCreated, ...data.filesRead][0] ?? "" },
164
+ { label: "problem", text: data.openProblems[0] ?? data.importantFindings[0] ?? "" },
165
+ ];
166
+
167
+ const rendered = messages.map((msg, index) => renderMessage(msg, index));
168
+
169
+ return rawProbes
170
+ .map(({ label, text }) => {
171
+ const sourceText = text.trim();
172
+ const query = queryOf(sourceText);
173
+ if (!query) return null;
174
+ return {
175
+ label,
176
+ sourceText,
177
+ query,
178
+ summaryMentioned: matchesQuery(summary, query),
179
+ recallHits: searchEntries(rendered, query).length,
180
+ };
181
+ })
182
+ .filter((probe): probe is RecallProbe => probe !== null);
183
+ };
184
+
185
+ export const buildCompactReport = (input: CompileInput): CompactReport => {
186
+ const summary = compile(input);
187
+ const data = buildSections({ blocks: normalize(input.messages), fileOps: input.fileOps });
188
+ const inputChars = inputCharsOf(input.messages);
189
+ const topFiles = topFilesOf(input.messages, input.fileOps);
190
+
191
+ return {
192
+ summary,
193
+ before: {
194
+ messageCount: input.messages.length,
195
+ roleCounts: countRoles(input.messages),
196
+ blockCounts: countBlocks(input.messages),
197
+ inputChars,
198
+ estimatedTokens: estimateTokensFromChars(inputChars),
199
+ topFiles,
200
+ preview: previewOf(input.messages),
201
+ },
202
+ after: {
203
+ summaryLength: summary.length,
204
+ estimatedTokens: estimateTokensFromChars(summary.length),
205
+ sectionCount: sectionCountOf(summary),
206
+ summaryPreview: summary,
207
+ goalsCount: data.sessionGoal.length,
208
+ findingsCount: data.importantFindings.length,
209
+ filesCount: data.filesRead.length + data.filesModified.length + data.filesCreated.length,
210
+ blockersCount: data.openProblems.length,
211
+ decisionsCount: data.decisions.length,
212
+ preferencesCount: data.userPreferences.length,
213
+ nextStepsCount: data.nextSteps.length,
214
+ },
215
+ compression: {
216
+ charsBefore: inputChars,
217
+ charsAfter: summary.length,
218
+ ratio: summary.length === 0 ? 0 : Number((inputChars / summary.length).toFixed(2)),
219
+ messagesBefore: input.messages.length,
220
+ },
221
+ recall: {
222
+ probes: probesOf(input.messages, summary, input.fileOps),
223
+ },
224
+ };
225
+ };
@@ -0,0 +1,5 @@
1
+ const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
2
+ const CTRL_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f]/g;
3
+
4
+ export const sanitize = (text: string): string =>
5
+ text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(ANSI_RE, "").replace(CTRL_RE, "");
@@ -0,0 +1,14 @@
1
+ import type { RenderedEntry } from "./render-entries";
2
+
3
+ export const searchEntries = (
4
+ entries: RenderedEntry[],
5
+ query?: string,
6
+ ): RenderedEntry[] => {
7
+ if (!query?.trim()) return entries;
8
+ const terms = query.toLowerCase().split(/\s+/);
9
+ return entries.filter((e) => {
10
+ const filePart = e.files?.join(" ") ?? "";
11
+ const hay = `${e.role} ${e.summary} ${filePart}`.toLowerCase();
12
+ return terms.every((t) => hay.includes(t));
13
+ });
14
+ };
@@ -0,0 +1,81 @@
1
+ import type { Message } from "@mariozechner/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 } from "./format";
7
+ import { redact } from "./redact";
8
+
9
+ export interface CompileInput {
10
+ messages: Message[];
11
+ previousSummary?: string;
12
+ fileOps?: FileOps;
13
+ }
14
+
15
+ const headers = [
16
+ "Session Goal", "Key Conversation Turns", "Actions Taken",
17
+ "Important Evidence", "Files And Changes", "Outstanding Context",
18
+ "User Preferences",
19
+ ];
20
+
21
+ const sectionOf = (text: string, header: string): string => {
22
+ const start = text.indexOf(`[${header}]`);
23
+ if (start < 0) return "";
24
+ const after = text.slice(start);
25
+ const next = headers.map((h) => h === header ? -1 : after.indexOf(`[${h}]`))
26
+ .filter((n) => n > 0).sort((a, b) => a - b)[0];
27
+ return (next ? after.slice(0, next) : after).trim();
28
+ };
29
+
30
+ const VOLATILE_SECTIONS = new Set([
31
+ "Outstanding Context",
32
+ ]);
33
+
34
+ const APPENDABLE_SECTIONS = new Set([
35
+ "Key Conversation Turns", "Actions Taken", "Important Evidence",
36
+ "Files And Changes", "User Preferences",
37
+ ]);
38
+
39
+ const extractBullets = (section: string): string[] =>
40
+ section.split("\n").filter((l) => /^\s*[-*]/.test(l) || /^\s*(Read|Modified|Created):/.test(l));
41
+
42
+ const mergeSectionContent = (header: string, prev: string, fresh: string): string => {
43
+ if (!prev) return fresh;
44
+ if (!fresh) {
45
+ if (VOLATILE_SECTIONS.has(header)) return "";
46
+ return prev;
47
+ }
48
+ if (VOLATILE_SECTIONS.has(header)) return fresh;
49
+ if (APPENDABLE_SECTIONS.has(header)) {
50
+ const oldBullets = extractBullets(prev);
51
+ const newBullets = extractBullets(fresh);
52
+ const combined = [...new Set([...oldBullets, ...newBullets])];
53
+ const headerLine = `[${header}]`;
54
+ return headerLine + "\n" + combined.join("\n");
55
+ }
56
+ return fresh;
57
+ };
58
+
59
+ const mergePrevious = (prev: string, fresh: string): string => {
60
+ const merged = headers
61
+ .map((header) => {
62
+ const freshSec = sectionOf(fresh, header);
63
+ const prevSec = sectionOf(prev, header);
64
+ return mergeSectionContent(header, prevSec, freshSec);
65
+ })
66
+ .filter(Boolean);
67
+ return merged.join("\n\n");
68
+ };
69
+
70
+ const SUMMARY_MAX_CHARS = 12_000;
71
+
72
+ export const compile = (input: CompileInput): string => {
73
+ const blocks = filterNoise(normalize(input.messages));
74
+ const data = buildSections({ blocks, fileOps: input.fileOps });
75
+ const fresh = formatSummary(data);
76
+ const merged = input.previousSummary ? mergePrevious(input.previousSummary, fresh) : fresh;
77
+ const redacted = redact(merged);
78
+ return redacted.length > SUMMARY_MAX_CHARS
79
+ ? redacted.slice(0, SUMMARY_MAX_CHARS) + "\n...(summary truncated)"
80
+ : redacted;
81
+ };
@@ -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,7 @@
1
+ export interface PiVccCompactionDetails {
2
+ compactor: "pi-vcc";
3
+ version: number;
4
+ sections: string[];
5
+ sourceMessageCount: number;
6
+ previousSummaryUsed: boolean;
7
+ }
@@ -0,0 +1,32 @@
1
+ import type { NormalizedBlock } from "../types";
2
+ import { clip, nonEmptyLines } from "../core/content";
3
+
4
+ const DECISION_PATTERNS = [
5
+ /\bdecid(ed|ing)\b/i,
6
+ /\bchose\b/i,
7
+ /\bwill use\b/i,
8
+ /\bgoing with\b/i,
9
+ /\bapproach[:\s]/i,
10
+ /\bconstraint[:\s]/i,
11
+ /\brequirement[:\s]/i,
12
+ /\bmust\b/i,
13
+ /\bshould not\b/i,
14
+ /\barchitecture/i,
15
+ ];
16
+
17
+ export const extractDecisions = (blocks: NormalizedBlock[]): string[] => {
18
+ const decisions: string[] = [];
19
+
20
+ for (const b of blocks) {
21
+ if (b.kind !== "assistant" && b.kind !== "user") continue;
22
+ for (const line of nonEmptyLines(b.text)) {
23
+ const trimmed = line.trim();
24
+ if (!trimmed || trimmed.length < 10) continue;
25
+ if (DECISION_PATTERNS.some((p) => p.test(trimmed))) {
26
+ decisions.push(clip(trimmed, 200));
27
+ }
28
+ }
29
+ }
30
+
31
+ return [...new Set(decisions)].slice(0, 10);
32
+ };
@@ -0,0 +1,46 @@
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", "tilth", "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
+ export const extractFiles = (
24
+ blocks: NormalizedBlock[],
25
+ fileOps?: FileOps,
26
+ ): FileActivity => {
27
+ const act: FileActivity = {
28
+ read: new Set(fileOps?.readFiles ?? []),
29
+ modified: new Set(fileOps?.modifiedFiles ?? []),
30
+ created: new Set(fileOps?.createdFiles ?? []),
31
+ };
32
+
33
+ for (const b of blocks) {
34
+ if (b.kind !== "tool_call") continue;
35
+ const p = extractPath(b.args);
36
+ if (!p) continue;
37
+
38
+ if (FILE_READ_TOOLS.has(b.name)) act.read.add(p);
39
+ if (FILE_WRITE_TOOLS.has(b.name)) act.modified.add(p);
40
+ if (FILE_CREATE_TOOLS.has(b.name)) act.created.add(p);
41
+ }
42
+
43
+ return act;
44
+ };
45
+
46
+
@@ -0,0 +1,27 @@
1
+ import type { NormalizedBlock } from "../types";
2
+ import { clip } from "../core/content";
3
+
4
+ const TRUNCATE_TOKENS = 128;
5
+ const NOISE_TOOLS = new Set(["TodoWrite", "ToolSearch", "Skill"]);
6
+
7
+ const truncateText = (text: string, limit = TRUNCATE_TOKENS): string => {
8
+ const words = text.split(/\s+/).filter(Boolean);
9
+ if (words.length <= limit) return text;
10
+ return words.slice(0, limit).join(" ") + "...(truncated)";
11
+ };
12
+
13
+ export const extractFindings = (blocks: NormalizedBlock[]): string[] => {
14
+ const results: string[] = [];
15
+
16
+ for (const b of blocks) {
17
+ if (b.kind !== "tool_result") continue;
18
+ if (b.isError) continue;
19
+ if (NOISE_TOOLS.has(b.name)) continue;
20
+ const text = b.text.trim();
21
+ if (!text || text.length < 20) continue;
22
+ results.push(`[${b.name}] ${truncateText(text, TRUNCATE_TOKENS)}`);
23
+ }
24
+
25
+ return results.slice(-15);
26
+ };
27
+
@@ -0,0 +1,41 @@
1
+ import type { NormalizedBlock } from "../types";
2
+ import { nonEmptyLines, clip } from "../core/content";
3
+
4
+ const SCOPE_CHANGE_RE =
5
+ /\b(instead|actually|change of plan|forget that|new task|switch to|now I want|pivot|let'?s do|stop .* and)\b/i;
6
+
7
+ const TASK_RE =
8
+ /\b(fix|implement|add|create|build|refactor|debug|investigate|update|remove|delete|migrate|deploy|test|write|set up)\b/i;
9
+
10
+ const NOISE_SHORT_RE = /^(ok|yes|no|sure|yeah|yep|go|hi|hey|thx|thanks|ok\b.*|y|n|k)\s*[.!?]*$/i;
11
+
12
+ const isSubstantiveGoal = (text: string): boolean =>
13
+ text.length > 5 && !NOISE_SHORT_RE.test(text.trim());
14
+
15
+ export const extractGoals = (blocks: NormalizedBlock[]): string[] => {
16
+ const goals: string[] = [];
17
+ let latestScopeChange: string[] | null = null;
18
+
19
+ for (const b of blocks) {
20
+ if (b.kind !== "user") continue;
21
+ const lines = nonEmptyLines(b.text).filter(isSubstantiveGoal);
22
+ if (lines.length === 0) continue;
23
+
24
+ if (goals.length === 0) {
25
+ goals.push(...lines.slice(0, 3));
26
+ continue;
27
+ }
28
+
29
+ if (SCOPE_CHANGE_RE.test(b.text)) {
30
+ latestScopeChange = lines.slice(0, 3).map((l) => clip(l, 200));
31
+ } else if (TASK_RE.test(b.text) && lines[0].length > 15) {
32
+ latestScopeChange = lines.slice(0, 2).map((l) => clip(l, 200));
33
+ }
34
+ }
35
+
36
+ if (latestScopeChange) {
37
+ goals.push("[Scope change]", ...latestScopeChange);
38
+ }
39
+
40
+ return goals.slice(0, 8);
41
+ };
@@ -0,0 +1,30 @@
1
+ import type { NormalizedBlock } from "../types";
2
+ import { clip, nonEmptyLines } from "../core/content";
3
+
4
+ const PREF_PATTERNS = [
5
+ /\bprefer\b/i,
6
+ /\bdon'?t want\b/i,
7
+ /\balways\b/i,
8
+ /\bnever\b/i,
9
+ /\bplease\s+(use|avoid|keep|make)\b/i,
10
+ /\bstyle[:\s]/i,
11
+ /\bformat[:\s]/i,
12
+ /\blanguage[:\s]/i,
13
+ ];
14
+
15
+ export const extractPreferences = (blocks: NormalizedBlock[]): string[] => {
16
+ const prefs: string[] = [];
17
+
18
+ for (const b of blocks) {
19
+ if (b.kind !== "user") continue;
20
+ for (const line of nonEmptyLines(b.text)) {
21
+ const trimmed = line.trim();
22
+ if (!trimmed || trimmed.length < 5) continue;
23
+ if (PREF_PATTERNS.some((p) => p.test(trimmed))) {
24
+ prefs.push(clip(trimmed, 200));
25
+ }
26
+ }
27
+ }
28
+
29
+ return [...new Set(prefs)].slice(0, 10);
30
+ };