SLOPSHOPPER

jev-context

Jev keeps the context window lean so compaction comes later: prunes tool output on entry, caps subagent reports, carries exact facts across compaction…

new
★ 6v0.1.0MITupdated 2026-09-29HyunjunJeon/jev-context
A shopper browsing a rack in a slop shop
README

jev-context

Claude Code와 Codex에서 쓰는 플러그인이다. 컨텍스트 창을 가볍게 유지해 압축(compaction)을 최대한 늦추고, 압축이 오더라도 필요한 원문이 살아남게 한다. 판단이 필요한 몇 곳에서만 TypeSafe의 Jev 모델을 부른다.

  1. 들어오는 순간에 한 번 정하고, 그 뒤로는 고치지 않는다. 이미 보낸 기록을 고치면 프롬프트 캐시가 깨진다. Opus 5.5·Sonnet 5.5에서는 그 뒤의 thinking 블록도 무효가 된다(preserved thinking).
  2. 숨기기 전에 원문을 먼저 저장한다. 줄인 결과에는 항상 원문 경로가 붙는다.
  3. Jev는 기본으로 켜 두되, 필요할 때만 부른다. 규칙으로 정할 수 없고 Jev의 답이 결과를 바꿀 수 있는 곳에서만 요청을 보낸다.

전체 구성

flowchart LR
  subgraph HOST["호스트: Claude Code · Codex"]
    T["도구 호출"]
    S["서브에이전트"]
    C["압축"]
    R["세션 재개"]
  end
  subgraph PLUGIN["jev-context"]
    RG["읽기 게이트"]
    PR["prune"]
    RP["report"]
    RT["retain"]
    TM["timing"]
    VB["verbatim (옵트인)"]
  end
  JEV[("Jev · TypeSafe")]
  T -- "PreToolUse" --> RG
  T -- "PostToolUse" --> PR
  S -- "Agent · SubagentHandback · SubagentStop" --> RP
  C -- "PreCompact → SessionStart(compact)" --> RT
  C -. "session.compact (function hook)" .-> VB
  R -- "SessionStart(resume) · UserPromptSubmit" --> TM
  RG -- "목적이 보이는 100줄 이상 읽기" --> JEV
  RT -- "압축마다 1회, 1,000자 이상 결과" --> JEV
  VB -. "호출·주제 판단" .-> JEV
모듈하는 일Claude Code 2.1.284Codex 0.158.0
읽기 게이트오케스트레이터가 100줄 이상을 한꺼번에 읽으려 할 때, 이번 단계에 전체가 필요한지 Jev가 판단한다. 필요 없으면 줄 번호가 달린 개요와 함께 한 번 되돌려 좁혀 읽게 한다. 같은 읽기를 다시 요청하면 통과한다`PreToolUse(Bash\Read)` 거부PreToolUse(Bash) 거부
prune도구 출력이 들어올 때 반복 줄을 규칙으로 접는다. 원문은 먼저 저장하고 경로를 남긴다PostToolUse(Bash) updatedToolOutputPreToolUse(Bash)로 실행기 감싸기(옵트인)
report서브에이전트에게 ## Summary와 ## Details로 나눠 보고하게 하고, 오케스트레이터에는 Summary와 전체 보고 파일 경로만 전달한다`PreToolUse(Agent\Task\SubagentHandback)`SubagentStop(긴 보고를 한 번 되돌림)
retain압축은 호스트의 내장 요약이 하고, retain은 그 옆에 원문을 덧붙인다. 규칙으로 튀는 줄(값·실패·요약)을 고른다. Jev에게 압축마다 한 번 "다음 단계가 편집할 출력"을 물어 그 출력을 통째로 붙인다PreCompact → SessionStart(compact)같음
timing프롬프트 캐시가 이미 식었을 때만 압축을 권한다. 재개가 압축을 되돌린 경우도 알아챈다SessionStart(resume) · UserPromptSubmit없음(캐시 신호가 없음)
verbatim (옵트인)요약 대신 원문을 유지하는 압축이다. 옛 도구 결과와 사용자가 닫은 주제를 지우고, 글은 한 글자도 바꾸지 않는다function hook session.compact없음

워크플로

도구 호출: 읽기 게이트와 prune

flowchart TD
  A["에이전트가 도구 호출"] --> Q1{"오케스트레이터의 읽기이고<br/>크기를 미리 알 수 있는 100줄 이상인가"}
  Q1 -- "아니오" --> RUN["그대로 실행"]
  Q1 -- "예" --> Q2{"읽는 목적이 보이나<br/>같은 읽기의 반복은 아닌가"}
  Q2 -- "아니오" --> RUN
  Q2 -- "예" --> J1{{"Jev: 이번 단계에 전체가 필요한가"}}
  J1 -- "필요" --> RUN
  J1 -- "불필요" --> DENY["한 번 되돌림<br/>줄 번호가 달린 개요 첨부"]
  DENY --> A
  RUN --> OUT["도구 출력"]
  OUT --> P1{"4,000자 이상이고<br/>검색·목록·문서형이 아닌가"}
  P1 -- "아니오" --> KEEP["원문 그대로"]
  P1 -- "예" --> SAVE["원문 저장"]
  SAVE --> FOLD["반복 줄 접기<br/>원문 경로 표시"]

Codex는 셸 출력을 hook에서 바꿀 수 없다. 그래서 JEV_CONTEXT_CODEX_WRAP=on이면 단일 빌드·테스트·설치·린트 명령을 실행기(bin/codex-run.mjs)로 감싸서 같은 접기를 적용한다. Codex는 명령을 바꿀 때 승인도 함께 요구하므로, 감싼 명령은 승인 절차를 건너뛴다.

서브에이전트 보고: report

sequenceDiagram
  participant O as 오케스트레이터
  participant H as jev-context
  participant S as 서브에이전트
  O->>H: Agent 호출(지시문)
  H->>S: 지시문 + "Summary 1,500자 이내 / Details" 안내
  S->>H: SubagentHandback(보고 전체)
  H->>H: 보고 전체를 파일로 저장
  H->>O: Summary + 저장 경로만 전달
  Note over O: 세부가 필요하면 그 파일을 읽는다

안내는 서브에이전트에게만 가고, 오케스트레이터 기록에는 남지 않는다. 이 구조가 없는 보고가 3,000자를 넘으면 한 번 되돌려 다시 받는다. Codex에서는 SubagentStop에서 긴 보고를 한 번 되돌리는 것까지만 된다.

압축: 내장 요약 + retain (기본)

