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,404 @@
1
+ // @ts-nocheck
2
+ import type { NormalizedBlock } from "../types";
3
+ import { clip } from "./content";
4
+ import { extractPath } from "./tool-args";
5
+ import { collapseSkillText } from "./skill-collapse";
6
+
7
+ const TRUNCATE_USER = 256;
8
+ const SEGMENT_CLOSING_ASSISTANT_HEAD_WORDS = 120;
9
+ const SEGMENT_CLOSING_ASSISTANT_TAIL_WORDS = 120;
10
+ const ASSISTANT_HEAD_WORDS = 80;
11
+ const ASSISTANT_TAIL_WORDS = 120;
12
+
13
+ // Strip common self-reflective assistant prefixes that carry no semantic info.
14
+ // Conservative list: only removes the leading filler, preserves the actual content.
15
+ const SELF_TALK_PREFIX_RE =
16
+ /^\s*(?:hmm|wait|actually|oh|okay|ok|well|so)[,.!\s-]+/i;
17
+
18
+ // ── noise filtering ──
19
+
20
+ const isNoiseUser = (text: string): boolean => {
21
+ return !text.trim();
22
+ };
23
+
24
+ // ── truncation ──
25
+
26
+ // Unicode-aware word segmentation via Intl.Segmenter (built-in, zero dependency)
27
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
28
+
29
+ /** Check if segment is a word (Bun's isWordLike is unreliable for alphanumeric tokens) */
30
+ const isWord = (seg: { segment: string; isWordLike: boolean }): boolean =>
31
+ seg.isWordLike || /[\p{L}\p{N}]/u.test(seg.segment);
32
+
33
+ // Common stop words — don't count toward budget
34
+ const STOP_WORDS = new Set([
35
+ "a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
36
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
37
+ "should", "may", "might", "shall", "can", "need", "must",
38
+ "to", "of", "in", "for", "on", "with", "at", "by", "from", "as",
39
+ "into", "through", "during", "before", "after", "above", "below",
40
+ "between", "under", "over",
41
+ "and", "but", "or", "nor", "not", "so", "yet", "both", "either",
42
+ "neither", "each", "every", "all", "any", "few", "more", "most",
43
+ "other", "some", "such", "no",
44
+ "that", "this", "these", "those", "it", "its",
45
+ "i", "me", "my", "we", "our", "you", "your", "he", "him", "his",
46
+ "she", "her", "they", "them", "their", "who", "which", "what",
47
+ "if", "then", "than", "when", "where", "how", "just", "also",
48
+ ]);
49
+
50
+ const normalizeForTokenBudget = (text: string): string =>
51
+ text
52
+ .replace(/\r\n?/g, "\n")
53
+ .replace(/[^\S\n]+/g, " ")
54
+ .replace(/\n{3,}/g, "\n\n")
55
+ .trim();
56
+
57
+ const truncateTokens = (text: string, limit: number): string => {
58
+ const flat = normalizeForTokenBudget(text);
59
+ let count = 0;
60
+ let lastEnd = 0;
61
+ for (const seg of segmenter.segment(flat)) {
62
+ if (isWord(seg)) {
63
+ if (!STOP_WORDS.has(seg.segment.toLowerCase())) {
64
+ count++;
65
+ if (count > limit) {
66
+ return flat.slice(0, lastEnd).trimEnd() + "...(truncated)";
67
+ }
68
+ }
69
+ }
70
+ lastEnd = seg.index + seg.segment.length;
71
+ }
72
+ return flat;
73
+ };
74
+
75
+ const significantWordSpans = (flat: string): { start: number; end: number }[] => {
76
+ const words: { start: number; end: number }[] = [];
77
+ for (const seg of segmenter.segment(flat)) {
78
+ if (!isWord(seg)) continue;
79
+ if (STOP_WORDS.has(seg.segment.toLowerCase())) continue;
80
+ words.push({ start: seg.index, end: seg.index + seg.segment.length });
81
+ }
82
+ return words;
83
+ };
84
+
85
+
86
+ const truncateTokensHeadTail = (text: string, headLimit: number, tailLimit: number): string => {
87
+ const flat = normalizeForTokenBudget(text);
88
+ if (headLimit <= 0 || tailLimit <= 0) return flat;
89
+ const words = significantWordSpans(flat);
90
+ if (words.length <= headLimit + tailLimit) return flat;
91
+ const head = flat.slice(0, words[headLimit - 1].end).trimEnd();
92
+ const tail = flat.slice(words[words.length - tailLimit].start).trimStart();
93
+ return `${head}\n...(middle truncated)...\n${tail}`;
94
+ };
95
+
96
+ const nextRenderableBlock = (blocks: NormalizedBlock[], index: number): NormalizedBlock | undefined => {
97
+ for (let i = index + 1; i < blocks.length; i++) {
98
+ if (blocks[i].kind !== "tool_result") return blocks[i];
99
+ }
100
+ return undefined;
101
+ };
102
+
103
+ const isSegmentClosingAssistant = (blocks: NormalizedBlock[], index: number): boolean => {
104
+ if (blocks[index]?.kind !== "assistant") return false;
105
+ const next = nextRenderableBlock(blocks, index);
106
+ return !next || next.kind === "user";
107
+ };
108
+
109
+ // ── bash command compression ──
110
+
111
+ const BASH_CAP = 240;
112
+ const PIPE_TAIL_RE = /\s*\|\s*(?:head|tail|sort|wc|column|tr|cut|awk|uniq|python3|node|bun)(?:\s[^|]*)?$/;
113
+ // Preamble/boilerplate lines that carry no durable fact on their own. Dropped
114
+ // when a script has more informative lines, so `set -euo pipefail\ngit commit`
115
+ // renders the commit rather than the shell option.
116
+ const TRIVIAL_LINE_RE = /^(?:set\s+[-+]|cd\s+\S+$|export\s+\w+=|(?:source|\.)\s+\S+|pwd$|true$|:$|#)/;
117
+ // A real heredoc opener: `<<` at a command boundary — not preceded by a word
118
+ // char or `)`, so shift ops (`8 << 20`, `Rd<<8`) are not misread — with an
119
+ // identifier terminator starting [A-Za-z_], so numeric `<< 10` is rejected too.
120
+ export const HEREDOC_OPEN_RE = /(?<![\w)])<<-?\s*["']?([A-Za-z_]\w*)["']?/;
121
+ // File-writer heredocs (`cat > f <<EOF`, tee, dd) already name their target, so
122
+ // the opener alone is informative → body-only. EVERY other heredoc has a
123
+ // content-free opener (interpreters python3/node, remote shells `ssh host <<CMD`,
124
+ // sqlite3, ...) → we surface a one-line body preview. This denylist replaces an
125
+ // interpreter allowlist that missed ssh/sqlite3/etc. and mis-handled heredocs
126
+ // combined with a `>` redirect.
127
+ const FILEWRITER_HEREDOC_RE = /(?:^|[|&;]\s*)(?:cat|tee|dd)\b/;
128
+ const HEREDOC_BODY_CAP = 80;
129
+ // Body lines that are pure boilerplate and make a poor preview.
130
+ const BODY_NOISE_RE = /^(?:import\s|from\s+\S+\s+import|require\(|const\s+\w+\s*=\s*require|#|\/\/|"""|'''|"use strict"|use\s+strict|<\?php)/;
131
+
132
+ /**
133
+ * If `lines[i]` opens a heredoc whose terminator actually appears on a later
134
+ * line, return that terminator's line index; otherwise -1. Callers use -1 to
135
+ * leave following lines intact instead of treating a stray `<<` (a shift op, a
136
+ * quoted string, or a truncated body) as a heredoc and skipping real commands.
137
+ */
138
+ export const heredocCloseIndex = (lines: string[], i: number): number => {
139
+ const hd = lines[i].match(HEREDOC_OPEN_RE);
140
+ if (!hd) return -1;
141
+ const term = hd[1];
142
+ for (let j = i + 1; j < lines.length; j++) {
143
+ if (lines[j].trim() === term) return j;
144
+ }
145
+ return -1;
146
+ };
147
+
148
+ const stripCdPrefix = (line: string): string => line.replace(/^cd\s+\S+\s*&&\s*/, "").trim();
149
+ const stripPipeTail = (line: string): string => {
150
+ let c = line;
151
+ for (let i = 0; i < 3; i++) {
152
+ const stripped = c.replace(PIPE_TAIL_RE, "");
153
+ if (stripped === c) break;
154
+ c = stripped;
155
+ }
156
+ return c.trim();
157
+ };
158
+
159
+ /**
160
+ * Semantic compression of a (possibly multi-line) bash command.
161
+ * 1. Drop heredoc BODIES (keep the opener line, e.g. `cat > f <<EOF` or
162
+ * `python3 - <<PY`): the body is prose/script content that bloats the brief
163
+ * without adding countable facts, while the opener still records what ran.
164
+ * 2. Drop trivial preamble lines (set -euo pipefail, cd-only, export, source,
165
+ * comments) unless they are the ONLY line, so real work below them surfaces.
166
+ * 3. Strip `cd <path> &&` prefixes and pipe-tail formatting per line.
167
+ * 4. Join the remaining meaningful lines with `; ` and cap length.
168
+ */
169
+ const compressBash = (raw: string): string => {
170
+ // Pass 1: keep heredoc opener lines, skip their bodies + terminators.
171
+ const rawLines = raw.split("\n");
172
+ const withoutHeredocBodies: string[] = [];
173
+ for (let i = 0; i < rawLines.length; i++) {
174
+ const line = rawLines[i];
175
+ withoutHeredocBodies.push(line);
176
+ // Only treat this as a heredoc when its terminator appears downstream; a
177
+ // stray `<<` (string/expression or truncated body) leaves later lines intact.
178
+ const close = heredocCloseIndex(rawLines, i);
179
+ if (close === -1) continue;
180
+ // File-writer heredocs (`cat > f <<EOF`) name their target → keep opener only.
181
+ // Every other heredoc has a content-free opener → grab the first meaningful
182
+ // body line as a preview (`python3 - <<PY`, `ssh host <<CMD`, `sqlite3 <<SQL`).
183
+ const wantPreview = !FILEWRITER_HEREDOC_RE.test(line);
184
+ let preview = "";
185
+ for (let j = i + 1; wantPreview && !preview && j < close; j++) {
186
+ const t = rawLines[j].trim();
187
+ if (t && !BODY_NOISE_RE.test(t)) preview = t;
188
+ }
189
+ if (preview) {
190
+ const clipped = preview.length > HEREDOC_BODY_CAP ? preview.slice(0, HEREDOC_BODY_CAP - 1) + "\u2026" : preview;
191
+ withoutHeredocBodies[withoutHeredocBodies.length - 1] = `${line.trim()} ${clipped}`;
192
+ }
193
+ i = close; // for-loop's i++ then skips past the terminator line
194
+ }
195
+
196
+ const lines = withoutHeredocBodies.map(l => l.trim()).filter(Boolean);
197
+ if (lines.length === 0) return raw.trim();
198
+
199
+ const meaningful = lines
200
+ .filter(l => !TRIVIAL_LINE_RE.test(l))
201
+ .map(l => stripPipeTail(stripCdPrefix(l)))
202
+ .filter(Boolean);
203
+ // If everything was trivial (e.g. a bare `set -e` or `ls`), fall back to the
204
+ // first line so we never emit an empty marker.
205
+ const chosen = meaningful.length ? meaningful : [stripPipeTail(stripCdPrefix(lines[0]))].filter(Boolean);
206
+
207
+ const cmd = chosen.join("; ");
208
+ if (cmd.length > BASH_CAP) {
209
+ return cmd.slice(0, BASH_CAP - 3) + "...";
210
+ }
211
+ return cmd;
212
+ };
213
+
214
+ // ── tool summary ──
215
+
216
+ const TOOL_SUMMARY_FIELDS: Record<string, string> = {
217
+ Read: "file_path", Edit: "file_path", Write: "file_path",
218
+ read: "file_path", edit: "file_path", write: "file_path",
219
+ Glob: "pattern", Grep: "pattern",
220
+ };
221
+
222
+ const toolOneLiner = (name: string, args: Record<string, unknown>): string => {
223
+ const field = TOOL_SUMMARY_FIELDS[name];
224
+ if (field && typeof args[field] === "string") {
225
+ return `* ${name} "${args[field] as string}"`;
226
+ }
227
+ const path = extractPath(args);
228
+ if (path) return `* ${name} "${path}"`;
229
+ if (name === "bash" || name === "Bash") {
230
+ const raw = (args.command ?? args.description ?? "") as string;
231
+ const cmd = compressBash(raw);
232
+ return `* ${name} "${cmd}"`;
233
+ }
234
+ if (typeof args.query === "string") {
235
+ return `* ${name} "${clip(args.query as string, 60)}"`;
236
+ }
237
+ return `* ${name}`;
238
+ };
239
+
240
+ export interface BriefLine {
241
+ /** Section header like "[user]" or "[assistant]" */
242
+ header: string;
243
+ /** Content lines for this section */
244
+ lines: string[];
245
+ }
246
+
247
+ /**
248
+ * Build BriefLine sections from NormalizedBlocks.
249
+ */
250
+ export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
251
+ const sections: BriefLine[] = [];
252
+ let lastHeader = "";
253
+
254
+ const push = (header: string, line: string) => {
255
+ if (header === lastHeader && sections.length > 0) {
256
+ sections[sections.length - 1].lines.push(line);
257
+ return;
258
+ }
259
+ sections.push({ header, lines: [line] });
260
+ lastHeader = header;
261
+ };
262
+
263
+ const pushText = (header: string, text: string, ref = "") => {
264
+ const lines = text.split("\n");
265
+ if (ref && lines.length > 0) {
266
+ lines[lines.length - 1] = `${lines[lines.length - 1]}${ref}`;
267
+ }
268
+ for (const line of lines) push(header, line);
269
+ };
270
+
271
+ for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
272
+ const b = blocks[blockIndex];
273
+ switch (b.kind) {
274
+ case "user": {
275
+ if (isNoiseUser(b.text)) break;
276
+ const text = truncateTokens(collapseSkillText(b.text), TRUNCATE_USER);
277
+ if (text) {
278
+ const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
279
+ pushText("[user]", text, ref);
280
+ }
281
+ lastHeader = "[user]";
282
+ break;
283
+ }
284
+ case "bash": {
285
+ const cmd = compressBash(b.command);
286
+ const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
287
+ if (cmd) {
288
+ push("[user]", `$ ${cmd}${ref}`);
289
+ }
290
+ lastHeader = "[user]";
291
+ break;
292
+ }
293
+ case "assistant": {
294
+ let raw = b.text;
295
+ // Strip leading self-talk prefix (up to 2x; assistants sometimes chain "Hmm, actually, ...")
296
+ for (let i = 0; i < 2; i++) {
297
+ const stripped = raw.replace(SELF_TALK_PREFIX_RE, "");
298
+ if (stripped === raw) break;
299
+ raw = stripped;
300
+ }
301
+ const text = isSegmentClosingAssistant(blocks, blockIndex)
302
+ ? truncateTokensHeadTail(raw, SEGMENT_CLOSING_ASSISTANT_HEAD_WORDS, SEGMENT_CLOSING_ASSISTANT_TAIL_WORDS)
303
+ : truncateTokensHeadTail(raw, ASSISTANT_HEAD_WORDS, ASSISTANT_TAIL_WORDS);
304
+ if (text) {
305
+ const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
306
+ pushText("[assistant]", text, ref);
307
+ }
308
+ break;
309
+ }
310
+ case "tool_call": {
311
+ // Skip malformed tool calls from streaming providers (empty name / fragmented args).
312
+ if (!b.name || b.name.trim() === "") break;
313
+ const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
314
+ const summary = toolOneLiner(b.name, b.args) + ref;
315
+ push("[assistant]", summary);
316
+ break;
317
+ }
318
+ case "tool_result":
319
+ // Tool result bodies are intentionally omitted from compact briefs.
320
+ break;
321
+ }
322
+ }
323
+
324
+ // Collapse consecutive identical tool lines (same text, different #ref)
325
+ for (const sec of sections) {
326
+ if (sec.header !== "[assistant]") continue;
327
+ const out: string[] = [];
328
+ for (const line of sec.lines) {
329
+ if (!line.startsWith("* ")) { out.push(line); continue; }
330
+ const ref = line.match(/\(#(\d+)\)$/)?.[1] ?? "";
331
+ const base = ref ? line.slice(0, -(ref.length + 3)).trimEnd() : line;
332
+ const last = out.length > 0 ? out[out.length - 1] : "";
333
+ const m = last.match(/^(.*) \((#[\d, #]+)\) x(\d+)$/);
334
+ if (m && m[1] === base) {
335
+ out[out.length - 1] = `${base} (${m[2]}, #${ref}) x${parseInt(m[3]) + 1}`;
336
+ } else if (last.match(/\(#\d+\)$/) && last.replace(/\s*\(#\d+\)$/, "") === base) {
337
+ const prevRef = last.match(/\(#(\d+)\)$/)?.[1];
338
+ out[out.length - 1] = `${base} (#${prevRef}, #${ref}) x2`;
339
+ } else {
340
+ out.push(line);
341
+ }
342
+ }
343
+ sec.lines = out;
344
+ }
345
+
346
+ // Cap tool calls per [assistant] turn — keep tail (latest actions tend to
347
+ // be the deciding edits/writes; head is usually exploration noise).
348
+ const TOOL_CALLS_PER_TURN = 8;
349
+ for (const sec of sections) {
350
+ if (sec.header !== "[assistant]") continue;
351
+ const toolIdxs = sec.lines
352
+ .map((l, i) => (l.startsWith("* ") ? i : -1))
353
+ .filter((i) => i >= 0);
354
+ if (toolIdxs.length <= TOOL_CALLS_PER_TURN) continue;
355
+ const dropCount = toolIdxs.length - TOOL_CALLS_PER_TURN;
356
+ const dropSet = new Set(toolIdxs.slice(0, dropCount));
357
+ const firstKeptToolIdx = toolIdxs[dropCount];
358
+ const next: string[] = [];
359
+ let inserted = false;
360
+ for (let i = 0; i < sec.lines.length; i++) {
361
+ if (dropSet.has(i)) continue;
362
+ if (!inserted && i === firstKeptToolIdx) {
363
+ next.push(`* (${dropCount} earlier tool-call entries omitted)`);
364
+ inserted = true;
365
+ }
366
+ next.push(sec.lines[i]);
367
+ }
368
+ sec.lines = next;
369
+ }
370
+
371
+ return sections;
372
+ };
373
+
374
+ /**
375
+ * Stringify BriefLine sections into text format.
376
+ */
377
+ export const stringifyBrief = (sections: BriefLine[]): string => {
378
+
379
+ // Emit sections -- suppress blank lines between consecutive tool summaries
380
+ const out: string[] = [];
381
+ for (let i = 0; i < sections.length; i++) {
382
+ const sec = sections[i];
383
+ if (i > 0) {
384
+ const prev = sections[i - 1];
385
+ const prevIsTools = prev.header === "[assistant]" &&
386
+ prev.lines.every((l) => l.startsWith("* "));
387
+ const curIsTools = sec.header === "[assistant]" &&
388
+ sec.lines.every((l) => l.startsWith("* "));
389
+ if (!(prevIsTools && curIsTools)) {
390
+ out.push("");
391
+ }
392
+ }
393
+ out.push(sec.header);
394
+ for (const line of sec.lines) {
395
+ out.push(line);
396
+ }
397
+ }
398
+
399
+ return out.join("\n");
400
+ };
401
+
402
+ /** Convenience: build sections from blocks and stringify to text */
403
+ export const compileBrief = (blocks: NormalizedBlock[]): string =>
404
+ stringifyBrief(buildBriefSections(blocks));
@@ -0,0 +1,77 @@
1
+ // @ts-nocheck
2
+ import type { FileOps, NormalizedBlock } from "../types";
3
+ import { clip, clipSentence, nonEmptyLines } from "./content";
4
+ import type { SectionData } from "../sections";
5
+ import { extractGoals } from "../extract/goals";
6
+ import { extractFiles } from "../extract/files";
7
+ import { extractPreferences, dedupPreferencesAgainstGoals } from "../extract/preferences";
8
+ import { extractCommits, formatCommits } from "../extract/commits";
9
+ import { buildBriefSections, stringifyBrief } from "./brief";
10
+
11
+ export interface BuildSectionsInput {
12
+ blocks: NormalizedBlock[];
13
+ briefBlocks?: NormalizedBlock[];
14
+ /** Hook-provided file activity; authoritative for files touched before this compaction. */
15
+ fileOps?: FileOps;
16
+ }
17
+
18
+ const BLOCKER_RE =
19
+ /\b(fail(ed|s|ure|ing)?|broken|cannot|can't|won't work|does not work|doesn't work|still (broken|failing|wrong)|blocked|blocker|not (fixed|resolved|working)|crash(es|ed|ing)?)\b/i;
20
+
21
+ const extractOutstandingContext = (blocks: NormalizedBlock[]): string[] => {
22
+ const items: string[] = [];
23
+ const tail = blocks.slice(-20);
24
+
25
+ for (const b of tail) {
26
+ if (b.kind === "assistant" || b.kind === "user") {
27
+ for (const line of nonEmptyLines(b.text)) {
28
+ if (!BLOCKER_RE.test(line)) continue;
29
+ if (line.length < 15) continue;
30
+ // Skip continuation fragments (sub-bullets, parentheticals, dangling clauses)
31
+ if (/^\s*[-*+>]\s/.test(line)) continue;
32
+ if (/^\s*\(/.test(line)) continue;
33
+ // Require sentence-like start: capital letter, code identifier, or quote
34
+ if (!/^\s*["'`*_]?[A-Z`]/.test(line)) continue;
35
+ const clipped = b.kind === "user" ? `[user] ${clipSentence(line, 150)}` : clipSentence(line, 150);
36
+ if (!items.includes(clipped)) items.push(clipped);
37
+ break;
38
+ }
39
+ }
40
+ }
41
+
42
+ return items.slice(0, 5);
43
+ };
44
+
45
+ const formatFileActivity = (blocks: NormalizedBlock[], fileOps?: FileOps): string[] => {
46
+ const act = extractFiles(blocks, fileOps);
47
+ // Dedup: if already Modified, drop from Created (file existed before)
48
+ for (const p of act.modified) act.created.delete(p);
49
+ const lines: string[] = [];
50
+ const cap = (set: Set<string>, limit: number) => {
51
+ const arr = [...set];
52
+ if (arr.length <= limit) return arr.join(", ");
53
+ return arr.slice(0, limit).join(", ") + ` (+${arr.length - limit} more)`;
54
+ };
55
+ if (act.modified.size > 0) lines.push(`Modified: ${cap(act.modified, 10)}`);
56
+ if (act.created.size > 0) lines.push(`Created: ${cap(act.created, 10)}`);
57
+ if (act.read.size > 0) lines.push(`Read: ${cap(act.read, 10)}`);
58
+ return lines;
59
+ };
60
+
61
+ export const buildSections = (input: BuildSectionsInput): SectionData => {
62
+ const { blocks } = input;
63
+ const briefSections = buildBriefSections(input.briefBlocks ?? blocks);
64
+ const sessionGoal = extractGoals(blocks);
65
+ const userPreferences = dedupPreferencesAgainstGoals(
66
+ extractPreferences(blocks),
67
+ sessionGoal,
68
+ );
69
+ return {
70
+ sessionGoal,
71
+ outstandingContext: extractOutstandingContext(blocks),
72
+ filesAndChanges: formatFileActivity(blocks, input.fileOps),
73
+ commits: formatCommits(extractCommits(blocks)),
74
+ userPreferences,
75
+ briefTranscript: stringifyBrief(briefSections),
76
+ };
77
+ };
@@ -0,0 +1,46 @@
1
+ // @ts-nocheck
2
+ export const PI_VCC_COMPACT_INSTRUCTION = "__pi_vcc__";
3
+
4
+ const KEEP_TOKEN_RE = /^keep:(\d+)$/;
5
+
6
+ export interface ParsedCompactionArgs {
7
+ followUpPrompt: string;
8
+ keepUserTurns: number | null;
9
+ keepUserTurnsExplicit: boolean;
10
+ }
11
+
12
+ const parseKeepUserTurns = (raw: string): number => {
13
+ const value = Number(raw);
14
+ return Number.isSafeInteger(value) ? value : Number.MAX_SAFE_INTEGER;
15
+ };
16
+
17
+ export const parseKeepAndPrompt = (args?: string): ParsedCompactionArgs => {
18
+ const trimmed = args?.trim() ?? "";
19
+ if (!trimmed) return { followUpPrompt: "", keepUserTurns: null, keepUserTurnsExplicit: false };
20
+
21
+ const startMatch = trimmed.match(/^keep:(\d+)(?:\s+|$)([\s\S]*)$/);
22
+ if (startMatch) {
23
+ return {
24
+ followUpPrompt: startMatch[2].trim(),
25
+ keepUserTurns: parseKeepUserTurns(startMatch[1]),
26
+ keepUserTurnsExplicit: true,
27
+ };
28
+ }
29
+
30
+ const parts = trimmed.split(/\s+/);
31
+ const endMatch = parts[parts.length - 1].match(KEEP_TOKEN_RE);
32
+ if (endMatch) {
33
+ return {
34
+ followUpPrompt: trimmed.slice(0, trimmed.length - parts[parts.length - 1].length).trim(),
35
+ keepUserTurns: parseKeepUserTurns(endMatch[1]),
36
+ keepUserTurnsExplicit: true,
37
+ };
38
+ }
39
+
40
+ return { followUpPrompt: trimmed, keepUserTurns: null, keepUserTurnsExplicit: false };
41
+ };
42
+
43
+ export const buildPiVccCustomInstructions = (keepUserTurns: number | null): string => {
44
+ if (keepUserTurns == null) return PI_VCC_COMPACT_INSTRUCTION;
45
+ return `${PI_VCC_COMPACT_INSTRUCTION} keep:${keepUserTurns}`;
46
+ };
@@ -0,0 +1,157 @@
1
+ // @ts-nocheck
2
+ import type { Message } from "@oh-my-pi/pi-ai";
3
+ import { PATH_KEYS } from "./tool-args";
4
+
5
+ export const clip = (text: string, max = 200): string => {
6
+ if (text.length <= max) return text;
7
+ // Try to cut at a word boundary
8
+ const cut = text.lastIndexOf(" ", max);
9
+ let end = cut > max * 0.6 ? cut : max;
10
+ // Avoid splitting a surrogate pair
11
+ if (end > 0 && end < text.length) {
12
+ const code = text.charCodeAt(end - 1);
13
+ if (code >= 0xd800 && code <= 0xdbff) end--;
14
+ }
15
+ return text.slice(0, end);
16
+ };
17
+
18
+ /**
19
+ * Clip text to last sentence boundary at or before `max` chars.
20
+ * Falls back to word boundary (clip()) if no sentence end is found in the
21
+ * acceptable range. Trailing whitespace stripped.
22
+ */
23
+ export const clipSentence = (text: string, max = 200): string => {
24
+ if (text.length <= max) return text;
25
+ // Look for sentence terminators followed by space/newline within [max*0.5, max]
26
+ const window = text.slice(0, max);
27
+ const matches = [...window.matchAll(/[.!?](?:\s|$)/g)];
28
+ if (matches.length > 0) {
29
+ const last = matches[matches.length - 1];
30
+ const end = (last.index ?? 0) + 1; // include the punctuation
31
+ if (end >= max * 0.5) return text.slice(0, end);
32
+ }
33
+ return clip(text, max);
34
+ };
35
+
36
+ export const nonEmptyLines = (text: string): string[] =>
37
+ text.split("\n").map((line) => line.trim()).filter(Boolean);
38
+
39
+ export const firstLine = (text: string, max = 200): string =>
40
+ clip(text.split("\n")[0] ?? "", max);
41
+
42
+ export const textParts = (content: Message["content"]): string[] => {
43
+ if (!content) return [];
44
+ if (typeof content === "string") return [content];
45
+ return content
46
+ .filter((part) => part.type === "text")
47
+ .map((part) => part.text);
48
+ };
49
+
50
+ export const textOf = (content: Message["content"]): string =>
51
+ textParts(content).join("\n");
52
+
53
+ /**
54
+ * Check if tool call arguments contain content-bearing data.
55
+ *
56
+ * A call is content-bearing if it has a path argument AND at least one
57
+ * large string/array field (content, edits, oldText, newText).
58
+ * This is a generic heuristic — not dependent on tool names.
59
+ *
60
+ * Ported from pi-blackhole (https://github.com/k0valik/pi-blackhole, MIT) by
61
+ * k0valik — a pi-vcc derivative.
62
+ */
63
+ export const isContentBearing = (args: Record<string, unknown>): boolean => {
64
+ if (!args || typeof args !== "object") return false;
65
+ // Must have a path in one of the known keys
66
+ const hasPath = PATH_KEYS.some((k) => typeof args[k] === "string");
67
+ if (!hasPath) return false;
68
+ // Must have at least one content-bearing field
69
+ if (typeof args.content === "string" && args.content.length > 0) return true;
70
+ // edits must be a non-empty array of objects (each with oldText/newText)
71
+ if (
72
+ Array.isArray(args.edits) &&
73
+ args.edits.length > 0 &&
74
+ args.edits.every((e) => typeof e === "object" && e !== null)
75
+ )
76
+ return true;
77
+ // oldText/newText without edits are content-bearing
78
+ if (
79
+ typeof args.oldText === "string" &&
80
+ args.oldText.length > 0 &&
81
+ args.edits === undefined
82
+ )
83
+ return true;
84
+ if (
85
+ typeof args.newText === "string" &&
86
+ args.newText.length > 0 &&
87
+ args.edits === undefined
88
+ )
89
+ return true;
90
+ return false;
91
+ };
92
+
93
+ /**
94
+ * Extract textual content from tool call arguments (content, edits,
95
+ * oldText, newText). Used for counting touched-file lines and search.
96
+ *
97
+ * Ported from pi-blackhole (https://github.com/k0valik/pi-blackhole, MIT) by
98
+ * k0valik — a pi-vcc derivative.
99
+ */
100
+ export const extractToolCallText = (args: Record<string, unknown>): string => {
101
+ let text = "";
102
+ if (typeof args.content === "string") text += args.content + "\n";
103
+ if (Array.isArray(args.edits)) {
104
+ for (const edit of args.edits) {
105
+ if (edit && typeof edit === "object") {
106
+ if (typeof edit.oldText === "string") text += edit.oldText + "\n";
107
+ if (typeof edit.newText === "string") text += edit.newText + "\n";
108
+ }
109
+ }
110
+ }
111
+ if (typeof args.oldText === "string" && !Array.isArray(args.edits))
112
+ text += args.oldText + "\n";
113
+ if (typeof args.newText === "string" && !Array.isArray(args.edits))
114
+ text += args.newText + "\n";
115
+ return text;
116
+ };
117
+
118
+ /**
119
+ * Extract every scalar string argument from a tool call for search indexing
120
+ * — command, query, content, oldText/newText, etc. Generic value walk (no
121
+ * tool-name allowlist): top-level strings plus strings one level into
122
+ * array-of-object fields (e.g. `edits`). Unbounded by design — a single
123
+ * toolCall's raw argument text — so a message with several toolCalls doesn't
124
+ * silently multiply an internal cap. The caller (search-entries.ts) applies
125
+ * one shared budget across all toolCalls in a message.
126
+ */
127
+ export const extractToolCallArgsText = (args: Record<string, unknown>): string => {
128
+ if (!args || typeof args !== "object") return "";
129
+ const parts: string[] = [];
130
+ for (const value of Object.values(args)) {
131
+ if (typeof value === "string") {
132
+ parts.push(value);
133
+ } else if (Array.isArray(value)) {
134
+ for (const item of value) {
135
+ if (typeof item === "string") {
136
+ parts.push(item);
137
+ } else if (item && typeof item === "object") {
138
+ for (const v of Object.values(item)) {
139
+ if (typeof v === "string") parts.push(v);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ }
145
+ return parts.join("\n");
146
+ };
147
+
148
+ /** Extract a snippet of ~`radius` chars around the first match of `term` in `text`. */
149
+ export const snippet = (text: string, term: string, radius = 60): string | null => {
150
+ const idx = text.toLowerCase().indexOf(term.toLowerCase());
151
+ if (idx === -1) return null;
152
+ const start = Math.max(0, idx - radius);
153
+ const end = Math.min(text.length, idx + term.length + radius);
154
+ const prefix = start > 0 ? "..." : "";
155
+ const suffix = end < text.length ? "..." : "";
156
+ return `${prefix}${text.slice(start, end)}${suffix}`;
157
+ };