- `SearchState.worker_thread` 필드 제거 — `JoinHandle` 을 저장만 하고
어디서도 join 안 했음. fire_search 가 spawn 후 handle 을 즉시 drop
하면 OS 가 thread 를 detach (search 는 pure read 라 cleanup 의무
없음). YAGNI — ask.rs 의 thread 와 달리 cancel/observe 수요가 없는
fire-and-forget. doc 으로 의도 명시.
- `debounce_due` 가 `pub` 으로 노출 — 새 skip 분기 (`searching && 같은
query`) 회귀 테스트 추가:
- `debounce_due_skips_when_in_flight_for_same_query`: 같은 input/mode
재입력 시 spawn 안 함 (worker 누적 방지)
- `debounce_due_fires_when_in_flight_for_different_query`: 사용자가
in-flight 보다 빠르게 새 query 입력하면 정상 spawn (poll_worker 의
stale guard 가 이전 결과 처리)
- `search_state_with` 헬퍼: `SearchState::default()` + field 재할당
패턴이 clippy `field_reassign_with_default` 위반 → `#[allow(...)]`
로 lint 무시 (테스트 helper 의 가독성 우선).
23 tests/search.rs + 35 lib + 18 ask + 12 inspect + 10 library = 98
통과. clippy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
도그푸딩 item 6 — TUI search 의 200ms debounce 후 동기 호출이 vector
/ hybrid 모드에서 50-200ms 동안 UI 를 freeze 시키던 문제 해소. 별
thread 에서 search 돌리고 결과 mpsc 로 받음. 사용자가 계속 타이핑하면
stale 결과 자동 폐기 (generation counter pattern, ask.rs 의 worker
패턴과 동일).
## 핵심 변경
- **`SearchState` 필드 3 개 신규**:
- `generation: u64` — 각 spawn 마다 increment, worker 가 carry
- `worker_thread: Option<JoinHandle<()>>`
- `worker_rx: Option<Receiver<SearchWorkerMessage>>`
- **`SearchWorkerMessage`** (`pub enum`) — 단일 변종 `Done {
generation, result }`. ask.rs 의 token stream 과 달리 search 는
최종 결과만 한 번 send, 그래서 enum 으로 추후 확장 여지 둠.
- **`fire_search`** rewrite: generation+1 → debounce snapshot 갱신 →
`std::thread::Builder::spawn` 으로 별 thread, `kebab_app::search_
with_config(cfg, query)` 호출, channel 로 `(gen, result)` post.
return 은 즉시 — event loop 안 막힘.
- **`poll_worker`** 신규 (`pub`, integration test 위해 노출): tick
마다 try_recv. `gen != s.generation` 이면 stale → silently drop +
`searching` 그대로 (newer worker 가 처리). 일치하면 hits 적용 +
`searching=false`. Disconnect 면 worker 패닉 처리 — searching
clear, 다음 tick 의 debounce_due 가 재 spawn.
- **`debounce_due`** 강화: `searching && last_query == 현 input/mode`
케이스 skip — 같은 query 재 spawn 방지. 기존 dedupe 도 유지.
- **run loop** 의 `Pane::Search` 분기에 `poll_worker(app)` 한 줄
추가 (debounce_due 호출 직전). 매 tick drain.
## 테스트 (tests/search.rs 신규 4 개)
- `poll_worker_applies_fresh_result_to_hits` — gen 일치 시 hits 적용
+ searching clear + rx drain
- `poll_worker_drops_stale_result` — gen 불일치 시 hits 비어 있음
+ searching 유지 (newer worker 기다림)
- `poll_worker_noop_when_no_rx` — 평상시 tick 에 noop, 기존 hits
보존
- `poll_worker_handles_disconnected_channel` — 워커 panic (tx drop)
복구 — searching clear, rx 비움
기존 17 search + 35 lib + 18 ask + 12 inspect + 10 library = 92
통과. clippy clean.
## 문서
- README `kebab tui` 행: "Search 패널은 200ms debounce 후 background
worker, stale 결과 자동 폐기" 한 줄 추가
- HANDOFF: 2026-05-03 entry
- spec status planned → in_progress
## Out of scope
- 캐시 (p9-fb-19 별도)
- 동일 query 의 inflight worker 합치기 — 현재는 dedupe + 가장 최근
spawn 만 살아남는 fire-and-forget. 합치는 건 mpsc multiplexing
로직 필요해 P+ 로 미룸.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `compose_style` 레이어링 정리 — base color 는 가장 구체적인 컨테이너
(heading > link > inline_code > body) 에서 가져오되 modifier 는
link 의 UNDERLINED, inline code 의 DIM 도 헤딩 위에 add. `# Section
[docs](url)` 의 `docs` 가 헤딩 색 + UNDERLINE 둘 다 받음.
- `Event::InlineMath` / `Event::DisplayMath` silently drop 폐기 →
raw 글자 (예: `E = mc^2`, `\sum_i x_i`) 를 `Role::Hint` 스타일로
보존. 답변에서 수학 표현이 사라지던 문제 수정.
- `Event::FootnoteReference` → `[^label]`, `Event::TaskListMarker` →
`[x] ` / `[ ] ` 로 raw 표시 (이전엔 silently drop).
- `unterminated_bold_renders_literal_asterisks` 테스트의 OR 어설션을
강화 — `still typing` 글자만 누락 안 되면 통과 (literal `**` 보존
여부는 cosmetic, 글자 누락은 진짜 회귀).
- 신규 unit 3 개: heading 안 link 가 UNDERLINE+BOLD 둘 다, math 보존,
task list 체크박스 글리프.
35 lib + 17 search + 18 ask + 12 inspect + 10 library 통과. clippy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
도그푸딩 item 9 — TUI Ask 답변 본문이 raw `**bold**` / `# Title` /
` ```code``` ` 그대로 보여 가독성 떨어지던 문제 해소. pulldown-cmark
파싱 → ratatui Span/Line 변환.
## 핵심 변경
- **`kebab-tui::markdown::render(text, &Theme) -> Vec<Line<'static>>`**
신규. pulldown-cmark = "0.13" (이미 kebab-parse-md 가 사용 중인
버전) 위에 build.
inline:
- `**bold**` / `__bold__` → `Modifier::BOLD`
- `*italic*` / `_italic_` → `Modifier::ITALIC`
- `~~strike~~` → `Modifier::CROSSED_OUT`
- `` `code` `` → `Role::Hint` (DIM 스타일 — 터미널 호환성 위해 bg
color 보다 안전)
- `[text](url)` → `Role::CitationMarker` + `Modifier::UNDERLINED`
block:
- heading H1/H2 → `Role::Heading` (Cyan + BOLD), H3-H6 → `Role::Title`
(White + BOLD)
- bullet list `-`/`*` → `- ` + 깊이별 indent
- ordered list `1.` → 실제 번호 prefix + indent
- fenced code block ``` ``` ``` → ` ` indented + `Role::Hint`
- blockquote `>` → 좌측 `▎` bar (중첩 시 반복) + `Role::Hint`
- table `| col |` → `| col1 | col2 |` 식 줄, `|` separator 색 강조
- horizontal rule `---` → `─` × 40
- **streaming 안전성**: 매 frame 재 parse 가 spec — pulldown
토크나이저가 µs/KB 라 비용 무시. unterminated `**` (사용자가 한창
입력 중인 inline 가 닫히기 전) 은 pulldown 이 Text 로 처리 →
literal `**` 그대로 표시 (글자 누락 X).
- **`ask::push_turn_lines` 통합**: grounded 답변에서만 markdown
렌더 사용. refusal turn (`Role::Warning` override) 와 streaming
turn (`Role::Hint`) 은 raw 로 두어 role color 시그널이 markdown
스타일에 묻히지 않도록. body line 들은 ` ` indent 로 transcript
에서 답변 본문 시각 구분.
- **CLI `kebab ask` 출력은 raw markdown** — 터미널 호환성 + pipe
처리 시 안정성 위해 (ANSI escape 없이 plain text).
## 테스트 (markdown.rs 14 unit)
- empty input → 빈 라인 1 줄 (caller scroll/measure 안전)
- plain text → 단일 라인 + paragraph blank
- bold / italic / strikethrough / inline code → 해당 modifier 검증
- link → UNDERLINED 검증
- heading H1 → BOLD 텍스트 span
- bullet list `-` / numbered list `1./2.` → prefix 검증
- code fence body → 줄별 ` ` indent 보존
- blockquote → `▎` prefix
- 2x2 table → `|`-separated 줄 검증
- unterminated `**` → 글자 누락 없음 (streaming 안전성 회귀 방지)
- composite (heading + para + list + code) → 문서 순서 보존
기존 75 TUI 테스트 + 신규 14 markdown = 89 통과. clippy clean.
## 문서
- README `kebab tui` 행에 markdown 렌더 안내 + CLI 는 raw 명시
- HANDOFF: 2026-05-03 entry
- spec status planned → in_progress
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ask.rs `push_turn_lines` Q label: `Role::Heading + add_modifier(BOLD)`
의 BOLD 가 중복 (Role::Heading 이 양 팔레트 모두 BOLD 포함). 제거 +
주석으로 \"Heading 은 이미 BOLD\" 명시.
- run.rs `render_ingest_status` aborted 분기: `Role::Title` (= White +
BOLD) 보다 `Role::Warning` (Yellow) 이 \"비정상 종료\" 의미와 정렬.
BOLD 는 명시적으로 add_modifier 하여 라이브 진행 라인과의 대비 유지.
- theme.rs `impl Default for Theme` 위에 doc comment 추가 — `default()
== dark()` invariant 와 `default_palette_is_dark` 테스트가 묶여
있음을 명시.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
도그푸딩 item 12 — TUI 가 모든 정보 종류에 같은 회색 / 시안 만 쓰던
\"빈약한 색감\" 해소. inline `Style::default().fg(Color::*)` 호출을
single source `theme` 모듈로 격리 + dark / light 두 팔레트 제공.
## 핵심 변경
- **`kebab-tui::theme::{Theme, Role, Palette}`** 신규 (132 라인). 16
개 Role enum (BorderActive/BorderInactive/Title/Path/ModeLexical/
ModeVector/ModeHybrid/Selected/Hint/Heading/Warning/Error/Success/
CitationMarker/Bullet/Body) 을 dark + light 두 팔레트가 exhaustive
match 로 매핑. 새 Role 추가 시 두 팔레트 모두 갱신해야 컴파일됨.
- **`Theme::from_name(s)`** — 알 수 없는 값 (e.g. \"solarized\") →
dark fallback. config typo 가 TUI 를 죽이지 않음 (spec 명시).
- **`App.theme: Theme`** 신규 — `App::new` 가 `config.ui.theme` 에서
resolve. 모든 pane (library/search/ask/inspect/run/error_popup) 이
`app.theme.style(Role::X)` 로 style 가져옴.
- **`Config.ui.theme: String`** 신규 — `[ui] theme = \"dark\" | \"light\"`
(default `\"dark\"`). `#[serde(default)]` 로 기존 config 파일 호환.
- **Pane sweep**: search.rs / ask.rs / library.rs / inspect.rs /
run.rs / error_popup.rs 의 모든 inline `Style::default().fg(Color::*)`
/ `add_modifier(Modifier::DIM/REVERSED)` 호출 제거. 일부 helper
(`render_filter_overlay`, `header_kv`, `kv`, `push_section_header`,
`build_doc_lines`, `build_chunk_lines`, `render_input/answer/bottom/
status/citations`, `render_error_overlay`) 가 `theme: &Theme` 파라
미터 추가.
## Out of scope
- `T` 키 runtime toggle — mode machine (p9-fb-12) 미진행이라 NORMAL
모드 정의 불가, config 만으로 결정. 추후 p9-fb-12 후속에서 추가.
- 사용자 정의 `[theme.custom]` 절 — P+ task.
- truecolor → 256-color fallback — terminal 가정.
## 테스트
- 신규 4 개 (theme.rs):
- `every_role_resolves_in_dark_and_light` — 16 Role 전부 panic 없이
Style 반환 (exhaustive match runtime 검증)
- `from_name_recognizes_dark_light_and_falls_back` — 입력 정규화 +
fallback 정책
- `default_palette_is_dark` — 기본값 pin
- `primary_roles_carry_decoration_in_dark` — Title/Selected/Heading/
Error/Warning/Success 가 bare default 로 회귀 안 함
- 기존 75 개 TUI 테스트 (14 lib + 18 ask + 12 inspect + 10 library +
17 search + 4 theme) 모두 통과
- `cargo test --workspace --no-fail-fast -j 1` exit 0
- `cargo clippy -p kebab-tui -p kebab-config --all-targets -- -D warnings`
clean
## 문서
- README Configuration 절: `[ui]` 섹션 + `theme = \"dark\"|\"light\"`
안내
- docs/SMOKE.md: config 예시에 `[ui] theme = \"dark\"` 라인 추가
- HANDOFF: 2026-05-03 머지 후 발견 entry
- spec status: planned → in_progress
p9-fb-11 (ask markdown render) 의 `Theme` 의존성 unblock.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `App.pending_editor` / `force_redraw` 를 `pub(crate)` 로 좁힘.
외부 caller (kebab-cli/desktop) 는 enqueue 할 일 없고, 잘못
mutate 하면 \"set 후 다음 tick 에 drain\" invariant 가 깨짐.
- 외부 read access 가 필요한 경우를 위해 `App::pending_editor()` 읽기
전용 accessor 추가 — integration test (`tests/search.rs`) 가 사용.
- `App.force_redraw` doc comment 의 \"ratchet incremented\" 표현을
실제 type (bool flag) 에 맞게 \"when set, the next draw clears\" 로
교체.
- `editor::tests::unspawnable_program_surfaces_program_name_in_error`
를 `command_status_returns_not_found_for_missing_program` 으로
rename + doc 수정 — 실제로 `with_external_program` 호출하지 않고
`Command::status()` 만 검증한다는 점을 솔직하게 명시 (helper
end-to-end 는 dogfooding 으로 검증).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Search `g` 키 (citation jump) 후 TUI 화면이 깨지는 버그 수정. 도그푸딩
item 7 — `g` 로 vim 띄우고 `:q` 후 복귀하면 이전 frame 의 잔상이 새
draw 위에 겹쳐 보였음.
## 핵심 변경
- **`kebab-tui::editor::with_external_program(&mut TuiTerminal, Command)`**
helper 추가. suspend / spawn / restore 시퀀스를 RAII guard 로 atomic
하게 묶어 panic 발생해도 raw mode + alt screen 복구 보장:
1. LeaveAlternateScreen + Show cursor + disable_raw_mode
2. Command::status() 로 child 실행
3. enable_raw_mode + EnterAlternateScreen + Hide cursor +
`terminal.clear()` ← 이 한 줄이 핵심 fix
- **`App.pending_editor: Option<EditorRequest>`** 추가. 키 핸들러
(현재 `kebab-tui::search::handle_key_search` 의 `g`) 가 직접 spawn
하는 대신 EditorRequest 를 enqueue, 실제 spawn 은 run loop 가
`TuiTerminal` 핸들 in scope 일 때 처리.
- **`App.force_redraw: bool`** ratchet. with_external_program 종료 후
set, run loop draw 직전 check → terminal.clear() 후 reset. editor
외 다른 향후 use case (config reload, theme change 등) 도 같은 hook
사용 가능.
## 가시성 정리
`with_external_program` / `jump_to_citation` 은 `pub(crate)` 로 좁혀짐
— `TuiTerminal` 자체가 module-private (raw mode + alt screen 의 안전
한 lifecycle 은 `Drop` 만 보장) 이므로 외부 caller 는 `App.pending_
editor` enqueue 패턴으로만 spawn 요청 가능. 외부 surface (`build_jump_
command`, `handle_key_search`, `render_search`) 는 그대로.
## 테스트
- `unspawnable_program_surfaces_program_name_in_error` — helper 의 spawn
실패 경로 (ENOENT) error context 검증
- `g_key_enqueues_pending_editor_request` — `g` on hit → EditorRequest
enqueue, citation 정보 보존
- `g_key_with_no_hits_does_not_enqueue` — empty hits → no-op
- 기존 17 개 search 테스트 + 14 lib + 18 ask + 12 inspect + 10 library
모두 통과
- `cargo clippy -p kebab-tui --all-targets -- -D warnings` clean
## 문서
- README: `kebab tui` 행에 Search `g` 동작 + 자동 redraw 안내
- HANDOFF: 2026-05-03 머지 후 발견 entry
- spec status: `planned` → `in_progress`
후속 task (p9-fb-20 의 citation jump in TUI Ask 등) 가 같은
`pending_editor` queue + `with_external_program` helper 위에 build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `derive_title` doc 의 step 5 표현 "kebab-case preserved" → "returned
verbatim, no case transformation" (실제 동작과 일치)
- `file_stem` NFC 변환 제거 — workspace_path 가 to_posix 단계에서 이미
NFC 정규화되므로 (§6.6) 이중 호출은 군더더기. 의도 명시 주석 추가.
- M7 revised 테스트 docstring 의 "p9-fb-07 line 37" 참조를 인용문
("빈 문자열 반환 금지") 으로 교체 — line number 변동에 안전.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`kebab-normalize::derive_title(frontmatter_title, blocks, file_stem)` 가
다음 단계로 비어있지 않은 첫 결과를 사용:
1. frontmatter `title` (trim 후)
2. 첫 H1 텍스트
3. 첫 H2 텍스트
4. 첫 Paragraph (Quote / List / Code / Table / ImageRef 제외) 의 첫 80 자
5. 파일 stem (확장자 제외)
6. (sentinel) `"untitled"` — 위 다섯 단계가 모두 blank 인 병적 케이스
선택된 문자열은 NFC 정규화. 빈 문자열은 절대 반환하지 않음.
`build_canonical_document` 가 metadata lift 직후 helper 호출. 기존 단순
lift 로직 (metadata.user["title"] → CanonicalDocument.title) 은 fallback
chain 의 1 단계 입력으로 자리 이동.
`KEBAB_PARSE_MD_VERSION` 상수를 `pulldown-cmark-0.x` → `md-frontmatter-v2`
로 bump. parser_version 변경 → §4.2 doc_id 입력 변화 → 기존 markdown
doc 의 `doc_id` 갱신, 다음 ingest 시 idempotent upsert 로 자동 재처리
(design §9 cascade). `kebab-store-sqlite` 의 snapshot fixture 도 같은
literal 로 갱신.
기존 M7 정책 ("metadata.user[\"title\"] = '' 가 빈 title 로 lift") 은
폐기. 빈 문자열 입력은 fallback chain 을 타고 file stem 까지 떨어진다.
spec p9-fb-07 line 37: "빈 문자열 반환 금지".
테스트 (kebab-normalize):
- 8 개 단위 테스트 (각 fallback 단계 + NFC + sentinel)
- `build_canonical_document` 통합 테스트 2 개 (H1 / file stem)
- 기존 M7 테스트 2 개를 새 정책에 맞춰 갱신
문서:
- README: `kebab ingest` 행에 "title 자동 채움" 안내 + 기존 doc 도
다음 ingest 에서 갱신
- HANDOFF: 2026-05-03 머지 후 발견 entry
- spec status: `planned` → `in_progress`
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
회차 1 actionable 2건 반영.
- (information accuracy) 모든 citation 라인이 같은 ans.retrieval.top_score
반복 출력했던 문제 — AnswerCitation 에 per-citation score 없으므로
사용자 오해 회피 위해 score 컬럼 제거. 대신 retrieval 메타 한 줄로
분리: '(retrieval: top_score=X.XX, k=N, used=M/N)'. per-citation
score 노출은 facade + AnswerCitation 의 미래 확장 후 (별 task).
- (cleanup) marker fallback 의 두 번 변환 (as_deref + unwrap_or +
to_string) → c.marker.clone().unwrap_or_else(|| format!(...))
한 단계로 단순화.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
답변 출력 후 `근거:` 절 — `[N] <full path>#<fragment> (score=<s>)`
한 줄씩. spec p9-fb-20 의 핵심 (full path 가독성) 충족.
신규 flag:
- --show-citations: default ON. 답변 후 citation block 출력.
- --hide-citations: 답변 본문만 출력 (pipe 시 다른 도구가 trailing
metadata 안 받기 원할 때).
`--json` 모드 무영향 — citations 가 wire payload 에 항상 포함되므로
flag 가 영향 X (외부 wrapper 호환성).
spec p9-fb-20 의 \"TUI citation pane + jump (Enter/o editor jump,
i inspect)\" 부분은 본 PR scope 에서 제외 — TUI 의 기존
render_citations_or_explain (P9-3) 가 이미 citation list 표시,
추가 fold/jump 는 후속 task. 사용자 도그푸딩 priority 5위 의
핵심 = \"full path 가독성\" 이라 CLI block 만으로 충분.
Plan 갱신:
- p9-fb-20 status planned → in_progress. 머지 후 한 줄 commit
으로 completed flip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
회차 1 actionable 2건 반영.
- (UX 회귀) push_turn_lines 가 answer_color_override: Option<Color>
추가 받음. render_answer 가 마지막 turn 에 한해 last_answer.grounded
== false 면 Yellow override 전달 → P9-3 의 refusal 시각 구분
contract 가 transcript 안에서도 보존. test:
render_refusal_turn_in_transcript_uses_yellow_when_last_answer_ungrounded
가 buffer 의 Yellow R 셀 검사로 검증.
- (race) Ctrl-L 가 turns/conversation_id/last_answer/partial/
current_question/scroll 외에도 thread/rx/streaming 까지 detach.
in-flight worker 가 다음 frame 에 finish 해도 새 conv 의 stale
Turn 으로 graduate 안 됨 — JoinHandle Drop 으로 detach (P9-3 Esc
cancel pattern 동일). worker 자체는 background 에서 SQLite
answers 에 \"실패한 conv\" 흔적 commit. ctrl_l_clears_conversation_state
test 가 streaming/thread/rx 도 함께 검증.
18 PASS. clippy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-turn ask pane. AskState 가 turns: Vec<Turn> + current_question
+ conversation_id + last_answer 로 재설계. answer area 가 transcript
형식 (Q1/A1, Q2/A2, ...) 로 갈음, 매 Enter 가 이전 turns 를 history
로 worker 에 전달 — RagPipeline::ask_with_history 호출.
신규 (kebab-tui::app):
- AskState 에 turns / current_question / conversation_id / last_answer
4 field 추가. 기존 answer field 제거 (last_answer 가 갈음).
신규 (kebab-tui::ask):
- spawn_ask_worker: 첫 submit 시 conversation_id 자동 생성
(conv_<unix_nanos_hex>), input → current_question, input clear.
history = turns.clone(), turn_index = turns.len(). worker 가
ask_with_history 호출 (kebab-app facade 가 _cancellable 통해
RagPipeline::ask_with_history 까지 thread).
- poll_worker: Answer 받으면 Turn { question: current_question,
answer, citations, created_at } 만들어 turns 에 push, last_answer
도 보존.
- handle_key_ask: Ctrl-L 가 turns + conversation_id 초기화 (in-flight
worker 는 그대로 finish — 결과는 새 conversation 의 stale turn 으로
silently 폐기, 사용자 의도와 일치).
- render_answer: 모든 completed turns + (있으면) in-flight turn
chronological 출력. Q/A 라벨 색상 구분 (Q cyan bold, A green bold).
in-flight answer 는 ▍ cursor + dim. transcript title 에 turn count.
- render_status / render_citations_or_explain: s.last_answer 사용.
Test:
- 17 PASS (3 신규: ctrl_l_clears_conversation_state /
render_transcript_shows_completed_turns_in_order /
render_streaming_inflight_turn_appears_below_completed_turns).
- 기존 14 회귀 0 (기존 s.answer → s.last_answer + Turn fixture
push).
README + HANDOFF: TUI 행에 multi-turn 동작 추가. spec status
planned → in_progress.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
회차 1 nit 반영. helper 가 본 PR 안 caller 0 — 모든 사용처가
struct literal 패턴. CLAUDE.md "Don't add abstractions beyond
what the task requires" 룰. 미래 caller 가 필요 시 본인이 추가.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec PR #59 의 §3.8 multi-turn behaviour 구현. RAG facade 가 prior
turns 받아 prompt 에 prepend, retrieval query expansion 적용,
Answer 에 conversation_id / turn_index 채움.
신규 (kebab-core):
- Answer 에 conversation_id (Option<String>) / turn_index (Option<u32>)
field 추가. serde skip_serializing_if 로 single-shot 의 wire
output 변경 0 (기존 외부 wrapper 영향 없음).
- Turn struct (question + answer + citations + created_at).
- RefusalReason::LlmStreamAborted variant.
신규 (kebab-rag):
- AskOpts 에 history (Vec<Turn>) / conversation_id / turn_index 3 field.
- AskOpts::single_shot(mode) helper.
- RagPipeline::ask_with_history(query, history, conversation_id,
turn_index, opts) — combined opts 로 ask 호출.
- expand_query_with_history: history.last() 의 answer 첫 200 자
concat 해 SearchQuery.text 확장 (spec §3.8 의 \"cheap concat\";
LLM-based standalone-question rewriting 은 P+).
- serialize_history + remaining_history_budget_chars: spec 의 priority
enforcement — system+question 필수, retrieved chunks 가 차지한
뒤 남은 char budget 안에서 newest 우선, oldest drop.
- ask 본문: history 가 비어있지 않으면 [이전 대화] 블록을 user
prompt 위에 prepend. Answer 생성 site 3 곳 (정상 / NoChunks /
ScoreGate refuse) 모두 conversation_id / turn_index 채움.
신규 (kebab-store-sqlite):
- refusal_reason_label 가 LlmStreamAborted → 'llm_stream_aborted'.
기존 caller 변경 (single-shot 동작 동일):
- kebab-cli main.rs Cmd::Ask: AskOpts 에 history=Vec::new(),
conversation_id=None, turn_index=None 명시 (CLI multi-turn 은
p9-fb-18 의 --session/--repl 가 채움).
- kebab-tui src/ask.rs spawn site 동일 (multi-turn UI 는 p9-fb-16).
- kebab-eval runner.rs golden eval 동일 (single-shot per query).
- kebab-app tests/ask_smoke.rs / kebab-tui tests/ask.rs / kebab-rag
tests/pipeline.rs / kebab-eval metrics.rs Answer literal 갱신.
Test:
- 9 신규 lib unit (expand_query 4 / serialize_history 3 / remaining_budget 2).
- 기존 12 PASS 회귀 0.
Plan 갱신:
- p9-fb-15 status planned → in_progress. 머지 후 한 줄 commit
으로 completed flip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
회차 1 actionable 2건 반영 + 1건 (CLI Ctrl-C integration test)
은 본 PR 에서 별도 task 로 미룸 (signal handler subprocess test 의
flaky 위험 + facade 3 PASS + tui lib 3 PASS 가 안정 surface).
- cancel.rs::install_sigint_cancel: SIGNAL_COUNT 위에 process-lifetime
invariant 코멘트 — multi-install 차단 (ctrlc::set_handler) 덕분에
reset 불필요. 미래 다중 caller 가 같은 cancel token 공유하려면
install 함수 분리 필요.
- ingest_cancel.rs::cancel_mid_loop: redundant `report.new == 1 || 0
|| 2` 제거, race timing 의도 코멘트로 대체 (0=listener 승, 1=first
only, 2=extra slipped in 모두 valid; 3 = cancel never propagated
= 유일한 fail).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ctrl-C / Esc 가 ingest 를 즉시 중단. 현재 in-flight asset 마무리 후
이후 asset 미실행, IngestEvent::Aborted { partial_counts } 발신,
Ok(IngestReport) 정상 반환 (Err 아님). 부분 commit 보존, 다음 ingest
가 idempotent 재개.
신규 facade: kebab-app::ingest_with_config_cancellable(.., progress,
cancel: Option<Arc<AtomicBool>>). 기존 _progress 가 cancel=None
forwarding wrapper. asset loop 시작 boundary 마다 atomic load —
true 면 break + Aborted emit + 정상 종료. Lock 없음.
CLI: ctrlc crate 신규 dep. SIGINT handler 가 첫 신호에 cancel.store(true)
+ stderr hint, 두 번째 신호에 std::process::exit(130) (canonical SIGINT
exit code). install_sigint_cancel() helper 가 Arc<AtomicBool> 반환,
Cmd::Ingest 가 facade 에 전달.
TUI: IngestState 에 cancel: Arc<AtomicBool> field 추가 (회차 1 review
결과의 reshape 정확). start_ingest 가 둘 다 만들어 worker 에 clone
move. cancel_running_ingest(&app) helper — Esc / Ctrl-C 가
ingest 진행 중일 때만 cancel 우선, 그 외에는 quit.
Test:
- 3 facade integration (cancel-before / cancel-mid / no-cancel
default).
- 3 tui lib unit (cancel_running_ingest no-state / in-flight /
terminated).
Plan 갱신: p9-fb-04 status planned → in_progress. 머지 후 한 줄
commit 으로 completed flip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
회차 1 의 설계 결함 지적 반영. 원래 IngestState 에 cancel_tx:
Sender<()> 만 두고 receiver 는 start_ingest 안에서 즉시 drop —
실제 send() 호출 시 항상 Err(SendError) 인 dead channel 이 됨.
\"slot 만 정의\" 의도였으나 실용 가치 0 + CLAUDE.md 의 backward-
compat shim 금지 룰 위반.
수정:
- IngestState 에서 cancel_tx field 제거.
- start_ingest 의 cancel channel allocation 제거.
- doc comment 갱신 — p9-fb-04 가 (cancel_tx, cancel_rx) pair 동시
추가 + receiver 를 worker thread 로 move 하는 형태로 reshape 한다고
명시.
- test fresh_state helper 도 cancel_tx 인자 제거.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Library 의 `r` 키가 `kebab_app::ingest_with_config_progress` 를
spawned thread 에서 호출. run loop 가 매 frame 마다 progress channel
drain → 화면 하단 status bar 1 줄 갱신. blocking 하지 않음.
신규:
- crates/kebab-tui/src/app.rs: `IngestState` struct (rx + counts +
current_path + started_at + terminal_at + aborted + thread +
cancel_tx) + `App.ingest_state` slot + `TERMINAL_LINE_HOLD_SECS`.
- crates/kebab-tui/src/ingest_progress.rs: `start_ingest` (worker
spawn + channel allocation), `drain_progress` (try_recv loop),
`apply_event` (per-kind counter accumulation + Completed/Aborted
marking), `status_line` (사람-친화 텍스트), `ready_to_clear`
(3 초 hold).
- 키 cheatsheet: Library footer 에 `r=ingest` 추가.
Run loop:
- 매 tick `drain_progress` + `ready_to_clear` 체크 → terminal 후
3 초 경과 시 slot drop + worker 스레드 join + Library refresh
큐.
- Layout: ingest_state Some 일 때 footer 위에 status line 1 줄
추가 (있을 때만, 평시 영향 0).
- status line: scanning 중 / 진행 (idx/total %, current path,
elapsed) / 완료 (✓) / abort (✗) 4 모드.
Cancel wiring (p9-fb-04) 의 `IngestState.cancel_tx: Sender<()>`
slot 은 정의만 — 본 PR 에서 sender 보유, send 호출 X.
Test:
- 10 lib unit (apply_event 분기 5 / status_line 4 / ready_to_clear 2).
- 기존 15 tui test 회귀 0.
Plan 갱신:
- p9-fb-03: status `planned` → `in_progress`. 머지 후 한 줄
commit 으로 `completed` flip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
회차 1 actionable 모두 동작 변경 없음, 의도 명시.
- progress.rs handle_human: doc-comment 한 단락 — `let _ = writeln!`
의 IO error swallow 와 `bar.as_ref()` None 분기 silent skip 의
두 best-effort 의도 + §2.4a ordering invariant (ScanStarted 가
bar 를 lazy 초기화) 명시.
- main.rs Cmd::Ingest: `let _ = display_handle.join();` 위에 한 줄
trailing comment — Result<Result<(), anyhow::Error>, Box<dyn Any>>
를 모두 discard 하는 이유 (display thread 의 에러 / panic 이
ingest exit code 에 영향 없어야 함).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`kebab ingest` 가 진행 상황을 사용자에게 보여주는 두 surface 추가:
- **사람 모드 (TTY)**: indicatif `ProgressBar` on stderr — scan 중에는
spinner, ScanCompleted 후 bar 로 전환, 매 asset 마다 message 갱신.
- **사람 모드 (non-TTY, CI/pipe)**: indicatif draw target 을 hidden
으로 두고 stderr 에 한 줄씩 (`ingest: scanning`, `ingest: 1/N path`,
`ingest: complete (...)`).
- **`--json` 모드**: stderr 비우고 stdout 에 line-delimited
`ingest_progress.v1` JSON 을 emit. 마지막 줄은 기존
`ingest_report.v1` 그대로 (외부 wrapper backward-compat).
구현:
- 신규 `crates/kebab-cli/src/progress.rs` — `ProgressMode::{Json,
Human { tty }}`, `ProgressDisplay` (background thread 가 channel
drain + 모드별 render), `now_rfc3339` helper. mode 가 무엇이든 ts
는 wire emit 시점에 stamp.
- `crates/kebab-cli/src/wire.rs` 에 `wire_ingest_progress` 추가.
serde tag (`kind`) 위에 `schema_version` + `ts` 두 필드 더해 spec
§2.4a wire shape 완성.
- `Cmd::Ingest` 핸들러: mpsc channel 만들고 background thread 가
display 돌리는 동안 main 이 `ingest_with_config_progress` 호출.
ingest 반환 시 Sender drop → display thread 정상 종료. join 후
최종 ingest_report 출력.
- 새 dep: `indicatif` 0.17 (TTY 전용 진행 바, non-TTY/--json 에서는
hidden draw target).
Test:
- 3 lib unit (mode resolution + RFC 3339 round-trip).
- 3 integration (--json line-delimited / non-TTY stderr text /
ts+kind 검증). 16 PASS 전체 회귀 0.
Plan 갱신:
- p9-fb-01: status `in_progress` → `completed` (PR #52 머지 후속).
- p9-fb-02: status `planned` → `in_progress`. 머지 후 별도 한 줄
commit 으로 `completed` flip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
회차 1 nit 반영. 'suppressing further sends would require caller
cooperation' 의 두 번째 절이 의미 불명확 — 단순 'event discarded
(best-effort send per ingest_progress contract)' 로 단축. 사실 +
영향만 기록, caller 가 어떻게 cooperate 하는지 추측은 제거.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- (critical) embeddings.rs: truncate_embedding_records 위치 이동.
mark_embedding_records_committed 함수 위에 끼워 넣었더니 위쪽
mark_committed 의 14 줄짜리 doc comment (`WHERE status='pending'`
의 design rationale 등) 가 truncate 의 doc 으로 흡수되고
mark_committed 자체는 doc 없이 남는 버그. impl block 끝 (mark_committed
의 닫는 } 다음) 으로 옮겨 plan 의 원래 의도와도 일치.
- (nit) tests/reset_cli.rs: removed_paths 의 idempotency 검증 보강.
data dir 은 reported, cache dir 은 omit (생성 안 했으니)
되어야 함을 strict 하게 assert. state dir 은 logging init 의
side-effect 로 자동 생성되어 둘 다 가능하므로 허용.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mutually-exclusive scope flags (--all / --data-only / --vector-only /
--config-only via clap ArgGroup) plus --yes for non-interactive use.
Aborts with exit 2 when stdin is non-interactive and --yes is missing
— silent destruction is forbidden. Self-contained 20-line confirm
prompt (no new dep; std::io::IsTerminal).
Integration tests exercise the bin in a fresh subprocess against
tempdir-rooted XDG env to keep the assertions independent of the host
config:
- --data-only --yes wipes data + cache + state, preserves config.
- non-TTY without --yes exits 2 with the documented hint.
- --json emits reset_report.v1 schema with snake_case scope.
- conflicting --all + --data-only rejected by clap before any wipe.
Plan deviation (task 4): the data-only test used to write a stub
config.toml containing only `schema_version = 1`, but Config parsing
requires every section. Switched to a marker file in the cfg dir +
the documented Config::load(None)→defaults fallback.
p9-fb-06 task 4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Provides the wipe core for `kebab reset`. Mutually-exclusive ResetScope
variants (All / DataOnly / VectorOnly / ConfigOnly), pure path
enumeration for the confirm UI preview, byte-size estimator, and an
execute helper that removes paths off-disk + truncates
embedding_records when scope is VectorOnly.
Plan deviation from the original spec (task 2):
- Original `truncate_embeddings` helper opened SqliteStore via path and
ran a separate COUNT query through `lock_conn` (private). Both APIs
are unavailable from outside the crate, so the helper now opens the
store via `SqliteStore::open(&Config)` and lets
`truncate_embedding_records` (task 1) report the deleted count
directly.
- Skipped the XDG-env-overriding unit test from the original plan to
avoid race conditions with sibling tests; the equivalent integration
coverage moves up to the CLI tests in task 4 where each invocation
runs in a fresh process.
- Added an FS-touching unit test (`estimate_size_sums_file_lengths`)
to cover the read-side of `estimate_size_bytes` against a tempdir.
p9-fb-06 task 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wipes every row from embedding_records and returns the deleted row
count. Used by the upcoming `kebab reset --vector-only` to keep SQLite
consistent after the on-disk Lance store is removed.
Plan deviation from the original spec (task 1):
- Original test plan opened SqliteStore with a raw path; the actual
signature is `SqliteStore::open(&Config)`, so the integration test
builds a Config with `storage.data_dir` pointed at a tempdir.
- Original return type was Result<()>; bumped to Result<u64> so the
caller (kebab-app::reset) can surface the truncated count in the
reset_report.v1 wire payload without a separate COUNT query.
p9-fb-06 task 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
도그푸딩 시 사용자 결정 (2026-05-02): 텍스트 LLM 기본을 gemma4 계열로
통일. OCR/caption 어댑터 (P6-2/P6-3) 가 이미 gemma4:e4b 사용 중 —
사용자가 한 family 만 pull 하면 ingest + ask 모두 작동.
같이 발견된 ~ expansion 불일치:
- kebab-source-fs::connector 는 expand_tilde 사용 (walk 정상)
- kebab-app::ingest_one_image_asset / ingest_one_pdf_asset 은 직접
PathBuf::from → ~ 미확장 → ExtractContext 에 ~/KnowledgeBase
그대로 전달
- kebab-tui::search::handle_key_search 의 editor jump 도 동일 →
의미 없는 경로 spawn
Fix:
- Config::defaults().models.llm.model = \"gemma4:e4b\". OCR/caption
family 통일 코멘트 추가.
- kebab-app 의 image / pdf 분기 두 곳 모두 expand_tilde 호출.
- kebab-tui::search jump 가 kebab_config::expand_path(.., \"\") 사용
(expand_path 는 ~ / ${XDG_DATA_HOME} / {data_dir} 모두 처리하는
정식 helper).
Caveat: kebab-app::expand_tilde 와 kebab-config::expand_path 가 별도
정의. 통합은 P+ task.
Docs (sync rule):
- README 사전 요구 절: gemma4:e4b 기본 + 더 큰 variant override 안내.
- docs/ARCHITECTURE 핵심 결정 표: LLM default qwen2.5:7b-instruct →
gemma4:e4b.
- docs/SMOKE: ollama pull 예시 + KEBAB_MODELS_LLM_MODEL env 예시
qwen2.5:32b → gemma4:26b.
- HOTFIXES: 새 entry (\"Config defaults: LLM = gemma4:e4b + workspace.root
tilde expansion\").
- Memory: project_llm_default.md 신설, MEMORY.md 인덱스 추가.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
blocks / embeddings 섹션의 count 라인이 collapse 검사 *밖* 에서 push
되어 collapsed 상태에서 부분만 사라지던 일관성 깨짐. fix: count 를
section header 에 inline 으로 (`▾ blocks (N)`, `▾ embeddings (N)`),
body 만 collapse 검사 안. 새 helper `push_section_header_with_count`
가 둘 다 통일.
회귀 테스트 보강:
- doc_view_collapse_hides_section_body: collapsed 상태에서 \"blocks (2)\"
inline count 표시 + \"Heading L1\" body 숨김 검증.
- chunk_view_renders_text_and_block_ids: \"embeddings (2)\" inline
count 검증.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Esc 후 재질문 시 detached prior worker + 새 worker 동시 in-flight 가능
했음. Ollama endpoint 에 두 요청 동시 발사 → 응답 시간 두 배 + stream
혼동. spawn_ask_worker 진입 시 `s.thread.is_some()` 검사 추가, 이전
worker 가 still alive 면 Enter 무시. input bar 의 busy 텍스트 가 세
상태 (streaming / awaiting prior / idle) 분리 표시 — 사용자가 Enter
가 왜 안 먹히는지 즉시 확인.
회귀 테스트 `enter_with_detached_prior_thread_is_blocked` 추가 — never-
ending 더미 thread 를 hand-install 후 Enter no-op 검증, 종료 시 thread
take() 로 leak 명시 (test process 종료 시 OS 가 reap).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. **Citation::Page 분기 fix** — `args.push(format!(\"# page {page}\"))` 가
vim/code/cursor 에 \"두 번째 파일\" 로 해석돼 의도 외 동작 (split / new
buffer). 마지막 push 제거, path 만 열고 `tracing::debug!` 한 줄.
PDF 페이지 jump 는 사용자 PDF reader 책임 — `KEBAB_EDITOR_JUMP_FORMAT`
env hook 은 P+ enhancement.
2. **j/k/g 의 SHIFT modifier 차단** — `is_typing_mod` 가 SHIFT 를 typing
으로 취급하던 부분이 J/K/G 를 selection 키로 흡수해 \"JSON\" / \"PostgreSQL\"
/ \"Go\" 같은 대문자 검색어 깨짐. arrow 키 (Down/Up) 는 modifier 무관 유지,
문자 키 (j/k/g) 는 `KeyModifiers::NONE` 만. SHIFT-J / SHIFT-G 회귀 테스트
2건 추가.
3. **`format_hit_lines` 의 unused `_width` 인자 제거** — ratatui 자동
truncate 신뢰 (Library 의 한국어 column 정렬은 별도 path).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- p9-2/3/4 미머지 시점에 / ? Enter 키로 focus 가 Search/Ask/Inspect 로
옮겨가면 헤더만 바뀌고 본문은 Library 그대로 + 키 매핑도 Library 라
사용자에게 거짓말. footer hint 가 \"Search pane not yet implemented
(lands with p9-2) — q to return\" 로 전환된다. 새 stub 핸들러
`handle_key_unimplemented_pane` 가 q / Esc 만 받아 Library 로 복귀,
나머지 키는 no-op (이전 구현은 handle_key_library 로 위임해서 focus
와 다른 pane state 가 mutate 되던 절뚝거림 차단).
- `format_doc_row` 의 `{title:<title_w$}` 가 std::fmt 의 named-arg
width specifier — 미래 reader 가 같은 패턴 보고 헷갈리지 않도록
doc 링크 한 줄 코멘트 추가.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
새 crate `kebab-tui` 가 §8 facade rule 따라 `kebab-app` 만 import.
Ratatui 0.28 + crossterm 0.28 기반 shell 이 다음을 제공:
- `App` 구조체: config + focus + library + 3 Option sub-state slot
(search/ask/inspect — p9-2/3/4 가 자기 모듈에서 채우는 parallel-safety
contract). p9-1 외에 App 정의 손대지 않음.
- `Pane` enum (Library/Search/Ask/Inspect/Jobs).
- `KeyOutcome` (Continue/Quit/SwitchPane/Refresh).
- `LibraryState` + 내부 inner: docs / list_state / filter / filter_edit /
needs_refresh / loading / pending_g.
- `render_library` (Frame, area, &App) — heading/body, filter overlay
toggleable, Korean/wide-char 너비는 unicode-width 로 계산.
- `handle_key_library`: j/k/Down/Up 이동, gg/G 끝, f 필터 overlay,
/=>Search ?=>Ask Enter=>Inspect, q/Esc 종료. error overlay 가 켜
있으면 어떤 키든 dismiss.
- 필터 overlay: tags_any (CSV) + lang 두 필드, Tab cycle, Enter
apply→Refresh, Esc cancel.
- `ErrorOverlay`: anyhow chain 캡쳐 후 popup 렌더 (Clear + 빨간 border).
- 터미널 lifecycle: `TuiTerminal` 가 enter raw mode + alt screen,
Drop 이 종료 시 (panic 포함) restore — 사용자 쉘 깨지지 않게.
- 비동기 없음: facade 호출은 main thread 동기. v1 의 brief hang 수용.
CLI: `kebab tui` 서브커맨드 추가, --config 받아 App::new + run.
테스트 10건 (`tests/library.rs`, TestBackend 사용):
- 빈 library / 3-doc render / q,Esc quit / / Search 전환 / ? Ask 전환
- Enter 빈 list 무동작 / Enter Inspect 전환 / j 이동 (3-step clamp) /
f 필터 overlay → 입력 → Enter Refresh.
Test seam: `App::populate_library_for_testing` (#[doc(hidden)]) 가
`pub(crate)` inner 를 우회. spec parallel-safety contract 그대로 유지.
Spec deviation (HOTFIXES `2026-05-02 P9-1`):
- `render_library` 의 `<B: Backend>` generic 제거 — ratatui 0.28 의 Frame
이 backend-agnostic.
- `populate_library_for_testing` 추가 (test seam, 공식 API 아님).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`delete_by_chunk_ids` 의 SQL IN(...) 입력에 대한 hex invariant 를
`debug_assert!` 로 명시. `id_for_chunk` 가 항상 hex 를 emit 하지만
`ChunkId(pub String)` 가 hand-construct 가능해 미래 contributor 가
tainted 문자열을 넣을 가능성 차단. dev / test build 에서 즉시
panic 으로 잡힘 (release 는 그대로 SQL 진행 — 운영 경로는 hex 가
강제되므로 false positive 없음).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P7-3 의 storage UNIQUE bug fix 가 SQLite 측 (documents → blocks /
chunks / embedding_records) 만 sweep 했음. LanceDB 의 vector 는 별도
store 라 옛 chunk_id 를 가진 row 가 디스크에 잔존. 검색에는 영향 없지만
디스크는 무한 누적. HOTFIXES `2026-05-02 P7-3` caveat 의 "P+ task" 약속을
같은 후속 PR 안에서 닫음.
변경:
- `VectorStore::delete_by_chunk_ids(&[ChunkId])` trait method 추가 (default
no-op 제공 — 테스트 fake / 기존 impl 이 그대로 컴파일).
- `LanceVectorStore::delete_by_chunk_ids` 가 connection 의 모든
`chunk_embeddings_*` 테이블을 순회 + `Table::delete("chunk_id IN (...)")`
를 batch=200 단위로 실행. 다중 모델 워크스페이스 (마이그레이션 중간 등)
에서도 안전.
- `SqliteStore::stale_chunk_ids_at(workspace_path, new_asset_id)` 가
read-only SELECT 로 옛 chunk_id 들 반환. CASCADE 가 흐르기 *전* 에
caller 가 호출.
- `kebab-app::purge_vector_orphans_for_workspace_path` 가 위 두 단계를
orchestrate. 세 ingest path (markdown / image / pdf) 의
`put_asset_with_bytes` 호출 직전에 한 줄로 호출.
Smoke 검증 (release binary, fastembed enabled):
- whitepaper.pdf 첫 ingest → chunk_ids = {f616…, 4e0f…}, vector store 에
그 두 ID 의 row 존재.
- byte 변경 후 re-ingest → 새 doc_id (3741…) + 새 chunk_ids
(ed0c…, e13c…). vector search "REWRITTEN chapter two" → 새 chunk_ids 만
hit. 옛 query "Edited page two body" 시도해도 옛 chunk_ids 는 vector
store 에 더 이상 없음 (의미적으로 가장 가까운 새 chunks 가 hit).
HOTFIXES `2026-05-02 P7-3` 의 \"vector store cleanup\" 항목이 \"deferred\" →
\"closed by follow-up PR\" 로 갱신. SMOKE.md 의 알려진 동작 (\"옛 vector
잔존\") 도 \"두 store 정합\" 으로 갱신.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P7-3 통합 테스트가 노출한 storage 레이어 버그 fix.
`assets.workspace_path` 의 UNIQUE 제약과 `upsert_asset_row` 의
`ON CONFLICT(asset_id)` 만 처리하던 gap 사이 — byte 가 변경된 자산
re-ingest 시 새 asset_id 가 같은 workspace_path 에서 secondary UNIQUE
충돌. md / image / pdf 모두 영향.
Fix:
- 새 helper `purge_orphan_at_workspace_path` 가 같은 `workspace_path`
의 *다른* `asset_id` 를 발견하면 documents → assets 순서로 sweep.
documents 의 ON DELETE RESTRICT 회피 + CASCADE 로 blocks / chunks /
embedding_records 정리. copied 모드면 storage_path 의 byte 파일도
best-effort 삭제.
- `put_asset_with_bytes` 의 두 분기 (copy / reference) + `DocumentStore
::put_asset` 모두 호출.
- 회귀 테스트 `put_asset_with_bytes_sweeps_workspace_path_orphan` (이전
의 "UPSERT 실패시 orphan 청소" 테스트가 더 이상 doable 하지 않으므로
대체).
- `re_ingest_edited_pdf_produces_new_doc_id` integration `#[ignore]` 해제 →
9 통합 테스트 모두 default 로 통과.
Vector store orphan 은 별도 P+ task — LanceDB 가 SQLite cascade 와 무관하게
운영되므로 stale chunk_id vector 가 디스크에 남음. 검색에는 영향 없음 (search 가
SQLite join 통해 surface).
Smoke 검증 (release binary, markdown 2 + image 1 + PDF 2):
- doctor pass
- 첫 ingest: 5 new
- list docs: 5 docs all media types
- search lexical "pdf-page-v1 chunker" → whitepaper.pdf hit
- search hybrid → cross-media 결과
- inspect doc PDF: parser_version=pdf-text-v1, blocks 가 SourceSpan::Page
- 동일 byte re-ingest: 5 updated, 0 errors (P1 idempotency)
- byte 수정 후 re-ingest: 1 new (해당 PDF) + 4 updated, 0 errors (storage fix)
- corrupt PDF 추가: errors+=1 + IngestItem.error 메시지 정확, 다른 자산 영향 0
- 정리 후 다시 ingest: errors=0
- RAG ask: PDF 인용 + `citations[].citation` 에 `kind: "page"` + `page: <N>` +
`path: <pdf_path>` 정확히 노출
운영 fixture 보조:
- `crates/kebab-parse-pdf/examples/gen_smoke_pdf.rs` — `cargo run --release
--example gen_smoke_pdf -p kebab-parse-pdf -- <out.pdf> <text-pages>` 로
reportlab/qpdf 없이 in-tree PDF 생성.
- `crates/kebab-parse-image/examples/gen_smoke_png.rs` — 동일 방식의 PNG
fixture 생성.
- SMOKE.md 가 두 example 사용법 + 갱신된 HOTFIXES 동작 (byte 수정 시
errors+=1 → new+=1) 반영.
HOTFIXES `2026-05-02 P7-3` entry 가 \"deferred\" → \"fixed in same PR\" 로
업데이트, vector store orphan caveat 만 남음.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `IngestItem.warnings` 가 PDF path 에서 빈 vec 였던 갭 해소. P7-1 의
Provenance Warning (scanned candidate / extract panic 흡수) 노트들을
`IngestItem.warnings` 로 surface — md path 의 `fm_warns + blk_warns`
patten 과 평행. 사용자가 ingest summary 에서 "이 PDF page 2 가 스캔
이라 검색 불가" 를 즉시 확인 가능.
- `mixed_page_pdf_stores_asset_with_scanned_candidate_warning` 에
`IngestItem.warnings` 단정 추가 (정확히 1건 + 노트 내용 검증).
- `encrypted_pdf` / `corrupt_pdf` 테스트의 `errors >= 1` → `errors == 1`
strict 단정. 미래에 다른 source 가 errors 늘리면 즉시 빨개짐.
- `re_ingest_identical_pdf` 에 `chunk_count` 동일성 단정 추가. P1
idempotency contract 의 chunk-단위 axis 검증 (chunk_id 전체 set 비교는
pdf-page-v1 의 `deterministic_chunk_ids_1000` 가 잠그고 있어 chunk_count
가 가벼운 proxy 로 충분).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P7-1 (`PdfTextExtractor`) + P7-2 (`PdfPageV1Chunker`) 의 라이브러리를
`kebab-app::ingest_with_config` 에 와이어링. `kebab-source-fs` 가 이미
`*.pdf` 를 `MediaType::Pdf` 로 분류하던 자산이 이제 검색 가능한 doc 으로
색인됨. P6-4 image wiring 패턴과 평행 — `ingest_one_asset` 에 `MediaType::Pdf`
arm 추가, 새 private fn `ingest_one_pdf_asset` 로 분기.
핵심 동작:
- per-medium chunker 선택: PDF 자산은 `PdfPageV1Chunker` 하드코딩 (compile-time
match 기반). `config.chunking.chunker_version` 은 markdown 만 represent —
PDF 는 항상 `pdf-page-v1`. HOTFIXES entry `2026-05-02 P7-3` 에 deviation 기록.
- encrypted PDF / corrupt PDF → `errors+=1` + P7-1 의 `qpdf --decrypt` hint
를 `IngestItem.error` 에 verbatim 보존.
- 빈/scanned candidate 페이지 → 0 chunk, P7-1 의 `Provenance::Warning` 그대로
통과. v1 에서는 검색 불가, P+ scanned-PDF OCR fallback 대기.
- determinism stress: extract → chunk 사이 `now()` 추가 호출 없음 (P6-4 invariant
계승). PDF doc/chunk_id 모두 결정적.
통합 테스트 (`tests/pdf_pipeline.rs`, 8 passed + 1 ignored):
- 3-page text PDF → 1 doc + 3 chunk + Page span 검증
- identical re-ingest → Updated, doc_id 동일
- encrypted PDF → Error + `qpdf` hint 보존
- corrupt header PDF → Error + 미저장
- mixed page (page 2 빈) → 2 chunk + Warning 1개
- IngestReport 산술 invariant
- 50-page 긴 PDF → ≥50 chunk
- inspect doc → SourceSpan::Page round-trip
- (ignored) edited bytes re-ingest → storage UNIQUE bug 노출, P+ fix 대기
추가 발견 (HOTFIXES `2026-05-02 P7-3`): `assets.workspace_path` 의 UNIQUE
제약과 `upsert_asset_row` 의 `ON CONFLICT(asset_id)` 만 처리하는 부분 사이에
gap 존재. byte 변경 시 새 asset_id → 같은 workspace_path 충돌. md / image / pdf
모두 영향. P7-3 통합 테스트가 처음 노출. 본 PR 은 fix 안 함 — P+ storage task.
`docs/SMOKE.md` 에 PDF 섹션 + 검증 체크리스트 + 알려진 동작 4건 추가.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- chunk 진입부에 overlap clamp 추가 (`target_bytes / 2` 상한). 병적 정책
(`overlap_tokens >= target_tokens`) 에서 chunk 가 직전 chunk 의 텍스트를
완전히 재발행하던 위험 차단. md-heading-v1 의 `seed_budget = overlap_tokens
.min(target/2)` 가드 패턴과 일치. 회귀 테스트 `overlap_clamped_when
_overlap_exceeds_target` 추가 — `actual_start` 가 인접 chunk 사이에
엄격 증가하는지 검증.
- `char_start as u32` / `char_end as u32` silent truncation → `try_from
::expect` 로 corrupted input 시 명시 panic.
- 모듈 doc 의 `## Splitting policy` 에 약어 케이스 (`Mr.` / `i.e.` 등) +
overlap clamp 두 항목 명시.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`PdfPageV1Chunker` 가 `kebab-parse-pdf` 가 emit 한
`CanonicalDocument` (블록당 한 페이지, 모두 `SourceSpan::Page`) 를 받아
페이지 경계를 절대 넘지 않는 `Chunk` 들을 생성. `chunker_version =
"pdf-page-v1"`.
핵심 동작:
- 페이지 텍스트가 `target_tokens × BYTES_PER_TOKEN` (= 3) 안이면 한
덩어리. 초과 시 `\n\n` (paragraph) 또는 sentence-end 구두점 + whitespace
경계를 segment 로 보고 greedy 누적, 기본 한 chunk 당 최소 한 segment.
- 다음 chunk 의 prefix 에 `overlap_tokens × BYTES_PER_TOKEN` 만큼의 직전
꼬리를 prepend (char 단위, 이전 chunk 시작 너머로 backtrack 안 함).
- 빈/공백-only 페이지는 0 chunk (페이지의 `Provenance::Warning` 으로
`kebab-parse-pdf` 단계에 이미 표시됨).
- 비-PDF doc (Block::Paragraph 가 아니거나 SourceSpan 이 Page 아님) →
명시 에러.
Spec deviation (HOTFIXES 2026-05-02 P7-2):
- `chunk_id` 충돌 가드: 같은 페이지에서 여러 chunk 가 나오면 `block_ids`
가 모두 같아 §4.2 recipe 가 충돌. `id_for_chunk` 의 `policy_hash` 인풋을
per-chunk 로 `format!("{base}#c{char_start}")` 변형해 회피. recipe 자체는
불변. `Chunk.policy_hash` 필드는 base 유지.
- `BYTES_PER_TOKEN = 3` (md-heading-v1 실제 코드와 일치). spec 본문은
"/ 4" 라고 했지만 그 자체가 md-heading-v1 의 실코드와 어긋나 있어 일관성
쪽을 택함. cross-chunker `policy_hash` 동일성 unit test 로 잠금.
테스트 (10개 신규):
- chunker_version label, 3-page small, 1-page huge + overlap + chunk_id
유일성, empty page skip, whitespace-only skip, non-PDF error,
cross-page boundary 절대 안 만들어짐, determinism (1000회), snapshot
shape 안정, md-heading-v1 와 policy_hash 동일.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Cargo.toml: 사용하지 않는 deps 제거 (`kebab-config`, `thiserror`,
`pdf-extract`, dev `tempfile` / `serde_json` / `serde`). 특히
`pdf-extract` 가 끌어오던 transitive ~150 crate (pom, postscript,
type1-encoding-parser, adobe-cmap-parser, euclid, chrono, md5,
linked-hash-map …) 가 모두 사라짐. lopdf 만 남음.
- info.rs: BOM 없는 PDFDocEncoded Title 디코드 버그 수정. `from_utf8_lossy`
는 0x80–0xFF 를 U+FFFD 로 치환해 "Café" 같은 레거시 타이틀을 망가뜨림.
byte → `char` 직접 캐스팅 (Latin-1 디코더) 로 교체. 회귀 테스트
`info_dict_title_pdfdocencoding_latin1_high_bytes_decoded` 추가.
- info.rs: 모듈 doc 의 "Latin-1 superset" 부정확 표현 정정 — PDFDocEncoding
은 0x18–0x1F / 0x80–0x9F 영역에서 Latin-1 과 다름.
- lib.rs: `saturating_sub(1)` 가 page=0 케이스를 silent 흡수하던 부분에
`debug_assert!` 추가. release 는 saturating fallback 유지 (panic 보다
garbled order 가 운영에 유리).
- tests: UTF-16 surrogate pair 커버리지 갭 보완 — 🥙 (U+1F959) 가 포함된
타이틀로 `String::from_utf16_lossy` 의 페어-결합 경로 검증.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`PdfTextExtractor`(MediaType::Pdf) lopdf 기반 per-page 텍스트 추출.
페이지마다 `Block::Paragraph` + `SourceSpan::Page { page, char_start, char_end }`
emit. 본문이 비거나 추출 panic 인 페이지는 빈 paragraph + `Provenance::Warning`
("scanned candidate") 로 표시 — 이후 OCR fallback (별도 task) 의 입력.
핵심 동작:
- `lopdf::Document::load_mem` + `is_encrypted()` → 암호화 PDF 는 명시 에러
(`qpdf --decrypt` 안내).
- 페이지 단위 `extract_text(&[page])` 를 `catch_unwind` 로 감싸 malformed
page panic 을 recoverable warning 으로 변환.
- `/Info` dict 에서 Title/Producer/Creator best-effort 추출. UTF-16BE BOM
prefixed 문자열도 디코드 (한국어 등 non-ASCII Title 정상 처리).
- 9개 통합 테스트: 3-page emit, scanned-mixed warning, encrypted refuse,
corrupt header error, page_count 메타, UTF-16BE Title, filename
fallback, determinism, snapshot.
`parser_version = "pdf-text-v1"`. Allowed deps: `lopdf 0.32` + `pdf-extract 0.7`
(원본 spec 그대로). 본문 다국어 OCR fallback 은 §9.2 후속 task (out of scope).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
수동 스모크 검증 (12 PNG + 손상 PNG) 중 발견. `IngestReport.errors`
가 자산 한 장당 2회 증가해서 `scanned = new + updated + skipped +
errors` invariant 가 깨짐:
- `garbage.png` (이미지 아닌 바이트, .png 확장자만) 1장 + 정상 자산
3장 → 기대 `scanned=4 errors=1`, 실제 `scanned=4 errors=2`.
- 원인: `match item { Err(e) => { error_count += 1; IngestItem {...} }
}` 에서 1회 증가 후, 직후 `match item.kind { Error => { error_count
+= 1 } }` arm 에서 또 1회 증가.
- markdown 경로의 `ingest_one_asset` Err 가 거의 발생 안 해서 P6-4
머지 전까지 표면화 안 됐던 기존 결함. 이미지 dispatch 가 garbage
bytes 를 Err 로 흘려보내며 처음으로 노출.
수정: `Err(e)` 분기의 `error_count.saturating_add(1)` 제거. 단일
증가 지점은 `match item.kind { Error => ... }` arm. 코멘트로 의도
명시.
회귀 테스트 추가 (`tests/image_pipeline.rs`):
- `garbage_png_increments_errors_counter_exactly_once` — 정확히 1
증가 + `scanned == new + updated + skipped + errors` invariant
검증.
검증 — release binary + 실 Ollama (192.168.0.47 / gemma4:e4b):
```
$ kebab --json ingest
scanned=4 new=3 updated=0 skipped=0 errors=1
error garbage.png (extract Err — unrecognised format)
new intro.md
new normal.png (OCR success)
new truncated.png (OcrFailed warning, asset still indexed)
```
cargo test --workspace --no-fail-fast -j 1 — 전부 pass.
cargo clippy --workspace --all-targets -- -D warnings — pass.
cargo test -p kebab-app --test image_pipeline — 6 pass (5 기존 + 1 회귀).
- src/lib.rs:
• `ingest_one_asset` 의 doc-comment 가 새 `ImagePipeline` struct 와
합쳐지던 (rustdoc 가 두 doc 을 struct 의 것으로 합치던) 문제
해소 — 두 doc-comment 위치 교환 + 빈 줄 분리.
• `if let Some(Block::ImageRef(...)) = blocks.first_mut()` 의
silent-skip 분기를 `match` 의 `other` arm 으로 명시 — 미래에
P6-1 contract 가 깨지면 `tracing::warn!` + Provenance Warning +
`IngestItem.warnings` 에 \"ImageDispatchAnomaly\" 노트로 즉시
가시화. 운영 디버깅 단서 제공.
• OCR 실패 분기 + caption 실패 분기의 ~25줄 boilerplate 를
`record_image_analysis_failure` 헬퍼로 추출 — 두 호출이 한 줄로
줄고 미래 ProvenanceEvent 필드 변경이 한 곳에서 끝남.
• 분석 단계 Warning 이벤트가 fn 진입 시 캡처한 단일
`OffsetDateTime::now_utc()` 를 공유 — spec Risks/notes 의
\"Determinism stress: must not introduce a second `now()` call
between extract and apply_ocr/caption\" 약속 회복.
• 경고 라벨을 markdown 경로의 `WarningKind` 컨벤션 (`{kind}: {note}`)
에 맞춤 — `\"ocr_failed: ...\"` → `\"OcrFailed: ...\"`,
`\"caption_failed: ...\"` → `\"CaptionFailed: ...\"`. 같은 wire
필드 (`IngestItem.warnings`) 가 두 갈래의 다른 형식을 갖던
inconsistency 해소.
- tests/image_pipeline.rs:
• 회귀 테스트의 \"ocr_failed\" assertion 을 \"OcrFailed\" 로 갱신.
cargo test -p kebab-app -p kebab-chunk — 전부 pass.
cargo clippy --workspace --all-targets -- -D warnings — pass.
P6-1/P6-2/P6-3 의 라이브러리 (`ImageExtractor`, `OllamaVisionOcr`,
`apply_caption`) 가 그동안 CLI 에서 보이지 않던 미완 구간을 완성.
이제 `kebab ingest` 가 markdown 외에 이미지 자산을 end-to-end 로
색인하고, `kebab search` / `kebab ask` 가 OCR 텍스트 + caption 으로
이미지를 매칭/인용한다.
## kebab-app
- `[dependencies]` 에 `kebab-parse-image` 추가.
- `ingest_with_config` 진입 시 `image.ocr.enabled` / `image.caption.enabled`
플래그에 따라 `OllamaVisionOcr` / `OllamaLanguageModel` 을 **ingest
세션당 1회** 빌드. 자산 루프에서 trait object 로 공유.
reqwest::blocking::Client 의 내부 Arc 덕분에 알로케이션 비용은
자산 수와 무관.
- 두 어댑터 + ImageExtractor 를 한 묶음으로 `ImagePipeline` 구조체에
담아 `ingest_one_asset` 매개변수 폭증 차단 (clippy::too_many_arguments
대응).
- `ingest_one_asset` 의 markdown-only 가드를 `match media_type` 으로
교체 — Markdown 은 기존 경로, Image(_) 는 새 `ingest_one_image_asset`
로 분기, PDF/Audio/Other 는 종전대로 skipped.
- 신규 `ingest_one_image_asset`:
- bytes 읽기 → `ImageExtractor::extract` (실패 시 caller 가 errors+=1)
- `apply_ocr` (Lenient — 실패 시 ProvenanceKind::Warning 이벤트 +
`IngestItem.warnings` 에 \"ocr_failed: ...\", `block.ocr` 는 None
유지)
- `apply_caption` (동일 Lenient 정책)
- 기존 `MdHeadingV1Chunker` 호출 — 청커는 이미 `Block::ImageRef` 를
단일 청크로 emit
- 기존 persist + embed 시퀀스 그대로 (markdown 과 byte-identical)
- `lang_hint_from_doc` — `Lang(\"und\")` 또는 빈 문자열을 None 으로
매핑 (image-pipeline 어댑터의 build_prompt 가 \"und\" 를 silent drop
하지 않도록 caller 측에서 미리).
## kebab-chunk
- `render_block_text` 의 `Block::ImageRef` 분기를 P6-4 (β) plain
concat 정책으로 교체 — `[alt, ocr.joined, caption.text]` 를 `\\n\\n`
로 join, 빈 부분은 drop. alt 가 비면 `src` 의 basename 으로 fallback
(P6-1 contract 의 defensive guard).
- 신규 unit 테스트 `image_ref_p6_4_plain_concat_drops_empty_parts` —
alt-only / alt+ocr / alt+caption / alt+ocr+caption / 빈 alt → src
fallback 다섯 케이스 모두 검증.
- 기존 `image_ref_emits_own_chunk_zero_tokens` 그대로 통과 — 청커의
per-block dispatch 는 변경 없음, text 렌더링만 갱신.
## 통합 테스트 (kebab-app/tests/image_pipeline.rs)
wiremock 으로 Ollama 를 stub. 5건:
1. OCR-only happy path — 1 PNG + ocr.enabled → 1 doc + 1 chunk emit,
`block.ocr.joined` 가 mock 의 \"Hello World 2026\".
2. OCR + caption 동시 활성 — 두 필드 모두 채워지고 chunk text 에
alt + ocr + caption 세 부분 모두 포함.
3. Lenient 실패 검증 — OCR 503 시 자산은 indexed (kind=New),
`errors=0`, ProvenanceKind::Warning attributed to \"kb-app\",
`IngestItem.warnings` 에 \"ocr_failed:\" 노트.
4. 양쪽 비활성 — `image.ocr.enabled=false && image.caption.enabled=false`
여도 자산은 chunk 1개로 indexed (chunk text=filename), EXIF +
dimensions 그대로 채워짐.
5. 결정성 (re-ingest) — 동일 PNG 두 번 ingest 시 두 번째는
`Updated` + 동일 `doc_id`.
## SMOKE.md
`kebab search --mode lexical \"Hello World\"` 단계를 명령 시퀀스에
추가. `[image.ocr]` / `[image.caption]` config 절 예시 + ingest 시간
추정 (자산당 ~5-10초) 추가. \"책은 P7 PDF 라인으로\" 가이드를 검증
체크리스트 와 \"알려진 동작\" 양쪽에 박음.
## 실 Ollama 통합 검증
192.168.0.47 + gemma4:e4b 기준:
```
$ kebab --config /tmp/kebab-smoke/config.toml ingest
scanned 2 new 2 updated 0 skipped 0 errors 0 (18395 ms)
$ kebab inspect doc <image_doc_id>
parser_version: image-meta-v1
blocks: [{
alt: \"hello.png\",
ocr: \"Hello World 2026\",
caption: \"The image displays the text \\\"Hello World 2026\\\" in a large, black, sans-serif font.\"
}]
$ kebab --json ask \"Hello World 텍스트가 어디에 있나?\" --mode hybrid
grounded: true
citations: [{marker: \"[1]\", doc_path: \"hello.png\"}]
```
## 검증
- `cargo test --workspace --no-fail-fast -j 1` — 전부 pass
- `cargo clippy --workspace --all-targets -- -D warnings` — pass
- `cargo test -p kebab-chunk image_ref` — 2 pass (P1-5 회귀 + P6-4
신규 unit)
- `cargo test -p kebab-app --test image_pipeline` — 5 pass
## 의존성 경계
- `kebab-app` 이 `kebab-parse-image` 추가 — spec Allowed dep 그대로.
- 새 forbidden 침범 없음 (기존 `kebab-tui` / `kebab-desktop` /
`kebab-eval` 미참조 유지).
- 본 task 가 신설하는 image-specific 비즈니스 로직 0줄 — 모두
`kebab-parse-image` 에 위임.
`tasks/p6/p6-4-image-ingest-wiring.md` status: planned → completed.
contract: docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
sections: §3.4 ImageRefBlock, §6.1 ingest pipeline, §7.2
Extractor/Chunker traits, §9.1 image extraction policy.