flowchart TD
  C["호스트가 압축 시작<br/>/compact 또는 자동"] --> PC["PreCompact"]
  PC --> RULE["규칙: 도구 출력의 튀는 줄<br/>값 · 실패 · 요약"]
  PC --> Q{"1,000자 이상인<br/>도구 결과가 있나"}
  Q -- "없음" --> NOREQ["Jev 요청 없음"]
  Q -- "있음" --> J{{"Jev 1회<br/>다음 단계가 이 호출이 읽은 파일을 편집하나"}}
  J -- "0.3 이상" --> WHOLE["그 출력을 통째로<br/>합계 12,000자까지"]
  J -- "미만" --> NOREQ
  RULE --> STATE[("상태 저장")]
  WHOLE --> STATE
  C --> SUM["호스트의 내장 요약"]
  SUM --> SS["SessionStart(compact)"]
  STATE --> SS
  SS --> NEXT["요약 + 덧붙인 원문으로 다음 요청"]

두 호스트 모두 공식 hook만 쓰므로 따로 켤 플래그가 없다. Claude Code는 압축 뒤 Read 도구로 읽은 파일을 스스로 다시 붙이지만, 셸로 읽은 내용은 붙이지 않는다. Codex는 모든 읽기가 셸이다. 이 두 경우에 "다음 단계가 편집할 출력"을 통째로 붙이는 것이 효과를 냈다(아래 검증 요약).

압축: verbatim (옵트인, Claude Code 전용)

flowchart TD
  C["압축 시작"] --> F{"COMPACT=verbatim이고<br/>function hook 플래그가 켜져 있나"}
  F -- "아니오" --> BASE["기본 흐름: 내장 요약 + retain"]
  F -- "예" --> Q{"물어볼 만큼 큰 호출이나<br/>교환이 있나"}
  Q -- "없음" --> BASE
  Q -- "있음" --> J{{"Jev 최대 2회<br/>호출: 편집할 파일인가 · 호출을 남길까<br/>교환: 사용자가 주제를 닫았나"}}
  J --> CUT["옛 결과 삭제, 튀는 줄 보존<br/>닫은 주제 삭제, thinking 제거<br/>글은 그대로"]
  CUT --> M{"25% 이상 줄었나"}
  M -- "아니오" --> BASE
  M -- "예" --> DONE["요약 없이 원문 유지 압축"]

fast-jev-compaction의 구조(도구 호출 짝짓기, 대화 전체를 state로 맞추기)를 그대로 쓰고, 질문과 고정 범위를 측정에 맞게 바꿨다. 압축이 약 0.3초에 끝나고 요약 모델 호출이 없다. 다만 Claude Code의 early-access 기능이라 플래그가 필요하고, --resume하면 압축이 사라진다(timing이 감지해 다시 압축한다).

세션 재개: timing (Claude Code)

flowchart TD
  R["세션 재개"] --> L{"지난 압축이 재개에서<br/>되돌려졌나"}
  L -- "예" --> H{"헤드리스 실행인가"}
  L -- "아니오" --> E{"프롬프트 캐시가 식었고<br/>컨텍스트가 60k 토큰 이상인가"}
  E -- "아니오" --> GO["그대로 진행"]
  E -- "예" --> H
  H -- "예" --> CP["/compact 먼저 실행"]
  H -- "아니오" --> NB["알리고 첫 프롬프트를 한 번 막음"]

캐시가 살아 있을 때 압축하면 이미 낸 캐시 비용을 버리게 된다. 그래서 캐시가 식은 순간(재개, 긴 휴식 뒤 첫 프롬프트)에만 압축을 권한다.

Jev를 부르는 곳과 조건

Jev는 셸에 TYPESAFE_API_KEY가 있으면 켜지고, JEV_CONTEXT_JEV=off면 꺼진다. 켜져 있어도 아래 조건에 맞을 때만 요청을 보낸다. 측정해 보니 Jev는 답의 근거가 대화 안에 있을 때(사용자의 말, 읽는 목적, 파일 경로) 잘 판단했다. 미래를 예측하게 하는 질문("나중에 필요할까")에서는 점수가 한쪽으로 몰렸다.

호출 지점기본부르는 때부르지 않는 때근거(라이브 측정)
읽기 게이트켜짐오케스트레이터의 100줄 이상 읽기이고, 목적이 보일 때(같은 응답에 40자 이상 설명, 또는 사용자 요청 후 3응답 이내)서브에이전트의 읽기, 짧은 읽기, 파이프·복합 명령, 목적이 안 보일 때, 반복 요청목적이 보이는 10건의 순위 일치도 0.75(나머지 0.46–0.57). 실제 과제에서 6번 발동해 모두 타당
retain: "다음 단계가 이 호출이 읽은 파일을 편집하나"켜짐압축마다 한 번, 1,000자 이상인 도구 결과가 있을 때큰 결과가 없을 때, JEV_CONTEXT_RETAIN=rules셸로 읽고 다음에 편집할 파일 0.38–0.55, 로그 0.11 이하. 0.3 이상이면 통째로 붙인다
verbatim: 같은 질문 + "호출을 남길까"옵트인압축할 때, 결과와 입력이 합쳐 1,000자 이상인 호출첫 메시지, 아직 답하지 않은 결과, 작은 호출편집할 파일 0.45–0.85, 끝난 파일 0.09, 로그 0.17 이하
verbatim: "사용자가 이 주제를 닫았나"verbatim과 함께1,500자 이상이고 최근이 아닌 교환짧은 교환, 최근 교환닫은 주제 0.87–0.94, 넘어가기만 한 주제 0.50–0.57. 0.8 이상만 지운다
retain hybrid·jev, prune의 Jev 단계꺼짐옵트인기본값 청크를 가려내지 못했다(점수가 한쪽으로 몰림). prune은 줄인 사례가 없었다

기본 설정에서 Jev 요청은 압축마다 많아야 한 번이다. 판단 로그(logs/decisions.jsonl)의 carryAsked·carried·carryScores, callsAsked·callsTooSmall·jevRequests로 무엇을 물었고 무엇을 건너뛰었는지 볼 수 있다.

설치

git clone <this repo> jev-context && cd jev-context
npm run setup      # vendor/jev-pruner(070d4af), vendor/fast-jev-compaction(e3f262a)을 고정 커밋으로 받아 빌드
npm test
npm run package    # package/jev-context: 설치용 폴더(약 0.7MB)
npm run doctor

