@monotykamary/pi-vcc 0.4.0 → 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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/core/brief.ts +245 -1
- package/src/core/causal-keys.ts +56 -0
- package/src/core/summarize.ts +53 -3
- package/__tests__/helpers.ts +0 -135
- package/__tests__/root-causes.test.ts +0 -858
- package/demo.gif +0 -0
- package/knip.json +0 -4
- package/scripts/audit-sessions.ts +0 -88
- package/scripts/benchmark-real-sessions.ts +0 -25
- package/scripts/compare-before-after.ts +0 -36
- package/scripts/dump-branch-output.ts +0 -20
- package/stress-debug.ts +0 -202
- package/stress-degradation-curve.ts +0 -251
- package/tests/australian-government.test.ts +0 -479
- package/tests/before-compact-hook.test.ts +0 -185
- package/tests/before-compact.test.ts +0 -227
- package/tests/breadcrumbs.test.ts +0 -312
- package/tests/brief.test.ts +0 -215
- package/tests/build-sections.test.ts +0 -59
- package/tests/compile.test.ts +0 -89
- package/tests/content.test.ts +0 -66
- package/tests/deep-extraction.test.ts +0 -322
- package/tests/extract-goals.test.ts +0 -86
- package/tests/extract-preferences.test.ts +0 -30
- package/tests/filter-noise.test.ts +0 -61
- package/tests/fixtures.ts +0 -61
- package/tests/format-recall.test.ts +0 -87
- package/tests/format.test.ts +0 -73
- package/tests/government-domain.test.ts +0 -542
- package/tests/lineage.test.ts +0 -33
- package/tests/load-messages.test.ts +0 -49
- package/tests/model-threshold-hook.test.ts +0 -311
- package/tests/model-threshold.test.ts +0 -223
- package/tests/normalize.test.ts +0 -104
- package/tests/proactive-threshold.test.ts +0 -490
- package/tests/real-sessions.test.ts +0 -38
- package/tests/recall-expand.test.ts +0 -15
- package/tests/recall-scope.test.ts +0 -32
- package/tests/recall-tool-scope.test.ts +0 -67
- package/tests/render-entries.test.ts +0 -92
- package/tests/report.test.ts +0 -58
- package/tests/sanitize.test.ts +0 -24
- package/tests/search-entries.test.ts +0 -233
- package/tests/stress-100-compactions.test.ts +0 -373
- package/tests/support/load-session.ts +0 -23
- package/tests/support/real-sessions.ts +0 -51
- package/tests/threshold-integration.test.ts +0 -335
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@monotykamary/pi-vcc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@monotykamary/pi-vcc",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.4.1",
|
|
10
10
|
"devDependencies": {
|
|
11
11
|
"knip": "6.14.1",
|
|
12
12
|
"typescript": "6.0.3"
|
package/package.json
CHANGED
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
|
+
};
|
package/src/core/summarize.ts
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
if (
|
|
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);
|
package/__tests__/helpers.ts
DELETED
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Helpers for constructing mock session entries and messages.
|
|
3
|
-
*
|
|
4
|
-
* These mirror pi-agent-core types but use loose object shapes
|
|
5
|
-
* so we don't need the actual runtime dependency in tests.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export interface MockEntry {
|
|
9
|
-
type: string;
|
|
10
|
-
id?: string;
|
|
11
|
-
message?: {
|
|
12
|
-
role?: string;
|
|
13
|
-
content?: unknown;
|
|
14
|
-
stopReason?: string;
|
|
15
|
-
};
|
|
16
|
-
firstKeptEntryId?: string;
|
|
17
|
-
summary?: string;
|
|
18
|
-
timestamp?: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
let nextId = 1;
|
|
22
|
-
|
|
23
|
-
function makeId(): string {
|
|
24
|
-
return `entry_${nextId++}`;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function resetIds(): void {
|
|
28
|
-
nextId = 1;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function makeUserEntry(
|
|
32
|
-
text: string,
|
|
33
|
-
overrides?: Partial<MockEntry>,
|
|
34
|
-
): MockEntry {
|
|
35
|
-
const id = makeId();
|
|
36
|
-
return {
|
|
37
|
-
type: "message",
|
|
38
|
-
id,
|
|
39
|
-
message: {
|
|
40
|
-
role: "user",
|
|
41
|
-
content: [{ type: "text", text }],
|
|
42
|
-
},
|
|
43
|
-
...overrides,
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function makeAssistantEntry(
|
|
48
|
-
text: string,
|
|
49
|
-
stopReason = "stop",
|
|
50
|
-
overrides?: Partial<MockEntry>,
|
|
51
|
-
): MockEntry {
|
|
52
|
-
const id = makeId();
|
|
53
|
-
return {
|
|
54
|
-
type: "message",
|
|
55
|
-
id,
|
|
56
|
-
message: {
|
|
57
|
-
role: "assistant",
|
|
58
|
-
content: [{ type: "text", text }],
|
|
59
|
-
stopReason,
|
|
60
|
-
},
|
|
61
|
-
...overrides,
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function makeToolResultEntry(
|
|
66
|
-
toolCallId: string,
|
|
67
|
-
content: string,
|
|
68
|
-
overrides?: Partial<MockEntry>,
|
|
69
|
-
): MockEntry {
|
|
70
|
-
const id = makeId();
|
|
71
|
-
return {
|
|
72
|
-
type: "message",
|
|
73
|
-
id,
|
|
74
|
-
message: {
|
|
75
|
-
role: "toolResult",
|
|
76
|
-
content: [{ type: "toolResult", toolCallId, content }],
|
|
77
|
-
},
|
|
78
|
-
...overrides,
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function makeCompactionEntry(
|
|
83
|
-
firstKeptEntryId: string,
|
|
84
|
-
overrides?: Partial<MockEntry>,
|
|
85
|
-
): MockEntry {
|
|
86
|
-
const id = makeId();
|
|
87
|
-
return {
|
|
88
|
-
type: "compaction",
|
|
89
|
-
id,
|
|
90
|
-
firstKeptEntryId,
|
|
91
|
-
summary: "Compaction summary",
|
|
92
|
-
...overrides,
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Build a context snapshot dict matching Agent.state.messages shape.
|
|
98
|
-
* Used for testing the "continue from assistant message" scenarios.
|
|
99
|
-
*/
|
|
100
|
-
export interface ContextMessage {
|
|
101
|
-
role: string;
|
|
102
|
-
content: unknown;
|
|
103
|
-
stopReason?: string;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function makeContextMessages(
|
|
107
|
-
...msgs: ContextMessage[]
|
|
108
|
-
): ContextMessage[] {
|
|
109
|
-
return msgs;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function userMsg(text: string): ContextMessage {
|
|
113
|
-
return { role: "user", content: [{ type: "text", text }] };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function assistantMsg(
|
|
117
|
-
text: string,
|
|
118
|
-
stopReason = "stop",
|
|
119
|
-
): ContextMessage {
|
|
120
|
-
return { role: "assistant", content: [{ type: "text", text }], stopReason };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export function toolResultMsg(
|
|
124
|
-
toolCallId: string,
|
|
125
|
-
content: string,
|
|
126
|
-
): ContextMessage {
|
|
127
|
-
return {
|
|
128
|
-
role: "toolResult",
|
|
129
|
-
content: [{ type: "toolResult", toolCallId, content }],
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function compactionSummaryMsg(summary: string): ContextMessage {
|
|
134
|
-
return { role: "user", content: [{ type: "compactionSummary", text: summary }] };
|
|
135
|
-
}
|