@monotykamary/pi-vcc 0.4.1 → 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-vcc",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Algorithmic conversation compactor for pi - transcript-preserving structured summaries, no LLM calls",
5
5
  "main": "index.ts",
6
6
  "keywords": [
package/src/core/brief.ts CHANGED
@@ -2,6 +2,7 @@ import type { NormalizedBlock } from "../types";
2
2
  import { clip, firstLine } from "./content";
3
3
  import { extractPath } from "./tool-args";
4
4
  import { collapseSkillText } from "./skill-collapse";
5
+ import { KEY_STOPS, refineBreadcrumbKey } from "./causal-keys";
5
6
 
6
7
  const TRUNCATE_USER = 256;
7
8
  const TRUNCATE_ASSISTANT = 200;
@@ -409,13 +410,203 @@ const shortenPath = (p: string): string => {
409
410
  return parts.length > 2 ? parts.slice(-2).join("/") : p;
410
411
  };
411
412
 
413
+ // ── causal extraction ──
414
+ // Marker-based extraction: scan for specific phrases that signal cause or
415
+ // resolution, then extract a bounded fragment after the marker.
416
+ //
417
+ // Why markers instead of regex patterns?
418
+ // 1. No backtracking risk — indexOf + linear char scan is O(n) worst case.
419
+ // Regex with lazy quantifiers + alternation ([^.,;!?]+?)(?:[.,;!?]|$)
420
+ // can theoretically backtrack, though JS engines limit this in practice.
421
+ // 2. Bounded by construction — FRAGMENT_MAX hard cap, no unbounded capture.
422
+ // 3. Easy to extend — add a string to the list, no regex syntax to debug.
423
+ // 4. Multi-sentence aware — tries full text first, then per-sentence.
424
+
425
+ // Cause markers: phrases that signal "here comes the reason/problem".
426
+ // Ordered from most specific to least specific; first match wins.
427
+ const CAUSE_MARKERS: readonly string[] = [
428
+ "the issue is",
429
+ "the problem is",
430
+ "the problem was",
431
+ "the bug is",
432
+ "the bug was",
433
+ "the cause is",
434
+ "root cause:",
435
+ "root cause is",
436
+ "the reason is",
437
+ "fails because",
438
+ "fails when",
439
+ "fails due to",
440
+ "crashes because",
441
+ "crashes when",
442
+ "crashes due to",
443
+ "breaks because",
444
+ "breaks when",
445
+ "breaks due to",
446
+ "because ",
447
+ "since ",
448
+ "due to ",
449
+ "missing ",
450
+ "lacking ",
451
+ "lack of ",
452
+ "absence of ",
453
+ "can't ",
454
+ "cannot ",
455
+ "not properly ",
456
+ "not correctly ",
457
+ "not validating ",
458
+ "not returning ",
459
+ "not handling ",
460
+ "not releasing ",
461
+ "not checking ",
462
+ "wrong ",
463
+ "incorrect ",
464
+ "stale ",
465
+ "outdated ",
466
+ "unhandled ",
467
+ "uncaught ",
468
+ ];
469
+
470
+ // Resolution markers: phrases that signal "here comes the fix/action".
471
+ // Ordered from most specific to least specific; first match wins.
472
+ const RESOLUTION_MARKERS: readonly string[] = [
473
+ "fix this by",
474
+ "fix it by",
475
+ "resolve this by",
476
+ "resolve it by",
477
+ "resolve by",
478
+ "handle this by",
479
+ "handle by",
480
+ "address this by",
481
+ "address by",
482
+ "by adding",
483
+ "by creating",
484
+ "by implementing",
485
+ "by introducing",
486
+ "by applying",
487
+ "by inserting",
488
+ "by using",
489
+ "by swapping",
490
+ "by migrating",
491
+ "by isolating",
492
+ "by splitting",
493
+ "by extracting",
494
+ "by replacing",
495
+ "by refactoring",
496
+ "by wrapping",
497
+ "by moving",
498
+ "by removing",
499
+ "added ",
500
+ "created ",
501
+ "implemented ",
502
+ "introduced ",
503
+ "applied ",
504
+ "inserted ",
505
+ "changed to",
506
+ "updated to",
507
+ "switched to",
508
+ "migrated to",
509
+ "replaced with",
510
+ "replaced by",
511
+ "refactored to",
512
+ "extracted into",
513
+ "set up ",
514
+ "configured ",
515
+ "enabled ",
516
+ "swapped ",
517
+ "isolated ",
518
+ "splitting ",
519
+ "split ",
520
+ "wrapped ",
521
+ "guarded ",
522
+ "moved ",
523
+ "removed ",
524
+ ];
525
+
526
+ // Maximum chars for a causal fragment — hard cap prevents unbounded capture.
527
+ const FRAGMENT_MAX = 60;
528
+ // Maximum chars for a causal breadcrumb key — keeps the ...recall line bounded.
529
+ const CAUSAL_BREADCRUMB_MAX = 40;
530
+
531
+ // Characters that terminate a causal fragment.
532
+ const SENTINEL_CHARS = new Set([...
533
+ ",.;!?\n"
534
+ ]);
535
+
536
+ /**
537
+ * Extract a bounded fragment starting after the first matching marker.
538
+ * Stops at the first sentinel character or FRAGMENT_MAX chars, whichever
539
+ * comes first. O(n) single pass — indexOf + linear scan, no regex.
540
+ */
541
+ const extractFragment = (
542
+ text: string,
543
+ markers: readonly string[],
544
+ ): string | null => {
545
+ const lower = text.toLowerCase();
546
+ for (const marker of markers) {
547
+ const idx = lower.indexOf(marker);
548
+ if (idx < 0) continue;
549
+ const start = idx + marker.length;
550
+ if (start >= text.length) continue;
551
+
552
+ let end = start;
553
+ while (end < text.length && end - start < FRAGMENT_MAX) {
554
+ if (SENTINEL_CHARS.has(text[end])) break;
555
+ end++;
556
+ }
557
+ const fragment = text.slice(start, end).trim();
558
+ if (fragment.length < 4) continue;
559
+ return fragment;
560
+ }
561
+ return null;
562
+ };
563
+
564
+ /**
565
+ * Extract a causal fragment from assistant text.
566
+ * Returns { cause, resolution } where each is a short string or null.
567
+ *
568
+ * Deterministic: same text always produces the same result.
569
+ * O(n): indexOf + linear char scan per marker; no regex backtracking.
570
+ * Bounded: fragments are capped at CAUSAL_BREADCRUMB_MAX chars.
571
+ */
572
+ export const extractCausalChain = (
573
+ text: string,
574
+ ): { cause: string | null; resolution: string | null } => {
575
+ // Try full text first (markers can span sentence boundaries)
576
+ let cause = extractFragment(text, CAUSE_MARKERS);
577
+ let resolution = extractFragment(text, RESOLUTION_MARKERS);
578
+
579
+ // If we didn't find both, also try per-sentence (handles cases like
580
+ // "The issue is a race condition. I fixed it by adding a mutex."
581
+ // where cause and resolution are in different sentences but the
582
+ // resolution marker "it by" doesn't match because "it" is too far
583
+ // from the full text's start).
584
+ if (!cause || !resolution) {
585
+ const sentences = text.split(/[.!?]/).filter(s => s.trim().length > 3);
586
+ for (const sentence of sentences) {
587
+ if (!cause) cause = extractFragment(sentence, CAUSE_MARKERS);
588
+ if (!resolution) resolution = extractFragment(sentence, RESOLUTION_MARKERS);
589
+ if (cause && resolution) break;
590
+ }
591
+ }
592
+
593
+ return {
594
+ cause: cause ? clip(cause, CAUSAL_BREADCRUMB_MAX) : null,
595
+ resolution: resolution ? clip(resolution, CAUSAL_BREADCRUMB_MAX) : null,
596
+ };
597
+ };
598
+
412
599
  /**
413
600
  * Synthesize a one-line summary for a conversational turn.
414
601
  * No LLM — purely algorithmic compression.
602
+ *
603
+ * V2: includes causal information when available (cause → resolution → actions).
604
+ * Falls back to V1 format when no causal chain is found.
415
605
  */
416
606
  const synthesizeTurnSummary = (
417
607
  userText: string | null,
418
608
  toolActions: string[],
609
+ causalChain: { cause: string | null; resolution: string | null } = { cause: null, resolution: null },
419
610
  ): string => {
420
611
  const parts: string[] = [];
421
612
 
@@ -424,6 +615,13 @@ const synthesizeTurnSummary = (
424
615
  parts.push(clip(userText, 50));
425
616
  }
426
617
 
618
+ // Causal chain: cause → resolution (before actions)
619
+ const hasCausal = causalChain.cause || causalChain.resolution;
620
+ if (hasCausal) {
621
+ if (causalChain.cause) parts.push(causalChain.cause);
622
+ if (causalChain.resolution) parts.push(causalChain.resolution);
623
+ }
624
+
427
625
  // Key actions — dedup and cap
428
626
  const uniqueActions = [...new Set(toolActions)].slice(0, 5);
429
627
  if (uniqueActions.length > 0) {
@@ -442,6 +640,36 @@ const synthesizeTurnSummary = (
442
640
  return parts.join(" \u2192 ") || "(no actions)";
443
641
  };
444
642
 
643
+ /**
644
+ * Build a causal breadcrumb for a turn summary line.
645
+ * Format: "file|resolution-key" instead of just "file" or keywords.
646
+ *
647
+ * Deterministic: same input always produces the same breadcrumb.
648
+ * Idempotent: the breadcrumb is a pure function of the line text and causal chain.
649
+ */
650
+ const buildCausalBreadcrumb = (
651
+ turnSummary: string,
652
+ causalChain: { cause: string | null; resolution: string | null },
653
+ ): string => {
654
+ // Extract file from action part (after →)
655
+ const fileMatch = turnSummary.match(/(?:edited |read |wrote |created |deleted )?([^\s→.]+\.\w{1,12})/);
656
+ const file = fileMatch ? shortenPath(fileMatch[1]) : null;
657
+
658
+ // Build resolution key from causal chain using stop-word-aware refinement
659
+ const resolutionKey = causalChain.resolution
660
+ ? refineBreadcrumbKey(causalChain.resolution)
661
+ : null;
662
+
663
+ if (file && resolutionKey) return `${file}|${resolutionKey}`;
664
+ if (resolutionKey) return resolutionKey;
665
+ // Fallback: V1 breadcrumb logic
666
+ const beforeArrow = turnSummary.split("\u2192")[0].trim();
667
+ const words = beforeArrow.split(/\s+/).filter(w => w.length > 2).slice(0, 3);
668
+ if (words.length > 0) return words.join(" ");
669
+ if (file) return file;
670
+ return "";
671
+ };
672
+
445
673
  /**
446
674
  * Identify conversational turns and produce one-liner summaries.
447
675
  *
@@ -449,19 +677,29 @@ const synthesizeTurnSummary = (
449
677
  * responses, tool calls, and tool results until the next user/bash block.
450
678
  * This is the HCA zone — the heaviest compression layer that covers turns
451
679
  * that would otherwise fall off the brief transcript's capBrief cutoff.
680
+ *
681
+ * V2: extracts causal chains from assistant text and includes them in
682
+ * turn summaries. Causal breadcrumbs are emitted for the ...recall system.
452
683
  */
453
684
  export const identifyTurns = (blocks: NormalizedBlock[]): TurnInfo[] => {
454
685
  const turns: TurnInfo[] = [];
455
686
  let currentUserText: string | null = null;
456
687
  const toolActions: string[] = [];
688
+ const assistantTexts: string[] = [];
457
689
 
458
690
  const flush = () => {
459
691
  if (currentUserText === null && toolActions.length === 0) return;
692
+
693
+ // Extract causal chain from collected assistant text in this turn
694
+ const combinedAssistant = assistantTexts.join(" ");
695
+ const causalChain = extractCausalChain(combinedAssistant);
696
+
460
697
  turns.push({
461
- summary: synthesizeTurnSummary(currentUserText, toolActions),
698
+ summary: synthesizeTurnSummary(currentUserText, toolActions, causalChain),
462
699
  });
463
700
  currentUserText = null;
464
701
  toolActions.length = 0;
702
+ assistantTexts.length = 0;
465
703
  };
466
704
 
467
705
  for (const b of blocks) {
@@ -472,6 +710,12 @@ export const identifyTurns = (blocks: NormalizedBlock[]): TurnInfo[] => {
472
710
  : `$ ${compressBash(b.command)}`;
473
711
  continue;
474
712
  }
713
+ if (b.kind === "assistant") {
714
+ // Collect assistant text for causal extraction
715
+ if (b.text && b.text.trim().length > 0) {
716
+ assistantTexts.push(b.text.trim());
717
+ }
718
+ }
475
719
  if (b.kind === "tool_call") {
476
720
  if (!b.name || b.name.trim() === "") continue;
477
721
  const path = extractPath(b.args);
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Shared stop words and key refinement for causal breadcrumb extraction.
3
+ *
4
+ * Used by both brief.ts (turn summary key generation) and summarize.ts
5
+ * (breadcrumb extraction from capped lines) to ensure consistent key output.
6
+ */
7
+
8
+ // Stop words filtered out when building breadcrumb keys.
9
+ // These carry no causal signal and would bloat the key.
10
+ export const KEY_STOPS = new Set([
11
+ // Articles, pronouns, demonstratives
12
+ "the", "a", "an", "this", "that", "these", "those", "it", "its",
13
+ // Be-verbs, auxiliaries
14
+ "is", "was", "are", "were", "been", "being",
15
+ "has", "have", "had", "does", "do", "did",
16
+ "will", "would", "could", "should", "may", "might", "shall", "can",
17
+ // Prepositions
18
+ "to", "of", "in", "for", "on", "with", "at", "by", "from", "as",
19
+ "into", "through", "before", "after", "above", "below",
20
+ // Conjunctions, adverbs
21
+ "and", "but", "or", "not", "so", "yet",
22
+ "before", "after", "when", "where", "while", "during",
23
+ // Contextual filler that doesn't carry causal signal
24
+ "against", "into", "around", "before", "all",
25
+ // Marker remnant words: verbs from resolution markers that survive
26
+ // the extraction because the fragment starts right after the marker.
27
+ // E.g. "added session check" → we want "session-check", not "added-session-check".
28
+ "added", "adding", "created", "creating",
29
+ "applied", "applying", "inserted", "inserting",
30
+ "implemented", "implementing", "introduced", "introducing",
31
+ "using", "swapped", "swapping", "split", "splitting",
32
+ "migrated", "migrating", "isolated", "isolating",
33
+ "removed", "removing", "extracted", "extracting",
34
+ "replaced", "replacing", "refactored", "refactoring",
35
+ "wrapped", "wrapping", "guarded", "guarding",
36
+ "moved", "moving", "updated", "configured",
37
+ "enabled", "switched",
38
+ ]);
39
+
40
+ /** Maximum content words in a breadcrumb key. */
41
+ const KEY_MAX_WORDS = 3;
42
+
43
+ /**
44
+ * Build a compact breadcrumb key from a raw fragment.
45
+ * Takes up to KEY_MAX_WORDS content words (skipping stop words), joined with "-".
46
+ */
47
+ export const refineBreadcrumbKey = (fragment: string, maxChars = 40): string => {
48
+ const words = fragment.split(/\s+/);
49
+ const content: string[] = [];
50
+ for (const w of words) {
51
+ if (KEY_STOPS.has(w.toLowerCase())) continue;
52
+ content.push(w);
53
+ if (content.length >= KEY_MAX_WORDS) break;
54
+ }
55
+ return content.join("-") || fragment.slice(0, maxChars);
56
+ };
@@ -4,6 +4,7 @@ import { normalize } from "./normalize";
4
4
  import { filterNoise } from "./filter-noise";
5
5
  import { buildSections } from "./build-sections";
6
6
  import { formatSummary, capBrief, wrapLongLines } from "./format";
7
+ import { refineBreadcrumbKey } from "./causal-keys";
7
8
 
8
9
  export interface CompileInput {
9
10
  messages: Message[];
@@ -65,9 +66,58 @@ const briefOf = (text: string): string => {
65
66
  const extractBreadcrumb = (line: string): string => {
66
67
  const text = line.replace(/^\s*-\s*/, "").trim();
67
68
  if (!text) return "";
68
- // "edited auth.ts" → "auth.ts"
69
- const fileMatch = text.match(/(?:edited |read |wrote |created |deleted )?(\S+\.\w{1,12})/);
70
- if (fileMatch) return fileMatch[1];
69
+
70
+ // V1: ...recall: breadcrumb — preserve as-is (they already carry causal keys)
71
+ if (text.startsWith("...recall:")) return text.slice("...recall:".length).trim();
72
+
73
+ // V2: causal breadcrumb from turn summary (contains →)
74
+ // Format: "goal → cause_fragment → resolution_fragment → tool_actions"
75
+ // The cause/resolution fragments are produced by extractCausalChain and
76
+ // are typically short phrases without a leading verb.
77
+ if (text.includes("\u2192")) {
78
+ const parts = text.split("\u2192").map(p => p.trim());
79
+
80
+ // Extract file from the action parts
81
+ const fileMatch = text.match(/(?:edited |read |wrote |created |deleted )?([^\s.]+\.\w{1,12})/);
82
+ const file = fileMatch ? fileMatch[1] : null;
83
+
84
+ // Identify structural parts: goal is first, tool actions are at the end
85
+ // (start with read/edited/ran or contain file paths).
86
+ // Middle parts are causal fragments.
87
+ const toolActionRe = /^(?:read|edited|wrote|created|deleted|ran)\s?/i;
88
+ const toolActionIdx = parts.findIndex(p => toolActionRe.test(p) || /\+\d+ more/.test(p));
89
+
90
+ // Causal parts are between goal (index 0) and tool actions
91
+ const causalEnd = toolActionIdx >= 0 ? toolActionIdx : parts.length;
92
+ const causalParts = parts.slice(1, causalEnd); // skip goal at index 0
93
+
94
+ // The last causal part before tool actions is the resolution fragment
95
+ // (from extractCausalChain). The one before that is the cause fragment.
96
+ const causePart = causalParts.length >= 2 ? causalParts[0] : null;
97
+ const resolutionPart = causalParts.length >= 1 ? causalParts[causalParts.length - 1] : null;
98
+
99
+ // Build breadcrumb: file|resolution-key
100
+ // Resolution key: content words from the resolution fragment, refined by shared key filter
101
+ if (resolutionPart) {
102
+ const resKey = refineBreadcrumbKey(resolutionPart);
103
+ if (file && resKey) return `${file}|${resKey}`;
104
+ if (resKey) return resKey;
105
+ }
106
+
107
+ // Cause key fallback
108
+ if (causePart) {
109
+ const causeKey = refineBreadcrumbKey(causePart);
110
+ if (file && causeKey) return `${file}|${causeKey}`;
111
+ if (causeKey) return causeKey;
112
+ }
113
+
114
+ // Final fallback for → lines: just the file
115
+ if (file) return file;
116
+ }
117
+
118
+ // V1 fallback: "edited auth.ts" → "auth.ts"
119
+ const fileMatch1 = text.match(/(?:edited |read |wrote |created |deleted )?(\S+\.\w{1,12})/);
120
+ if (fileMatch1) return fileMatch1[1];
71
121
  // "Fix login bug → ..." → first few words before →
72
122
  const beforeArrow = text.split("\u2192")[0].trim();
73
123
  const words = beforeArrow.split(/\s+/).filter(w => w.length > 2).slice(0, 3);