설치는 package/jev-context에서 한다. 이 폴더가 두 호스트의 마켓플레이스 루트다. 저장소를 그대로 설치하면 안 되는 이유는 두 가지다. 로컬 마켓플레이스는 .gitignore와 상관없이 폴더를 통째로 복사해서 vendor/*/node_modules(169MB)와 logs/(평가 데이터)가 딸려 간다. 반대로 git에서 바로 받으면 vendor/가 없어서 hook이 import 단계에서 실패한다.

export TYPESAFE_API_KEY=...   # 셸에만 둔다. 파일·로그·화면에 남기지 않는다

Claude Code

claude --plugin-dir package/jev-context                                   # 이 프로세스에만
claude plugin marketplace add "$PWD/package/jev-context" [--scope local]
claude plugin install jev-context@jev-context-local [--scope local]

--scope local이면 그 프로젝트의 .claude/settings.local.json에만 기록되어 다른 프로젝트 세션에는 영향이 없다. verbatim을 쓰려면 아래 두 변수를 켠다. 매번 입력하지 않으려면 ~/.claude/settings.json의 env에 한 번 적어 둔다.

export JEV_CONTEXT_COMPACT=verbatim CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

Codex

codex plugin marketplace add "$PWD/package/jev-context"
codex plugin add jev-context@jev-context-codex
# 제거: codex plugin remove jev-context@jev-context-codex && codex plugin marketplace remove jev-context-codex

새 세션에서 /hooks로 신뢰를 승인해야 hook이 실행된다(codex exec는 --dangerously-bypass-hook-trust). Codex에서 직접 시험하는 절차는 docs/codex-test.md에 있다. Jev의 의미 판단만 제한적으로 시험하려면 다음 프로필로 시작한다.

export JEV_CONTEXT_READ_GATE=on JEV_CONTEXT_RETAIN=hybrid JEV_CONTEXT_CODEX_WRAP=off JEV_CONTEXT_PRUNE_JEV=off
codex

설정 (환경 변수)

변수기본값의미
JEV_CONTEXT_JEV키가 있으면 live, 없으면 offoff면 아무것도 보내지 않는다. simulated는 고정 점수를 쓰는 테스트용이다
JEV_CONTEXT_RETAINonon: 튀는 줄 + Jev 1회로 다음 단계가 편집할 출력을 통째로. rules: 튀는 줄만. hybrid: 규칙 뒤 남은 예산에서 Jev가 의미 청크를 보완. jev: 비교용. off: 끔
JEV_CONTEXT_RETAIN_MAX_CHARS / _RETAIN_WHOLE_MAX_CHARS6000 / 12000덧붙이는 튀는 줄의 합계 / 통째로 붙이는 출력의 합계
JEV_CONTEXT_COMPACTsummarysummary: 호스트의 내장 요약 + retain. verbatim: Claude Code function hook(플래그 필요)
JEV_CONTEXT_COMPACT_KEEP0.3결과를 통째로 남기는 Jev 점수(retain·verbatim 공통)
JEV_CONTEXT_COMPACT_TOPICS / _COMPACT_TOPIC_DROPon / 0.8verbatim에서 사용자가 닫은 주제를 지우는 판단과 그 기준
JEV_CONTEXT_COMPACT_MIN_REDUCTION0.25verbatim이 이보다 덜 줄이면 내장 요약으로 넘어간다
JEV_CONTEXT_REPORT_SPLIT / _REPORT_SUMMARY_CHARS / _REPORT_MAX_CHARSon / 1500 / 3000Summary/Details 분리, Summary 예산, 구조 없는 보고를 되돌리는 길이
JEV_CONTEXT_PRUNE / _PRUNE_MIN_CHARS / _PRUNE_MIN_SAVINGon / 4000 / 0.25접기 대상 출력의 최소 길이와 최소 절감률. 검색·목록 출력은 접지 않는다
JEV_CONTEXT_PRUNE_JEV / _PRUNE_MAX_CHARSoff / 8000규칙이 접지 못한 큰 출력에 Jev 청크 판단을 쓸지와 그 상한
JEV_CONTEXT_READ_GATE / _READ_GATE_LINES / _READ_GATE_INTENTon / 100 / on읽기 게이트, 줄 수 기준, 목적이 보일 때만 묻기
JEV_CONTEXT_CODEX_WRAPoffCodex에서 단일 빌드·테스트·설치·린트 명령을 실행기로 감싼다. 감싼 명령은 승인을 건너뛴다
JEV_CONTEXT_RESUME / _IDLE / _COMPACT_MIN_TOKENScompact / block-once / 60000Claude Code 캐시 타이밍
JEV_CONTEXT_LOG / _STATE_DIRlogs/decisions.jsonl / .state/판단 기록과 상태. 키·프롬프트·출력 내용은 남기지 않는다

검증 요약 (2026-09-29, 라이브)

장면결과
retain + Jev 1회: 셸로 읽은 파일을 압축 뒤 다시 읽지 않고 다섯 줄 인용규칙만 1/5 → 기본 5/5 (Claude Code 2/2, Codex 2/2, 압축마다 Jev 1회)
retain 규칙: 도구 출력에만 있던 해시내장 요약만 1/3 → 규칙 retain 2/2 (Sonnet 5.5, 173k 토큰 세션)
prune빌드 출력 130,478자 → 약 1.1k자, 값 보존 (두 호스트, 설치본으로 확인)
report오케스트레이터에 도착한 보고 54k → 13k자, 정답률 같음(5/6 대 5/6). Codex 6,439 → 440자
읽기 게이트실제 과제에서 6번 발동, 모두 타당. Opus 한 과제에서 도구 결과 24k → 4k자
verbatim (옵트인)압축 0.3초 대 내장 요약 24–28초. 주제 판단을 켜면 다음 요청 25.8k 대 23.5k
timingverbatim 압축이 재개에서 사라진 것을 감지해 다시 압축(205k → 37k 토큰)

측정 전체와 조건, 실패한 시도는 docs/verification.md에 있다. Codex 세션에서 직접 돌린 결과는 docs/codex-test-results-2026-09-29.md에 있다.

npm test                                        # 오프라인 테스트 (44개)
npm run e2e:claude                              # Claude Code e2e
E2E_CODEX_SANDBOX=danger-full-access npm run e2e:codex
node scripts/e2e-compact.mjs shell-base && node scripts/e2e-compact.mjs shell rules,on   # 셸 읽기 + 압축 (Claude Code)
node scripts/e2e-codex-carry.mjs rules,on                                                # 같은 장면 (Codex, 포크)

폴더 구조

bin/              hook 진입점(hook.mjs), Codex 실행기(codex-run.mjs)
src/core/         규칙과 Jev 판단: reduce, prune, retain, carry, verbatim, readgate, jev, config
src/handlers/     hook 이벤트별 처리
src/transcript/   Claude Code·Codex 기록 읽기(Codex 포크의 부모 기록 추적 포함)
hooks/            Claude Code hooks.json, verbatim function hook 모듈
codex/            Codex hooks.json, jev-context 스킬
scripts/          setup · package · doctor, e2e와 평가 스크립트, 픽스처
tests/            오프라인 테스트(node --test)
docs/             검증 기록, Codex 테스트 절차와 결과

한계와 주의

  • 압축 요약 자체는 호스트 것이다. 공식 hook은 요약 내용을 바꿀 수 없다. retain은 요약 옆에 원문을 덧붙일 뿐이다. verbatim은 early-access function hook에 기대므로, Claude Code를 올리면 debug 로그에서 hooks module jev-context@… loaded부터 확인한다. 모듈은 $.env.get에 리터럴 이름만 쓸 수 있다. 로드할 때 Claude Code가 플러그인 폴더에 .claude-plugin/types/와 tsconfig.json을 만든다.
  • verbatim은 재개하면 사라진다. timing이 감지해 다시 압축하게 하지만, JEV_CONTEXT_RESUME=off면 재개한 첫 요청이 압축 전 전체를 다시 보낸다.
  • Claude Code에서 --fork-session 직후 바로 압축하면 retain이 빈손이다. 그 시점에는 fork의 기록 파일이 아직 없다(로그에 no_transcript). Codex 포크는 부모 기록을 참조하는 방식이라, 파서가 부모 rollout을 따라가 읽는다.
  • report는 서브에이전트가 한 번 더 일하게 만든다. 그 추가 턴은 서브에이전트 창에서만 생긴다. 요약된 보고로 오케스트레이터의 판단이 나빠지는지는 아직 평가하지 않았다.
  • Claude Code 내부 동작에 기대는 부분이 있다(2.1.284 실측, 공식 문서에 없음). 버전을 올리면 npm test와 e2e를 다시 돌린다.
  • 서브에이전트 보고는 SubagentHandback 도구로만 전달된다. Claude Code는 서브에이전트가 보고서 파일을 쓰는 것을 막으므로 hook이 원문을 저장한다.
  • /compact 요약기는 agent_type이 빈 서브에이전트로 돌고 SubagentStop을 발생시킨다. report는 이것을 건드리지 않는다.
  • updatedToolOutput은 Bash 출력 객체여야 적용된다. initialUserMessage는 헤드리스에서만 제출된다.
  • Codex 감싸기는 승인을 대신한다. 그래서 기본값이 off이고, 파이프·연결·리다이렉션이 없는 단일 명령만 감싼다.
  • 기준선은 작은 표본에서 나왔다. 0.3·0.8·1,000자·1,500자는 이번 측정 세션들에서 정한 값이다. 다른 작업에서는 판단 로그의 점수를 보고 다시 맞춘다.
  • Jev에 보내는 것: 읽기 게이트는 대화 일부와 파일 개요를 보낸다. retain은 압축 때 대화 전체(도구 결과는 크기만)와 도구 호출 입력을 보낸다. verbatim도 같다. prune의 Jev 단계와 retain jev는 도구 출력을 보낸다. 비밀처럼 보이는 출력은 원문 경로를 인용하지 않는다. 공개 가능한 작업에만 켠다.

참조한 프로젝트

  • jev-pruner (MIT): Jev 클라이언트, 청크 트리머, 보존 규칙. npm run setup이 070d4af를 받는다.
  • fast-jev-compaction (MIT): 호출 짝짓기, 고정, state 맞춤, 판단 적용을 그대로 쓴다. e3f262a를 받는다. turn.complete 60% 자동 압축과 userConfig의 API 키는 가져오지 않았다.
Source 4 files
hooks/verbatim-compact.mjs 98 lines
1// Claude Code function hook (early access: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1).
2// With JEV_CONTEXT_COMPACT=verbatim it answers `session.compact` with the
3// verbatim compaction (src/core/verbatim.mjs) instead of a summary. Answering
4// without next() means core never runs, so the command PreCompact/SessionStart
5// hooks stay out of it. When Jev fails or the history shrinks too little it
6// passes on: the built-in summary runs, and with it the retain hooks.
7// The worker has no Node: settings come from $.env, the log goes through $.fs.
8import { buildJevRequest, parseJevResponse } from '../vendor/jev-pruner/dist/jev.js';
9import { asker } from '../src/core/jev.mjs';
10import { compactVerbatim, MIN_REDUCTION } from '../src/core/verbatim.mjs';
11
12const JEV_TIMEOUT_MS = 20_000;
13const TIMED_OUT = Symbol('timed out');
14
15// $.env.get takes literal names only (Claude Code lists what a module reads).
16async function settings($) {
17  const number = (value, fallback) => (value && Number.isFinite(Number(value)) ? Number(value) : fallback);
18  const apiKey = await $.env.get('TYPESAFE_API_KEY');
19  return {
20    compact: (await $.env.get('JEV_CONTEXT_COMPACT')) || 'summary',
21    // Same defaults as src/core/config.mjs: live whenever a key is in the shell.
22    jev: (await $.env.get('JEV_CONTEXT_JEV')) || (apiKey ? 'live' : 'off'),
23    jevModel: (await $.env.get('JEV_CONTEXT_JEV_MODEL')) || 'jev-latest',
24    retainMaxChars: number(await $.env.get('JEV_CONTEXT_RETAIN_MAX_CHARS'), 6_000),
25    compactMinReduction: number(await $.env.get('JEV_CONTEXT_COMPACT_MIN_REDUCTION'), MIN_REDUCTION),
26    compactTopics: (await $.env.get('JEV_CONTEXT_COMPACT_TOPICS')) || 'on',
27    compactKeep: number(await $.env.get('JEV_CONTEXT_COMPACT_KEEP'), 0.3),
28    compactTopicDrop: number(await $.env.get('JEV_CONTEXT_COMPACT_TOPIC_DROP'), 0.8),
29    log: (await $.env.get('JEV_CONTEXT_LOG')) || `${$.plugin.root}/logs/decisions.jsonl`,
30    apiKey,
31  };
32}
33
34/** A Jev asker over the host's fetch (the worker has no network of its own). */
35function jevFor($, cfg) {
36  if (cfg.jev === 'simulated') return asker(cfg);
37  if (cfg.jev !== 'live' || !cfg.apiKey) return null;
38  const counter = { requests: 0 };
39  return {
40    counter,
41    async ask(state, questions) {
42      counter.requests += 1;
43      const request = buildJevRequest({ apiKey: cfg.apiKey, model: cfg.jevModel }, state, questions);
44      // The losing timer resolves later to nothing; a rejecting one would go unhandled.
45      const timeout = $.clock.sleep(JEV_TIMEOUT_MS).then(() => TIMED_OUT);
46      const response = await Promise.race([$.http.fetch(request.url, { method: request.method, headers: request.headers, body: request.body }), timeout]);
47      if (response === TIMED_OUT) throw new Error('Jev timed out');
48      return parseJevResponse(response.status, response.ok, response.text);
49    },
50  };
51}
52
53/** Same line format as bin/hook.mjs. $.fs writes whole files, so this appends by rewriting. */
54async function log($, cfg, entry) {
55  const line = JSON.stringify({ at: new Date().toISOString(), host: 'claude', hook: 'session-compact', ...entry });
56  $.ui.log(`jev-context ${line}`);
57  try {
58    const before = (await $.fs.exists(cfg.log)) ? await $.fs.read(cfg.log) : '';
59    await $.fs.write(cfg.log, `${before}${line}\n`);
60  } catch {
61    // Logging never changes the compaction.
62  }
63}
64
65export const register = (on) => {
66  on('session.compact', async ($, e, next) => {
67    const cfg = await settings($);
68    if (cfg.compact !== 'verbatim') return next(e);
69    const started = Date.now();
70    const entry = { trigger: e.trigger, session: await $.session.id().catch(() => undefined) };
71    // An ahead-of-time summary would be installed without asking this hook
72    // again; skipping it keeps the real compaction on this path.
73    if (e.trigger === 'precompute') return { skip: 'jev-context: verbatim compaction runs at compaction time' };
74    if (e.agentId) {
75      await log($, cfg, { ...entry, decision: 'subagent_passed' });
76      return next(e);
77    }
78    const jev = jevFor($, cfg);
79    if (!jev) {
80      await log($, cfg, { ...entry, decision: 'fallback_no_jev' });
81      return next(e);
82    }
83    try {
84      const result = await compactVerbatim(cfg, e.messages, jev, { goal: e.instructions ?? '' });
85      Object.assign(entry, result.stats, { elapsedMs: Date.now() - started });
86      if (result.stage !== 'compacted') {
87        await log($, cfg, { ...entry, decision: `fallback_${result.stage}` });
88        return next(e);
89      }
90      await log($, cfg, { ...entry, decision: 'compacted' });
91      return { messages: result.messages };
92    } catch (error) {
93      await log($, cfg, { ...entry, decision: 'fallback_error', error: String(error?.message ?? error).slice(0, 200), jevRequests: jev.counter?.requests, elapsedMs: Date.now() - started });
94      return next(e);
95    }
96  });
97};
98
src/core/jev.mjs 44 lines
1import { buildJevRequest, parseJevResponse } from '../../vendor/jev-pruner/dist/jev.js';
2
3/**
4 * A JevAsker for jev-pruner and for this plugin's own questions. `simulated`
5 * is for tests and rehearsals only: fixed scores from a regex, never Jev.
6 */
7export function asker(cfg, counter = { requests: 0 }) {
8  if (cfg.jev === 'simulated') {
9    const needed = /error|warn|fail|BUNDLE|ROLLBACK|completed|sha256|digest|rollback|release/i;
10    return {
11      counter,
12      async ask(state, questions) {
13        counter.requests += 1;
14        const answers = {};
15        for (const id of Object.keys(questions)) {
16          // Verbatim compaction's call-level pair: keep every call, drop every result.
17          if (id.startsWith('call_') || id.startsWith('result_')) {
18            answers[id] = { type: 'noul', noul: id.startsWith('call_') ? 0.99 : 0.01 };
19            continue;
20          }
21          const text = state.chunks?.find((chunk) => chunk.id === id)?.text ?? '';
22          answers[id] = { type: 'noul', noul: needed.test(text) ? 0.99 : 0.01 };
23        }
24        return { answers };
25      },
26    };
27  }
28  if (cfg.jev !== 'live' || !cfg.apiKey) return null;
29  return {
30    counter,
31    async ask(state, questions) {
32      counter.requests += 1;
33      const request = buildJevRequest({ apiKey: cfg.apiKey, model: cfg.jevModel }, state, questions);
34      const response = await fetch(request.url, {
35        method: request.method,
36        headers: request.headers,
37        body: request.body,
38        signal: AbortSignal.timeout(20_000),
39      });
40      return parseJevResponse(response.status, response.ok, await response.text());
41    },
42  };
43}
44
src/core/verbatim.mjs 310 lines
1// Verbatim compaction, from fast-jev-compaction: instead of replacing the
2// conversation with a summary, drop the tool calls and results Jev says are no
3// longer needed and keep every user and assistant text as written. The call
4// decisions are the library's own (pinning, whole-history state, batching).
5// What this plugin changes, so that it shrinks the window about as far as a
6// summary does while keeping the text:
7// - Only the first message and results the assistant has not answered yet are
8//   pinned. The library pins the newest six messages whole, which after a busy
9//   turn kept two 22k-char outputs Jev was never asked about (68% of what was
10//   left, live 2026-09-29).
11// - The result question is this plugin's. The library keeps a result only when
12//   "re-running the tool would not do"; with that premise every result scored
13//   below 0.5, even a file the next step was about to edit (0.15-0.18, live
14//   2026-09-29). Asked whether the next step edits what the call read (naming
15//   the file), Jev scored the file 0.45-0.85 whenever the next step edited it
16//   (0.45 when it was read 25 messages earlier), 0.09 once the edit was done,
17//   and every log 0.17 or below. So a result stays whole from 0.3
18//   (JEV_CONTEXT_COMPACT_KEEP), not the library's 0.5; either mistake is cheap
19//   to undo (a few tokens kept, or one re-read). A wording that also counted
20//   "values found only in the output" kept whole 22k-char logs; values are the
21//   standout rule's job.
22// - A dropped result keeps its standout lines (a value, a failure, a summary).
23//   That is a rule, not Jev: asked per chunk, Jev scored a 31-chunk test log
24//   0.65-0.85 throughout and ranked the failure 7th-8th.
25// - Long inputs of calls whose result goes are cut to their head.
26// - Assistant messages come back without their thinking. After a compaction
27//   no earlier thinking fits the new prefix (preserved thinking), and a summary
28//   drops it as well.
29// - On by default (cfg.compactTopics; JEV_CONTEXT_COMPACT_TOPICS=off stops it): Jev is also asked, per exchange (a
30//   user request and everything up to the next one), whether the user closed
31//   its topic. Only a confident yes (0.8, JEV_CONTEXT_COMPACT_TOPIC_DROP) drops
32//   it: a topic the user explicitly closed scored 0.87-0.93, one they only
33//   moved on from 0.50-0.56, and a dropped exchange cannot be brought back. It
34//   leaves a note with the lines that stood out in its tool output. Asked
35//   instead whether the work "comes back to" a topic, Jev scored both kinds
36//   below 0.5 and a release id went with the closed topic. The first exchange
37//   and those in the newest messages are not asked about.
38// - Jev is asked only when its answer can change the window (ASK_CALL_CHARS,
39//   ASK_EXCHANGE_CHARS); with nothing that large, no request is sent and the
40//   built-in summary runs.
41// Runs inside Claude Code's function-hook worker, so nothing here imports Node.
42import { applyDecisions, batchCalls, messageChars, questionsFor, resolveOptions } from '../../vendor/fast-jev-compaction/dist/compact.js';
43import { noulAnswer } from '../../vendor/fast-jev-compaction/dist/request.js';
44import { collectToolCalls, fitState } from '../../vendor/fast-jev-compaction/dist/state.js';
45import { pickStandouts } from './reduce.mjs';
46
47/** Below this share of characters removed, the built-in summary is used instead. */
48export const MIN_REDUCTION = 0.25;
49/**
50 * Jev is asked only where its answer can change the window: a call whose
51 * result and input together are this long, an exchange holding this much.
52 * Anything smaller stays as it is without a question, and a compaction with
53 * nothing large enough sends no request at all.
54 */
55export const ASK_CALL_CHARS = 1_000;
56export const ASK_EXCHANGE_CHARS = 1_500;
57
58function outputsById(messages) {
59  const outputs = new Map();
60  for (const message of messages) {
61    for (const tool of message.toolUses) if (typeof tool.text === 'string') outputs.set(tool.tool_use_id, tool.text);
62    for (const result of message.toolResults ?? []) outputs.set(result.tool_use_id, result.text);
63  }
64  return outputs;
65}
66
67const sourceOf = (input) => [input?.command, input?.file_path, input?.pattern].find((v) => typeof v === 'string') ?? '';
68
69/** First line only (the command banner, capped at `headChars`), a short note, then the standout lines. */
70function withKeptLines(original, lines, headChars) {
71  const head = original.split('\n', 1)[0].slice(0, headChars);
72  return [
73    head,
74    `[jev-context: ${original.length - head.length} chars dropped at compaction; standout lines kept, re-run for the rest]`,
75    ...lines.filter((line) => !head.includes(line)),
76  ].join('\n');
77}
78
79/** Jev's question about a call's output: will the next step edit what the call read? */
80export function resultQuestion(call) {
81  const source = sourceOf(call.input).replace(/\s+/g, ' ').slice(0, 120);
82  return {
83    type: 'noul',
84    instructions: `The work right after this compaction edits or rewrites what tool call ${call.id} read (${call.tool}${source ? ` ${source}` : ''}, ${call.resultChars} chars), such as a source file or document, so the assistant needs that text exactly as it was read. Logs, listings and command output whose key lines the assistant already reported do not count.`,
85  };
86}
87
88/** Pinned calls stay; a result stays whole from `keep`; otherwise the call stays from 0.5 (the library's call question). */
89function decide(call, answer, keep) {
90  const base = { id: call.id, tool: call.tool, ...answer };
91  if (call.pinned) return { ...base, action: 'keep', reason: 'pinned' };
92  if (answer.keepResult >= keep) return { ...base, action: 'keep', reason: 'kept' };
93  if (answer.keepCall >= 0.5) return { ...base, action: 'drop_result', reason: 'result_dropped' };
94  return { ...base, action: 'drop_call', reason: 'call_dropped' };
95}
96
97/** The library's call question with this plugin's result question. */
98const questionsOf = (call) => ({ ...questionsFor(call), [`result_${call.id}`]: resultQuestion(call) });
99
100/** A result the assistant has already answered in text is no longer pinned; the first message stays pinned. */
101function repin(messages, calls) {
102  const lastText = messages.findLastIndex((m) => m.role === 'assistant' && m.text.trim());
103  return calls.map((call) => (call.pinned && call.callIndex > 0 && call.resultIndex < lastText ? { ...call, pinned: false } : call));
104}
105
106const INPUT_HEAD = 300;
107
108/** An input with its long string fields cut to their head; the same object when nothing is long. */
109function shortInput(input) {
110  let changed = false;
111  const out = {};
112  for (const [key, value] of Object.entries(input ?? {})) {
113    if (typeof value === 'string' && value.length > INPUT_HEAD * 2) {
114      out[key] = `${value.slice(0, INPUT_HEAD)}[… ${value.length - INPUT_HEAD} chars of this input dropped at compaction]`;
115      changed = true;
116    } else out[key] = value;
117  }
118  return changed ? out : input;
119}
120
121/** Assistant messages rebuilt without a handle (so without thinking), with the inputs of result-dropped calls cut. */
122function withoutThinking(messages, cutInputs, cut = { inputs: 0 }) {
123  return messages.flatMap((m) => {
124    if (m.role !== 'assistant') return [m];
125    const toolUses = m.toolUses.map((tool) => {
126      const input = cutInputs.has(tool.tool_use_id) ? shortInput(tool.input) : tool.input;
127      if (input === tool.input) return tool;
128      cut.inputs += 1;
129      const copy = { tool_use_id: tool.tool_use_id, tool: tool.tool, input };
130      if (tool.text !== undefined) copy.text = tool.text;
131      if (tool.isError) copy.isError = true;
132      return copy;
133    });
134    if (!m.text.trim() && toolUses.length === 0) return [];
135    return [{ role: 'assistant', text: m.text, toolUses }];
136  });
137}
138
139/** Exchanges: a user request (not a tool result, not a harness record) up to the next one. */
140function exchangesOf(messages) {
141  const starts = messages.flatMap((m, i) => (m.role === 'user' && m.text.trim() && !m.text.trimStart().startsWith('<') && !(m.toolResults ?? []).length ? [i] : []));
142  return starts.map((start, k) => ({ id: `e${k + 1}`, start, end: (starts[k + 1] ?? messages.length) - 1 }));
143}
144
145/** Exchanges Jev may drop: not the first, not one reaching into the newest messages, none with an unanswered result. */
146function topicCandidates(messages, calls, recent) {
147  const waiting = calls.filter((c) => c.pinned && c.callIndex > 0).map((c) => c.resultIndex);
148  return exchangesOf(messages).filter((e) => e.start > 0 && e.end < messages.length - recent && !waiting.some((i) => i >= e.start && i <= e.end));
149}
150
151const inputChars = (input) => {
152  try {
153    return JSON.stringify(input ?? {}).length;
154  } catch {
155    return 0;
156  }
157};
158const callChars = (call) => call.resultChars + inputChars(call.input);
159const exchangeChars = (messages, t) => messages.slice(t.start, t.end + 1).reduce((sum, m) => sum + messageChars(m), 0);
160
161const opening = (messages, t) => messages[t.start].text.replace(/\s+/g, ' ').trim().slice(0, 200);
162
163/** Jev's question about an exchange: did the user close its topic? */
164export function topicQuestion(t, messages) {
165  return {
166    type: 'noul',
167    instructions: `The user closed the topic of the exchange in history entries ${t.start}-${t.end} (it opens with the user saying: "${opening(messages, t)}"): they said it is finished or set it aside, and the remaining work does not come back to it.`,
168  };
169}
170
171const TOPIC_NOTE_LINES = 600;
172
173/** The note left for a dropped exchange: its opening words and what stood out in its tool output. */
174function topicNote(messages, t) {
175  const outputs = messages.slice(t.start, t.end + 1).flatMap((m) => (m.toolResults ?? []).map((r) => r.text));
176  const lines = pickStandouts(outputs, { budget: TOPIC_NOTE_LINES }).picked.flat();
177  const head = `[jev-context: an earlier exchange was dropped at compaction because the user closed its topic. It opened with: "${opening(messages, t).slice(0, 120)}"`;
178  return lines.length ? `${head}. Lines that stood out in its tool output:\n${lines.join('\n')}]` : `${head}]`;
179}
180
181const count = (decisions, action) => decisions.filter((d) => d.action === action && d.reason !== 'pinned').length;
182
183/**
184 * Compacts `messages` (Claude Code's SessionMessage shape). User messages the
185 * decisions leave alone come back as the same objects, so the engine keeps its
186 * own copy; assistant messages and anything edited come back rebuilt, without
187 * a handle. Throws when Jev fails or the history cannot be fitted: the caller
188 * falls back to the built-in summary.
189 */
190export async function compactVerbatim(cfg, messages, jev, { goal = '' } = {}) {
191  const started = Date.now();
192  const options = resolveOptions({ goal });
193  const collected = collectToolCalls(messages, options.preserveRecentMessages);
194  const collectedPinned = collected.map((c) => c.pinned);
195  const allCalls = repin(messages, collected);
196  const unpinned = allCalls.filter((call) => !call.pinned);
197  const candidates = unpinned.filter((call) => callChars(call) >= ASK_CALL_CHARS);
198  const small = new Set(unpinned.filter((call) => callChars(call) < ASK_CALL_CHARS).map((call) => call.tool_use_id));
199  const exchanges = cfg.compactTopics === 'on' ? topicCandidates(messages, allCalls, options.preserveRecentMessages) : [];
200  const topics = exchanges.filter((t) => exchangeChars(messages, t) >= ASK_EXCHANGE_CHARS);
201  const charsBefore = messages.reduce((sum, m) => sum + messageChars(m), 0);
202  const base = { calls: allCalls.length, charsBefore };
203  if (candidates.length === 0 && topics.length === 0) return { stage: 'no_candidates', messages, stats: { ...base, charsAfter: charsBefore, reduction: 0 } };
204
205  // 1. Questions per call (and per exchange when asked for), with the whole
206  // history (results as notes) as state; the requests run side by side.
207  const fitted = fitState(messages, allCalls, options);
208  const answers = new Map();
209  const topicP = new Map();
210  await Promise.all([
211    ...batchCalls(candidates, fitted.tokens, options).map(async (batch) => {
212      const { answers: got } = await jev.ask(fitted.state, Object.assign({}, ...batch.map(questionsOf)));
213      for (const call of batch) answers.set(call.id, { keepCall: noulAnswer(got, `call_${call.id}`), keepResult: noulAnswer(got, `result_${call.id}`) });
214    }),
215    topics.length > 0 && (async () => {
216      const { answers: got } = await jev.ask(fitted.state, Object.fromEntries(topics.map((t) => [`topic_${t.id}`, topicQuestion(t, messages)])));
217      for (const t of topics) topicP.set(t.id, noulAnswer(got, `topic_${t.id}`));
218    })(),
219  ]);
220  const keep = cfg.compactKeep ?? 0.3;
221  // A call too small to ask about stays as it is.
222  const decisionOf = new Map(allCalls.map((call) => [call.tool_use_id, small.has(call.tool_use_id)
223    ? { id: call.id, tool: call.tool, keepCall: 1, keepResult: 1, action: 'keep', reason: 'small' }
224    : decide(call, answers.get(call.id) ?? { keepCall: 1, keepResult: 1 }, keep)]));
225
226  // 2. Exchanges whose topic the user closed go whole; the next kept request
227  // carries a note that they existed. Calls are then collected again over
228  // what is left, with the answers they already got.
229  const gone = topics.filter((t) => topicP.get(t.id) >= (cfg.compactTopicDrop ?? 0.8));
230  const dropIndex = new Set(gone.flatMap((t) => Array.from({ length: t.end - t.start + 1 }, (_, k) => t.start + k)));
231  const notes = new Map();
232  for (const t of gone) {
233    let next = t.end + 1;
234    while (dropIndex.has(next)) next += 1;
235    const opener = messages[next];
236    if (opener) notes.set(opener, [...(notes.get(opener) ?? []), topicNote(messages, t)]);
237  }
238  const left = messages.filter((_, i) => !dropIndex.has(i));
239  const calls = gone.length ? repin(left, collectToolCalls(left, options.preserveRecentMessages)) : allCalls;
240  let decisions = calls.map((call) => (call.pinned ? { id: call.id, tool: call.tool, keepCall: 1, keepResult: 1, action: 'keep', reason: 'pinned' } : { ...decisionOf.get(call.tool_use_id), id: call.id }));
241
242  // 3. What each dropped result held that looked like nothing else in it.
243  const byId = new Map(calls.map((call) => [call.id, call]));
244  const outputs = outputsById(left);
245  const dropped = decisions.filter((d) => d.action !== 'keep').map((d) => byId.get(d.id));
246  const texts = dropped.map((call) => outputs.get(call.tool_use_id) ?? '');
247  const { picked, chars: rescuedChars } = pickStandouts(texts, {
248    budget: cfg.retainMaxChars ?? 6_000,
249    skip: (i, line) => texts[i].slice(0, options.truncateHeadChars).includes(line),
250  });
251  const kept = new Map(dropped.flatMap((call, i) => (picked[i].length ? [[call.tool_use_id, picked[i]]] : [])));
252  // A call whose output still holds needed lines is not dropped whole.
253  decisions = decisions.map((d) => (d.action === 'drop_call' && kept.has(byId.get(d.id).tool_use_id) ? { ...d, action: 'drop_result', reason: 'result_dropped', rescued: true } : d));
254
255  // 4. Rebuild. applyDecisions hands back fresh copies for what it truncated;
256  // only those get the kept lines, so the engine's own objects stay untouched.
257  const originals = new Set(left.flatMap((m) => [m, ...m.toolUses, ...(m.toolResults ?? [])]));
258  const applied = applyDecisions(left, decisions, calls, options.truncateHeadChars);
259  for (const message of applied) {
260    if (originals.has(message)) continue;
261    for (const item of [...message.toolUses, ...(message.toolResults ?? [])]) {
262      if (originals.has(item) || !kept.has(item.tool_use_id)) continue;
263      item.text = withKeptLines(outputs.get(item.tool_use_id) ?? '', kept.get(item.tool_use_id), options.truncateHeadChars);
264    }
265  }
266  const cutInputs = new Set(decisions.filter((d) => d.action === 'drop_result').map((d) => byId.get(d.id).tool_use_id));
267  const cut = { inputs: 0 };
268  const out = withoutThinking(applied, cutInputs, cut).map((m) => {
269    const lines = notes.get(m);
270    if (!lines) return m;
271    const noted = { role: m.role, text: `${lines.join('\n')}\n\n${m.text}`, toolUses: m.toolUses };
272    if (m.toolResults) noted.toolResults = m.toolResults;
273    return noted;
274  });
275  const charsAfter = out.reduce((sum, m) => sum + messageChars(m), 0);
276  const reduction = charsBefore === 0 ? 0 : (charsBefore - charsAfter) / charsBefore;
277  return {
278    stage: reduction >= (cfg.compactMinReduction ?? MIN_REDUCTION) ? 'compacted' : 'below_min',
279    messages: out,
280    decisions,
281    topics: topics.map((t) => ({ ...t, p: topicP.get(t.id), dropped: gone.includes(t) })),
282    stats: {
283      ...base,
284      charsAfter,
285      reduction: Math.round(reduction * 1000) / 1000,
286      messagesBefore: messages.length,
287      messagesAfter: out.length,
288      kept: count(decisions, 'keep'),
289      resultsDropped: count(decisions, 'drop_result'),
290      callsDropped: count(decisions, 'drop_call'),
291      pinned: decisions.filter((d) => d.reason === 'pinned').length,
292      unpinnedRecent: allCalls.filter((c, i) => !c.pinned && collectedPinned[i]).length,
293      inputsCut: cut.inputs,
294      rescuedResults: kept.size,
295      rescuedLines: [...kept.values()].reduce((n, lines) => n + lines.length, 0),
296      rescuedChars,
297      callsAsked: candidates.length,
298      callsTooSmall: small.size,
299      topicsAsked: topics.length,
300      topicsTooSmall: exchanges.length - topics.length,
301      topicsDropped: gone.length,
302      topicP: topics.map((t) => Math.round((topicP.get(t.id) ?? 1) * 100) / 100),
303      stateTokens: fitted.tokens,
304      stateStage: fitted.stage,
305      jevRequests: jev.counter?.requests,
306      ms: Date.now() - started,
307    },
308  };
309}
310
src/core/reduce.mjs 112 lines
1// Deterministic reduction of command output. Nothing here guesses what the
2// task needs: it only folds what is structurally repetitive, and it never
3// folds a line jev-pruner treats as a diagnostic or a result.
4import { isProtectedLine } from '../../vendor/jev-pruner/dist/retention.js';
5
6const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
7const MIN_RUN = 6;
8const FOLD_MARK = /^\[… \d+ similar lines …\]$/;
9const SIMILARITY = 0.7;
10
11/** A line's shape: words kept, numbers and long hex runs abstracted. */
12function shape(line) {
13  return line
14    .toLowerCase()
15    .replace(/[0-9a-f]{6,}/g, '<h>')
16    .replace(/\d+/g, '#')
17    .split(/[^a-z#<>]+/)
18    .filter(Boolean);
19}
20
21function similar(a, b) {
22  if (a.length === 0 || b.length === 0) return false;
23  let same = 0;
24  for (let i = 0; i < Math.min(a.length, b.length); i += 1) if (a[i] === b[i]) same += 1;
25  return same / Math.max(a.length, b.length) >= SIMILARITY;
26}
27
28/** Only the final state of a line rewritten with carriage returns (progress bars). */
29function settle(line) {
30  if (!line.includes('\r')) return line;
31  const parts = line.split('\r').filter((part) => part.length > 0);
32  return parts.at(-1) ?? '';
33}
34
35/**
36 * Folds runs of at least MIN_RUN similar lines to their first two and last
37 * line plus a count, strips ANSI codes, settles progress bars, squeezes blank
38 * runs. Returns the text and how many lines were folded away.
39 */
40export function reduceOutput(text) {
41  const lines = text.replace(ANSI, '').split('\n').map(settle);
42  const out = [];
43  let folded = 0;
44  let i = 0;
45  while (i < lines.length) {
46    const line = lines[i];
47    if (!line.trim()) {
48      if (out.at(-1)?.trim() !== '' || out.length === 0) out.push('');
49      else folded += 1;
50      i += 1;
51      continue;
52    }
53    if (isProtectedLine(line)) {
54      out.push(line);
55      i += 1;
56      continue;
57    }
58    const head = shape(line);
59    let j = i + 1;
60    while (j < lines.length && lines[j].trim() && !isProtectedLine(lines[j]) && similar(head, shape(lines[j]))) j += 1;
61    const run = j - i;
62    if (run >= MIN_RUN) {
63      out.push(lines[i], lines[i + 1], `[… ${run - 3} similar lines …]`, lines[j - 1]);
64      folded += run - 3;
65    } else {
66      out.push(...lines.slice(i, j));
67    }
68    i = j;
69  }
70  return { text: out.join('\n'), folded };
71}
72
73/** A coarser shape than folding uses: any word is `w`, any number `#`, any hex run `h`. */
74const outline = (line) => line.trim().replace(/\b[0-9a-f]{4,}\b/gi, 'h').replace(/[A-Za-z]+/g, 'w').replace(/\d+/g, '#').replace(/w(?:[ _-]w)+/g, 'w+');
75
76/**
77 * The lines that look like nothing else in their output: in a long log the
78 * value, the failure and the summary usually do. The first line (the command
79 * banner) is left out. Output where many lines stand out (code, prose, a
80 * table of distinct rows) has no standouts by this measure and returns none.
81 */
82export function standoutLines(text, { minLines = 20, maxShare = 0.05 } = {}) {
83  const lines = text.replace(ANSI, '').split('\n').map(settle).filter((line) => line.trim());
84  // Output reduceOutput already folded was long, and its repeats are gone:
85  // judge what is left however short it is, leaving out the fold markers.
86  const folded = lines.some((line) => FOLD_MARK.test(line));
87  if (lines.length < (folded ? 1 : minLines)) return [];
88  const counts = new Map();
89  for (const line of lines) counts.set(outline(line), (counts.get(outline(line)) ?? 0) + 1);
90  const rare = lines.slice(1).filter((line) => !FOLD_MARK.test(line) && counts.get(outline(line)) <= 2);
91  return rare.length <= Math.max(5, lines.length * (folded ? 0.5 : maxShare)) ? rare : [];
92}
93
94/**
95 * Standout lines of several outputs, newest (last) first, within a total and a
96 * per-output budget. `skip(i, line)` leaves out lines already kept elsewhere.
97 */
98export function pickStandouts(texts, { budget, perOutput = 1_500, skip = () => false }) {
99  const picked = texts.map(() => []);
100  let used = 0;
101  for (let i = texts.length - 1; i >= 0; i -= 1) {
102    let size = 0;
103    for (const line of standoutLines(texts[i])) {
104      if (skip(i, line) || size + line.length > perOutput || used + size + line.length > budget) continue;
105      picked[i].push(line);
106      size += line.length + 1;
107    }
108    used += size;
109  }
110  return { picked, chars: used };
111}
112