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

Claude Code와 Codex에서 쓰는 플러그인이다. 컨텍스트 창을 가볍게 유지해 압축(compaction)을 최대한 늦추고, 압축이 오더라도 필요한 원문이 살아남게 한다. 판단이 필요한 몇 곳에서만 TypeSafe의 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.284 | Codex 0.158.0 | ||
|---|---|---|---|---|---|
| 읽기 게이트 | 오케스트레이터가 100줄 이상을 한꺼번에 읽으려 할 때, 이번 단계에 전체가 필요한지 Jev가 판단한다. 필요 없으면 줄 번호가 달린 개요와 함께 한 번 되돌려 좁혀 읽게 한다. 같은 읽기를 다시 요청하면 통과한다 | `PreToolUse(Bash\ | Read)` 거부 | PreToolUse(Bash) 거부 | |
| prune | 도구 출력이 들어올 때 반복 줄을 규칙으로 접는다. 원문은 먼저 저장하고 경로를 남긴다 | PostToolUse(Bash) updatedToolOutput | PreToolUse(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 | 없음 |
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는 명령을 바꿀 때 승인도 함께 요구하므로, 감싼 명령은 승인 절차를 건너뛴다.
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에서 긴 보고를 한 번 되돌리는 것까지만 된다.
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는 모든 읽기가 셸이다. 이 두 경우에 "다음 단계가 편집할 출력"을 통째로 붙이는 것이 효과를 냈다(아래 검증 요약).
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이 감지해 다시 압축한다).
flowchart TD
R["세션 재개"] --> L{"지난 압축이 재개에서<br/>되돌려졌나"}
L -- "예" --> H{"헤드리스 실행인가"}
L -- "아니오" --> E{"프롬프트 캐시가 식었고<br/>컨텍스트가 60k 토큰 이상인가"}
E -- "아니오" --> GO["그대로 진행"]
E -- "예" --> H
H -- "예" --> CP["/compact 먼저 실행"]
H -- "아니오" --> NB["알리고 첫 프롬프트를 한 번 막음"]
캐시가 살아 있을 때 압축하면 이미 낸 캐시 비용을 버리게 된다. 그래서 캐시가 식은 순간(재개, 긴 휴식 뒤 첫 프롬프트)에만 압축을 권한다.
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, 없으면 off | off면 아무것도 보내지 않는다. simulated는 고정 점수를 쓰는 테스트용이다 |
JEV_CONTEXT_RETAIN | on | on: 튀는 줄 + Jev 1회로 다음 단계가 편집할 출력을 통째로. rules: 튀는 줄만. hybrid: 규칙 뒤 남은 예산에서 Jev가 의미 청크를 보완. jev: 비교용. off: 끔 |
JEV_CONTEXT_RETAIN_MAX_CHARS / _RETAIN_WHOLE_MAX_CHARS | 6000 / 12000 | 덧붙이는 튀는 줄의 합계 / 통째로 붙이는 출력의 합계 |
JEV_CONTEXT_COMPACT | summary | summary: 호스트의 내장 요약 + retain. verbatim: Claude Code function hook(플래그 필요) |
JEV_CONTEXT_COMPACT_KEEP | 0.3 | 결과를 통째로 남기는 Jev 점수(retain·verbatim 공통) |
JEV_CONTEXT_COMPACT_TOPICS / _COMPACT_TOPIC_DROP | on / 0.8 | verbatim에서 사용자가 닫은 주제를 지우는 판단과 그 기준 |
JEV_CONTEXT_COMPACT_MIN_REDUCTION | 0.25 | verbatim이 이보다 덜 줄이면 내장 요약으로 넘어간다 |
JEV_CONTEXT_REPORT_SPLIT / _REPORT_SUMMARY_CHARS / _REPORT_MAX_CHARS | on / 1500 / 3000 | Summary/Details 분리, Summary 예산, 구조 없는 보고를 되돌리는 길이 |
JEV_CONTEXT_PRUNE / _PRUNE_MIN_CHARS / _PRUNE_MIN_SAVING | on / 4000 / 0.25 | 접기 대상 출력의 최소 길이와 최소 절감률. 검색·목록 출력은 접지 않는다 |
JEV_CONTEXT_PRUNE_JEV / _PRUNE_MAX_CHARS | off / 8000 | 규칙이 접지 못한 큰 출력에 Jev 청크 판단을 쓸지와 그 상한 |
JEV_CONTEXT_READ_GATE / _READ_GATE_LINES / _READ_GATE_INTENT | on / 100 / on | 읽기 게이트, 줄 수 기준, 목적이 보일 때만 묻기 |
JEV_CONTEXT_CODEX_WRAP | off | Codex에서 단일 빌드·테스트·설치·린트 명령을 실행기로 감싼다. 감싼 명령은 승인을 건너뛴다 |
JEV_CONTEXT_RESUME / _IDLE / _COMPACT_MIN_TOKENS | compact / block-once / 60000 | Claude Code 캐시 타이밍 |
JEV_CONTEXT_LOG / _STATE_DIR | logs/decisions.jsonl / .state/ | 판단 기록과 상태. 키·프롬프트·출력 내용은 남기지 않는다 |
| 장면 | 결과 |
|---|---|
| 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 |
| timing | verbatim 압축이 재개에서 사라진 것을 감지해 다시 압축(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 테스트 절차와 결과
hooks module jev-context@… loaded부터 확인한다. 모듈은 $.env.get에 리터럴 이름만 쓸 수 있다. 로드할 때 Claude Code가 플러그인 폴더에 .claude-plugin/types/와 tsconfig.json을 만든다.JEV_CONTEXT_RESUME=off면 재개한 첫 요청이 압축 전 전체를 다시 보낸다.--fork-session 직후 바로 압축하면 retain이 빈손이다. 그 시점에는 fork의 기록 파일이 아직 없다(로그에 no_transcript). Codex 포크는 부모 기록을 참조하는 방식이라, 파서가 부모 rollout을 따라가 읽는다.npm test와 e2e를 다시 돌린다.SubagentHandback 도구로만 전달된다. Claude Code는 서브에이전트가 보고서 파일을 쓰는 것을 막으므로 hook이 원문을 저장한다./compact 요약기는 agent_type이 빈 서브에이전트로 돌고 SubagentStop을 발생시킨다. report는 이것을 건드리지 않는다.updatedToolOutput은 Bash 출력 객체여야 적용된다. initialUserMessage는 헤드리스에서만 제출된다.off이고, 파이프·연결·리다이렉션이 없는 단일 명령만 감싼다.jev는 도구 출력을 보낸다. 비밀처럼 보이는 출력은 원문 경로를 인용하지 않는다. 공개 가능한 작업에만 켠다.npm run setup이 070d4af를 받는다.e3f262a를 받는다. turn.complete 60% 자동 압축과 userConfig의 API 키는 가져오지 않았다.hooks/verbatim-compact.mjs 98 lines1// 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};
98src/core/jev.mjs 44 lines1import { 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}
44src/core/verbatim.mjs 310 lines1// 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}
310src/core/reduce.mjs 112 lines1// 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