Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dcc5ce412 | |||
|
|
751377cae8 | ||
| 4922f9fc64 | |||
|
|
47bfd518c8 | ||
|
|
7f5739d8fb | ||
|
|
dc24cb34b1 | ||
|
|
ccee30037d | ||
|
|
e041173e8e | ||
|
|
345a4f363a | ||
|
|
71c2bbdc97 | ||
|
|
ecd77290cd | ||
|
|
fbc01eda50 | ||
|
|
0386adcb5e | ||
|
|
9cc7deca11 | ||
|
|
a42f907640 | ||
|
|
67050016cc | ||
|
|
73ee64c73f | ||
|
|
9b53dcb94f | ||
|
|
41061a38ac | ||
| 177ce21f88 | |||
|
|
b7c85e8887 | ||
|
|
7772fbc00f | ||
| 138f31d661 | |||
|
|
5495d96275 | ||
| eb4f594dda | |||
|
|
4e2090e54d | ||
|
|
2387c6cd11 | ||
|
|
ee4f198308 | ||
|
|
f758d51a01 | ||
|
|
366b647a1a | ||
|
|
4a30959fdd | ||
|
|
61eef9bc82 | ||
|
|
bc16dbf12a | ||
|
|
f9a1548b53 | ||
|
|
c8e04c65e0 | ||
|
|
4b1b8a15bf | ||
|
|
52782fdf72 | ||
|
|
360fa53b02 | ||
|
|
8ca8e18d12 | ||
|
|
8f6e6bc01a | ||
|
|
2c09ed6af4 | ||
|
|
1f53930234 | ||
| 65cfaf7c75 | |||
|
|
72855df98b | ||
|
|
58ec4578b9 | ||
| 769139996b | |||
|
|
2e8de14434 | ||
| 50ad7f1f28 | |||
|
|
73f5d73112 | ||
| c732189eb3 | |||
|
|
1bcca7f9ca | ||
|
|
f7d6ea593e | ||
|
|
cc0e4e6551 | ||
|
|
bb7b1cec4b | ||
|
|
c25f4f89e3 | ||
|
|
3725986af7 | ||
|
|
912c7aa07d | ||
|
|
4eb13c63ae | ||
|
|
c91228e7d5 | ||
|
|
3e33daaa9b | ||
|
|
ab96335174 | ||
|
|
61aae1c1d5 | ||
|
|
39b4433549 | ||
|
|
1c4d554bf4 | ||
|
|
d7bfd01ef5 | ||
|
|
26a2e021b0 | ||
|
|
58c06664b8 | ||
|
|
3efdf7ef2f | ||
|
|
c20da0f274 | ||
|
|
f01f8dfc9b | ||
| 16b4f9fb9f | |||
| dd172af47c | |||
|
|
e31871d03e | ||
| 5398fb057c | |||
| 1409eaae51 | |||
| 8dadac2a45 | |||
| e4432a2388 | |||
| 51feff5f16 | |||
| 44dee2c30f | |||
| 9545367904 | |||
| 693f5582f0 | |||
| d64282433c | |||
| ef5d0770ae | |||
| 7f31721a47 | |||
| b22c8cfd45 | |||
| 7c8f1f2637 | |||
| 40ca4bf27e | |||
| a68c47124f | |||
| a98767088f | |||
| c6a67555d8 | |||
| d417f843f8 | |||
| ce68885d92 |
27
CLAUDE.md
27
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project
|
||||
|
||||
Single-user local-first knowledge base + RAG. Rust 2024 workspace, ~20 crates, single binary (`kebab`). All inference is local (Ollama + fastembed + whisper.cpp).
|
||||
Single-user local-first knowledge base + RAG. Rust 2024 workspace, ~21 crates, single binary (`kebab`). All inference is local (Ollama + fastembed + whisper.cpp).
|
||||
|
||||
The repo's documentation is split by audience — don't duplicate across them:
|
||||
|
||||
@@ -54,18 +54,38 @@ Each task spec lists `Allowed dependencies` and `Forbidden dependencies` per des
|
||||
|
||||
- `kebab-core` MUST NOT depend on any other `kebab-*` crate. Domain types only.
|
||||
- `kebab-eval`'s `metrics` and `compare` modules MUST NOT import retrieval / embedding / LLM crates directly. The runner is allowed to use `kebab-app`'s facade (P5-1 inheritance — see deviations in that task spec).
|
||||
- UI crates (`kebab-cli`, future `kebab-tui`, `kebab-desktop`) MUST NOT import `kebab-store-*` / `kebab-llm-*` / `kebab-parse-*` directly — only `kebab-app`.
|
||||
- UI crates (`kebab-cli`, `kebab-mcp`, `kebab-tui`, future `kebab-desktop`) MUST NOT import `kebab-store-*` / `kebab-llm-*` / `kebab-parse-*` directly — only `kebab-app`.
|
||||
|
||||
Read the relevant task spec's deps section before adding an import. New crates inherit the same boundary rules.
|
||||
|
||||
## Wire schema v1
|
||||
|
||||
All `--json` output carries a `schema_version` field (`ingest_report.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`, …). Schemas live in `docs/wire-schema/v1/`. The wire shape is the contract for external integrations (Claude Code skills, MCP, etc.); breaking it requires a `*.v2` major bump and parallel-running both for one phase.
|
||||
All `--json` output carries a `schema_version` field. Current schemas: `ingest_report.v1`, `ingest_progress.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`, `reset_report.v1`, `eval_run.v1`, `eval_compare.v1`, `list_docs.v1`, `schema.v1`, `error.v1`. Schemas live in `docs/wire-schema/v1/`. The wire shape is the contract for external integrations (Claude Code skills, MCP, etc.); breaking it requires a `*.v2` major bump and parallel-running both for one phase. In `--json` mode, fatal errors emit `error.v1` to stderr as ndjson (non-`--json` mode keeps plain stderr text); exit codes 0/1/2/3 are unchanged — `error.v1.code` provides fine-grained agent branching.
|
||||
|
||||
In-tree integration packages live under `integrations/<host>/` — currently `integrations/claude-code/kebab/` (a Claude Code skill that calls `kebab search --json` / `kebab ask --json`). Any wire schema major bump (v1→v2) MUST update each shipped integration in the same PR, same as the version-cascade rule below. Per-user trigger keywords (team / system / acronym) belong in the user's local copy of the skill, not in the repo-shipped frontmatter — keep `integrations/claude-code/kebab/SKILL.md`'s `description` generic.
|
||||
|
||||
## Versioning cascade
|
||||
|
||||
`parser_version` / `chunker_version` / `embedding_version` / `prompt_template_version` / `index_version` follow the cascade rule in design §9. Changing any of these invalidates downstream records (chunks, embeddings, eval runs, …). When changing a version: either ship a re-process job or treat it as a breaking schema bump. The eval runner snapshots all five into `eval_runs.config_snapshot_json`.
|
||||
|
||||
## Release / binary version bump
|
||||
|
||||
Workspace `Cargo.toml` 의 `version` 은 binary release 의 정체성. 다음 트리거 중 하나 발생 시 **bump + 새 release 컷**:
|
||||
|
||||
- 사용자가 새 바이너리로 **도그푸딩** 또는 **실사용** 을 할 필요가 있다고 명시.
|
||||
- breaking schema change (V00X migration / wire schema major bump v1→v2 등) 가 머지된 후 — 이전 릴리즈 binary 가 새 DB / 새 wire 와 호환 안 됨. wire 의 additive minor 변경 (예: `IngestReport.unchanged` 같은 필드 추가) 은 backward-compat 이라 본 트리거에 해당 안 됨.
|
||||
- frozen design contract 변경 (design §X 갱신) 이 머지된 후.
|
||||
|
||||
Bump 자체는 단순 minor / patch 한 줄 수정 (`Cargo.toml` workspace `version`) — 이미 모든 kebab-* crate 가 `version = { workspace = true }` 라 자동 cascade. 동시에 `Cargo.lock` 자동 갱신.
|
||||
|
||||
Release 절차:
|
||||
|
||||
1. `gitea-release v<X.Y.Z>` (gitea-ops skill) 으로 tag + push + release notes.
|
||||
2. release notes 는 사용자 도그푸딩에 영향 가는 surface 변경 위주 — wire schema 추가, CLI flag 신규, TUI 키 변경, V00X migration 등.
|
||||
3. 프리-1.0 (`0.x.y`) 단계: minor bump 시 wire schema additive / surface 변경 누적, patch bump 시 bug fix only.
|
||||
|
||||
**bump 시점 = release 시점 같은 commit**. 즉 commit `chore: bump version 0.x → 0.y` 직후 같은 commit 에 tag. v0.1.0 (`2319206`) 처럼 bump 없이 tag 만 찍는 패턴은 후속 release 가 대상 commit 을 헷갈리게 함 — pre-release snapshot 은 SHA reference 로 충분.
|
||||
|
||||
## Naming + paths
|
||||
|
||||
- Crate prefix: `kebab-` (kebab-case package, `kebab_` snake_case in Rust modules).
|
||||
@@ -74,6 +94,7 @@ All `--json` output carries a `schema_version` field (`ingest_report.v1`, `searc
|
||||
- XDG paths: `~/.config/kebab/`, `~/.local/share/kebab/`, `~/.cache/kebab/`, `~/.local/state/kebab/`.
|
||||
- SQLite filename: `kebab.sqlite` (under `data_dir`).
|
||||
- Workspace ignore: `.kebabignore` (per directory).
|
||||
- `_external/` (under `workspace.root`): single-file / stdin ingest 가 외부 file 을 deterministic 명명 (`<blake3-12>.<ext>`) 으로 copy. 첫 생성 시 `.kebabignore` 자동 append.
|
||||
|
||||
The migration from the old `kb` name lives in commits `911fb49 / f1a448d / f9714aa`. If you spot a leftover `kb` reference, treat it as a leftover and fix it (the rename PR sweep covered crates/, docs/, tasks/, README, design doc, fixtures — but workspace root `Cargo.toml` comments needed a follow-up; assume similar misses are possible).
|
||||
|
||||
|
||||
166
Cargo.lock
generated
166
Cargo.lock
generated
@@ -549,7 +549,7 @@ dependencies = [
|
||||
"log",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
"pastey",
|
||||
"pastey 0.1.1",
|
||||
"rayon",
|
||||
"thiserror 2.0.18",
|
||||
"v_frame",
|
||||
@@ -1229,6 +1229,16 @@ dependencies = [
|
||||
"darling_macro 0.21.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core 0.23.0",
|
||||
"darling_macro 0.23.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.20.11"
|
||||
@@ -1257,6 +1267,19 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
@@ -1279,6 +1302,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core 0.23.0",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dary_heap"
|
||||
version = "0.3.9"
|
||||
@@ -3491,11 +3525,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-app"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
"dirs 5.0.1",
|
||||
"ignore",
|
||||
"image",
|
||||
"kebab-chunk",
|
||||
"kebab-config",
|
||||
@@ -3516,6 +3551,7 @@ dependencies = [
|
||||
"kebab-store-vector",
|
||||
"lopdf",
|
||||
"lru",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3532,7 +3568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-chunk"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -3547,7 +3583,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3557,7 +3593,9 @@ dependencies = [
|
||||
"kebab-config",
|
||||
"kebab-core",
|
||||
"kebab-eval",
|
||||
"kebab-mcp",
|
||||
"kebab-tui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"time",
|
||||
@@ -3565,20 +3603,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-config"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs 5.0.1",
|
||||
"kebab-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kebab-core"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -3592,7 +3632,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-embed"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -3606,7 +3646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-embed-local"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fastembed",
|
||||
@@ -3619,7 +3659,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-eval"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-app",
|
||||
@@ -3638,7 +3678,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-llm"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-core",
|
||||
@@ -3647,7 +3687,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-llm-local"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-config",
|
||||
@@ -3662,9 +3702,26 @@ dependencies = [
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kebab-mcp"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-app",
|
||||
"kebab-config",
|
||||
"kebab-core",
|
||||
"rmcp",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kebab-normalize"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-core",
|
||||
@@ -3679,7 +3736,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-image"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"anyhow",
|
||||
@@ -3703,7 +3760,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-md"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kebab-core",
|
||||
@@ -3720,7 +3777,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-pdf"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -3733,7 +3790,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-parse-types"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"kebab-core",
|
||||
"serde",
|
||||
@@ -3741,7 +3798,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-rag"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -3762,7 +3819,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-search"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"globset",
|
||||
@@ -3780,7 +3837,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-source-fs"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -3797,7 +3854,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-store-sqlite"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -3818,7 +3875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-store-vector"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arrow",
|
||||
@@ -3842,7 +3899,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kebab-tui"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"crossterm",
|
||||
@@ -5453,6 +5510,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a"
|
||||
|
||||
[[package]]
|
||||
name = "path_abs"
|
||||
version = "0.5.1"
|
||||
@@ -6302,6 +6365,40 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e12ca9067b5ebfbd5b3fcdc4acfceb81aa7d5ab2a879dff7cb75d22434276aad"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"futures",
|
||||
"pastey 0.2.2",
|
||||
"pin-project-lite",
|
||||
"rmcp-macros",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_json",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roaring"
|
||||
version = "0.10.12"
|
||||
@@ -6508,12 +6605,26 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"schemars_derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
@@ -6602,6 +6713,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive_internals"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
|
||||
@@ -22,6 +22,7 @@ members = [
|
||||
"crates/kebab-parse-image",
|
||||
"crates/kebab-parse-pdf",
|
||||
"crates/kebab-tui",
|
||||
"crates/kebab-mcp",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -29,7 +30,7 @@ edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/altair823/kebab"
|
||||
version = "0.1.0"
|
||||
version = "0.3.2"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
@@ -71,6 +72,10 @@ futures = "0.3"
|
||||
# pass; pulled into the workspace deps so future crates can share the
|
||||
# same major.
|
||||
regex = "1"
|
||||
# MCP (Model Context Protocol) SDK. server + macros + transport-io provide
|
||||
# stdio JSON-RPC transport for `kebab-mcp` (p9-fb-30). schemars feature
|
||||
# exposes the derive macro used by tool input schemas.
|
||||
rmcp = { version = "1.6", default-features = false, features = ["server", "macros", "transport-io", "schemars"] }
|
||||
# Dev-only HTTP mock server for kebab-llm-local Ollama adapter tests. Requires
|
||||
# a tokio runtime to host its mock server (the runtime adapter crate stays
|
||||
# sync via reqwest::blocking — wiremock is dev-only there).
|
||||
|
||||
15
HANDOFF.md
15
HANDOFF.md
@@ -31,6 +31,8 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
|
||||
|
||||
머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.md). 본 요약은 \"누군가가 인수받을 때 알아두면 시간을 많이 절약하는\" 항목만:
|
||||
|
||||
- **2026-05-07 P9 post-도그푸딩 (p9-fb-31)** — `kebab ingest-file <path>` + `kebab ingest-stdin --title <T>` 두 신규 subcommand + MCP tool `ingest_file` / `ingest_stdin` (4 → 6 tool). agent 가 fetch 한 web markdown / 외부 file 을 KB 에 즉시 저장. workspace 외부 file 은 `<workspace.root>/_external/<blake3-12>.<ext>` 로 copy (deterministic 명명 → idempotent). `_external/` 디렉토리 첫 생성 시 `.kebabignore` 자동 append (walk 무한 루프 방지). stdin 은 markdown 전용 + flag (`--title`, `--source-uri`) → frontmatter 자동 prepend. .kebabignore 매치 시 stderr warn 후 진행 (explicit ingest = bypass intent). fb-30 의 v1 read-only MCP 정책 변경 — 첫 mutation tool 도입. spec: `tasks/p9/p9-fb-31-single-file-stdin-ingest.md`. design: `docs/superpowers/specs/2026-05-07-p9-fb-31-single-file-stdin-ingest-design.md`.
|
||||
- **2026-05-07 P9 post-도그푸딩 (p9-fb-30)** — `kebab mcp` 신규 subcommand + new crate `kebab-mcp` (lib only) — stdio JSON-RPC server. 4 read-only tool (`search` / `ask` / `schema` / `doctor`) 가 `kebab-app` facade 위에 build. rmcp 1.6 SDK 채택, manual `tools/list` + `tools/call` dispatch (rmcp 의 `#[tool_router]` 매크로 대신). `error_classify` 모듈을 `kebab-cli` → `kebab-app::error_wire` 로 promotion (UI crate 끼리 import 회피, facade 룰 준수). `ErrorV1` 에 `schema_version: String` 필드 추가 — kebab-mcp 의 직접 serialize 경로에서도 wire 정합. `KebabAppState` 가 `(Config, Option<PathBuf>)` carry — doctor tool 의 path-aware behavior 위해. ask + search arm 의 `tokio::task::spawn_blocking` wrap — `OllamaLanguageModel` 의 reqwest blocking client 가 async 안에서 panic 회피. capability flag `mcp_server` `false` → `true`. agent integration MVP 완성 — Claude Code / Cursor / OpenAI Agents 등 host-agnostic 사용 가능. spec: `tasks/p9/p9-fb-30-mcp-server.md`. design: `docs/superpowers/specs/2026-05-07-p9-fb-30-mcp-server-design.md`.
|
||||
- **P3-5 / P4-3 `--config` 누락** — `kebab-cli` 가 `--config <path>` 를 honor 하려면 `kebab_app::*_with_config` companion 을 호출해야 함. 두 번 같은 모양으로 회귀했음.
|
||||
- **P6-2 OCR 기본 엔진** — spec literal 의 Tesseract 가 시스템 dep 부담으로 거부됨, Ollama vision LM 으로 대체. `OcrEngine` trait 그대로라 future swap 가능.
|
||||
- **P6-3 caption** — `GenerateRequest.images` 필드를 `kebab-core::LanguageModel` trait 에 신설. 기존 caller 모두 `images: Vec::new()` 로 마이그레이션.
|
||||
@@ -60,10 +62,12 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
|
||||
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-12 follow-up)** — heuristic 제거 (partial PR 의 deferred 부분 finalize). `search::is_typing_mod` (CTRL/ALT chord filter) 함수 삭제 + `ask::handle_key_ask` 의 input-empty heuristic 삭제. 새 dispatch: `search::handle_key_search` 의 `i` (chunk inspect) / `g` (editor jump) pre-pass 가 `state.mode == Mode::Normal` 일 때만 fire (Insert 에서는 typed char). main match 의 `j`/`k`/Char(c) 가 `state.mode` 로 분기 (Normal → 선택 이동, Insert → input.push). `ask::handle_key_ask` 의 `e`/`j`/`k` 도 동일 패턴 — Normal 에서 toggle/scroll, Insert 에서 input typing. 테스트 fixture (`tests/search.rs::fresh_app`, `tests/ask.rs::fresh_app`) 가 `app.mode = Mode::auto_for(focus)` 로 run-loop 동작 mirror. 기존 nav 테스트 (j_k_move, g_key_enqueues, e_toggles) 는 explicit `app.mode = Mode::Normal` 추가, 신규 4 테스트 (j_in_insert_types / arbitrary_char_in_normal_noop / e_types_in_insert / jk_scroll-in-normal-type-in-insert) 가 mode-authoritative 동작 pin. spec status `in_progress` → `completed`. spec: `tasks/p9/p9-fb-12-tui-mode-machine.md`.
|
||||
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-10 partial)** — TUI CJK rendering helpers. `kebab-tui::input::{display_width, truncate_to_display_width}` 신규 — `unicode-width` 위에서 column-단위 width 계산 (ASCII=1, Hangul/CJK/fullwidth=2, combining=0) + char-boundary 안전 truncate (wide char 를 split 없이 keep-or-omit, ellipsis 1 col). library.rs 의 중복 `truncate_to_display_width` private fn 제거 — 단일 source. 9 unit tests (ASCII / Hangul / Japanese / mixed / truncate fits·overflow·zero-cols·wide-char-boundary / `String::pop` char-aware sanity) + 1 integration render test (Korean + Japanese fixture, TestBackend 80×20, 한글/일본어 글자가 frame 에 살아남음 확인). spec 의 `InputBuffer` struct (cursor 가 column 단위 wide-char width 추적) 도입은 follow-up — Ask/Search/Editor pane 의 String + cursor 일괄 마이그레이션이 회귀 표면이 커서 helper 만 먼저 머지. backspace 는 모든 pane 이 이미 `String::pop()` 사용 (char-aware) → byte-boundary 안전성 helper 없이도 확보. crossterm 0.28 이 native IME composing 미노출 — preedit handling out of scope. spec status `planned` → `in_progress`. spec: `tasks/p9/p9-fb-10-tui-cjk-input.md`.
|
||||
- **2026-05-04 P9 post-도그푸딩 (p9-fb-23)** — Incremental ingest. 사용자 도그푸딩 피드백: 변하지 않은 문서는 다시 ingest 하지 않기. blake3 checksum + parser_version + chunker_version + embedding_version 4개 input 이 모두 일치할 때 parse/chunk/embed/vector upsert 모두 회피. SQLite V006 마이그레이션 — `documents` 에 `last_chunker_version` + `last_embedding_version` 컬럼 추가. 신규 `IngestItemKind::Unchanged` variant + `IngestReport.unchanged` + `AggregateCounts.unchanged` (wire schema additive). `IngestOpts { progress, cancel, force_reingest }` struct 도입 — `AskOpts` 패턴. `--force-reingest` CLI flag 로 skip 우회. 비용 dominator (fastembed) 가 변경된 / 새 doc 에만 발생. spec: `tasks/p9/p9-fb-23-incremental-ingest.md`. HOTFIXES `2026-05-04 — p9-fb-23` 항목이 version cascade 명시 동작의 source of truth.
|
||||
- **2026-05-05 P9 post-도그푸딩 (p9-fb-25)** — Config 의 `workspace.include` 필드 제거 + 지원 형식 가시성. 사용자 도그푸딩 피드백: include + exclude 동시 존재가 case 4 (둘 다 매치 안 함) 의미 모호 + 어차피 처리 가능 형식 (md / png / jpg / pdf) 이 정해져 있으니 명시 필요. `WorkspaceCfg.include` 제거 (옛 config 의 `include = [...]` 은 silently 무시 + 단발 deprecation warning). `IngestItem.warnings` 가 Skipped 시 사유 (`"unsupported media type: .docx"` 등) 채움. `IngestReport.skipped_by_extension: BTreeMap<String, u32>` 신규 (additive wire — release 트리거 안 됨). CLI / TUI summary 에 breakdown 표시 (`"5 skipped: 3 docx, 1 txt, 1 epub"`). README + `kebab init` 헤더 주석에 지원 형식 명시. spec: `tasks/p9/p9-fb-25-config-include-removal.md`. HOTFIXES `2026-05-05 — p9-fb-25` 가 source of truth.
|
||||
- **2026-05-04 P9 post-도그푸딩 (p9-fb-24)** — TUI status/key bar + Library 컬럼 헤더 + Ask/Inspect PgUp/PgDn. 사용자 도그푸딩 3 건 (Library 컬럼 의미 부재, 페이지 스크롤 키 부재, 상태바 + 버전 정보 항상 노출 요청) 을 단일 PR 로 통합. bottom 영역을 status bar (1 row, version + pane + docs + dynamic state) + key hint bar (1 row, 기존 `footer_hints` 그대로) 두 줄로 분할; 기존 ingest progress dedicated row 는 status bar 의 dynamic slot 에 흡수 (priority cascade: streaming → searching → indexing → idle). Library `List` 위에 `format_doc_header` 행 + Layout 분할로 헤더 표시 (TITLE / TAGS / UPDATED / CHUNKS, display-width 정렬). `kebab-tui::pager::PAGE_STEP = 10` 신규 — Ask 의 PgUp/PgDn 추가 + Inspect 의 기존 +/-10 hardcode 가 같은 상수 참조로 통일. Ask 의 page-scroll 은 `j`/`k` 와 동일하게 `follow_tail = false` 로 freeze. spec: `tasks/p9/p9-fb-24-tui-affordances.md`. HOTFIXES `2026-05-04 — p9-fb-24` 항목이 footer 단행 row (p9-fb-13) + ingest dedicated row (p9-fb-03) 와의 layout 충돌의 source of truth.
|
||||
- **2026-05-04 P9 post-도그푸딩 (p9-fb-22)** — TUI 입력 cursor mid-string 편집 + Ask follow-tail auto-scroll. Gitea #94 (입력 후 커서 이동 안 됨) + #95 (새 응답 자동 스크롤 안 됨) 두 건. `InputBuffer` 의 cursor 모델을 byte-position 기반으로 재구성 — cursor 가 끝일 때 기존 append 동작과 backwards-compatible, mid-string 일 때는 `←/→/Home/End/Delete` 로 편집. `AskState` 에 `follow_tail: bool` (default true). `Paragraph::line_count(width)` (ratatui `unstable-rendered-line-info` feature 활성화) 로 매 프레임 wrapped row 수 계산해 follow-tail 시 scroll 을 bottom 에 pin. `j`/`k` 가 follow-tail 끄고 `Shift-G` 가 다시 켬. 12 신규 InputBuffer unit + 6 신규 Ask integration. spec: `tasks/p9/p9-fb-22-tui-cursor-and-autoscroll.md`. HOTFIXES 항목 `2026-05-04` 가 live cursor 모델 source of truth.
|
||||
- **2026-05-03 P9 post-도그푸딩 (p9-fb-21)** — `i` 가 universal Normal→Insert toggle (모든 pane). 이전 mode_intercept 는 Library/Inspect/Jobs 만 `i` intercept 였고 Search/Ask 는 fall-through (자동 INSERT 가정). 사용자가 Esc 로 NORMAL 로 빠진 후 Insert 복귀 키 없어 dead-end → 도그푸딩에서 보고됨. mode_intercept 의 `(Char('i'), Normal, _)` arm 이 pane 무관 모두 INSERT flip. Search 의 chunk inspect 키 `i`→`o` rebind (vim "open") 으로 충돌 해소. footer hint 모든 (pane, mode, filter) 조합 첫 fragment = `F1 도움말` (cheatsheet binding discoverability). Search/Ask Normal hint 에 `i 입력모드` fragment 추가. cheatsheet popup Global/Search/Ask section 갱신. 6 신규 unit + 3 기존 갱신. spec: `tasks/p9/p9-fb-21-tui-insert-key-discoverability.md` (status `completed` 직접). HOTFIXES 항목이 Search `i`→`o` rebind 의 source of truth.
|
||||
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-10 follow-up)** — InputBuffer struct + 모든 text-input pane 마이그레이션 + cursor column 정렬. `kebab-tui::input::InputBuffer { content, cursor_col }` 신규 — `push_char` / `pop_char` / `clear` / `take` 가 wide-char 단위로 cursor_col 진행 (ASCII=1, Hangul/CJK=2, combining=0). `SearchState.input` / `AskState.input` / `FilterEdit.{tags_buf, lang_buf}` 가 InputBuffer 로 교체. render 단계에서 `f.set_cursor_position(...)` 가 `block.inner(area)` 기반 prompt 폭 + cursor_col 으로 caret 을 정확한 column 에 배치 (right-edge clamp). ratatui 0.28 의 cursor visibility 는 `cursor_position` Some/None 으로 자동 결정 — Search/Ask/Filter 가 `Some` 이라 caret 보임, Library/Inspect 는 `None` 이라 hidden. Korean lexical 검색은 `crates/kebab-app/tests/search_korean.rs` 에서 ingest → search → 결과 한 건 이상 + Korean 파일 stem 매칭 assert 로 회귀 핀. `lexical_query` test helper 가 `crates/kebab-app/tests/common/mod.rs` 로 promotion. spec status `in_progress` → `completed`. spec: `tasks/p9/p9-fb-10-tui-cjk-input.md`.
|
||||
- **2026-05-07 P9 post-도그푸딩 (p9-fb-27)** — `kebab schema [--json]` introspection 명령 + `error.v1` wire 도입. 정적 (wire schemas / capabilities / models) + 동적 (stats) 한 번에. `--json` 모드에서 fatal error 가 stderr ndjson 으로 emit (비 `--json` 은 기존 stderr text 유지). exit code 0/1/2/3 unchanged — `error.v1.code` 가 fine-grained 분기. fb-30 MCP `initialize` capability matrix 의 prerequisite. spec: `tasks/p9/p9-fb-27-introspection-and-error-wire.md`. design: `docs/superpowers/specs/2026-05-07-p9-fb-27-introspection-and-error-wire-design.md`.
|
||||
- **2026-05-03 P9 도그푸딩 피드백 20/20 ✅** — `tasks/p9/p9-fb-01..20` 모든 spec status `completed`. 사용자가 `kebab` 직접 돌려서 수집한 UX 잡음 (ingest 진행 표시 부재, mode 혼란, CJK column drift, multi-turn 부재, citation 부재 등) 이 모두 코드 또는 spec-acknowledged-deferred 형태로 해소. 도그푸딩 사이클 한 바퀴 완성 — P9-5 desktop tauri 와 별개로 TUI/CLI 사용자 경험 측면은 한 단계 안정화. P9 phase row 는 P9-5 미진행이라 🟡 유지.
|
||||
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-13 follow-up)** — verb-form hint line 재구성. `pub fn footer_hints(focus: Pane, mode: Mode, filter_open: bool) -> &'static str` 신규 (run.rs). 한국어 동사구 (`"위로"` / `"아래로"` / `"필터"` / `"타이핑 검색어"` / `"Esc 로 NORMAL 모드"` 등) + mode-aware (NORMAL = navigation verbs, INSERT = typing + Esc reminder) + Library filter overlay 별 분기. 8 unit tests pin 모든 (pane, mode, filter) 조합 — exhaustive non-empty + Library Normal/filter, Search Normal/Insert, Ask Normal/Insert, Inspect Normal 별 verb fragment 존재 검증. spec status `in_progress` → `completed` — p9-fb-13 partial 의 deferred verb-form 항목이 닫힘.
|
||||
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-13)** — TUI cheatsheet popup. `kebab-tui::cheatsheet::render_cheatsheet(f, area, app)` 신규 — 70%/60% centered modal, sections (Global / Library / Search / Ask / Inspect) + global toggle table + 현재 focused pane footer. `App.cheatsheet_visible: bool` 필드 + `pub fn cheatsheet_visible()` getter. run loop `cheatsheet_intercept(app, key)` 가 mode_intercept 보다 먼저 dispatch — `F1` 토글 (open/close), `Esc` 가 visible 일 때 닫기 (mode_intercept 를 우회해서 cheatsheet 닫기 가 mode flip 도 발동시키지 않도록), 그 외 키는 fall-through (popup 열린 채 navigation 가능). modifier-bearing F1 (Ctrl-F1 등) 은 무시. **HOTFIXES 기록**: spec 의 `?` trigger 가 Library 의 quick-Ask binding 과 충돌해서 `F1` 으로 rebind. spec 의 verb-form hint line 재구성은 별 후속 PR (기존 footer 가 동일 역할). spec status `planned` → `in_progress` (verb hint deferral 으로 partial). spec: `tasks/p9/p9-fb-13-tui-cheatsheet.md`.
|
||||
@@ -78,6 +82,17 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
|
||||
|
||||
P9-2/3/4 는 P9-1 의 parallel-safety contract (sub-state slot 패턴) 덕에 병렬 진행 가능 — 같은 `App` 손대지 않음.
|
||||
|
||||
### P9 dogfooding 백로그 (fb-26 ~ fb-42) — 4 minor release 분할
|
||||
|
||||
2026-05-06 도그푸딩 누적 피드백 + "AI agent 가 kebab 을 쓰게 한다" 궁극 목표용 surface 확장. 17 항목 모두 **status: open + brainstorm 선행 필요**. 각 spec 상단 banner 명시. cascade 영향 / 분량 고려해 한 minor 에 묶지 않고 4 분할. 2026-05-06 renumber — **번호 = release 순서**:
|
||||
|
||||
- **0.3.0+ — agent foundation**: fb-26 (log), fb-27 (introspection/error wire) ✅ 머지 + v0.3.0 cut (2026-05-07), fb-28 (readonly/quiet), ~~fb-29 (daemon)~~ → 🚫 **deferred (2026-05-07 brainstorm)** — fb-30 stdio MCP 가 동일 가치 (agent integration + session 동안 hot cache) 를 daemon 복잡도 (PID file / port lock / loopback security / lifecycle UX) 없이 제공, single-user local-first 환경에 비대. fb-30 (MCP, stdio-only — fb-29 의존 제거 → depends_on `[p9-fb-27]` 만), fb-31 (single-file ingest). 후속 fb 들은 0.3.x patch / 0.4.0 minor 로 누적.
|
||||
- **0.4.0 — agent surface refinement (additive)**: fb-32 (stale), fb-33 (streaming), fb-34 (budget), fb-35 (verbatim fetch), fb-36 (filters), fb-37 (trace/stats).
|
||||
- **0.5.0 — RAG quality (cascade 동반)**: fb-38 (score semantics), fb-39 (precision tuning, embedding_version cascade + V00X), fb-40 (fact-grounded, prompt_template_version cascade).
|
||||
- **0.6.0 또는 P+**: fb-41 (multi-hop, XL), fb-42 (bulk/rerank, Nice).
|
||||
|
||||
각 fb spec frontmatter 의 `target_version` 필드가 source of truth. INDEX.md 의 release subheader 도 동일 grouping.
|
||||
|
||||
## 검증된 운영 동작 (release binary, fastembed enabled)
|
||||
|
||||
P7-3 머지 직후 25 시나리오 smoke 통과 — markdown + image + PDF 5 자산 워크스페이스에서 doctor / ingest / list / inspect / search (lex/vec/hybrid) / re-ingest / byte-edit re-ingest / corrupt PDF / RAG ask + page citation 모두. 자세한 시나리오 표는 conversation 기록 참조; 워크스페이스에 직접 돌려보는 절차는 [docs/SMOKE.md](docs/SMOKE.md).
|
||||
|
||||
36
README.md
36
README.md
@@ -42,7 +42,7 @@ cargo install --git https://gitea.altair823.xyz/altair823-org/kebab.git --bin ke
|
||||
# 첫 실행 — XDG 경로에 데이터 디렉토리 + config.toml 생성
|
||||
kebab init
|
||||
|
||||
# config 손보고 — `[workspace] include` 에 *.md / *.png / *.pdf 등 추가, 모델 endpoint 등
|
||||
# config 손보고 — workspace.root, 모델 endpoint 등 설정 (지원 형식은 md / png / jpg / pdf 로 고정)
|
||||
${EDITOR:-vi} ~/.config/kebab/config.toml
|
||||
|
||||
# 색인 (Markdown / 이미지 / PDF 모두 한 번에)
|
||||
@@ -70,7 +70,7 @@ kebab doctor
|
||||
| 명령 | 동작 |
|
||||
|------|------|
|
||||
| `kebab init` | XDG 경로에 데이터 디렉토리 + config.toml 생성 |
|
||||
| `kebab ingest [<path>]` | Markdown / 이미지 / PDF 색인 (idempotent). TTY 에서는 stderr 진행 바, non-TTY (CI / pipe) 는 stderr 한 줄씩, `--json` 은 stdout 에 `ingest_progress.v1` 라인 streaming 후 마지막에 `ingest_report.v1`. Ctrl-C 한 번이면 현재 asset 마무리 후 abort (부분 commit 보존, idempotent re-run), 두 번째 Ctrl-C 는 hard exit. Markdown title 이 frontmatter 에 없어도 첫 H1 → H2 → 첫 paragraph 80 자 → 파일명 순으로 자동 채움 (parser_version `md-frontmatter-v2`) — 기존 색인된 doc 도 다음 ingest 에서 새 title 로 갱신. **Incremental** (p9-fb-23): 두 번째 이후의 ingest 는 변하지 않은 doc (blake3 + parser/chunker/embedder version 모두 동일) 의 parse/chunk/embed/vector upsert 를 자동 스킵. final summary 에 `N unchanged` 카운트 표시. `--force-reingest` 로 skip 무시 강제 재처리. |
|
||||
| `kebab ingest [<path>]` | Markdown / 이미지 / PDF 색인 (idempotent). TTY 에서는 stderr 진행 바, non-TTY (CI / pipe) 는 stderr 한 줄씩, `--json` 은 stdout 에 `ingest_progress.v1` 라인 streaming 후 마지막에 `ingest_report.v1`. Ctrl-C 한 번이면 현재 asset 마무리 후 abort (부분 commit 보존, idempotent re-run), 두 번째 Ctrl-C 는 hard exit. Markdown title 이 frontmatter 에 없어도 첫 H1 → H2 → 첫 paragraph 80 자 → 파일명 순으로 자동 채움 (parser_version `md-frontmatter-v2`) — 기존 색인된 doc 도 다음 ingest 에서 새 title 로 갱신. **Incremental** (p9-fb-23): 두 번째 이후의 ingest 는 변하지 않은 doc (blake3 + parser/chunker/embedder version 모두 동일) 의 parse/chunk/embed/vector upsert 를 자동 스킵. final summary 에 `N unchanged` 카운트 표시. `--force-reingest` 로 skip 무시 강제 재처리. **지원 형식** (extractor 자동 결정 — config 에 명시 불가): Markdown (`.md`), 이미지 (`.png` / `.jpg` / `.jpeg`, OCR + caption), PDF (`.pdf`). 다른 확장자는 자동 skip — `IngestItem.warnings` 에 사유 (`"unsupported media type: .docx"` 등), `IngestReport.skipped_by_extension` 에 카운트 분류, CLI / TUI summary 에 breakdown 표시. |
|
||||
| `kebab search --mode {lexical,vector,hybrid} "<query>" [--no-cache]` | 검색. hybrid는 RRF fusion, citation 포함. 같은 process 안에서 동일 query (NFKC + trim + lowercase 정규화) 반복 시 in-process LRU 캐시 hit (capacity = `[search] cache_capacity`, default 256). `--no-cache` 로 강제 bypass — 디버깅용. ingest commit 발생 시 `kv['corpus_revision']` bump 으로 모든 entry 자동 stale |
|
||||
| `kebab list docs` | 색인된 문서 목록 |
|
||||
| `kebab inspect doc <id>` / `kebab inspect chunk <id>` | raw record 보기 |
|
||||
@@ -79,8 +79,12 @@ kebab doctor
|
||||
| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). vim-style mode (header 우측 `-- NORMAL --` / `-- INSERT --`) — Library/Inspect 는 자동 NORMAL, Search/Ask 는 자동 INSERT. `i` 로 Normal→Insert (모든 pane — p9-fb-21), `Esc` 로 Insert→Normal 어디서나. mode-authoritative dispatch — Search 의 `j/k/o/g`, Ask 의 `e/j/k` 는 NORMAL 모드에서만 명령으로 동작, INSERT 에서는 입력 문자로 typing. (Search 의 chunk inspect 키는 `i`→`o` 로 rebind — `i` 가 universal Insert toggle.) **`F1` 로 cheatsheet popup** (현재 pane 의 키 매핑 + global 토글 표) — `Esc` / `F1` 로 닫기. Search 패널은 200ms debounce 후 background worker 가 검색 — 키 입력으로 UI freeze 안 됨, 사용자가 계속 타이핑하면 stale 결과 자동 폐기 (generation counter). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해). Library 의 doc-list 가 한글 / 일본어 / 중국어 (CJK) 제목을 wide-char 정확한 column width 로 truncate — 한글 제목이 한 줄을 넘기지 않음 (CJK 1 자 = 2 col). Search/Ask/Filter 입력의 cursor 가 wide char 위에서 column 단위로 정렬 — 한글 입력 시 caret 이 글자 옆에 정확히 놓임. `← / →` 로 입력 문자열 중간 cursor 이동 (한글 한 글자 = 2 column 이라도 한 번에 이동), `Home / End` 로 양 끝 점프, `Delete` 로 cursor 위치 char 삭제 — 모든 input pane (Ask / Search / Library filter overlay) 동일 (p9-fb-22). Ask 트랜스크립트는 새 답변이 viewport 아래로 누적될 때 자동으로 tail 을 따라감 (auto-scroll); `j` / `k` 로 위로 스크롤하면 freeze, `Shift-G` 로 다시 bottom + auto-tail 재개. 화면 하단 hint line 은 한국어 동사구로 (`"위로"` / `"아래로"` / `"필터"` / `"타이핑 검색어"` / `"Esc 로 NORMAL 모드"` / `"i 입력모드"` 등) + 현재 (pane, mode) 조합에 맞춰 자동 분기, **첫 fragment 가 항상 `F1 도움말`** (cheatsheet 발견성 보장). 모든 모드에서 항상 떠 있는 상태바 — `kebab v<version> │ <pane> │ <docs> docs │ <state>` (state: streaming/searching/indexing/idle, ingest 진행 중에는 progress 가 같은 자리에 흡수됨). Ask 진입 시 conversation id 8 자 prefix 도 함께 표시. Ask 트랜스크립트와 Inspect 양쪽에서 `PgUp / PgDn` 으로 10 줄씩 페이지 스크롤. Library 의 doc list 위에는 `TITLE / TAGS / UPDATED / CHUNKS` 컬럼 헤더 행 표시 (display-width 정렬, Hangul / CJK 안전). |
|
||||
| `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) |
|
||||
| `kebab eval run / compare` | golden query 회귀 측정 |
|
||||
| `kebab schema [--json]` | introspection — wire schemas / capabilities / models / stats 한 번에. `--json` 은 `schema.v1` wire; 사람 모드는 서식 출력. |
|
||||
| `kebab ingest-file <path>` | 단일 파일 ingest (workspace 외부 가능). 바이트는 `<workspace.root>/_external/<hash12>.<ext>` 로 copy. `.kebabignore` 매치 시 stderr warn 후 진행 (explicit ingest 가 bypass intent). |
|
||||
| `kebab ingest-stdin --title <T> [--source-uri <URI>]` | stdin 의 markdown 본문 ingest. frontmatter (title + source_uri) 자동 prepend. v1 markdown only. |
|
||||
| `kebab mcp` | MCP (Model Context Protocol) stdio server. agent host (Claude Code / Cursor / OpenAI Agents) 가 spawn 하여 tool 호출 (`search` / `ask` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`). `--config` honor. |
|
||||
|
||||
모든 명령에 `--json` 플래그. 출력은 frozen wire schema v1 (`schema_version` 항상 포함, 예: `ingest_report.v1`, `ingest_progress.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`, `reset_report.v1`).
|
||||
모든 명령에 `--json` 플래그. 출력은 frozen wire schema v1 (`schema_version` 항상 포함, 예: `ingest_report.v1`, `ingest_progress.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`, `reset_report.v1`, `schema.v1`). `--json` 모드에서 fatal error 는 stderr 에 `error.v1` ndjson 으로 emit (exit code 0/1/2/3 unchanged).
|
||||
|
||||
## 논리 아키텍처
|
||||
|
||||
@@ -145,7 +149,7 @@ flowchart TB
|
||||
|
||||
## Configuration
|
||||
|
||||
- `~/.config/kebab/config.toml` — `kebab init` 가 XDG 경로에 생성. `[workspace] include`, `[storage]`, `[chunking]`, `[models.embedding]`, `[models.llm]`, `[image.ocr]`, `[image.caption]`, `[search]`, `[rag]`, `[ui]` 절. `[ui] theme = "dark" | "light"` 로 TUI 팔레트 선택 (default `"dark"`, 알 수 없는 값은 dark fallback).
|
||||
- `~/.config/kebab/config.toml` — `kebab init` 가 XDG 경로에 생성. `[workspace]` (root, exclude — include 필드는 제거됨, 지원 형식은 자동 결정), `[storage]`, `[chunking]`, `[models.embedding]`, `[models.llm]`, `[image.ocr]`, `[image.caption]`, `[search]`, `[rag]`, `[ui]` 절. `[ui] theme = "dark" | "light"` 로 TUI 팔레트 선택 (default `"dark"`, 알 수 없는 값은 dark fallback). 옛 config 의 `workspace.include = [...]` 은 silently 무시 + 단발 deprecation warning (p9-fb-25).
|
||||
- `--config <path>` flag — 임시 워크스페이스 / 격리 테스트 시 사용. CLI / TUI 모두 honor.
|
||||
- `KEBAB_*` env — 일부 키 override (`KEBAB_RAG_SCORE_GATE`, `KEBAB_EVAL_GOLDEN`, `KEBAB_COMMIT_HASH` 등).
|
||||
- XDG layout: `~/.config/kebab/`, `~/.local/share/kebab/`, `~/.cache/kebab/`, `~/.local/state/kebab/`.
|
||||
@@ -157,10 +161,30 @@ config 예시는 [docs/SMOKE.md](docs/SMOKE.md) 의 `/tmp/kebab-smoke/config.tom
|
||||
|
||||
`--json` 출력 + frozen wire schema v1 가 stable contract. 통합 옵션:
|
||||
|
||||
- **Claude Code / Codex skill** — `kebab search --json` / `kebab ask --json` 호출하는 ~50줄 wrapper. multi-turn 은 `kebab ask --session <id> --json` 으로 영속 — wrapper 가 conversation id 관리하면 외부 agent 도 `--repl` 없이 stateful 대화 가능 (p9-fb-18).
|
||||
- **MCP server** — stdio JSON-RPC 로 `kebab-app` facade 1:1 노출.
|
||||
- **Claude Code skill** — repo 의 [`integrations/claude-code/`](integrations/claude-code/) 가 ship-ready skill. `cp -r integrations/claude-code/kebab ~/.claude/skills/` 한 번이면 새 Claude Code 세션부터 자동 trigger (내부 시스템 / 위키 lookup / 사내 runbook 질문). multi-turn 은 `kebab ask --session <id> --json` 으로 영속 — skill 이 conversation id 관리하면 외부 agent 도 `--repl` 없이 stateful 대화 가능 (p9-fb-18).
|
||||
- **Codex / 기타 agent host** — `--json` + frozen wire schema v1 가 stable contract. 동일 패턴으로 ~50줄 wrapper 작성 가능. `integrations/<host>/` 에 추가 PR 환영.
|
||||
- **MCP server** — stdio JSON-RPC 로 `kebab-app` facade 1:1 노출. `kebab mcp` 참조.
|
||||
- **HTTP wrapper** — `kebab serve --bind 127.0.0.1:7711` (P+, local-only 가치 신중).
|
||||
|
||||
## MCP 사용
|
||||
|
||||
`kebab mcp` 가 stdio MCP server. 6 tool: `search` / `ask` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`.
|
||||
|
||||
Claude Code 빠른 등록 (`~/.claude/mcp.json` 또는 host 동등 위치):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kebab": {
|
||||
"command": "kebab",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
자세한 사용법 (Cursor / OpenAI Agents / Copilot CLI config, per-tool 입출력 예시, troubleshooting, multi-turn ask + session 관리, performance / security) — **[docs/mcp-usage.md](docs/mcp-usage.md)** 참조.
|
||||
|
||||
## 비-목표
|
||||
|
||||
다중 사용자 SaaS / K8s / 원격 vector DB / enterprise RBAC / 실시간 협업 / 모든 파일 포맷의 완벽한 parsing / agent 임의 파일 수정 / multi-workspace / LLM-as-judge eval / CLIP 시각 embedding / `kebab://` protocol handler — frozen 설계 §11 / §0 참조.
|
||||
|
||||
@@ -49,6 +49,9 @@ lru = { workspace = true }
|
||||
# `" foo "` collapse to one entry. Same crate kebab-normalize +
|
||||
# kebab-core already use, no version drift.
|
||||
unicode-normalization = "0.1"
|
||||
# p9-fb-31: GitignoreBuilder for .kebabignore matching in ingest_file_with_config.
|
||||
# Same version as kebab-source-fs (0.4) to avoid duplicate dep versions.
|
||||
ignore = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
rusqlite = { workspace = true }
|
||||
@@ -64,3 +67,6 @@ image = { version = "0.25", default-features = false, features =
|
||||
# to the same major (0.32) so byte output is identical between the two
|
||||
# fixture surfaces.
|
||||
lopdf = "0.32"
|
||||
# error_wire::tests::llm_unreachable_classifies_to_model_unreachable needs a real
|
||||
# reqwest::Error (private constructor) — built from a connect-refused call.
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||
|
||||
15
crates/kebab-app/src/error_signal.rs
Normal file
15
crates/kebab-app/src/error_signal.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! Typed signal re-exports + new signals introduced by fb-27.
|
||||
//!
|
||||
//! kebab-cli (and future kebab-tui / kebab-desktop) downcast on these to
|
||||
//! build `error.v1` wire records. The existing signals
|
||||
//! (`RefusalSignal`, `NoHitSignal`, `DoctorUnhealthy`) live in
|
||||
//! `doctor_signal.rs` — leave those unchanged and re-export via this
|
||||
//! module so callers have one place to import from.
|
||||
//!
|
||||
//! See `docs/superpowers/specs/2026-05-07-p9-fb-27-introspection-and-error-wire-design.md`.
|
||||
|
||||
pub use crate::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal};
|
||||
|
||||
pub use kebab_llm_local::LlmError;
|
||||
pub use kebab_config::ConfigInvalid;
|
||||
pub use kebab_store_sqlite::NotIndexed;
|
||||
200
crates/kebab-app/src/error_wire.rs
Normal file
200
crates/kebab-app/src/error_wire.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
//! Map `anyhow::Error` (returned by `kebab-app` facade calls) to the
|
||||
//! `error.v1` wire shape. The classifier downcasts to known typed errors
|
||||
//! re-exported via `kebab_app::error_signal` (LlmError, ConfigInvalid,
|
||||
//! NotIndexed) and falls back to `code: "generic"` for everything else.
|
||||
//!
|
||||
//! Refusal / no-hit / doctor-unhealthy are NOT routed here — they remain
|
||||
//! exit-code-only signals (see main.rs `exit_code()`).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::error_signal::{ConfigInvalid, LlmError, NotIndexed};
|
||||
|
||||
/// Wire schema id for [`ErrorV1`]. Single source of truth — kebab-cli
|
||||
/// + kebab-mcp use this via `kebab_app::ERROR_V1_ID`.
|
||||
pub const ERROR_V1_ID: &str = "error.v1";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ErrorV1 {
|
||||
pub schema_version: String,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub details: Value,
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 {
|
||||
if let Some(s) = err.downcast_ref::<ConfigInvalid>() {
|
||||
return ErrorV1 {
|
||||
schema_version: ERROR_V1_ID.to_string(),
|
||||
code: "config_invalid".to_string(),
|
||||
message: s.to_string(),
|
||||
details: json!({
|
||||
"path": s.path.to_string_lossy(),
|
||||
"cause": s.cause,
|
||||
}),
|
||||
hint: Some("check `--config <path>` and TOML syntax".to_string()),
|
||||
};
|
||||
}
|
||||
if let Some(s) = err.downcast_ref::<NotIndexed>() {
|
||||
return ErrorV1 {
|
||||
schema_version: ERROR_V1_ID.to_string(),
|
||||
code: "not_indexed".to_string(),
|
||||
message: s.to_string(),
|
||||
details: json!({
|
||||
"expected": s.expected,
|
||||
"found": s.found,
|
||||
}),
|
||||
hint: Some("run `kebab init` then `kebab ingest`".to_string()),
|
||||
};
|
||||
}
|
||||
if let Some(s) = err.downcast_ref::<LlmError>() {
|
||||
return classify_llm(s);
|
||||
}
|
||||
if let Some(io) = err.downcast_ref::<std::io::Error>() {
|
||||
return ErrorV1 {
|
||||
schema_version: ERROR_V1_ID.to_string(),
|
||||
code: "io_error".to_string(),
|
||||
message: io.to_string(),
|
||||
details: json!({"kind": format!("{:?}", io.kind())}),
|
||||
hint: None,
|
||||
};
|
||||
}
|
||||
let mut details = json!({});
|
||||
if verbose {
|
||||
let chain: Vec<String> = err.chain().map(|c| c.to_string()).collect();
|
||||
details = json!({"chain": chain});
|
||||
}
|
||||
ErrorV1 {
|
||||
schema_version: ERROR_V1_ID.to_string(),
|
||||
code: "generic".to_string(),
|
||||
message: err.to_string(),
|
||||
details,
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_llm(s: &LlmError) -> ErrorV1 {
|
||||
match s {
|
||||
LlmError::Unreachable { endpoint, source } => ErrorV1 {
|
||||
schema_version: ERROR_V1_ID.to_string(),
|
||||
code: "model_unreachable".to_string(),
|
||||
message: format!("ollama unreachable at {endpoint}"),
|
||||
details: json!({
|
||||
"endpoint": endpoint,
|
||||
"source": source.to_string(),
|
||||
}),
|
||||
hint: Some(format!("ensure `ollama serve` is reachable at {endpoint}")),
|
||||
},
|
||||
LlmError::ModelNotPulled(model) => ErrorV1 {
|
||||
schema_version: ERROR_V1_ID.to_string(),
|
||||
code: "model_not_pulled".to_string(),
|
||||
message: format!("ollama model `{model}` is not pulled"),
|
||||
details: json!({"model": model}),
|
||||
hint: Some(format!("run `ollama pull {model}`")),
|
||||
},
|
||||
LlmError::Timeout(e) => ErrorV1 {
|
||||
schema_version: ERROR_V1_ID.to_string(),
|
||||
code: "timeout".to_string(),
|
||||
message: format!("ollama timeout: {e}"),
|
||||
details: json!({"source": e.to_string()}),
|
||||
hint: Some("increase timeout or check Ollama load".to_string()),
|
||||
},
|
||||
LlmError::Stream(body) => ErrorV1 {
|
||||
schema_version: ERROR_V1_ID.to_string(),
|
||||
code: "generic".to_string(),
|
||||
message: format!("ollama HTTP error: {body}"),
|
||||
details: json!({"body": body}),
|
||||
hint: None,
|
||||
},
|
||||
LlmError::Malformed(line) => ErrorV1 {
|
||||
schema_version: ERROR_V1_ID.to_string(),
|
||||
code: "generic".to_string(),
|
||||
message: format!("malformed response line: {line}"),
|
||||
details: json!({"line": line}),
|
||||
hint: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_invalid_classifies_to_config_invalid_code() {
|
||||
let err = anyhow::Error::new(ConfigInvalid {
|
||||
path: std::path::PathBuf::from("/tmp/x.toml"),
|
||||
cause: "missing".to_string(),
|
||||
});
|
||||
let v1 = classify(&err, false);
|
||||
assert_eq!(v1.code, "config_invalid");
|
||||
assert_eq!(v1.details.get("path").and_then(|p| p.as_str()), Some("/tmp/x.toml"));
|
||||
assert!(v1.hint.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_indexed_classifies_correctly() {
|
||||
let err = anyhow::Error::new(NotIndexed {
|
||||
expected: "/data/k.sqlite".to_string(),
|
||||
found: None,
|
||||
});
|
||||
let v1 = classify(&err, false);
|
||||
assert_eq!(v1.code, "not_indexed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_unreachable_classifies_to_model_unreachable() {
|
||||
// We cannot construct a reqwest::Error from scratch (private constructor).
|
||||
// Approach: send a real request to a guaranteed-unroutable endpoint
|
||||
// (port 1 is reserved + connect-refused on all conformant TCP stacks).
|
||||
// 500ms timeout chosen as headroom over 50ms baseline — heavily loaded
|
||||
// CI may hit timeout race instead of connect-refused, but either way
|
||||
// the resulting LlmError::Unreachable maps to "model_unreachable".
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_millis(500))
|
||||
.build().unwrap();
|
||||
let err = client.get("http://127.0.0.1:1").send().unwrap_err();
|
||||
let llm = LlmError::Unreachable {
|
||||
endpoint: "http://127.0.0.1:1".to_string(),
|
||||
source: err,
|
||||
};
|
||||
let anyhow_err = anyhow::Error::new(llm);
|
||||
let v1 = classify(&anyhow_err, false);
|
||||
assert_eq!(v1.code, "model_unreachable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_not_pulled_classifies_correctly() {
|
||||
let llm = LlmError::ModelNotPulled("gemma4:e4b".to_string());
|
||||
let v1 = classify(&anyhow::Error::new(llm), false);
|
||||
assert_eq!(v1.code, "model_not_pulled");
|
||||
assert_eq!(v1.details.get("model").and_then(|p| p.as_str()), Some("gemma4:e4b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_error_classifies_to_generic() {
|
||||
let err = anyhow::anyhow!("something else");
|
||||
let v1 = classify(&err, false);
|
||||
assert_eq!(v1.code, "generic");
|
||||
assert!(v1.hint.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_with_verbose_includes_chain() {
|
||||
let err = anyhow::anyhow!("root").context("middle").context("leaf");
|
||||
let v1 = classify(&err, true);
|
||||
assert_eq!(v1.code, "generic");
|
||||
let chain = v1.details.get("chain").and_then(|c| c.as_array()).unwrap();
|
||||
assert_eq!(chain.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn io_error_classifies_correctly() {
|
||||
let io = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
|
||||
let err = anyhow::Error::new(io);
|
||||
let v1 = classify(&err, false);
|
||||
assert_eq!(v1.code, "io_error");
|
||||
}
|
||||
}
|
||||
253
crates/kebab-app/src/external.rs
Normal file
253
crates/kebab-app/src/external.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
//! Helpers for the `_external/` workspace subdirectory used by
|
||||
//! `ingest_file_with_config` and `ingest_stdin_with_config` (p9-fb-31).
|
||||
//!
|
||||
//! - `ensure_external_dir`: create `<workspace.root>/_external/` if absent.
|
||||
//! - `ensure_kebabignore_entry`: append `_external/` to `<workspace.root>/.kebabignore`
|
||||
//! if missing — prevents subsequent `kebab ingest` workspace walks from
|
||||
//! re-walking files that were imported via single-file ingest.
|
||||
//! - `copy_to_external`: write bytes to `_external/<blake3-12>.<ext>`, idempotent.
|
||||
//! - `inject_frontmatter`: prepend a YAML frontmatter block to a markdown body
|
||||
//! string (used by `ingest_stdin_with_config`).
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub const EXTERNAL_DIR: &str = "_external";
|
||||
const KEBABIGNORE_LINE: &str = "_external/";
|
||||
|
||||
/// Ensure `<workspace_root>/_external/` exists. Returns the directory path.
|
||||
pub fn ensure_external_dir(workspace_root: &Path) -> Result<PathBuf> {
|
||||
let dir = workspace_root.join(EXTERNAL_DIR);
|
||||
fs::create_dir_all(&dir)
|
||||
.with_context(|| format!("create _external dir at {}", dir.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Append `_external/` line to `<workspace_root>/.kebabignore` if not already
|
||||
/// present. Idempotent — checks for the exact line before appending.
|
||||
pub fn ensure_kebabignore_entry(workspace_root: &Path) -> Result<()> {
|
||||
let path = workspace_root.join(".kebabignore");
|
||||
let existing = if path.exists() {
|
||||
fs::read_to_string(&path)
|
||||
.with_context(|| format!("read existing .kebabignore at {}", path.display()))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let already = existing
|
||||
.lines()
|
||||
.any(|line| line.trim() == KEBABIGNORE_LINE);
|
||||
if already {
|
||||
return Ok(());
|
||||
}
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.with_context(|| format!("open .kebabignore for append at {}", path.display()))?;
|
||||
if !existing.is_empty() && !existing.ends_with('\n') {
|
||||
file.write_all(b"\n")?;
|
||||
}
|
||||
writeln!(file, "{}", KEBABIGNORE_LINE)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy bytes to `<external_dir>/<blake3-12>.<ext>`. Idempotent — if the
|
||||
/// destination file already exists with the expected hash, the existing
|
||||
/// file is reused (no second write). Returns the destination path.
|
||||
pub fn copy_to_external(
|
||||
external_dir: &Path,
|
||||
bytes: &[u8],
|
||||
ext: &str,
|
||||
) -> Result<PathBuf> {
|
||||
let hash = blake3::hash(bytes);
|
||||
let hex = hash.to_hex();
|
||||
let prefix = &hex.as_str()[..12];
|
||||
let filename = format!("{prefix}.{ext}");
|
||||
let dest = external_dir.join(&filename);
|
||||
if !dest.exists() {
|
||||
fs::write(&dest, bytes)
|
||||
.with_context(|| format!("write external file at {}", dest.display()))?;
|
||||
}
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Prepend a YAML frontmatter block to a markdown body. Returns the wrapped
|
||||
/// markdown string. Errors if `body` already starts with `---` (the user
|
||||
/// should use `ingest_file_with_config` for files that already carry
|
||||
/// frontmatter).
|
||||
///
|
||||
/// Internal `yaml_quote` always uses double-quoted YAML form with backslash
|
||||
/// escapes for `"` / `\` / control chars — agent-supplied titles with
|
||||
/// special characters are safe.
|
||||
pub fn inject_frontmatter(
|
||||
body: &str,
|
||||
title: &str,
|
||||
source_uri: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let head = body.trim_start();
|
||||
if head.starts_with("---\n") || head.starts_with("---\r\n") || head.starts_with("---\r") {
|
||||
anyhow::bail!(
|
||||
"stdin already has frontmatter; use `kebab ingest-file` for files with metadata"
|
||||
);
|
||||
}
|
||||
let title_yaml = yaml_quote(title);
|
||||
let mut header = String::new();
|
||||
header.push_str("---\n");
|
||||
header.push_str(&format!("title: {title_yaml}\n"));
|
||||
if let Some(uri) = source_uri {
|
||||
let uri_yaml = yaml_quote(uri);
|
||||
header.push_str(&format!("source_uri: {uri_yaml}\n"));
|
||||
}
|
||||
header.push_str("---\n\n");
|
||||
header.push_str(body);
|
||||
Ok(header)
|
||||
}
|
||||
|
||||
/// YAML-quote a string. Always uses double-quoted form with backslash-escape
|
||||
/// for `"` and `\`. Defensive against agent-supplied titles that contain
|
||||
/// quotes / control chars.
|
||||
fn yaml_quote(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn ensure_external_dir_creates_dir() {
|
||||
let dir = tempdir().unwrap();
|
||||
let result = ensure_external_dir(dir.path()).unwrap();
|
||||
assert_eq!(result, dir.path().join("_external"));
|
||||
assert!(result.is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_external_dir_is_idempotent() {
|
||||
let dir = tempdir().unwrap();
|
||||
let _ = ensure_external_dir(dir.path()).unwrap();
|
||||
let result = ensure_external_dir(dir.path()).unwrap();
|
||||
assert!(result.is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_kebabignore_entry_creates_file_with_line() {
|
||||
let dir = tempdir().unwrap();
|
||||
ensure_kebabignore_entry(dir.path()).unwrap();
|
||||
let content = fs::read_to_string(dir.path().join(".kebabignore")).unwrap();
|
||||
assert!(content.lines().any(|l| l.trim() == "_external/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_kebabignore_entry_appends_to_existing() {
|
||||
let dir = tempdir().unwrap();
|
||||
fs::write(dir.path().join(".kebabignore"), "*.tmp\n").unwrap();
|
||||
ensure_kebabignore_entry(dir.path()).unwrap();
|
||||
let content = fs::read_to_string(dir.path().join(".kebabignore")).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert!(lines.contains(&"*.tmp"));
|
||||
assert!(lines.contains(&"_external/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_kebabignore_entry_idempotent() {
|
||||
let dir = tempdir().unwrap();
|
||||
ensure_kebabignore_entry(dir.path()).unwrap();
|
||||
ensure_kebabignore_entry(dir.path()).unwrap();
|
||||
let content = fs::read_to_string(dir.path().join(".kebabignore")).unwrap();
|
||||
let count = content.lines().filter(|l| l.trim() == "_external/").count();
|
||||
assert_eq!(count, 1, "should not duplicate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_kebabignore_entry_handles_missing_trailing_newline() {
|
||||
let dir = tempdir().unwrap();
|
||||
fs::write(dir.path().join(".kebabignore"), "*.tmp").unwrap(); // no \n
|
||||
ensure_kebabignore_entry(dir.path()).unwrap();
|
||||
let content = fs::read_to_string(dir.path().join(".kebabignore")).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert!(lines.contains(&"*.tmp"));
|
||||
assert!(lines.contains(&"_external/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_to_external_writes_with_hash_prefix_filename() {
|
||||
let dir = tempdir().unwrap();
|
||||
let ext_dir = ensure_external_dir(dir.path()).unwrap();
|
||||
let path = copy_to_external(&ext_dir, b"hello", "md").unwrap();
|
||||
assert!(path.exists());
|
||||
assert!(path.file_name().unwrap().to_string_lossy().ends_with(".md"));
|
||||
let stem = path.file_stem().unwrap().to_string_lossy();
|
||||
assert_eq!(stem.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_to_external_is_idempotent_for_same_bytes() {
|
||||
let dir = tempdir().unwrap();
|
||||
let ext_dir = ensure_external_dir(dir.path()).unwrap();
|
||||
let p1 = copy_to_external(&ext_dir, b"hello", "md").unwrap();
|
||||
let p2 = copy_to_external(&ext_dir, b"hello", "md").unwrap();
|
||||
assert_eq!(p1, p2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_to_external_different_bytes_produce_different_filenames() {
|
||||
let dir = tempdir().unwrap();
|
||||
let ext_dir = ensure_external_dir(dir.path()).unwrap();
|
||||
let p1 = copy_to_external(&ext_dir, b"hello", "md").unwrap();
|
||||
let p2 = copy_to_external(&ext_dir, b"world", "md").unwrap();
|
||||
assert_ne!(p1, p2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_frontmatter_basic() {
|
||||
let out = inject_frontmatter("## Body", "Article X", None).unwrap();
|
||||
assert!(out.starts_with("---\ntitle: \"Article X\"\n---\n\n## Body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_frontmatter_with_source_uri() {
|
||||
let out = inject_frontmatter("## Body", "X", Some("https://example.com/x")).unwrap();
|
||||
assert!(out.contains("title: \"X\""));
|
||||
assert!(out.contains("source_uri: \"https://example.com/x\""));
|
||||
assert!(out.contains("\n## Body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_frontmatter_errors_on_existing_frontmatter() {
|
||||
let body = "---\ntitle: Existing\n---\n\n## Body";
|
||||
let err = inject_frontmatter(body, "New", None).unwrap_err();
|
||||
assert!(err.to_string().contains("already has frontmatter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_frontmatter_errors_on_existing_frontmatter_crlf() {
|
||||
let body = "---\r\ntitle: Existing\r\n---\r\n\r\n## Body";
|
||||
let err = inject_frontmatter(body, "New", None).unwrap_err();
|
||||
assert!(err.to_string().contains("already has frontmatter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_quote_escapes_quotes_and_backslashes() {
|
||||
assert_eq!(yaml_quote("hello \"world\""), "\"hello \\\"world\\\"\"");
|
||||
assert_eq!(yaml_quote("path\\to"), "\"path\\\\to\"");
|
||||
assert_eq!(yaml_quote("line\nbreak"), "\"line\\nbreak\"");
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ use kebab_core::IngestItemKind;
|
||||
/// `p9-fb-04`, `Aborted`) events. Mirrors the fields persisted into
|
||||
/// `ingest_runs.progress_json` so external tooling can reconstruct the
|
||||
/// run's outcome from either side.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AggregateCounts {
|
||||
pub scanned: u32,
|
||||
pub new: u32,
|
||||
@@ -35,6 +35,8 @@ pub struct AggregateCounts {
|
||||
pub errors: u32,
|
||||
pub chunks_indexed: u32,
|
||||
pub embeddings_indexed: u32,
|
||||
/// p9-fb-25: per-extension skip count. See [`IngestReport::skipped_by_extension`].
|
||||
pub skipped_by_extension: std::collections::BTreeMap<String, u32>,
|
||||
}
|
||||
|
||||
/// One streaming progress event. The CLI's `--json` mode serializes this
|
||||
@@ -98,6 +100,20 @@ pub fn media_label(media: &kebab_core::MediaType) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// p9-fb-25: render `": A docx, B txt"` breakdown after the
|
||||
/// `N skipped` count when the map is non-empty. Empty → empty
|
||||
/// string (no extra punctuation). desc sort by count, ties broken
|
||||
/// by key alphabetic.
|
||||
pub fn render_skipped_breakdown(map: &std::collections::BTreeMap<String, u32>) -> String {
|
||||
if map.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut entries: Vec<_> = map.iter().collect();
|
||||
entries.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
|
||||
let parts: Vec<String> = entries.iter().map(|(k, v)| format!("{v} {k}")).collect();
|
||||
format!(": {}", parts.join(", "))
|
||||
}
|
||||
|
||||
/// Best-effort send into an optional `mpsc::Sender`. A dropped receiver
|
||||
/// is silently absorbed — the ingest hot path must not stall on a slow
|
||||
/// consumer. Logged at `trace` for diagnostics.
|
||||
@@ -192,4 +208,19 @@ mod tests {
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_skipped_breakdown_desc_sort_with_tiebreak() {
|
||||
use std::collections::BTreeMap;
|
||||
let mut m = BTreeMap::new();
|
||||
assert_eq!(render_skipped_breakdown(&m), "");
|
||||
m.insert("txt".to_string(), 1);
|
||||
m.insert("docx".to_string(), 2);
|
||||
m.insert("epub".to_string(), 1);
|
||||
// 2 docx 먼저 (count desc), 그 다음 1 epub / 1 txt 는 alphabetic.
|
||||
assert_eq!(
|
||||
render_skipped_breakdown(&m),
|
||||
": 2 docx, 1 epub, 1 txt".to_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,28 +56,26 @@ use kebab_source_fs::FsSourceConnector;
|
||||
|
||||
mod app;
|
||||
pub mod doctor_signal;
|
||||
pub mod error_signal;
|
||||
pub mod error_wire;
|
||||
pub mod external;
|
||||
pub mod ingest_progress;
|
||||
pub mod logging;
|
||||
pub mod reset;
|
||||
pub mod schema;
|
||||
|
||||
pub use app::App;
|
||||
pub use ingest_progress::{AggregateCounts, IngestEvent};
|
||||
pub use ingest_progress::{AggregateCounts, IngestEvent, render_skipped_breakdown};
|
||||
pub use reset::{ResetReport, ResetScope};
|
||||
pub use error_wire::{ERROR_V1_ID, ErrorV1, classify};
|
||||
pub use schema::{Capabilities, Models, SCHEMA_V1_ID, SchemaV1, Stats, WireBlock, schema_with_config};
|
||||
|
||||
/// Parser-version label persisted in `documents.parser_version` for
|
||||
/// every Markdown file ingested through the `kb-parse-md` pipeline.
|
||||
/// Kept in lock-step with the literal used in the `kb-store-sqlite`
|
||||
/// idempotency / round-trip tests so the version label written by the
|
||||
/// app and the one used in cross-crate fixtures match.
|
||||
///
|
||||
/// p9-fb-07 bumped this from `pulldown-cmark-0.x` to `md-frontmatter-v2`
|
||||
/// because `kebab-normalize::derive_title` now applies a fallback chain
|
||||
/// (frontmatter → H1 → H2 → first paragraph → file stem) when the
|
||||
/// frontmatter title is blank. The bump invalidates `doc_id` for every
|
||||
/// pre-existing Markdown document, so a re-ingest is required for the
|
||||
/// new titles to land — this is the documented cascade behavior per
|
||||
/// design §9.
|
||||
const KEBAB_PARSE_MD_VERSION: &str = "md-frontmatter-v2";
|
||||
/// p9-fb-25: sentinel for files without an extension in
|
||||
/// `IngestReport.skipped_by_extension` keys + `IngestItem.warnings`
|
||||
/// `unsupported media type: ...` line. Wire schema description
|
||||
/// references this literal — changing the sentinel is a wire-
|
||||
/// compatibility break.
|
||||
pub const NO_EXT_SENTINEL: &str = "<no-ext>";
|
||||
|
||||
/// Caller-supplied knobs for one [`ask`] invocation.
|
||||
///
|
||||
@@ -146,6 +144,14 @@ pub fn init_workspace(force: bool) -> anyhow::Result<()> {
|
||||
# — relative paths resolve against the directory of THIS
|
||||
# config file, NOT the user's `cwd` at invocation time.
|
||||
#
|
||||
# 처리 가능한 형식 (extractor 가 자동 결정 — config 에 명시할 수 없음):
|
||||
# • Markdown: .md
|
||||
# • 이미지: .png .jpg .jpeg (OCR + caption)
|
||||
# • PDF: .pdf
|
||||
# 다른 확장자는 ingest 시 자동 skip + warning. 처리 대상 폴더의
|
||||
# 일부만 ingest 하고 싶으면 `kebab ingest <path>` 로 root 명시
|
||||
# 또는 `.kebabignore` 파일 / 본 `workspace.exclude` 로 denylist.
|
||||
#
|
||||
# Override individual keys at runtime with `KEBAB_*` env vars
|
||||
# (e.g. `KEBAB_WORKSPACE_ROOT=/tmp/test kebab ingest`).
|
||||
\n";
|
||||
@@ -315,7 +321,7 @@ pub fn ingest_with_config_opts(
|
||||
.context("kb-app::ingest: ensure Lance table")?;
|
||||
}
|
||||
|
||||
let parser_version = ParserVersion(KEBAB_PARSE_MD_VERSION.to_string());
|
||||
let parser_version = ParserVersion(kebab_parse_md::PARSER_VERSION.to_string());
|
||||
let chunk_policy = chunk_policy_from_config(&app.config);
|
||||
|
||||
// P6-4: build OCR / caption adapters once per ingest invocation,
|
||||
@@ -375,6 +381,9 @@ pub fn ingest_with_config_opts(
|
||||
// without re-walking the DB.
|
||||
let mut chunks_indexed: u32 = 0;
|
||||
let mut embeddings_indexed: u32 = 0;
|
||||
// p9-fb-25: per-extension skip count, populated in the Skipped arm below.
|
||||
let mut skipped_by_extension: std::collections::BTreeMap<String, u32> =
|
||||
std::collections::BTreeMap::new();
|
||||
let scanned_count: u32 = u32::try_from(assets.len()).unwrap_or(u32::MAX);
|
||||
|
||||
let embed_active = embedder.is_some() && vector_store.is_some();
|
||||
@@ -464,7 +473,9 @@ pub fn ingest_with_config_opts(
|
||||
}
|
||||
}
|
||||
kebab_core::IngestItemKind::Skipped => {
|
||||
skipped_count = skipped_count.saturating_add(1)
|
||||
skipped_count = skipped_count.saturating_add(1);
|
||||
let ext = ext_for_skip_warning(&item.doc_path.0);
|
||||
*skipped_by_extension.entry(ext).or_insert(0) += 1;
|
||||
}
|
||||
kebab_core::IngestItemKind::Unchanged => {
|
||||
unchanged_count = unchanged_count.saturating_add(1)
|
||||
@@ -613,6 +624,7 @@ pub fn ingest_with_config_opts(
|
||||
errors: error_count,
|
||||
chunks_indexed,
|
||||
embeddings_indexed,
|
||||
skipped_by_extension: skipped_by_extension.clone(),
|
||||
};
|
||||
let terminal_event = if was_cancelled {
|
||||
crate::ingest_progress::IngestEvent::Aborted {
|
||||
@@ -654,6 +666,7 @@ pub fn ingest_with_config_opts(
|
||||
unchanged: unchanged_count,
|
||||
errors: error_count,
|
||||
duration_ms,
|
||||
skipped_by_extension,
|
||||
items: if summary_only { None } else { Some(items) },
|
||||
})
|
||||
}
|
||||
@@ -813,6 +826,31 @@ fn try_skip_unchanged(
|
||||
}))
|
||||
}
|
||||
|
||||
/// p9-fb-25: extract the lowercase extension (no leading dot) from a
|
||||
/// workspace path for use in the `unsupported media type: .X` warning
|
||||
/// and `IngestReport.skipped_by_extension` key. Returns [`NO_EXT_SENTINEL`]
|
||||
/// for paths with no extension. Always lowercase so `Foo.DOCX` and
|
||||
/// `bar.docx` aggregate under the same key.
|
||||
fn ext_for_skip_warning(path: &str) -> String {
|
||||
std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.unwrap_or_else(|| NO_EXT_SENTINEL.to_string())
|
||||
}
|
||||
|
||||
/// p9-fb-25: render the `IngestItem.warnings` line for a Skipped
|
||||
/// asset. [`NO_EXT_SENTINEL`] renders without a leading dot;
|
||||
/// everything else gets `.ext` form.
|
||||
fn unsupported_media_warning(path: &str) -> String {
|
||||
let ext = ext_for_skip_warning(path);
|
||||
if ext == NO_EXT_SENTINEL {
|
||||
format!("unsupported media type: {NO_EXT_SENTINEL}")
|
||||
} else {
|
||||
format!("unsupported media type: .{ext}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a single asset: read bytes, parse, normalize, chunk,
|
||||
/// persist, embed. Per-asset failures bubble up to the caller for
|
||||
/// labelling as `IngestItemKind::Error` — they do NOT abort the
|
||||
@@ -876,7 +914,7 @@ fn ingest_one_asset(
|
||||
chunk_count: None,
|
||||
parser_version: None,
|
||||
chunker_version: None,
|
||||
warnings: Vec::new(),
|
||||
warnings: vec![unsupported_media_warning(&asset.workspace_path.0)],
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
@@ -895,9 +933,7 @@ fn ingest_one_asset(
|
||||
chunk_count: None,
|
||||
parser_version: None,
|
||||
chunker_version: None,
|
||||
warnings: vec![
|
||||
"kb:// source URIs are not supported by the fs ingester".into(),
|
||||
],
|
||||
warnings: vec!["kb:// URI not yet supported".to_string()],
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
@@ -1090,7 +1126,7 @@ fn ingest_one_image_asset(
|
||||
parser_version: None,
|
||||
chunker_version: None,
|
||||
warnings: vec![
|
||||
"kb:// source URIs are not supported by the fs ingester".into(),
|
||||
"kb:// URI not yet supported".to_string(),
|
||||
],
|
||||
error: None,
|
||||
});
|
||||
@@ -1425,7 +1461,7 @@ fn ingest_one_pdf_asset(
|
||||
parser_version: None,
|
||||
chunker_version: None,
|
||||
warnings: vec![
|
||||
"kb:// source URIs are not supported by the fs ingester".into(),
|
||||
"kb:// URI not yet supported".to_string(),
|
||||
],
|
||||
error: None,
|
||||
});
|
||||
@@ -1839,3 +1875,143 @@ pub fn doctor_with_config_path(config_path: Option<&std::path::Path>) -> anyhow:
|
||||
pub fn doctor() -> anyhow::Result<DoctorReport> {
|
||||
doctor_with_config_path(None)
|
||||
}
|
||||
|
||||
/// Single-file ingest (p9-fb-31). Copies the file to
|
||||
/// `<workspace.root>/_external/<blake3-12>.<ext>` and runs the
|
||||
/// per-medium ingest pipeline on that single asset. Returns an
|
||||
/// `IngestReport` with `scanned: 1` (and either `new: 1` or
|
||||
/// `unchanged: 1` depending on whether the content hash + version
|
||||
/// cascade match an existing doc — incremental ingest from p9-fb-23).
|
||||
///
|
||||
/// `path` may point inside or outside the workspace.
|
||||
///
|
||||
/// `.kebabignore` patterns matching `path` are bypassed with a stderr
|
||||
/// `warn:` line — explicit ingest is intent.
|
||||
#[doc(hidden)]
|
||||
pub fn ingest_file_with_config(
|
||||
config: kebab_config::Config,
|
||||
path: &std::path::Path,
|
||||
) -> anyhow::Result<IngestReport> {
|
||||
if !path.exists() {
|
||||
anyhow::bail!("ingest-file: source path does not exist: {}", path.display());
|
||||
}
|
||||
if !path.is_file() {
|
||||
anyhow::bail!("ingest-file: not a regular file: {}", path.display());
|
||||
}
|
||||
|
||||
let ext_raw = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("ingest-file: source has no extension: {}", path.display()))?;
|
||||
let ext = ext_raw.to_lowercase();
|
||||
|
||||
const SUPPORTED_EXTS: &[&str] = &["md", "pdf", "png", "jpg", "jpeg"];
|
||||
if !SUPPORTED_EXTS.contains(&ext.as_str()) {
|
||||
anyhow::bail!(
|
||||
"ingest-file: unsupported extension `.{}` (supported: {:?})",
|
||||
ext, SUPPORTED_EXTS
|
||||
);
|
||||
}
|
||||
|
||||
let bytes = std::fs::read(path)
|
||||
.with_context(|| format!("ingest-file: read source {}", path.display()))?;
|
||||
|
||||
let workspace_root = config.resolve_workspace_root();
|
||||
|
||||
// .kebabignore check — warn but continue.
|
||||
let ignore_match = check_kebabignore_match(&workspace_root, path);
|
||||
if ignore_match {
|
||||
eprintln!(
|
||||
"warn: {} matches .kebabignore patterns; proceeding (explicit ingest bypasses ignore)",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Set up _external/ dir + auto-ignore line.
|
||||
let external_dir = crate::external::ensure_external_dir(&workspace_root)
|
||||
.context("ingest-file: ensure _external/ dir")?;
|
||||
crate::external::ensure_kebabignore_entry(&workspace_root)
|
||||
.context("ingest-file: append _external/ to .kebabignore")?;
|
||||
|
||||
// Copy bytes to _external/<hash>.<ext>.
|
||||
let dest = crate::external::copy_to_external(&external_dir, &bytes, &ext)
|
||||
.context("ingest-file: copy to _external")?;
|
||||
|
||||
// Build a SourceScope that targets _external/ with include filter
|
||||
// restricting walk to the single dest filename.
|
||||
let filename = dest
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("ingest-file: dest has no filename"))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let scope = kebab_core::SourceScope {
|
||||
root: external_dir.clone(),
|
||||
include: vec![filename],
|
||||
exclude: config.workspace.exclude.clone(),
|
||||
};
|
||||
|
||||
let opts = IngestOpts::default();
|
||||
ingest_with_config_opts(config, scope, /* summary_only = */ false, opts)
|
||||
}
|
||||
|
||||
/// Stdin ingest (p9-fb-31, v1 markdown only). Prepends a YAML
|
||||
/// frontmatter block (`title` + optional `source_uri`) to `body`,
|
||||
/// writes the wrapped markdown to `_external/<hash12>.md`, and runs
|
||||
/// `ingest_file_with_config` on the resulting file.
|
||||
///
|
||||
/// Errors if `body` already starts with `---` (the user should call
|
||||
/// `ingest_file_with_config` directly for files that already carry
|
||||
/// frontmatter).
|
||||
#[doc(hidden)]
|
||||
pub fn ingest_stdin_with_config(
|
||||
config: kebab_config::Config,
|
||||
body: &str,
|
||||
title: &str,
|
||||
source_uri: Option<&str>,
|
||||
) -> anyhow::Result<IngestReport> {
|
||||
let wrapped = crate::external::inject_frontmatter(body, title, source_uri)?;
|
||||
|
||||
let workspace_root = config.resolve_workspace_root();
|
||||
// Note: ensure_external_dir + ensure_kebabignore_entry + copy_to_external
|
||||
// are called here AND inside ingest_file_with_config. All three are
|
||||
// idempotent; the redundancy is intentional — keeping stdin's wrapped
|
||||
// bytes accessible by `ingest_file_with_config` requires the dest path
|
||||
// to exist. The ~ms double-stat overhead is negligible at v1 scale.
|
||||
let external_dir = crate::external::ensure_external_dir(&workspace_root)?;
|
||||
crate::external::ensure_kebabignore_entry(&workspace_root)?;
|
||||
|
||||
let dest = crate::external::copy_to_external(
|
||||
&external_dir,
|
||||
wrapped.as_bytes(),
|
||||
"md",
|
||||
)?;
|
||||
|
||||
ingest_file_with_config(config, &dest)
|
||||
}
|
||||
|
||||
/// Returns true if `source_path` matches any `.kebabignore` pattern
|
||||
/// rooted at `workspace_root`. Used by `ingest_file_with_config` to
|
||||
/// emit a stderr warn before bypassing the ignore.
|
||||
fn check_kebabignore_match(workspace_root: &std::path::Path, source_path: &std::path::Path) -> bool {
|
||||
let kebabignore = workspace_root.join(".kebabignore");
|
||||
if !kebabignore.exists() {
|
||||
return false;
|
||||
}
|
||||
let text = match std::fs::read_to_string(&kebabignore) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let mut builder = ignore::gitignore::GitignoreBuilder::new(workspace_root);
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let _ = builder.add_line(None, line);
|
||||
}
|
||||
let matcher = match builder.build() {
|
||||
Ok(m) => m,
|
||||
Err(_) => return false,
|
||||
};
|
||||
matcher.matched(source_path, source_path.is_dir()).is_ignore()
|
||||
}
|
||||
|
||||
151
crates/kebab-app/src/schema.rs
Normal file
151
crates/kebab-app/src/schema.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
//! `kebab schema` — introspection report. See spec
|
||||
//! `docs/superpowers/specs/2026-05-07-p9-fb-27-introspection-and-error-wire-design.md`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use kebab_config::Config;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SchemaV1 {
|
||||
pub schema_version: String,
|
||||
pub kebab_version: String,
|
||||
pub wire: WireBlock,
|
||||
pub capabilities: Capabilities,
|
||||
pub models: Models,
|
||||
pub stats: Stats,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WireBlock {
|
||||
pub schemas: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Capabilities {
|
||||
pub json_mode: bool,
|
||||
pub ingest_progress: bool,
|
||||
pub ingest_cancellation: bool,
|
||||
pub rag_multi_turn: bool,
|
||||
pub search_cache: bool,
|
||||
pub incremental_ingest: bool,
|
||||
pub streaming_ask: bool,
|
||||
pub http_daemon: bool,
|
||||
pub mcp_server: bool,
|
||||
pub single_file_ingest: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Models {
|
||||
pub parser_version: String,
|
||||
pub chunker_version: String,
|
||||
pub embedding_version: String,
|
||||
pub prompt_template_version: String,
|
||||
pub index_version: String,
|
||||
pub corpus_revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Stats {
|
||||
pub doc_count: u64,
|
||||
pub chunk_count: u64,
|
||||
pub asset_count: u64,
|
||||
pub last_ingest_at: Option<String>,
|
||||
}
|
||||
|
||||
const KEBAB_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Wire schema id for [`SchemaV1`]. Single source of truth — `kebab-cli`
|
||||
/// re-uses this via `kebab_app::schema::SCHEMA_V1_ID` when wrapping.
|
||||
pub const SCHEMA_V1_ID: &str = "schema.v1";
|
||||
|
||||
// Authoritative list of wire schemas this binary emits. Keep in sync with
|
||||
// `docs/wire-schema/v1/*.schema.json` and `kebab-cli::wire::wire_*` helpers.
|
||||
const WIRE_SCHEMAS: &[&str] = &[
|
||||
"answer.v1",
|
||||
"search_hit.v1",
|
||||
"doc_summary.v1",
|
||||
"chunk_inspection.v1",
|
||||
"doctor.v1",
|
||||
"ingest_report.v1",
|
||||
"ingest_progress.v1",
|
||||
"reset_report.v1",
|
||||
"citation.v1",
|
||||
"schema.v1",
|
||||
"error.v1",
|
||||
];
|
||||
|
||||
/// Build a [`SchemaV1`] introspection report for the given config.
|
||||
///
|
||||
/// Opens the SQLite store read-only via [`kebab_store_sqlite::SqliteStore::open_existing`]
|
||||
/// so the caller (kebab-cli) does not need write access to the data dir.
|
||||
/// Returns a [`kebab_store_sqlite::NotIndexed`] error (wrapped in `anyhow`)
|
||||
/// if the database file does not exist — the CLI translates that to an
|
||||
/// `error.v1` / `"not_indexed"` wire record.
|
||||
#[doc(hidden)]
|
||||
pub fn schema_with_config(cfg: &Config) -> anyhow::Result<SchemaV1> {
|
||||
let store = open_store_for_stats(cfg)?;
|
||||
let stats = collect_stats(&store)?;
|
||||
let models = collect_models(cfg, &store);
|
||||
Ok(SchemaV1 {
|
||||
schema_version: SCHEMA_V1_ID.to_string(),
|
||||
kebab_version: KEBAB_VERSION.to_string(),
|
||||
wire: WireBlock {
|
||||
schemas: WIRE_SCHEMAS.iter().map(|s| (*s).to_string()).collect(),
|
||||
},
|
||||
capabilities: capabilities_snapshot(),
|
||||
models,
|
||||
stats,
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities_snapshot() -> Capabilities {
|
||||
Capabilities {
|
||||
json_mode: true,
|
||||
ingest_progress: true,
|
||||
ingest_cancellation: true,
|
||||
rag_multi_turn: true,
|
||||
search_cache: true,
|
||||
incremental_ingest: true,
|
||||
streaming_ask: false,
|
||||
http_daemon: false,
|
||||
mcp_server: true,
|
||||
single_file_ingest: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_store_for_stats(cfg: &Config) -> anyhow::Result<kebab_store_sqlite::SqliteStore> {
|
||||
// Mirror the data_dir resolution used in SqliteStore::open:
|
||||
// kebab_config::expand_path(&cfg.storage.data_dir, "") resolves tilde
|
||||
// and env vars. The SQLITE_FILE name ("kebab.sqlite") is the canonical
|
||||
// file name defined in kebab-store-sqlite.
|
||||
let data_dir = kebab_config::expand_path(&cfg.storage.data_dir, "");
|
||||
let db_path = data_dir.join("kebab.sqlite");
|
||||
kebab_store_sqlite::SqliteStore::open_existing(&db_path)
|
||||
}
|
||||
|
||||
fn collect_stats(store: &kebab_store_sqlite::SqliteStore) -> anyhow::Result<Stats> {
|
||||
let counts = store.count_summary()?;
|
||||
Ok(Stats {
|
||||
doc_count: counts.doc_count,
|
||||
chunk_count: counts.chunk_count,
|
||||
asset_count: counts.asset_count,
|
||||
last_ingest_at: counts.last_ingest_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_models(cfg: &Config, store: &kebab_store_sqlite::SqliteStore) -> Models {
|
||||
Models {
|
||||
// markdown parser only — pdf-page-v1 (P7) / image extractors (P6)
|
||||
// maintain their own versions; surface those when SchemaV1.models
|
||||
// becomes a multi-medium map (P+).
|
||||
parser_version: kebab_parse_md::PARSER_VERSION.to_string(),
|
||||
chunker_version: cfg.chunking.chunker_version.clone(),
|
||||
// EmbeddingModelCfg uses `.model` (not `.id`) — adapt from plan.
|
||||
embedding_version: cfg.models.embedding.model.clone(),
|
||||
prompt_template_version: cfg.rag.prompt_template_version.clone(),
|
||||
index_version: kebab_store_vector::INDEX_VERSION_STR.to_string(),
|
||||
// corpus_revision returns u64 directly (no Result) — matches
|
||||
// existing impl; treat 0 as the default for a fresh/unrevised store.
|
||||
corpus_revision: store.corpus_revision(),
|
||||
}
|
||||
}
|
||||
@@ -75,8 +75,8 @@ impl TestEnv {
|
||||
pub fn scope(&self) -> kebab_core::SourceScope {
|
||||
kebab_core::SourceScope {
|
||||
root: self.workspace_root.clone(),
|
||||
include: self.config.workspace.include.clone(),
|
||||
exclude: self.config.workspace.exclude.clone(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,10 +33,8 @@ fn write_red_png(root: &Path, name: &str) -> std::path::PathBuf {
|
||||
|
||||
fn cfg_with_image_pipeline(env: &TestEnv, mock_endpoint: &str) -> Config {
|
||||
let mut cfg = env.config.clone();
|
||||
// Ensure image assets are scanned.
|
||||
cfg.workspace
|
||||
.include
|
||||
.push("**/*.png".to_string());
|
||||
// p9-fb-25: workspace.include removed; extension routing is now
|
||||
// handled by extractor matching alone (no config knob).
|
||||
cfg.image.ocr.enabled = true;
|
||||
cfg.image.ocr.endpoint = Some(mock_endpoint.to_string());
|
||||
cfg.image.ocr.model = "vision-mock:1b".to_string();
|
||||
@@ -261,7 +259,8 @@ async fn image_indexed_with_filename_when_ocr_and_caption_disabled() {
|
||||
let env = TestEnv::lexical_only();
|
||||
write_red_png(&env.workspace_root, "raw.png");
|
||||
let mut cfg = env.config.clone();
|
||||
cfg.workspace.include.push("**/*.png".to_string());
|
||||
// p9-fb-25: workspace.include removed; extension routing is now
|
||||
// handled by extractor matching alone (no config knob).
|
||||
cfg.image.ocr.enabled = false;
|
||||
cfg.image.caption.enabled = false;
|
||||
|
||||
@@ -326,7 +325,8 @@ async fn garbage_png_increments_errors_counter_exactly_once() {
|
||||
)
|
||||
.expect("write garbage fixture");
|
||||
let mut cfg = env.config.clone();
|
||||
cfg.workspace.include.push("**/*.png".to_string());
|
||||
// p9-fb-25: workspace.include removed; extension routing is now
|
||||
// handled by extractor matching alone (no config knob).
|
||||
cfg.image.ocr.enabled = false;
|
||||
cfg.image.caption.enabled = false;
|
||||
|
||||
|
||||
111
crates/kebab-app/tests/ingest_file.rs
Normal file
111
crates/kebab-app/tests/ingest_file.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
//! Integration: kebab_app::ingest_file_with_config copies external file
|
||||
//! to _external/, ingests as single asset, idempotent on second call.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use kebab_config::Config;
|
||||
|
||||
#[test]
|
||||
fn ingest_file_copies_external_md_and_reports_new() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("notes");
|
||||
let data = dir.path().join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.workspace.root = workspace.to_string_lossy().into_owned();
|
||||
cfg.storage.data_dir = data.to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
|
||||
// Source file outside the workspace.
|
||||
let external_src = dir.path().join("source.md");
|
||||
fs::write(&external_src, "# Hello\n\nbody.").unwrap();
|
||||
|
||||
let report = kebab_app::ingest_file_with_config(cfg.clone(), &external_src).unwrap();
|
||||
assert_eq!(report.scanned, 1, "{report:?}");
|
||||
assert_eq!(report.new, 1, "{report:?}");
|
||||
assert_eq!(report.unchanged, 0, "{report:?}");
|
||||
|
||||
// _external/ dir created, file copied with hash prefix.
|
||||
let ext_dir = workspace.join("_external");
|
||||
assert!(ext_dir.is_dir());
|
||||
let entries: Vec<_> = fs::read_dir(&ext_dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
assert_eq!(entries.len(), 1, "exactly one file in _external/");
|
||||
let name = entries[0].file_name().to_string_lossy().into_owned();
|
||||
assert!(name.ends_with(".md"));
|
||||
|
||||
// .kebabignore has _external/ line.
|
||||
let ki = fs::read_to_string(workspace.join(".kebabignore")).unwrap();
|
||||
assert!(ki.lines().any(|l| l.trim() == "_external/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_file_idempotent_on_second_call() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("notes");
|
||||
let data = dir.path().join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.workspace.root = workspace.to_string_lossy().into_owned();
|
||||
cfg.storage.data_dir = data.to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
|
||||
let src = dir.path().join("doc.md");
|
||||
fs::write(&src, "# A\n\nbody.").unwrap();
|
||||
|
||||
let r1 = kebab_app::ingest_file_with_config(cfg.clone(), &src).unwrap();
|
||||
assert_eq!(r1.new, 1);
|
||||
|
||||
let r2 = kebab_app::ingest_file_with_config(cfg.clone(), &src).unwrap();
|
||||
assert_eq!(r2.new, 0, "{r2:?}");
|
||||
assert_eq!(r2.unchanged, 1, "{r2:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_file_errors_on_missing_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("notes");
|
||||
let data = dir.path().join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.workspace.root = workspace.to_string_lossy().into_owned();
|
||||
cfg.storage.data_dir = data.to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
|
||||
let nonexistent = dir.path().join("nope.md");
|
||||
let err = kebab_app::ingest_file_with_config(cfg, &nonexistent).unwrap_err();
|
||||
assert!(err.to_string().contains("does not exist"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_file_errors_on_unsupported_extension() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("notes");
|
||||
let data = dir.path().join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.workspace.root = workspace.to_string_lossy().into_owned();
|
||||
cfg.storage.data_dir = data.to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
|
||||
let docx = dir.path().join("doc.docx");
|
||||
fs::write(&docx, b"fake docx bytes").unwrap();
|
||||
|
||||
let err = kebab_app::ingest_file_with_config(cfg, &docx).unwrap_err();
|
||||
assert!(err.to_string().contains("unsupported extension"), "{err}");
|
||||
assert!(err.to_string().contains(".docx") || err.to_string().contains("docx"), "{err}");
|
||||
}
|
||||
78
crates/kebab-app/tests/ingest_stdin.rs
Normal file
78
crates/kebab-app/tests/ingest_stdin.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
//! Integration: kebab_app::ingest_stdin_with_config injects frontmatter,
|
||||
//! writes to _external/, ingests as single asset.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use kebab_config::Config;
|
||||
|
||||
fn fresh_cfg(dir: &std::path::Path) -> Config {
|
||||
let workspace = dir.join("notes");
|
||||
let data = dir.join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.workspace.root = workspace.to_string_lossy().into_owned();
|
||||
cfg.storage.data_dir = data.to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
cfg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_stdin_writes_frontmatter_and_reports_new() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = fresh_cfg(dir.path());
|
||||
|
||||
let report = kebab_app::ingest_stdin_with_config(
|
||||
cfg.clone(),
|
||||
"## Body content\n\nMore.",
|
||||
"Article X",
|
||||
Some("https://example.com/x"),
|
||||
).unwrap();
|
||||
assert_eq!(report.new, 1, "{report:?}");
|
||||
|
||||
// _external/ contains exactly one .md file with frontmatter.
|
||||
let ext_dir = std::path::PathBuf::from(&cfg.workspace.root).join("_external");
|
||||
let entries: Vec<_> = fs::read_dir(&ext_dir).unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
assert_eq!(entries.len(), 1);
|
||||
let content = fs::read_to_string(entries[0].path()).unwrap();
|
||||
assert!(content.starts_with("---\n"));
|
||||
assert!(content.contains("title: \"Article X\""));
|
||||
assert!(content.contains("source_uri: \"https://example.com/x\""));
|
||||
assert!(content.contains("## Body content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_stdin_without_source_uri() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = fresh_cfg(dir.path());
|
||||
|
||||
let report = kebab_app::ingest_stdin_with_config(
|
||||
cfg.clone(),
|
||||
"## Body",
|
||||
"Title",
|
||||
None,
|
||||
).unwrap();
|
||||
assert_eq!(report.new, 1);
|
||||
|
||||
let ext_dir = std::path::PathBuf::from(&cfg.workspace.root).join("_external");
|
||||
let entries: Vec<_> = fs::read_dir(&ext_dir).unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
let content = fs::read_to_string(entries[0].path()).unwrap();
|
||||
assert!(content.contains("title: \"Title\""));
|
||||
assert!(!content.contains("source_uri"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_stdin_errors_on_existing_frontmatter() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = fresh_cfg(dir.path());
|
||||
|
||||
let body = "---\ntitle: Already\n---\n\n## Body";
|
||||
let err = kebab_app::ingest_stdin_with_config(cfg, body, "New", None).unwrap_err();
|
||||
assert!(err.to_string().contains("already has frontmatter"), "{err}");
|
||||
}
|
||||
34
crates/kebab-app/tests/init_template.rs
Normal file
34
crates/kebab-app/tests/init_template.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
//! p9-fb-25 task 3: `kebab init` produces config.toml with a header
|
||||
//! comment listing the four supported extensions (md / png / jpg+jpeg
|
||||
//! / pdf) so a user editing the config knows what's processable.
|
||||
|
||||
#[test]
|
||||
fn init_workspace_header_lists_supported_extensions() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
// SAFETY: Rust 2024 marks set_var as unsafe — wrap in unsafe block.
|
||||
// Each test sets process-wide XDG_CONFIG_HOME to point at the
|
||||
// tempdir; init_workspace writes config.toml relative to it.
|
||||
unsafe {
|
||||
std::env::set_var("XDG_CONFIG_HOME", tmp.path());
|
||||
// Same dir for data + cache to avoid touching real user paths.
|
||||
std::env::set_var("XDG_DATA_HOME", tmp.path().join("data"));
|
||||
std::env::set_var("XDG_CACHE_HOME", tmp.path().join("cache"));
|
||||
std::env::set_var("XDG_STATE_HOME", tmp.path().join("state"));
|
||||
}
|
||||
kebab_app::init_workspace(true).expect("init_workspace");
|
||||
let cfg_path = kebab_config::Config::xdg_config_path();
|
||||
let body = std::fs::read_to_string(&cfg_path).unwrap_or_else(|e| {
|
||||
panic!("read config at {}: {e}", cfg_path.display())
|
||||
});
|
||||
assert!(
|
||||
body.contains("처리 가능한 형식"),
|
||||
"header lists supported types section: body=\n{body}"
|
||||
);
|
||||
assert!(body.contains("Markdown: .md"), "md listed");
|
||||
assert!(body.contains(".png .jpg .jpeg"), "image extensions listed");
|
||||
assert!(body.contains("PDF: .pdf"), "pdf listed");
|
||||
assert!(
|
||||
!body.contains("workspace.include"),
|
||||
"no leftover include reference"
|
||||
);
|
||||
}
|
||||
@@ -121,7 +121,8 @@ fn write_pdf(root: &Path, name: &str, bytes: &[u8]) -> std::path::PathBuf {
|
||||
|
||||
fn cfg_with_pdf(env: &TestEnv) -> Config {
|
||||
let mut cfg = env.config.clone();
|
||||
cfg.workspace.include.push("**/*.pdf".to_string());
|
||||
// p9-fb-25: workspace.include removed; extension routing is now
|
||||
// handled by extractor matching alone (no config knob).
|
||||
// PDF ingest does not need OCR / caption / LM — leave defaults
|
||||
// (ocr.enabled=false, caption.enabled=false). The image pipeline
|
||||
// construction step skips both adapters.
|
||||
|
||||
123
crates/kebab-app/tests/schema_report.rs
Normal file
123
crates/kebab-app/tests/schema_report.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
//! Integration test: kebab_app::schema_with_config returns a SchemaV1
|
||||
//! that is internally consistent with a freshly-ingested TempDir KB.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_core::SourceScope;
|
||||
|
||||
fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path) -> Config {
|
||||
let mut config = Config::defaults();
|
||||
config.workspace.root = workspace_root.to_string_lossy().into_owned();
|
||||
config.workspace.exclude.clear();
|
||||
config.storage.data_dir = data_dir.to_string_lossy().into_owned();
|
||||
config.storage.model_dir = data_dir.join("models").to_string_lossy().into_owned();
|
||||
config.models.embedding.provider = "none".to_string();
|
||||
config.models.embedding.dimensions = 0;
|
||||
config.chunking.target_tokens = 80;
|
||||
config.chunking.overlap_tokens = 20;
|
||||
config
|
||||
}
|
||||
|
||||
fn minimal_scope(workspace_root: &std::path::Path) -> SourceScope {
|
||||
SourceScope {
|
||||
root: workspace_root.to_path_buf(),
|
||||
include: vec![],
|
||||
exclude: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_report_reflects_freshly_ingested_kb() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let workspace_root = temp.path().join("workspace");
|
||||
let data_dir = temp.path().join("data");
|
||||
fs::create_dir_all(&workspace_root).unwrap();
|
||||
fs::create_dir_all(&data_dir).unwrap();
|
||||
|
||||
fs::write(workspace_root.join("a.md"), "# A\n\nbody A.").unwrap();
|
||||
fs::write(workspace_root.join("b.md"), "# B\n\nbody B.").unwrap();
|
||||
|
||||
let config = minimal_config(&data_dir, &workspace_root);
|
||||
let _report =
|
||||
kebab_app::ingest_with_config(config.clone(), minimal_scope(&workspace_root), false)
|
||||
.unwrap();
|
||||
|
||||
let schema = kebab_app::schema_with_config(&config).unwrap();
|
||||
|
||||
assert!(!schema.kebab_version.is_empty());
|
||||
assert!(
|
||||
schema.wire.schemas.contains(&"schema.v1".to_string()),
|
||||
"schema.v1 missing from wire.schemas: {:?}",
|
||||
schema.wire.schemas
|
||||
);
|
||||
assert!(
|
||||
schema.wire.schemas.contains(&"error.v1".to_string()),
|
||||
"error.v1 missing from wire.schemas: {:?}",
|
||||
schema.wire.schemas
|
||||
);
|
||||
assert!(schema.capabilities.json_mode);
|
||||
assert!(!schema.capabilities.streaming_ask);
|
||||
assert!(
|
||||
schema.capabilities.mcp_server,
|
||||
"mcp_server should be true after fb-30",
|
||||
);
|
||||
assert_eq!(
|
||||
schema.stats.doc_count, 2,
|
||||
"expected 2 docs (a.md + b.md): {:?}",
|
||||
schema.stats
|
||||
);
|
||||
assert!(
|
||||
schema.stats.last_ingest_at.is_some(),
|
||||
"last_ingest_at must be set after ingest: {:?}",
|
||||
schema.stats
|
||||
);
|
||||
assert!(
|
||||
schema.stats.chunk_count >= 2,
|
||||
"expected ≥2 chunks (a.md + b.md): {:?}",
|
||||
schema.stats
|
||||
);
|
||||
assert_eq!(
|
||||
schema.stats.asset_count, 2,
|
||||
"expected 2 assets (a.md + b.md): {:?}",
|
||||
schema.stats
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_report_on_empty_kb_has_zero_counts() {
|
||||
// An empty workspace dir with no .md files: ingest_with_config scans 0
|
||||
// files but still creates + migrates kebab.sqlite. This seeds the DB so
|
||||
// open_existing (used inside schema_with_config) succeeds and returns
|
||||
// all-zero counts.
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let workspace_root = temp.path().join("workspace");
|
||||
let data_dir = temp.path().join("data");
|
||||
fs::create_dir_all(&workspace_root).unwrap();
|
||||
fs::create_dir_all(&data_dir).unwrap();
|
||||
|
||||
let config = minimal_config(&data_dir, &workspace_root);
|
||||
// Run ingest over the empty workspace — creates kebab.sqlite, runs
|
||||
// migrations, records 0 docs. schema_with_config can then open_existing.
|
||||
let report =
|
||||
kebab_app::ingest_with_config(config.clone(), minimal_scope(&workspace_root), false)
|
||||
.unwrap();
|
||||
assert_eq!(report.new, 0, "empty workspace should yield 0 new docs");
|
||||
|
||||
let schema = kebab_app::schema_with_config(&config).unwrap();
|
||||
assert_eq!(
|
||||
schema.stats.doc_count, 0,
|
||||
"empty KB doc_count: {:?}",
|
||||
schema.stats
|
||||
);
|
||||
assert_eq!(
|
||||
schema.stats.chunk_count, 0,
|
||||
"empty KB chunk_count: {:?}",
|
||||
schema.stats
|
||||
);
|
||||
assert!(
|
||||
schema.stats.last_ingest_at.is_none(),
|
||||
"last_ingest_at must be None when no docs ingested: {:?}",
|
||||
schema.stats
|
||||
);
|
||||
}
|
||||
43
crates/kebab-app/tests/skip_reason.rs
Normal file
43
crates/kebab-app/tests/skip_reason.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! p9-fb-25 task 5: skipped per-asset items must carry a human-readable
|
||||
//! reason in `warnings`, and the report's `skipped_by_extension` must
|
||||
//! aggregate by lowercase extension.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::TestEnv;
|
||||
|
||||
#[test]
|
||||
fn unsupported_extension_skip_carries_warning_and_is_aggregated() {
|
||||
let env = TestEnv::lexical_only();
|
||||
let workspace_root = std::path::PathBuf::from(&env.config.workspace.root);
|
||||
std::fs::write(workspace_root.join("legacy.docx"), b"unsupported").unwrap();
|
||||
std::fs::write(workspace_root.join("Makefile"), b"unsupported").unwrap();
|
||||
|
||||
let report = kebab_app::ingest_with_config(
|
||||
env.config.clone(),
|
||||
env.scope(),
|
||||
false,
|
||||
).unwrap();
|
||||
|
||||
let items = report.items.as_ref().expect("items array populated");
|
||||
let docx_item = items
|
||||
.iter()
|
||||
.find(|i| i.doc_path.0.ends_with("legacy.docx"))
|
||||
.expect("docx in items");
|
||||
assert_eq!(docx_item.kind, kebab_core::IngestItemKind::Skipped);
|
||||
assert_eq!(
|
||||
docx_item.warnings,
|
||||
vec!["unsupported media type: .docx".to_string()],
|
||||
);
|
||||
let makefile_item = items
|
||||
.iter()
|
||||
.find(|i| i.doc_path.0.ends_with("Makefile"))
|
||||
.expect("Makefile in items");
|
||||
assert_eq!(makefile_item.kind, kebab_core::IngestItemKind::Skipped);
|
||||
assert_eq!(
|
||||
makefile_item.warnings,
|
||||
vec!["unsupported media type: <no-ext>".to_string()],
|
||||
);
|
||||
assert_eq!(report.skipped_by_extension.get("docx").copied(), Some(1));
|
||||
assert_eq!(report.skipped_by_extension.get("<no-ext>").copied(), Some(1));
|
||||
}
|
||||
@@ -27,7 +27,10 @@ kebab-eval = { path = "../kebab-eval" }
|
||||
# enforces the §8 boundary in its own Cargo.toml; kb-cli just
|
||||
# launches it.
|
||||
kebab-tui = { path = "../kebab-tui" }
|
||||
# p9-fb-30: MCP stdio server. `Cmd::Mcp` delegates entirely to this crate.
|
||||
kebab-mcp = { path = "../kebab-mcp" }
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
# p9-fb-02: ingest progress UI.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use kebab_app::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal};
|
||||
@@ -176,6 +177,9 @@ enum Cmd {
|
||||
/// Health check.
|
||||
Doctor,
|
||||
|
||||
/// Print introspection report (wire schemas, capabilities, model versions, stats).
|
||||
Schema,
|
||||
|
||||
/// Launch the Ratatui shell (P9-1 — Library pane only; search /
|
||||
/// ask / inspect panes land with p9-2 / p9-3 / p9-4).
|
||||
Tui,
|
||||
@@ -185,6 +189,29 @@ enum Cmd {
|
||||
#[command(subcommand)]
|
||||
what: EvalWhat,
|
||||
},
|
||||
|
||||
/// Run the MCP (Model Context Protocol) stdio server. Used by
|
||||
/// agent hosts (Claude Code / Cursor / OpenAI Agents) to call kebab
|
||||
/// tools (search / ask / schema / doctor).
|
||||
Mcp,
|
||||
|
||||
/// Ingest a single file (workspace external paths allowed).
|
||||
/// Bytes are copied into `<workspace.root>/_external/<hash>.<ext>`.
|
||||
IngestFile {
|
||||
/// File path to ingest.
|
||||
path: std::path::PathBuf,
|
||||
},
|
||||
|
||||
/// Ingest markdown content from stdin. v1 markdown only.
|
||||
/// Frontmatter (title + source_uri) is auto-injected.
|
||||
IngestStdin {
|
||||
/// Title — required, written to frontmatter.
|
||||
#[arg(long)]
|
||||
title: String,
|
||||
/// Source URI — optional, written to frontmatter when present.
|
||||
#[arg(long)]
|
||||
source_uri: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
@@ -277,10 +304,18 @@ fn main() -> ExitCode {
|
||||
// Refusals at exit code 1 print to stdout (already done by the
|
||||
// caller); errors go to stderr.
|
||||
if code != 1 {
|
||||
eprintln!("error: {e}");
|
||||
if cli.verbose {
|
||||
for cause in e.chain().skip(1) {
|
||||
eprintln!(" caused by: {cause}");
|
||||
if cli.json {
|
||||
let v1 = kebab_app::classify(&e, cli.verbose);
|
||||
let v = wire::wire_error_v1(&v1);
|
||||
eprintln!("{}", serde_json::to_string(&v).unwrap_or_else(|_| {
|
||||
"{\"schema_version\":\"error.v1\",\"code\":\"generic\",\"message\":\"serialize failed\"}".to_string()
|
||||
}));
|
||||
} else {
|
||||
eprintln!("error: {e}");
|
||||
if cli.verbose {
|
||||
for cause in e.chain().skip(1) {
|
||||
eprintln!(" caused by: {cause}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,8 +361,8 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
|
||||
let scope = kebab_core::SourceScope {
|
||||
root: root.clone().unwrap_or_else(|| PathBuf::from(&cfg.workspace.root)),
|
||||
include: cfg.workspace.include.clone(),
|
||||
exclude: cfg.workspace.exclude.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// p9-fb-02: spawn the progress display on a background
|
||||
@@ -371,12 +406,14 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
if cli.json {
|
||||
println!("{}", serde_json::to_string(&wire::wire_ingest(&report))?);
|
||||
} else {
|
||||
let skipped_breakdown = kebab_app::render_skipped_breakdown(&report.skipped_by_extension);
|
||||
println!(
|
||||
"scanned {} new {} updated {} skipped {} errors {} ({} ms)",
|
||||
"scanned {} new {} updated {} skipped {}{} errors {} ({} ms)",
|
||||
report.scanned,
|
||||
report.new,
|
||||
report.updated,
|
||||
report.skipped,
|
||||
skipped_breakdown,
|
||||
report.errors,
|
||||
report.duration_ms
|
||||
);
|
||||
@@ -601,6 +638,18 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Cmd::Schema => {
|
||||
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
|
||||
let report = kebab_app::schema_with_config(&cfg)?;
|
||||
if cli.json {
|
||||
let v = wire::wire_schema(&report);
|
||||
println!("{}", serde_json::to_string(&v)?);
|
||||
} else {
|
||||
print_schema_text(&report);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Cmd::Doctor => {
|
||||
let report = kebab_app::doctor_with_config_path(cli.config.as_deref())?;
|
||||
if cli.json {
|
||||
@@ -714,9 +763,100 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
|
||||
Cmd::IngestFile { path } => {
|
||||
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
|
||||
let report = kebab_app::ingest_file_with_config(cfg, path)?;
|
||||
if cli.json {
|
||||
let v = wire::wire_ingest(&report);
|
||||
println!("{}", serde_json::to_string(&v)?);
|
||||
} else {
|
||||
println!(
|
||||
"ingest-file: scanned={} new={} updated={} unchanged={} skipped={} errors={}",
|
||||
report.scanned, report.new, report.updated,
|
||||
report.unchanged, report.skipped, report.errors
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Cmd::IngestStdin { title, source_uri } => {
|
||||
use std::io::Read;
|
||||
let mut body = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut body)
|
||||
.context("kebab ingest-stdin: read stdin")?;
|
||||
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
|
||||
let report = kebab_app::ingest_stdin_with_config(
|
||||
cfg,
|
||||
&body,
|
||||
title,
|
||||
source_uri.as_deref(),
|
||||
)?;
|
||||
if cli.json {
|
||||
let v = wire::wire_ingest(&report);
|
||||
println!("{}", serde_json::to_string(&v)?);
|
||||
} else {
|
||||
println!(
|
||||
"ingest-stdin: scanned={} new={} updated={} unchanged={} skipped={} errors={}",
|
||||
report.scanned, report.new, report.updated,
|
||||
report.unchanged, report.skipped, report.errors
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Cmd::Mcp => {
|
||||
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
|
||||
kebab_mcp::serve_stdio(cfg, cli.config.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_schema_text(s: &kebab_app::SchemaV1) {
|
||||
println!("kebab v{}", s.kebab_version);
|
||||
println!();
|
||||
|
||||
println!("wire schemas");
|
||||
println!(" {}", s.wire.schemas.join(", "));
|
||||
println!();
|
||||
|
||||
println!("capabilities");
|
||||
let caps = [
|
||||
("json_mode", s.capabilities.json_mode),
|
||||
("ingest_progress", s.capabilities.ingest_progress),
|
||||
("ingest_cancellation", s.capabilities.ingest_cancellation),
|
||||
("rag_multi_turn", s.capabilities.rag_multi_turn),
|
||||
("search_cache", s.capabilities.search_cache),
|
||||
("incremental_ingest", s.capabilities.incremental_ingest),
|
||||
("streaming_ask", s.capabilities.streaming_ask),
|
||||
("http_daemon", s.capabilities.http_daemon),
|
||||
("mcp_server", s.capabilities.mcp_server),
|
||||
("single_file_ingest", s.capabilities.single_file_ingest),
|
||||
];
|
||||
for (name, on) in caps {
|
||||
let mark = if on { "✓" } else { "✗" };
|
||||
println!(" {mark} {name}");
|
||||
}
|
||||
println!();
|
||||
|
||||
println!("models");
|
||||
println!(" parser_version {}", s.models.parser_version);
|
||||
println!(" chunker_version {}", s.models.chunker_version);
|
||||
println!(" embedding_version {}", s.models.embedding_version);
|
||||
println!(" prompt_template_version {}", s.models.prompt_template_version);
|
||||
println!(" index_version {}", s.models.index_version);
|
||||
println!(" corpus_revision {}", s.models.corpus_revision);
|
||||
println!();
|
||||
|
||||
println!("stats");
|
||||
println!(" doc_count {}", s.stats.doc_count);
|
||||
println!(" chunk_count {}", s.stats.chunk_count);
|
||||
println!(" asset_count {}", s.stats.asset_count);
|
||||
let last = s.stats.last_ingest_at.as_deref().unwrap_or("(never)");
|
||||
println!(" last_ingest_at {last}");
|
||||
}
|
||||
|
||||
/// Minimal stdin/stdout confirm prompt for destructive ops. No new dep —
|
||||
/// uses stdlib `IsTerminal` (the caller is expected to have already
|
||||
/// short-circuited the non-TTY case). Returns `Ok(true)` only when the
|
||||
|
||||
@@ -133,6 +133,35 @@ pub fn wire_ingest_progress(
|
||||
Ok(tag_object(v, "ingest_progress.v1"))
|
||||
}
|
||||
|
||||
/// Wrap a [`kebab_app::SchemaV1`] as `schema.v1`.
|
||||
///
|
||||
/// Uses the idempotent re-tag pattern (mirrors `wire_doctor`) because
|
||||
/// `SchemaV1` already carries `schema_version: "schema.v1"` as a struct
|
||||
/// field. The re-tag is a defensive no-op when the field is present; it
|
||||
/// stamps the correct version if a future refactor ever drops the field.
|
||||
pub fn wire_schema(s: &kebab_app::SchemaV1) -> Value {
|
||||
let v = serde_json::to_value(s).expect("SchemaV1 serializes");
|
||||
if let Value::Object(ref map) = v {
|
||||
if matches!(
|
||||
map.get("schema_version"),
|
||||
Some(Value::String(s)) if s == kebab_app::SCHEMA_V1_ID
|
||||
) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
tag_object(v, kebab_app::SCHEMA_V1_ID)
|
||||
}
|
||||
|
||||
/// Wrap an [`kebab_app::ErrorV1`] as `error.v1`.
|
||||
///
|
||||
/// Uses the simple `tag_object` pattern because `ErrorV1` is a
|
||||
/// type that does NOT carry `schema_version` itself
|
||||
/// (kebab-core convention).
|
||||
pub fn wire_error_v1(e: &kebab_app::ErrorV1) -> Value {
|
||||
let v = serde_json::to_value(e).expect("ErrorV1 serializes");
|
||||
tag_object(v, "error.v1")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -171,6 +200,7 @@ mod tests {
|
||||
unchanged: 0,
|
||||
errors: 0,
|
||||
duration_ms: 0,
|
||||
skipped_by_extension: std::collections::BTreeMap::new(),
|
||||
items: None,
|
||||
};
|
||||
let v = wire_ingest(&r);
|
||||
@@ -199,6 +229,52 @@ mod tests {
|
||||
assert_eq!(schema_of(&tagged), Some("x.v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_wrapper_tags_schema_version() {
|
||||
use kebab_app::{Capabilities, Models, SchemaV1, Stats, WireBlock};
|
||||
let schema = SchemaV1 {
|
||||
schema_version: "schema.v1".to_string(),
|
||||
kebab_version: "0.2.1".to_string(),
|
||||
wire: WireBlock { schemas: vec!["answer.v1".to_string()] },
|
||||
capabilities: Capabilities {
|
||||
json_mode: true, ingest_progress: true, ingest_cancellation: true,
|
||||
rag_multi_turn: true, search_cache: true, incremental_ingest: true,
|
||||
streaming_ask: false, http_daemon: false, mcp_server: false,
|
||||
single_file_ingest: false,
|
||||
},
|
||||
models: Models {
|
||||
parser_version: "x".to_string(),
|
||||
chunker_version: "y".to_string(),
|
||||
embedding_version: "z".to_string(),
|
||||
prompt_template_version: "w".to_string(),
|
||||
index_version: "v".to_string(),
|
||||
corpus_revision: 7,
|
||||
},
|
||||
stats: Stats {
|
||||
doc_count: 1, chunk_count: 2, asset_count: 1,
|
||||
last_ingest_at: None,
|
||||
},
|
||||
};
|
||||
let v = wire_schema(&schema);
|
||||
assert_eq!(schema_of(&v), Some("schema.v1"));
|
||||
assert_eq!(v.get("kebab_version").and_then(Value::as_str), Some("0.2.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_wrapper_tags_schema_version_and_emits_code() {
|
||||
use kebab_app::ErrorV1;
|
||||
let err = ErrorV1 {
|
||||
schema_version: "error.v1".to_string(),
|
||||
code: "config_invalid".to_string(),
|
||||
message: "bad config".to_string(),
|
||||
details: serde_json::json!({"path": "/tmp/x"}),
|
||||
hint: Some("check the path".to_string()),
|
||||
};
|
||||
let v = wire_error_v1(&err);
|
||||
assert_eq!(schema_of(&v), Some("error.v1"));
|
||||
assert_eq!(v.get("code").and_then(Value::as_str), Some("config_invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_wrapper_tags_schema_version_and_serializes_scope() {
|
||||
let r = kebab_app::ResetReport {
|
||||
|
||||
105
crates/kebab-cli/tests/cli_error_wire.rs
Normal file
105
crates/kebab-cli/tests/cli_error_wire.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
//! Integration: spawn kebab and verify --json mode emits error.v1 ndjson
|
||||
//! on stderr while non-json mode emits the legacy `error:` text prefix.
|
||||
//!
|
||||
//! The `config_invalid` code is triggered by supplying an *existing* but
|
||||
//! malformed TOML file via `--config`. Note: supplying a *non-existent*
|
||||
//! path does NOT trigger this error — Config::load silently falls back to
|
||||
//! defaults when the specified config file is absent (by design, so that
|
||||
//! `kebab doctor` runs before `kebab init` is ever called). A file that
|
||||
//! exists but fails TOML parsing is the reliable path to `config_invalid`.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
fn kebab_bin() -> std::path::PathBuf {
|
||||
let manifest = env!("CARGO_MANIFEST_DIR");
|
||||
std::path::PathBuf::from(manifest)
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("target/debug/kebab")
|
||||
}
|
||||
|
||||
fn xdg_envs(tmp: &std::path::Path) -> [(&'static str, std::path::PathBuf); 4] {
|
||||
[
|
||||
("XDG_CONFIG_HOME", tmp.join("cfg")),
|
||||
("XDG_DATA_HOME", tmp.join("data")),
|
||||
("XDG_CACHE_HOME", tmp.join("cache")),
|
||||
("XDG_STATE_HOME", tmp.join("state")),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_mode_emits_error_v1_on_config_invalid() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Write a file that exists but is not valid TOML.
|
||||
let bad_config = tmp.path().join("bad.toml");
|
||||
std::fs::write(&bad_config, b"this is not { valid toml !!!").unwrap();
|
||||
|
||||
let mut cmd = Command::new(kebab_bin());
|
||||
cmd.args([
|
||||
"--json",
|
||||
"--config",
|
||||
bad_config.to_str().unwrap(),
|
||||
"ingest",
|
||||
]);
|
||||
for (k, v) in xdg_envs(tmp.path()) {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
|
||||
let out = cmd.output().unwrap();
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"expected non-zero exit for config_invalid"
|
||||
);
|
||||
let exit_code = out.status.code().unwrap_or(-1);
|
||||
assert_eq!(exit_code, 2, "expected exit code 2, got {exit_code}");
|
||||
|
||||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||
let first_line = stderr.lines().next().expect("stderr must have at least one line");
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(first_line).expect("stderr first line must be valid JSON");
|
||||
|
||||
assert_eq!(
|
||||
v.get("schema_version").and_then(|s| s.as_str()),
|
||||
Some("error.v1"),
|
||||
"schema_version must be error.v1"
|
||||
);
|
||||
assert_eq!(
|
||||
v.get("code").and_then(|s| s.as_str()),
|
||||
Some("config_invalid"),
|
||||
"code must be config_invalid"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_mode_emits_legacy_error_format() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Same trigger: an existing file with malformed TOML.
|
||||
let bad_config = tmp.path().join("bad.toml");
|
||||
std::fs::write(&bad_config, b"this is not { valid toml !!!").unwrap();
|
||||
|
||||
let mut cmd = Command::new(kebab_bin());
|
||||
cmd.args(["--config", bad_config.to_str().unwrap(), "ingest"]);
|
||||
for (k, v) in xdg_envs(tmp.path()) {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
|
||||
let out = cmd.output().unwrap();
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"expected non-zero exit for config_invalid"
|
||||
);
|
||||
let exit_code = out.status.code().unwrap_or(-1);
|
||||
assert_eq!(exit_code, 2, "expected exit code 2, got {exit_code}");
|
||||
|
||||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||
assert!(
|
||||
stderr.starts_with("error:"),
|
||||
"text mode stderr must start with 'error:', got: {stderr:?}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.trim_start().starts_with('{'),
|
||||
"text mode stderr must NOT be JSON, got: {stderr:?}"
|
||||
);
|
||||
}
|
||||
92
crates/kebab-cli/tests/cli_ingest_file.rs
Normal file
92
crates/kebab-cli/tests/cli_ingest_file.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
//! Integration: spawn `kebab ingest-file <path>` and verify ingest_report.v1.
|
||||
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn cli_ingest_file_emits_ingest_report_v1() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("notes");
|
||||
let data = dir.path().join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let cfg_path = dir.path().join("config.toml");
|
||||
fs::write(
|
||||
&cfg_path,
|
||||
format!(
|
||||
r#"schema_version = 1
|
||||
|
||||
[workspace]
|
||||
root = "{workspace}"
|
||||
exclude = [".git/**"]
|
||||
|
||||
[storage]
|
||||
data_dir = "{data}"
|
||||
sqlite = "{{data_dir}}/kebab.sqlite"
|
||||
vector_dir = "{{data_dir}}/lancedb"
|
||||
asset_dir = "{{data_dir}}/assets"
|
||||
artifact_dir = "{{data_dir}}/artifacts"
|
||||
model_dir = "{{data_dir}}/models"
|
||||
runs_dir = "{{data_dir}}/runs"
|
||||
copy_threshold_mb = 100
|
||||
|
||||
[indexing]
|
||||
max_parallel_extractors = 2
|
||||
max_parallel_embeddings = 1
|
||||
watch_filesystem = false
|
||||
|
||||
[chunking]
|
||||
target_tokens = 500
|
||||
overlap_tokens = 80
|
||||
respect_markdown_headings = true
|
||||
chunker_version = "md-heading-v1"
|
||||
|
||||
[models.embedding]
|
||||
provider = "none"
|
||||
model = "none"
|
||||
version = "v0"
|
||||
dimensions = 0
|
||||
batch_size = 1
|
||||
|
||||
[models.llm]
|
||||
provider = "ollama"
|
||||
model = "none"
|
||||
context_tokens = 4096
|
||||
endpoint = "http://127.0.0.1:11434"
|
||||
temperature = 0.0
|
||||
seed = 0
|
||||
|
||||
[search]
|
||||
default_k = 10
|
||||
hybrid_fusion = "rrf"
|
||||
rrf_k = 60
|
||||
snippet_chars = 220
|
||||
|
||||
[rag]
|
||||
prompt_template_version = "rag-v1"
|
||||
score_gate = 0.30
|
||||
explain_default = false
|
||||
max_context_tokens = 8000
|
||||
"#,
|
||||
workspace = workspace.display(),
|
||||
data = data.display(),
|
||||
),
|
||||
).unwrap();
|
||||
|
||||
let src = dir.path().join("doc.md");
|
||||
fs::write(&src, "# A\n\nbody.").unwrap();
|
||||
|
||||
let bin = env!("CARGO_BIN_EXE_kebab");
|
||||
let out = Command::new(bin)
|
||||
.args(["--json", "--config", cfg_path.to_str().unwrap(), "ingest-file"])
|
||||
.arg(&src)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
|
||||
assert_eq!(v.get("schema_version").and_then(|s| s.as_str()), Some("ingest_report.v1"));
|
||||
assert_eq!(v.get("new").and_then(|n| n.as_u64()), Some(1));
|
||||
}
|
||||
100
crates/kebab-cli/tests/cli_ingest_stdin.rs
Normal file
100
crates/kebab-cli/tests/cli_ingest_stdin.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
//! Integration: spawn `kebab ingest-stdin --title X` with stdin pipe.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[test]
|
||||
fn cli_ingest_stdin_emits_ingest_report_v1() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("notes");
|
||||
let data = dir.path().join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let cfg_path = dir.path().join("config.toml");
|
||||
fs::write(
|
||||
&cfg_path,
|
||||
format!(
|
||||
r#"schema_version = 1
|
||||
|
||||
[workspace]
|
||||
root = "{workspace}"
|
||||
exclude = [".git/**"]
|
||||
|
||||
[storage]
|
||||
data_dir = "{data}"
|
||||
sqlite = "{{data_dir}}/kebab.sqlite"
|
||||
vector_dir = "{{data_dir}}/lancedb"
|
||||
asset_dir = "{{data_dir}}/assets"
|
||||
artifact_dir = "{{data_dir}}/artifacts"
|
||||
model_dir = "{{data_dir}}/models"
|
||||
runs_dir = "{{data_dir}}/runs"
|
||||
copy_threshold_mb = 100
|
||||
|
||||
[indexing]
|
||||
max_parallel_extractors = 2
|
||||
max_parallel_embeddings = 1
|
||||
watch_filesystem = false
|
||||
|
||||
[chunking]
|
||||
target_tokens = 500
|
||||
overlap_tokens = 80
|
||||
respect_markdown_headings = true
|
||||
chunker_version = "md-heading-v1"
|
||||
|
||||
[models.embedding]
|
||||
provider = "none"
|
||||
model = "none"
|
||||
version = "v0"
|
||||
dimensions = 0
|
||||
batch_size = 1
|
||||
|
||||
[models.llm]
|
||||
provider = "ollama"
|
||||
model = "none"
|
||||
context_tokens = 4096
|
||||
endpoint = "http://127.0.0.1:11434"
|
||||
temperature = 0.0
|
||||
seed = 0
|
||||
|
||||
[search]
|
||||
default_k = 10
|
||||
hybrid_fusion = "rrf"
|
||||
rrf_k = 60
|
||||
snippet_chars = 220
|
||||
|
||||
[rag]
|
||||
prompt_template_version = "rag-v1"
|
||||
score_gate = 0.30
|
||||
explain_default = false
|
||||
max_context_tokens = 8000
|
||||
"#,
|
||||
workspace = workspace.display(),
|
||||
data = data.display(),
|
||||
),
|
||||
).unwrap();
|
||||
|
||||
let bin = env!("CARGO_BIN_EXE_kebab");
|
||||
let mut child = Command::new(bin)
|
||||
.args([
|
||||
"--json", "--config", cfg_path.to_str().unwrap(),
|
||||
"ingest-stdin", "--title", "X",
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
stdin.write_all(b"## Body\n\nbody text.\n").unwrap();
|
||||
}
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
|
||||
assert_eq!(v.get("schema_version").and_then(|s| s.as_str()), Some("ingest_report.v1"));
|
||||
assert_eq!(v.get("new").and_then(|n| n.as_u64()), Some(1));
|
||||
}
|
||||
77
crates/kebab-cli/tests/cli_mcp_smoke.rs
Normal file
77
crates/kebab-cli/tests/cli_mcp_smoke.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
//! Spawn `target/debug/kebab mcp` and exercise initialize → tools/list.
|
||||
//!
|
||||
//! rmcp 1.6 has no public in-memory test transport, so this is the only
|
||||
//! end-to-end MCP assertion in the suite. The binary is located via
|
||||
//! `CARGO_BIN_EXE_kebab` which cargo injects at test compile time.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[test]
|
||||
fn cli_mcp_initialize_then_tools_list() {
|
||||
let bin = env!("CARGO_BIN_EXE_kebab");
|
||||
let mut child = Command::new(bin)
|
||||
.arg("mcp")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
// rmcp 1.6 defaults to protocol version "2025-03-26" (confirmed by
|
||||
// manual smoke in Task 10). The server echoes whatever version the
|
||||
// client sends during the handshake, so this literal must match.
|
||||
let init_req = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}"#;
|
||||
writeln!(stdin, "{init_req}").unwrap();
|
||||
writeln!(
|
||||
stdin,
|
||||
r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"#
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
stdin,
|
||||
r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{{}}}}"#
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Read initialize response.
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
let init: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
|
||||
assert_eq!(
|
||||
init.get("id").and_then(|i| i.as_i64()),
|
||||
Some(1),
|
||||
"unexpected id in initialize response: {init}"
|
||||
);
|
||||
assert!(
|
||||
init.get("result").is_some(),
|
||||
"initialize result missing: {init}"
|
||||
);
|
||||
|
||||
// Read tools/list response.
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
let list: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
|
||||
assert_eq!(
|
||||
list.get("id").and_then(|i| i.as_i64()),
|
||||
Some(2),
|
||||
"unexpected id in tools/list response: {list}"
|
||||
);
|
||||
let tools = list["result"]["tools"]
|
||||
.as_array()
|
||||
.expect("tools/list result.tools must be an array");
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
6,
|
||||
"expected 6 tools (schema, doctor, search, ask, ingest_file, ingest_stdin), got {}: {list}",
|
||||
tools.len()
|
||||
);
|
||||
|
||||
// Gracefully close stdin so the server shuts down cleanly.
|
||||
drop(stdin);
|
||||
let _ = child.wait().unwrap();
|
||||
}
|
||||
134
crates/kebab-cli/tests/cli_schema.rs
Normal file
134
crates/kebab-cli/tests/cli_schema.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
//! Integration: spawn the kebab binary and parse `kebab schema [--json]`.
|
||||
//!
|
||||
//! Each test builds an isolated TempDir-rooted XDG layout, runs
|
||||
//! `kebab ingest` over an empty workspace (which creates and migrates
|
||||
//! kebab.sqlite), then exercises `kebab schema` in JSON and text modes.
|
||||
//! Using an empty workspace avoids the embedding model dependency while
|
||||
//! still seeding the DB so `open_existing` inside schema_with_config
|
||||
//! succeeds (a NotIndexed error fires when the DB file is absent).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
fn kebab_bin() -> std::path::PathBuf {
|
||||
let manifest = env!("CARGO_MANIFEST_DIR");
|
||||
std::path::PathBuf::from(manifest)
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("target/debug/kebab")
|
||||
}
|
||||
|
||||
fn xdg_envs(tmp: &std::path::Path) -> [(&'static str, std::path::PathBuf); 4] {
|
||||
[
|
||||
("XDG_CONFIG_HOME", tmp.join("cfg")),
|
||||
("XDG_DATA_HOME", tmp.join("data")),
|
||||
("XDG_CACHE_HOME", tmp.join("cache")),
|
||||
("XDG_STATE_HOME", tmp.join("state")),
|
||||
]
|
||||
}
|
||||
|
||||
/// Seed kebab.sqlite by running `kebab ingest` over an empty workspace dir.
|
||||
/// This is the minimum required for `kebab schema` to succeed: the store
|
||||
/// uses `open_existing`, which errors when the DB file is absent.
|
||||
fn seed_db(tmp: &tempfile::TempDir) {
|
||||
let ws = tmp.path().join("ws");
|
||||
std::fs::create_dir_all(&ws).unwrap();
|
||||
|
||||
let mut cmd = Command::new(kebab_bin());
|
||||
cmd.args(["ingest", "--root", ws.to_str().unwrap(), "--summary-only"]);
|
||||
for (k, v) in xdg_envs(tmp.path()) {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let out = cmd.output().unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"seed ingest failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_schema_json_emits_schema_v1() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
seed_db(&tmp);
|
||||
|
||||
let mut cmd = Command::new(kebab_bin());
|
||||
cmd.args(["--json", "schema"]);
|
||||
for (k, v) in xdg_envs(tmp.path()) {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let out = cmd.output().unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"kebab --json schema failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&stdout).expect("stdout must be valid JSON");
|
||||
|
||||
assert_eq!(
|
||||
v.get("schema_version").and_then(|s| s.as_str()),
|
||||
Some("schema.v1"),
|
||||
"schema_version must be schema.v1"
|
||||
);
|
||||
assert!(
|
||||
v.get("kebab_version")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false),
|
||||
"kebab_version must be a non-empty string"
|
||||
);
|
||||
|
||||
let caps = v
|
||||
.get("capabilities")
|
||||
.and_then(|c| c.as_object())
|
||||
.expect("capabilities must be a JSON object");
|
||||
assert_eq!(
|
||||
caps.get("json_mode").and_then(|b| b.as_bool()),
|
||||
Some(true),
|
||||
"capabilities.json_mode must be true"
|
||||
);
|
||||
assert_eq!(
|
||||
caps.get("mcp_server").and_then(|b| b.as_bool()),
|
||||
Some(true),
|
||||
"capabilities.mcp_server must be true (fb-30)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_schema_text_mode_runs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
seed_db(&tmp);
|
||||
|
||||
let mut cmd = Command::new(kebab_bin());
|
||||
cmd.args(["schema"]);
|
||||
for (k, v) in xdg_envs(tmp.path()) {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let out = cmd.output().unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"kebab schema (text) failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(
|
||||
stdout.contains("kebab v"),
|
||||
"text output must contain 'kebab v', got: {stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("capabilities"),
|
||||
"text output must contain 'capabilities', got: {stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("models"),
|
||||
"text output must contain 'models', got: {stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("stats"),
|
||||
"text output must contain 'stats', got: {stdout}"
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,12 @@ kebab-core = { path = "../kebab-core" }
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
toml = "0.8"
|
||||
dirs = "5"
|
||||
# p9-fb-05: warn-log when current_dir() fails (chroot, deleted cwd)
|
||||
# during workspace.root resolution.
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -11,6 +11,19 @@ use serde::{Deserialize, Serialize};
|
||||
mod paths;
|
||||
pub use paths::{expand_path, expand_path_with_base};
|
||||
|
||||
/// Signal: `Config::from_file` / `Config::load` failed due to missing path,
|
||||
/// I/O failure, TOML parse failure, or post-parse validation failure.
|
||||
///
|
||||
/// Wrapped into `anyhow::Error` at the API boundary so callers that need
|
||||
/// structured details (e.g. kebab-cli's `error_classify`) can
|
||||
/// `downcast_ref::<ConfigInvalid>()` for the wire record.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("config invalid at {path}: {cause}")]
|
||||
pub struct ConfigInvalid {
|
||||
pub path: PathBuf,
|
||||
pub cause: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub schema_version: u32,
|
||||
@@ -51,7 +64,6 @@ pub struct Config {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WorkspaceCfg {
|
||||
pub root: String,
|
||||
pub include: Vec<String>,
|
||||
pub exclude: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -251,7 +263,6 @@ impl Config {
|
||||
schema_version: 1,
|
||||
workspace: WorkspaceCfg {
|
||||
root: "~/KnowledgeBase".to_string(),
|
||||
include: vec!["**/*.md".to_string()],
|
||||
exclude: vec![
|
||||
".git/**".to_string(),
|
||||
"node_modules/**".to_string(),
|
||||
@@ -395,8 +406,41 @@ impl Config {
|
||||
/// values resolve against the config file's directory rather
|
||||
/// than the user's `cwd`.
|
||||
pub fn from_file(path: &Path) -> anyhow::Result<Self> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let mut cfg: Self = toml::from_str(&text)?;
|
||||
let text = std::fs::read_to_string(path).map_err(|e| {
|
||||
anyhow::Error::new(ConfigInvalid {
|
||||
path: path.to_path_buf(),
|
||||
cause: format!("read_failed: {e}"),
|
||||
})
|
||||
})?;
|
||||
|
||||
// p9-fb-25: probe for the legacy `workspace.include` key — if
|
||||
// present, emit a one-shot deprecation warning. Detection uses
|
||||
// raw `toml::Value` lookup; the warning fires via a process-
|
||||
// level OnceLock so a long-running TUI / CLI run doesn't spam
|
||||
// the log on every Config::load.
|
||||
if let Ok(value) = toml::from_str::<toml::Value>(&text) {
|
||||
if value
|
||||
.get("workspace")
|
||||
.and_then(|v| v.get("include"))
|
||||
.is_some()
|
||||
{
|
||||
static DEPRECATION_FIRED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
DEPRECATION_FIRED.get_or_init(|| {
|
||||
tracing::warn!(
|
||||
target: "kebab-config",
|
||||
config = %path.display(),
|
||||
"deprecated config: `workspace.include` 필드는 더 이상 사용되지 않습니다 (p9-fb-25, v0.2.1+). 처리 가능한 형식 (md / png / jpg / pdf) 은 extractor 가 자동 결정. config 에서 이 필드를 제거해도 안전 — 더 이상 enforce 안 됨."
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut cfg: Self = toml::from_str(&text).map_err(|e| {
|
||||
anyhow::Error::new(ConfigInvalid {
|
||||
path: path.to_path_buf(),
|
||||
cause: format!("parse_failed: {e}"),
|
||||
})
|
||||
})?;
|
||||
cfg.source_dir = path.parent().map(Path::to_path_buf);
|
||||
Ok(cfg)
|
||||
}
|
||||
@@ -868,6 +912,32 @@ max_context_tokens = 8000
|
||||
assert_eq!(c.image, ImageCfg::defaults());
|
||||
}
|
||||
|
||||
/// p9-fb-25: legacy config with `workspace.include = [...]` must
|
||||
/// still deserialize cleanly (silent unknown-field acceptance).
|
||||
#[test]
|
||||
fn legacy_include_field_is_ignored_silently() {
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.workspace.root = "/tmp/kebab-legacy".to_string();
|
||||
let mut toml_text = toml::to_string(&cfg).expect("default round-trips");
|
||||
// Inject a legacy `include = [...]` line into the [workspace] block.
|
||||
toml_text = toml_text.replace(
|
||||
"[workspace]",
|
||||
"[workspace]\ninclude = [\"**/*.md\", \"**/*.txt\"]",
|
||||
);
|
||||
let parsed: Result<Config, _> = toml::from_str(&toml_text);
|
||||
assert!(parsed.is_ok(), "legacy include must not break load: {:?}", parsed.err());
|
||||
let cfg = parsed.unwrap();
|
||||
assert_eq!(cfg.workspace.root, "/tmp/kebab-legacy");
|
||||
}
|
||||
|
||||
/// p9-fb-25: `WorkspaceCfg` must NOT have an `include` field.
|
||||
/// Compile-time proof: exhaustive destructure.
|
||||
#[test]
|
||||
fn workspace_cfg_has_only_root_and_exclude_fields() {
|
||||
let ws = Config::defaults().workspace;
|
||||
let WorkspaceCfg { root: _, exclude: _ } = &ws;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdg_paths_honor_env() {
|
||||
// Must restore env after the test to avoid polluting other tests.
|
||||
@@ -887,3 +957,31 @@ max_context_tokens = 8000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fb27_tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn config_invalid_carries_path_and_cause() {
|
||||
let nonexistent = PathBuf::from("/this/path/should/not/exist/kebab.toml");
|
||||
let err = Config::from_file(&nonexistent).unwrap_err();
|
||||
let signal = err.downcast_ref::<ConfigInvalid>()
|
||||
.expect("from_file error should downcast to ConfigInvalid");
|
||||
assert_eq!(signal.path, nonexistent);
|
||||
assert!(!signal.cause.is_empty(), "cause should be non-empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_invalid_on_malformed_toml() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("bad.toml");
|
||||
std::fs::write(&p, "this is not [valid toml").unwrap();
|
||||
let err = Config::from_file(&p).unwrap_err();
|
||||
let signal = err.downcast_ref::<ConfigInvalid>()
|
||||
.expect("malformed TOML should downcast to ConfigInvalid");
|
||||
assert_eq!(signal.path, p);
|
||||
assert!(!signal.cause.is_empty(), "cause should be non-empty");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ pub struct IngestReport {
|
||||
pub unchanged: u32,
|
||||
pub errors: u32,
|
||||
pub duration_ms: u32,
|
||||
/// p9-fb-25: per-extension skip count. Key = lowercase extension
|
||||
/// without leading dot (e.g. "docx", "txt"); files without an
|
||||
/// extension key under "<no-ext>". `BTreeMap` so the wire JSON
|
||||
/// has stable key order across runs.
|
||||
pub skipped_by_extension: std::collections::BTreeMap<String, u32>,
|
||||
/// `None` ↔ wire `items: null` (`--summary-only`).
|
||||
pub items: Option<Vec<IngestItem>>,
|
||||
}
|
||||
|
||||
27
crates/kebab-mcp/Cargo.toml
Normal file
27
crates/kebab-mcp/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "kebab-mcp"
|
||||
edition = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
license = { workspace = true }
|
||||
repository = { workspace = true }
|
||||
version = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
rmcp = { workspace = true }
|
||||
# rt-multi-thread + io-util + io-std extend the workspace tokio entry
|
||||
# (which only declares rt + macros) for the blocking stdio MCP transport.
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "io-std"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
# schemars 1.x matches rmcp 1.6's ^1.0 requirement (verified via crates.io
|
||||
# /dependencies endpoint — rmcp declares optional schemars = "^1.0").
|
||||
schemars = "1"
|
||||
|
||||
kebab-app = { path = "../kebab-app" }
|
||||
kebab-config = { path = "../kebab-config" }
|
||||
kebab-core = { path = "../kebab-core" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
22
crates/kebab-mcp/src/error.rs
Normal file
22
crates/kebab-mcp/src/error.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! Map `anyhow::Error` returned by kebab-app facades to MCP
|
||||
//! `CallToolResult` with `isError: true` + error.v1 JSON content.
|
||||
|
||||
use rmcp::model::{CallToolResult, Content};
|
||||
|
||||
use kebab_app::classify;
|
||||
|
||||
/// Convert an `anyhow::Error` to a `CallToolResult` with `isError: true`
|
||||
/// and the serialised `error.v1` envelope as the text content.
|
||||
pub fn to_tool_error(err: &anyhow::Error) -> CallToolResult {
|
||||
let v1 = classify(err, false);
|
||||
let body = serde_json::to_string(&v1).unwrap_or_else(|_| {
|
||||
r#"{"schema_version":"error.v1","code":"generic","message":"serialize failed"}"#
|
||||
.to_string()
|
||||
});
|
||||
CallToolResult::error(vec![Content::text(body)])
|
||||
}
|
||||
|
||||
/// Wrap a successful wire-schema JSON string as a `CallToolResult`.
|
||||
pub fn to_tool_success(json: String) -> CallToolResult {
|
||||
CallToolResult::success(vec![Content::text(json)])
|
||||
}
|
||||
188
crates/kebab-mcp/src/lib.rs
Normal file
188
crates/kebab-mcp/src/lib.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
//! MCP (Model Context Protocol) server over stdio. Exposes 6 tools
|
||||
//! (`search` / `ask` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`)
|
||||
//! backed by `kebab-app` facade methods. Used by `kebab-cli`'s `Cmd::Mcp` arm.
|
||||
//!
|
||||
//! See spec `docs/superpowers/specs/2026-05-07-p9-fb-30-mcp-server-design.md`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use rmcp::ServerHandler;
|
||||
use rmcp::handler::server::common::{schema_for_empty_input, schema_for_type};
|
||||
use rmcp::model::{
|
||||
CallToolRequestParams, CallToolResult, Implementation, ListToolsResult, ServerCapabilities,
|
||||
ServerInfo, Tool,
|
||||
};
|
||||
use rmcp::service::{RequestContext, ServiceExt};
|
||||
use rmcp::transport::stdio;
|
||||
use rmcp::{ErrorData, RoleServer};
|
||||
|
||||
use kebab_config::Config;
|
||||
|
||||
pub mod error;
|
||||
pub mod state;
|
||||
pub mod tools;
|
||||
pub use state::KebabAppState;
|
||||
|
||||
/// Build the canonical list of tools exposed by the MCP server.
|
||||
///
|
||||
/// Extracted from [`ServerHandler::list_tools`] so it can be called
|
||||
/// directly in tests without constructing a `RequestContext`.
|
||||
pub fn build_tools_vec() -> Vec<Tool> {
|
||||
vec![
|
||||
Tool::new(
|
||||
"schema",
|
||||
"Introspection — wire schemas, capabilities, model versions, index stats.",
|
||||
schema_for_empty_input(),
|
||||
),
|
||||
Tool::new(
|
||||
"doctor",
|
||||
"Health check — verifies config, storage, models, and Ollama connectivity.",
|
||||
schema_for_empty_input(),
|
||||
),
|
||||
Tool::new(
|
||||
"search",
|
||||
"Full-text / vector / hybrid search over the knowledge base. Returns search_hit.v1 array.",
|
||||
schema_for_type::<tools::search::SearchInput>(),
|
||||
),
|
||||
Tool::new(
|
||||
"ask",
|
||||
"RAG question answering over the knowledge base. Returns answer.v1 JSON. Pass session_id for multi-turn context.",
|
||||
schema_for_type::<tools::ask::AskInput>(),
|
||||
),
|
||||
Tool::new(
|
||||
"ingest_file",
|
||||
"Ingest a single file (path) into the knowledge base. Workspace external paths allowed — bytes are copied into _external/.",
|
||||
schema_for_type::<tools::ingest_file::IngestFileInput>(),
|
||||
),
|
||||
Tool::new(
|
||||
"ingest_stdin",
|
||||
"Ingest markdown content into the knowledge base. v1 markdown only. Frontmatter (title + source_uri) auto-injected.",
|
||||
schema_for_type::<tools::ingest_stdin::IngestStdinInput>(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct KebabHandler {
|
||||
state: KebabAppState,
|
||||
}
|
||||
|
||||
impl KebabHandler {
|
||||
pub fn new(state: KebabAppState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &KebabAppState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Spawn a tool handler on the blocking pool. Used by tools that
|
||||
/// transitively touch reqwest::blocking::Client (search, ask) — calling
|
||||
/// from the async dispatch directly panics inside the runtime.
|
||||
async fn spawn_tool<I, F>(
|
||||
&self,
|
||||
args: serde_json::Map<String, serde_json::Value>,
|
||||
handle: F,
|
||||
) -> Result<CallToolResult, ErrorData>
|
||||
where
|
||||
I: serde::de::DeserializeOwned + Send + 'static,
|
||||
F: FnOnce(KebabAppState, I) -> CallToolResult + Send + 'static,
|
||||
{
|
||||
let input: I = match serde_json::from_value(serde_json::Value::Object(args)) {
|
||||
Ok(i) => i,
|
||||
Err(e) => return Ok(error::to_tool_error(&anyhow::Error::from(e))),
|
||||
};
|
||||
let state = self.state.clone();
|
||||
tokio::task::spawn_blocking(move || handle(state, input))
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.to_string(), None))
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerHandler for KebabHandler {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
||||
.with_server_info(Implementation::new("kebab", env!("CARGO_PKG_VERSION")))
|
||||
}
|
||||
|
||||
async fn list_tools(
|
||||
&self,
|
||||
_request: Option<rmcp::model::PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListToolsResult, ErrorData> {
|
||||
Ok(ListToolsResult::with_all_items(build_tools_vec()))
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
match request.name.as_ref() {
|
||||
"schema" => {
|
||||
let input = tools::schema::SchemaInput::default();
|
||||
Ok(tools::schema::handle(&self.state, input))
|
||||
}
|
||||
"doctor" => {
|
||||
let input = tools::doctor::DoctorInput::default();
|
||||
Ok(tools::doctor::handle(&self.state, input))
|
||||
}
|
||||
"search" => {
|
||||
let args = request.arguments.unwrap_or_default();
|
||||
self.spawn_tool(args, |state, input| {
|
||||
tools::search::handle(&state, input)
|
||||
})
|
||||
.await
|
||||
}
|
||||
"ask" => {
|
||||
let args = request.arguments.unwrap_or_default();
|
||||
self.spawn_tool(args, |state, input| {
|
||||
tools::ask::handle(&state, input)
|
||||
})
|
||||
.await
|
||||
}
|
||||
"ingest_file" => {
|
||||
let args = request.arguments.unwrap_or_default();
|
||||
self.spawn_tool(args, |state, input| {
|
||||
tools::ingest_file::handle(&state, input)
|
||||
})
|
||||
.await
|
||||
}
|
||||
"ingest_stdin" => {
|
||||
let args = request.arguments.unwrap_or_default();
|
||||
self.spawn_tool(args, |state, input| {
|
||||
tools::ingest_stdin::handle(&state, input)
|
||||
})
|
||||
.await
|
||||
}
|
||||
_other => Err(ErrorData::method_not_found::<
|
||||
rmcp::model::CallToolRequestMethod,
|
||||
>()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the MCP server on stdio JSON-RPC. Blocks until the client closes
|
||||
/// the stream (typically when the agent host exits).
|
||||
///
|
||||
/// `config_path` is the path passed via `--config <path>`, if any.
|
||||
/// It is forwarded to `KebabAppState` so the doctor tool can honour the
|
||||
/// same config file the server was started with (falls back to XDG default
|
||||
/// when `None`).
|
||||
pub fn serve_stdio(cfg: Config, config_path: Option<PathBuf>) -> Result<()> {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
runtime.block_on(serve_stdio_async(cfg, config_path))
|
||||
}
|
||||
|
||||
async fn serve_stdio_async(cfg: Config, config_path: Option<PathBuf>) -> Result<()> {
|
||||
tracing::info!("kebab-mcp: starting stdio server");
|
||||
let state = KebabAppState::new(cfg, config_path);
|
||||
let handler = KebabHandler::new(state);
|
||||
let service = handler.serve(stdio()).await?;
|
||||
service.waiting().await?;
|
||||
Ok(())
|
||||
}
|
||||
26
crates/kebab-mcp/src/state.rs
Normal file
26
crates/kebab-mcp/src/state.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
//! Long-lived server state — holds Config so per-request handlers don't
|
||||
//! reload from disk. Future: cache opened SqliteStore / Lance handles
|
||||
//! here so first tool call pays the cost, subsequent calls hit warm
|
||||
//! state.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use kebab_config::Config;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct KebabAppState {
|
||||
pub config: Arc<Config>,
|
||||
/// `--config <path>` from CLI when present, else `None` (XDG default
|
||||
/// fallback applies in `doctor_with_config_path`).
|
||||
pub config_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl KebabAppState {
|
||||
pub fn new(config: Config, config_path: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
config: Arc::new(config),
|
||||
config_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
68
crates/kebab-mcp/src/tools/ask.rs
Normal file
68
crates/kebab-mcp/src/tools/ask.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! `ask` tool — wraps `kebab_app::ask_with_config` (single-shot) or
|
||||
//! `kebab_app::ask_with_session_with_config` when `session_id` is provided.
|
||||
//! Input: { query, session_id?, mode? }. Output: answer.v1 JSON.
|
||||
//!
|
||||
//! `Answer` (kebab-core) does NOT carry a `schema_version` field; we tag
|
||||
//! it inline here, matching the pattern from `search.rs`.
|
||||
|
||||
use rmcp::model::CallToolResult;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{to_tool_error, to_tool_success};
|
||||
use crate::state::KebabAppState;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct AskInput {
|
||||
/// The user question.
|
||||
pub query: String,
|
||||
/// Optional session id for multi-turn RAG context.
|
||||
pub session_id: Option<String>,
|
||||
/// Optional retrieval mode override ("lexical" / "vector" / "hybrid"). Default "hybrid".
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
|
||||
pub fn handle(state: &KebabAppState, input: AskInput) -> CallToolResult {
|
||||
let mode = match input.mode.as_deref() {
|
||||
Some("lexical") => kebab_core::SearchMode::Lexical,
|
||||
Some("vector") => kebab_core::SearchMode::Vector,
|
||||
_ => kebab_core::SearchMode::Hybrid, // default + "hybrid" + unknown
|
||||
};
|
||||
let opts = kebab_app::AskOpts {
|
||||
k: 10,
|
||||
explain: false,
|
||||
mode,
|
||||
temperature: None,
|
||||
seed: None,
|
||||
stream_sink: None,
|
||||
history: Vec::new(),
|
||||
conversation_id: None,
|
||||
turn_index: None,
|
||||
};
|
||||
let cfg_clone = (*state.config).clone();
|
||||
let result = match input.session_id {
|
||||
Some(sid) => {
|
||||
kebab_app::ask_with_session_with_config(cfg_clone, &sid, &input.query, opts)
|
||||
}
|
||||
None => kebab_app::ask_with_config(cfg_clone, &input.query, opts),
|
||||
};
|
||||
match result {
|
||||
Ok(answer) => {
|
||||
// `Answer` does not carry `schema_version`; tag inline (idempotent
|
||||
// via entry().or_insert_with in case a future version adds it).
|
||||
let mut v = match serde_json::to_value(&answer) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return to_tool_error(&anyhow::anyhow!("answer serialize failed: {e}")),
|
||||
};
|
||||
if let serde_json::Value::Object(ref mut map) = v {
|
||||
map.entry("schema_version".to_string())
|
||||
.or_insert_with(|| serde_json::Value::String("answer.v1".to_string()));
|
||||
}
|
||||
match serde_json::to_string(&v) {
|
||||
Ok(json) => to_tool_success(json),
|
||||
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
|
||||
}
|
||||
}
|
||||
Err(e) => to_tool_error(&e),
|
||||
}
|
||||
}
|
||||
28
crates/kebab-mcp/src/tools/doctor.rs
Normal file
28
crates/kebab-mcp/src/tools/doctor.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! `doctor` tool — wraps `kebab_app::doctor_with_config_path`.
|
||||
//! Input: {} (no args). Output: doctor.v1 JSON.
|
||||
//!
|
||||
//! `doctor_with_config_path(Option<&Path>)` re-reads config from disk so
|
||||
//! the report reflects the live file state. We forward `config_path` from
|
||||
//! `KebabAppState` so `--config <path>` users see results for their file;
|
||||
//! callers that pass `None` fall back to the XDG default (same as the CLI
|
||||
//! bare `kebab doctor`).
|
||||
|
||||
use rmcp::model::CallToolResult;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{to_tool_error, to_tool_success};
|
||||
use crate::state::KebabAppState;
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct DoctorInput {}
|
||||
|
||||
pub fn handle(state: &KebabAppState, _input: DoctorInput) -> CallToolResult {
|
||||
match kebab_app::doctor_with_config_path(state.config_path.as_deref()) {
|
||||
Ok(report) => match serde_json::to_string(&report) {
|
||||
Ok(json) => to_tool_success(json),
|
||||
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
|
||||
},
|
||||
Err(e) => to_tool_error(&e),
|
||||
}
|
||||
}
|
||||
39
crates/kebab-mcp/src/tools/ingest_file.rs
Normal file
39
crates/kebab-mcp/src/tools/ingest_file.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
//! `ingest_file` tool — wraps `kebab_app::ingest_file_with_config`.
|
||||
//! Input: { path }. Output: ingest_report.v1 JSON.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use rmcp::model::CallToolResult;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{to_tool_error, to_tool_success};
|
||||
use crate::state::KebabAppState;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct IngestFileInput {
|
||||
/// Absolute or relative path to the file to ingest. Workspace external
|
||||
/// paths are allowed — bytes are copied into `_external/`.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
pub fn handle(state: &KebabAppState, input: IngestFileInput) -> CallToolResult {
|
||||
let cfg_clone = (*state.config).clone();
|
||||
let path = PathBuf::from(input.path);
|
||||
match kebab_app::ingest_file_with_config(cfg_clone, &path) {
|
||||
Ok(report) => match serde_json::to_value(&report) {
|
||||
Ok(mut v) => {
|
||||
if let serde_json::Value::Object(ref mut map) = v {
|
||||
map.entry("schema_version".to_string())
|
||||
.or_insert_with(|| serde_json::Value::String("ingest_report.v1".to_string()));
|
||||
}
|
||||
match serde_json::to_string(&v) {
|
||||
Ok(json) => to_tool_success(json),
|
||||
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
|
||||
}
|
||||
}
|
||||
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
|
||||
},
|
||||
Err(e) => to_tool_error(&e),
|
||||
}
|
||||
}
|
||||
44
crates/kebab-mcp/src/tools/ingest_stdin.rs
Normal file
44
crates/kebab-mcp/src/tools/ingest_stdin.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
//! `ingest_stdin` tool — wraps `kebab_app::ingest_stdin_with_config`.
|
||||
//! Input: { content, title, source_uri? }. Output: ingest_report.v1 JSON.
|
||||
|
||||
use rmcp::model::CallToolResult;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{to_tool_error, to_tool_success};
|
||||
use crate::state::KebabAppState;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct IngestStdinInput {
|
||||
/// Markdown body content. v1 supports markdown only.
|
||||
pub content: String,
|
||||
/// Title for frontmatter injection.
|
||||
pub title: String,
|
||||
/// Optional source URI (e.g. https URL agent fetched from).
|
||||
pub source_uri: Option<String>,
|
||||
}
|
||||
|
||||
pub fn handle(state: &KebabAppState, input: IngestStdinInput) -> CallToolResult {
|
||||
let cfg_clone = (*state.config).clone();
|
||||
match kebab_app::ingest_stdin_with_config(
|
||||
cfg_clone,
|
||||
&input.content,
|
||||
&input.title,
|
||||
input.source_uri.as_deref(),
|
||||
) {
|
||||
Ok(report) => match serde_json::to_value(&report) {
|
||||
Ok(mut v) => {
|
||||
if let serde_json::Value::Object(ref mut map) = v {
|
||||
map.entry("schema_version".to_string())
|
||||
.or_insert_with(|| serde_json::Value::String("ingest_report.v1".to_string()));
|
||||
}
|
||||
match serde_json::to_string(&v) {
|
||||
Ok(json) => to_tool_success(json),
|
||||
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
|
||||
}
|
||||
}
|
||||
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
|
||||
},
|
||||
Err(e) => to_tool_error(&e),
|
||||
}
|
||||
}
|
||||
8
crates/kebab-mcp/src/tools/mod.rs
Normal file
8
crates/kebab-mcp/src/tools/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
//! Tool implementations — one module per tool.
|
||||
|
||||
pub mod schema;
|
||||
pub mod doctor;
|
||||
pub mod search;
|
||||
pub mod ask;
|
||||
pub mod ingest_file;
|
||||
pub mod ingest_stdin;
|
||||
22
crates/kebab-mcp/src/tools/schema.rs
Normal file
22
crates/kebab-mcp/src/tools/schema.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! `schema` tool — wraps `kebab_app::schema_with_config`.
|
||||
//! Input: {} (no args). Output: schema.v1 JSON.
|
||||
|
||||
use rmcp::model::CallToolResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use schemars::JsonSchema;
|
||||
|
||||
use crate::error::{to_tool_error, to_tool_success};
|
||||
use crate::state::KebabAppState;
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct SchemaInput {}
|
||||
|
||||
pub fn handle(state: &KebabAppState, _input: SchemaInput) -> CallToolResult {
|
||||
match kebab_app::schema_with_config(&state.config) {
|
||||
Ok(report) => match serde_json::to_string(&report) {
|
||||
Ok(json) => to_tool_success(json),
|
||||
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
|
||||
},
|
||||
Err(e) => to_tool_error(&e),
|
||||
}
|
||||
}
|
||||
71
crates/kebab-mcp/src/tools/search.rs
Normal file
71
crates/kebab-mcp/src/tools/search.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! `search` tool — wraps `kebab_app::search_with_config`.
|
||||
//! Input: { query, mode?, k? }. Output: search_hit.v1 array JSON.
|
||||
//!
|
||||
//! First tool with a non-empty `inputSchema`: `SearchInput` derives
|
||||
//! `JsonSchema` and `Tool::new` uses
|
||||
//! `rmcp::handler::server::common::schema_for_type::<SearchInput>()`.
|
||||
|
||||
use rmcp::model::CallToolResult;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{to_tool_error, to_tool_success};
|
||||
use crate::state::KebabAppState;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct SearchInput {
|
||||
/// User query (free text).
|
||||
pub query: String,
|
||||
/// Retrieval mode: "hybrid" (default), "lexical", or "vector".
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: String,
|
||||
/// Top-K results. Defaults to 10. Clamped to 1–100.
|
||||
#[serde(default = "default_k")]
|
||||
pub k: usize,
|
||||
}
|
||||
|
||||
fn default_mode() -> String {
|
||||
"hybrid".to_string()
|
||||
}
|
||||
fn default_k() -> usize {
|
||||
10
|
||||
}
|
||||
|
||||
pub fn handle(state: &KebabAppState, input: SearchInput) -> CallToolResult {
|
||||
let k = input.k.clamp(1, 100);
|
||||
let mode = match input.mode.as_str() {
|
||||
"lexical" => kebab_core::SearchMode::Lexical,
|
||||
"vector" => kebab_core::SearchMode::Vector,
|
||||
_ => kebab_core::SearchMode::Hybrid,
|
||||
};
|
||||
let query = kebab_core::SearchQuery {
|
||||
text: input.query,
|
||||
mode,
|
||||
k,
|
||||
filters: kebab_core::SearchFilters::default(),
|
||||
};
|
||||
match kebab_app::search_with_config((*state.config).clone(), query) {
|
||||
Ok(hits) => {
|
||||
// SearchHit (kebab-core) does not carry a `schema_version` field,
|
||||
// so we tag each element inline before serialising.
|
||||
let tagged: Vec<serde_json::Value> = hits
|
||||
.iter()
|
||||
.map(|h| {
|
||||
let mut v = serde_json::to_value(h).unwrap_or_default();
|
||||
if let serde_json::Value::Object(ref mut map) = v {
|
||||
map.insert(
|
||||
"schema_version".to_string(),
|
||||
serde_json::Value::String("search_hit.v1".to_string()),
|
||||
);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
match serde_json::to_string(&serde_json::Value::Array(tagged)) {
|
||||
Ok(json) => to_tool_success(json),
|
||||
Err(e) => to_tool_error(&anyhow::anyhow!(e)),
|
||||
}
|
||||
}
|
||||
Err(e) => to_tool_error(&e),
|
||||
}
|
||||
}
|
||||
36
crates/kebab-mcp/tests/error_mapping.rs
Normal file
36
crates/kebab-mcp/tests/error_mapping.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
//! tools/call with bad config → isError=true + error.v1 content.
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_mcp::{KebabAppState, KebabHandler};
|
||||
use rmcp::model::RawContent;
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_tool_emits_error_v1_when_db_missing() {
|
||||
// Point at a directory that does NOT have kebab.sqlite.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.storage.data_dir = dir.path().to_string_lossy().into_owned();
|
||||
cfg.workspace.root = dir.path().join("notes").to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
// Note: NO ingest call — kebab.sqlite is absent → schema_with_config
|
||||
// calls open_existing → NotIndexed → tool error.
|
||||
|
||||
let state = KebabAppState::new(cfg, None);
|
||||
let handler = KebabHandler::new(state);
|
||||
|
||||
let result = kebab_mcp::tools::schema::handle(
|
||||
handler.state(),
|
||||
kebab_mcp::tools::schema::SchemaInput::default(),
|
||||
);
|
||||
assert_eq!(result.is_error, Some(true), "expected isError=true on missing DB");
|
||||
|
||||
let content = result.content.first().unwrap();
|
||||
let text = match &content.raw {
|
||||
RawContent::Text(t) => &t.text,
|
||||
other => panic!("expected text content, got {other:?}"),
|
||||
};
|
||||
let v: serde_json::Value = serde_json::from_str(text).unwrap();
|
||||
assert_eq!(v.get("schema_version").and_then(|s| s.as_str()), Some("error.v1"));
|
||||
assert_eq!(v.get("code").and_then(|s| s.as_str()), Some("not_indexed"));
|
||||
}
|
||||
19
crates/kebab-mcp/tests/initialize.rs
Normal file
19
crates/kebab-mcp/tests/initialize.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
//! Integration: KebabHandler::get_info returns correct kebab serverInfo.
|
||||
//! Doesn't exercise full transport — that lands when we have at least
|
||||
//! one tool to call (Task 4+).
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_mcp::{KebabAppState, KebabHandler};
|
||||
use rmcp::ServerHandler;
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_returns_kebab_server_info() {
|
||||
let cfg = Config::defaults();
|
||||
let state = KebabAppState::new(cfg, None);
|
||||
let handler = KebabHandler::new(state);
|
||||
|
||||
let info = handler.get_info();
|
||||
assert_eq!(info.server_info.name, "kebab");
|
||||
assert!(!info.server_info.version.is_empty());
|
||||
assert!(info.capabilities.tools.is_some());
|
||||
}
|
||||
92
crates/kebab-mcp/tests/tools_call_ask.rs
Normal file
92
crates/kebab-mcp/tests/tools_call_ask.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
//! `ask` tool returns answer.v1 — refusal path covered (no Ollama
|
||||
//! required for refusal-on-empty-corpus case).
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_core::SourceScope;
|
||||
use kebab_mcp::{KebabAppState, KebabHandler};
|
||||
use rmcp::model::RawContent;
|
||||
|
||||
fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path) -> Config {
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.storage.data_dir = data_dir.to_string_lossy().into_owned();
|
||||
cfg.storage.model_dir = data_dir
|
||||
.join("models")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
cfg.workspace.root = workspace_root.to_string_lossy().into_owned();
|
||||
cfg.workspace.exclude.clear();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
cfg
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ask_tool_returns_answer_v1_with_refusal_on_empty_kb() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = dir.path().join("data");
|
||||
let workspace_root = dir.path().join("notes");
|
||||
std::fs::create_dir_all(&data_dir).unwrap();
|
||||
std::fs::create_dir_all(&workspace_root).unwrap();
|
||||
|
||||
let cfg = minimal_config(&data_dir, &workspace_root);
|
||||
|
||||
// Seed kebab.sqlite (empty corpus — no documents ingested).
|
||||
let scope = SourceScope {
|
||||
root: workspace_root.clone(),
|
||||
include: vec![],
|
||||
exclude: vec![],
|
||||
};
|
||||
let _ = kebab_app::ingest_with_config(cfg.clone(), scope, false).unwrap();
|
||||
|
||||
let state = KebabAppState::new(cfg, None);
|
||||
let handler = KebabHandler::new(state);
|
||||
|
||||
// `ask_with_config` builds a `reqwest::blocking::Client` internally (for
|
||||
// `OllamaLanguageModel`), which spins up and drops a tokio runtime — that
|
||||
// panics when called from inside an async context. Run it on the blocking
|
||||
// thread pool to avoid the conflict.
|
||||
let state_clone = handler.state().clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
kebab_mcp::tools::ask::handle(
|
||||
&state_clone,
|
||||
kebab_mcp::tools::ask::AskInput {
|
||||
query: "what is the meaning of life".to_string(),
|
||||
session_id: None,
|
||||
// Test env uses provider="none" — Hybrid would hard-error on embedding.
|
||||
// Pass Lexical explicitly so the test stays functional.
|
||||
mode: Some("lexical".to_string()),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Empty KB → refusal (grounded:false) is normal — NOT isError.
|
||||
assert!(
|
||||
!result.is_error.unwrap_or(false),
|
||||
"expected isError=false on refusal, got {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
let content = result
|
||||
.content
|
||||
.first()
|
||||
.expect("expected at least one content item");
|
||||
|
||||
let text = match &content.raw {
|
||||
RawContent::Text(t) => &t.text,
|
||||
other => panic!("expected text content, got {other:?}"),
|
||||
};
|
||||
|
||||
let v: serde_json::Value = serde_json::from_str(text).unwrap();
|
||||
assert_eq!(
|
||||
v.get("schema_version").and_then(|s| s.as_str()),
|
||||
Some("answer.v1"),
|
||||
"response should carry schema_version=answer.v1"
|
||||
);
|
||||
assert_eq!(
|
||||
v.get("grounded").and_then(|b| b.as_bool()),
|
||||
Some(false),
|
||||
"empty KB should produce grounded=false"
|
||||
);
|
||||
}
|
||||
50
crates/kebab-mcp/tests/tools_call_doctor.rs
Normal file
50
crates/kebab-mcp/tests/tools_call_doctor.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
//! Integration: tools/call name=doctor — returns doctor.v1.
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_mcp::{KebabAppState, KebabHandler};
|
||||
use rmcp::model::RawContent;
|
||||
|
||||
#[tokio::test]
|
||||
async fn doctor_tool_returns_doctor_v1_json() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.storage.data_dir = dir.path().join("data").to_string_lossy().into_owned();
|
||||
cfg.workspace.root = dir.path().join("notes").to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
std::fs::create_dir_all(&cfg.workspace.root).unwrap();
|
||||
|
||||
// Pass None for config_path — doctor falls back to XDG default probe
|
||||
// (path won't exist in the tempdir, which is fine; doctor reports it
|
||||
// as missing / error rather than panicking).
|
||||
let state = KebabAppState::new(cfg, None);
|
||||
let handler = KebabHandler::new(state);
|
||||
|
||||
let result = kebab_mcp::tools::doctor::handle(
|
||||
handler.state(),
|
||||
kebab_mcp::tools::doctor::DoctorInput::default(),
|
||||
);
|
||||
|
||||
let content = result
|
||||
.content
|
||||
.first()
|
||||
.expect("expected at least one content item");
|
||||
|
||||
let text = match &content.raw {
|
||||
RawContent::Text(t) => &t.text,
|
||||
other => panic!("expected text content, got {other:?}"),
|
||||
};
|
||||
|
||||
let v: serde_json::Value = serde_json::from_str(text).unwrap();
|
||||
assert_eq!(
|
||||
v.get("schema_version").and_then(|s| s.as_str()),
|
||||
Some("doctor.v1"),
|
||||
"unexpected schema_version in: {v}"
|
||||
);
|
||||
// `ok` boolean must be present (value may be false in CI where Ollama
|
||||
// is not reachable — that's expected and acceptable).
|
||||
assert!(
|
||||
v.get("ok").and_then(|b| b.as_bool()).is_some(),
|
||||
"`ok` field missing in doctor.v1 response: {v}"
|
||||
);
|
||||
}
|
||||
117
crates/kebab-mcp/tests/tools_call_ingest_file.rs
Normal file
117
crates/kebab-mcp/tests/tools_call_ingest_file.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! Integration: tools/call name=ingest_file → ingest_report.v1.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_mcp::{KebabAppState, KebabHandler};
|
||||
use rmcp::model::RawContent;
|
||||
|
||||
#[tokio::test]
|
||||
async fn ingest_file_tool_returns_ingest_report_v1() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("notes");
|
||||
let data = dir.path().join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.workspace.root = workspace.to_string_lossy().into_owned();
|
||||
cfg.storage.data_dir = data.to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
|
||||
let src = dir.path().join("doc.md");
|
||||
fs::write(&src, "# Title\n\nbody.").unwrap();
|
||||
|
||||
let state = KebabAppState::new(cfg, None);
|
||||
let handler = KebabHandler::new(state);
|
||||
|
||||
let result = tokio::task::spawn_blocking({
|
||||
let state = handler.state().clone();
|
||||
let path = src.to_string_lossy().into_owned();
|
||||
move || {
|
||||
kebab_mcp::tools::ingest_file::handle(
|
||||
&state,
|
||||
kebab_mcp::tools::ingest_file::IngestFileInput { path },
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.is_error.unwrap_or(false), "{result:?}");
|
||||
let text = match &result.content.first().unwrap().raw {
|
||||
RawContent::Text(t) => &t.text,
|
||||
other => panic!("expected text content, got {other:?}"),
|
||||
};
|
||||
let v: serde_json::Value = serde_json::from_str(text).unwrap();
|
||||
assert_eq!(
|
||||
v.get("schema_version").and_then(|s| s.as_str()),
|
||||
Some("ingest_report.v1")
|
||||
);
|
||||
assert_eq!(v.get("new").and_then(|n| n.as_u64()), Some(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ingest_file_tool_idempotent_on_second_call() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("notes");
|
||||
let data = dir.path().join("data");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
std::fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let mut cfg = kebab_config::Config::defaults();
|
||||
cfg.workspace.root = workspace.to_string_lossy().into_owned();
|
||||
cfg.storage.data_dir = data.to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
|
||||
let src = dir.path().join("doc.md");
|
||||
std::fs::write(&src, "# A\n\nbody.").unwrap();
|
||||
|
||||
let state = kebab_mcp::KebabAppState::new(cfg, None);
|
||||
let handler = kebab_mcp::KebabHandler::new(state);
|
||||
|
||||
// First call.
|
||||
let r1 = tokio::task::spawn_blocking({
|
||||
let state = handler.state().clone();
|
||||
let path = src.to_string_lossy().into_owned();
|
||||
move || {
|
||||
kebab_mcp::tools::ingest_file::handle(
|
||||
&state,
|
||||
kebab_mcp::tools::ingest_file::IngestFileInput { path },
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!r1.is_error.unwrap_or(false));
|
||||
let text1 = match &r1.content.first().unwrap().raw {
|
||||
rmcp::model::RawContent::Text(t) => &t.text,
|
||||
other => panic!("expected text, got {other:?}"),
|
||||
};
|
||||
let v1: serde_json::Value = serde_json::from_str(text1).unwrap();
|
||||
assert_eq!(v1.get("new").and_then(|n| n.as_u64()), Some(1));
|
||||
|
||||
// Second call — same content, expect unchanged=1.
|
||||
let r2 = tokio::task::spawn_blocking({
|
||||
let state = handler.state().clone();
|
||||
let path = src.to_string_lossy().into_owned();
|
||||
move || {
|
||||
kebab_mcp::tools::ingest_file::handle(
|
||||
&state,
|
||||
kebab_mcp::tools::ingest_file::IngestFileInput { path },
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!r2.is_error.unwrap_or(false));
|
||||
let text2 = match &r2.content.first().unwrap().raw {
|
||||
rmcp::model::RawContent::Text(t) => &t.text,
|
||||
other => panic!("expected text, got {other:?}"),
|
||||
};
|
||||
let v2: serde_json::Value = serde_json::from_str(text2).unwrap();
|
||||
assert_eq!(v2.get("new").and_then(|n| n.as_u64()), Some(0), "{v2:?}");
|
||||
assert_eq!(v2.get("unchanged").and_then(|n| n.as_u64()), Some(1), "{v2:?}");
|
||||
}
|
||||
89
crates/kebab-mcp/tests/tools_call_ingest_stdin.rs
Normal file
89
crates/kebab-mcp/tests/tools_call_ingest_stdin.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! Integration: tools/call name=ingest_stdin → ingest_report.v1.
|
||||
//! Frontmatter precheck path also covered.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_mcp::KebabAppState;
|
||||
use rmcp::model::RawContent;
|
||||
|
||||
fn fresh_state(dir: &std::path::Path) -> KebabAppState {
|
||||
let workspace = dir.join("notes");
|
||||
let data = dir.join("data");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.workspace.root = workspace.to_string_lossy().into_owned();
|
||||
cfg.storage.data_dir = data.to_string_lossy().into_owned();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
KebabAppState::new(cfg, None)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ingest_stdin_tool_returns_ingest_report_v1() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = fresh_state(dir.path());
|
||||
|
||||
let result = tokio::task::spawn_blocking({
|
||||
let state = state.clone();
|
||||
move || {
|
||||
kebab_mcp::tools::ingest_stdin::handle(
|
||||
&state,
|
||||
kebab_mcp::tools::ingest_stdin::IngestStdinInput {
|
||||
content: "## Body".to_string(),
|
||||
title: "X".to_string(),
|
||||
source_uri: Some("https://example.com/x".to_string()),
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.is_error.unwrap_or(false), "{result:?}");
|
||||
let text = match &result.content.first().unwrap().raw {
|
||||
RawContent::Text(t) => &t.text,
|
||||
other => panic!("expected text content, got {other:?}"),
|
||||
};
|
||||
let v: serde_json::Value = serde_json::from_str(text).unwrap();
|
||||
assert_eq!(
|
||||
v.get("schema_version").and_then(|s| s.as_str()),
|
||||
Some("ingest_report.v1")
|
||||
);
|
||||
assert_eq!(v.get("new").and_then(|n| n.as_u64()), Some(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ingest_stdin_tool_emits_error_v1_on_existing_frontmatter() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = fresh_state(dir.path());
|
||||
|
||||
let result = tokio::task::spawn_blocking({
|
||||
let state = state.clone();
|
||||
move || {
|
||||
kebab_mcp::tools::ingest_stdin::handle(
|
||||
&state,
|
||||
kebab_mcp::tools::ingest_stdin::IngestStdinInput {
|
||||
content: "---\ntitle: Existing\n---\n\n## Body".to_string(),
|
||||
title: "New".to_string(),
|
||||
source_uri: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.is_error, Some(true), "{result:?}");
|
||||
let text = match &result.content.first().unwrap().raw {
|
||||
RawContent::Text(t) => &t.text,
|
||||
other => panic!("expected text content, got {other:?}"),
|
||||
};
|
||||
let v: serde_json::Value = serde_json::from_str(text).unwrap();
|
||||
assert_eq!(
|
||||
v.get("schema_version").and_then(|s| s.as_str()),
|
||||
Some("error.v1")
|
||||
);
|
||||
}
|
||||
75
crates/kebab-mcp/tests/tools_call_schema.rs
Normal file
75
crates/kebab-mcp/tests/tools_call_schema.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
//! Integration: tools/call name=schema — verify response is schema.v1.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_core::SourceScope;
|
||||
use kebab_mcp::{KebabAppState, KebabHandler};
|
||||
use rmcp::model::RawContent;
|
||||
|
||||
fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path) -> Config {
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.storage.data_dir = data_dir.to_string_lossy().into_owned();
|
||||
cfg.storage.model_dir = data_dir
|
||||
.join("models")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
cfg.workspace.root = workspace_root.to_string_lossy().into_owned();
|
||||
cfg.workspace.exclude.clear();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
cfg
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_tool_returns_schema_v1_json() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = dir.path().join("data");
|
||||
let workspace_root = dir.path().join("notes");
|
||||
fs::create_dir_all(&data_dir).unwrap();
|
||||
fs::create_dir_all(&workspace_root).unwrap();
|
||||
|
||||
let config = minimal_config(&data_dir, &workspace_root);
|
||||
|
||||
// Seed kebab.sqlite via 0-file ingest so open_existing succeeds later.
|
||||
let scope = SourceScope {
|
||||
root: workspace_root.clone(),
|
||||
include: vec![],
|
||||
exclude: vec![],
|
||||
};
|
||||
let _ = kebab_app::ingest_with_config(config.clone(), scope, false).unwrap();
|
||||
|
||||
let state = KebabAppState::new(config, None);
|
||||
let handler = KebabHandler::new(state);
|
||||
|
||||
let result = kebab_mcp::tools::schema::handle(
|
||||
handler.state(),
|
||||
kebab_mcp::tools::schema::SchemaInput::default(),
|
||||
);
|
||||
|
||||
assert!(
|
||||
!result.is_error.unwrap_or(false),
|
||||
"expected isError=false on healthy schema, got {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
let content = result.content.first().expect("expected at least one content item");
|
||||
|
||||
// Content = Annotated<RawContent>; deref to get the inner RawContent.
|
||||
let text = match &content.raw {
|
||||
RawContent::Text(t) => &t.text,
|
||||
other => panic!("expected text content, got {other:?}"),
|
||||
};
|
||||
|
||||
let v: serde_json::Value = serde_json::from_str(text).unwrap();
|
||||
assert_eq!(
|
||||
v.get("schema_version").and_then(|s| s.as_str()),
|
||||
Some("schema.v1"),
|
||||
"unexpected schema_version in: {v}"
|
||||
);
|
||||
assert_eq!(
|
||||
v.get("capabilities").and_then(|c| c.get("mcp_server")).and_then(|b| b.as_bool()),
|
||||
Some(true),
|
||||
"mcp_server capability flag should be true after fb-30",
|
||||
);
|
||||
}
|
||||
90
crates/kebab-mcp/tests/tools_call_search.rs
Normal file
90
crates/kebab-mcp/tests/tools_call_search.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
//! Integration: tools/call name=search — verify response is search_hit.v1 array.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_core::SourceScope;
|
||||
use kebab_mcp::{KebabAppState, KebabHandler};
|
||||
use rmcp::model::RawContent;
|
||||
|
||||
fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path) -> Config {
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.storage.data_dir = data_dir.to_string_lossy().into_owned();
|
||||
cfg.storage.model_dir = data_dir
|
||||
.join("models")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
cfg.workspace.root = workspace_root.to_string_lossy().into_owned();
|
||||
cfg.workspace.exclude.clear();
|
||||
cfg.models.embedding.provider = "none".to_string();
|
||||
cfg.models.embedding.dimensions = 0;
|
||||
cfg
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_tool_returns_search_hits_array() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = dir.path().join("data");
|
||||
let workspace_root = dir.path().join("notes");
|
||||
fs::create_dir_all(&data_dir).unwrap();
|
||||
fs::create_dir_all(&workspace_root).unwrap();
|
||||
|
||||
let config = minimal_config(&data_dir, &workspace_root);
|
||||
|
||||
// Write a markdown document containing the query term.
|
||||
fs::write(
|
||||
workspace_root.join("a.md"),
|
||||
"# Alpha\n\nThis document mentions kebab and bread.",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Seed kebab.sqlite via ingest so search has indexed content.
|
||||
let scope = SourceScope {
|
||||
root: workspace_root.clone(),
|
||||
include: vec![],
|
||||
exclude: vec![],
|
||||
};
|
||||
let _ = kebab_app::ingest_with_config(config.clone(), scope, false).unwrap();
|
||||
|
||||
let state = KebabAppState::new(config, None);
|
||||
let handler = KebabHandler::new(state);
|
||||
|
||||
let result = kebab_mcp::tools::search::handle(
|
||||
handler.state(),
|
||||
kebab_mcp::tools::search::SearchInput {
|
||||
query: "kebab".to_string(),
|
||||
mode: "lexical".to_string(),
|
||||
k: 5,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(
|
||||
!result.is_error.unwrap_or(false),
|
||||
"expected isError=false, got {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
let content = result
|
||||
.content
|
||||
.first()
|
||||
.expect("expected at least one content item");
|
||||
|
||||
let text = match &content.raw {
|
||||
RawContent::Text(t) => &t.text,
|
||||
other => panic!("expected text content, got {other:?}"),
|
||||
};
|
||||
|
||||
let v: serde_json::Value = serde_json::from_str(text).unwrap();
|
||||
let arr = v.as_array().expect("search returns a JSON array");
|
||||
assert!(
|
||||
!arr.is_empty(),
|
||||
"expected at least one hit for 'kebab' in 'a.md'"
|
||||
);
|
||||
assert_eq!(
|
||||
arr[0]
|
||||
.get("schema_version")
|
||||
.and_then(|s| s.as_str()),
|
||||
Some("search_hit.v1"),
|
||||
"first hit should carry schema_version=search_hit.v1"
|
||||
);
|
||||
}
|
||||
70
crates/kebab-mcp/tests/tools_list.rs
Normal file
70
crates/kebab-mcp/tests/tools_list.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
//! Integration: `build_tools_vec` returns 6 tools with correct names and
|
||||
//! inputSchema. Uses the extracted `pub fn build_tools_vec()` helper — no
|
||||
//! transport or RequestContext needed.
|
||||
|
||||
use kebab_mcp::build_tools_vec;
|
||||
|
||||
#[test]
|
||||
fn tools_list_returns_six_tools() {
|
||||
let tools = build_tools_vec();
|
||||
assert_eq!(tools.len(), 6, "expected exactly 6 tools, got {}", tools.len());
|
||||
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
|
||||
assert!(names.contains(&"schema"), "missing 'schema' tool");
|
||||
assert!(names.contains(&"doctor"), "missing 'doctor' tool");
|
||||
assert!(names.contains(&"search"), "missing 'search' tool");
|
||||
assert!(names.contains(&"ask"), "missing 'ask' tool");
|
||||
assert!(names.contains(&"ingest_file"), "missing 'ingest_file' tool");
|
||||
assert!(names.contains(&"ingest_stdin"), "missing 'ingest_stdin' tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_tool_input_schema_has_required_query() {
|
||||
let tools = build_tools_vec();
|
||||
let search = tools
|
||||
.iter()
|
||||
.find(|t| t.name.as_ref() == "search")
|
||||
.expect("search tool must be present");
|
||||
|
||||
// input_schema is Arc<JsonObject> (serde_json::Map<String, Value>).
|
||||
let schema_val = serde_json::Value::Object(search.input_schema.as_ref().clone());
|
||||
|
||||
let required = schema_val
|
||||
.get("required")
|
||||
.and_then(|v| v.as_array())
|
||||
.expect("search inputSchema must have a 'required' array");
|
||||
|
||||
assert!(
|
||||
required.iter().any(|v| v.as_str() == Some("query")),
|
||||
"search inputSchema 'required' must contain 'query', got: {required:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_and_doctor_tools_accept_empty_input() {
|
||||
let tools = build_tools_vec();
|
||||
|
||||
for name in &["schema", "doctor"] {
|
||||
let tool = tools
|
||||
.iter()
|
||||
.find(|t| t.name.as_ref() == *name)
|
||||
.unwrap_or_else(|| panic!("{name} tool must be present"));
|
||||
|
||||
let schema_val = serde_json::Value::Object(tool.input_schema.as_ref().clone());
|
||||
// An empty-input schema has type "object" and no required fields
|
||||
// (or no 'required' key at all).
|
||||
let ty = schema_val.get("type").and_then(|v| v.as_str());
|
||||
assert_eq!(
|
||||
ty,
|
||||
Some("object"),
|
||||
"{name} inputSchema 'type' must be 'object', got {ty:?}"
|
||||
);
|
||||
|
||||
if let Some(required) = schema_val.get("required").and_then(|v| v.as_array()) {
|
||||
assert!(
|
||||
required.is_empty(),
|
||||
"{name} inputSchema 'required' must be empty, got: {required:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,3 +19,10 @@ pub mod frontmatter;
|
||||
|
||||
pub use blocks::parse_blocks;
|
||||
pub use frontmatter::{BodyHints, FrontmatterSpan, parse_frontmatter};
|
||||
|
||||
/// Parser-version label for Markdown files ingested through this crate.
|
||||
/// Re-exported so `kebab-app::schema_with_config` can embed it in
|
||||
/// `SchemaV1.models.parser_version` without duplicating the literal.
|
||||
///
|
||||
/// Kept in sync with `KEBAB_PARSE_MD_VERSION` in `kebab-app/src/lib.rs`.
|
||||
pub const PARSER_VERSION: &str = "md-frontmatter-v2";
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"root": "/home/u/KB"
|
||||
},
|
||||
"skipped": 0,
|
||||
"skipped_by_extension": {},
|
||||
"unchanged": 0,
|
||||
"updated": 1
|
||||
}
|
||||
|
||||
@@ -34,4 +34,4 @@ pub use error::StoreError;
|
||||
pub use eval::{EvalQueryResultRecord, EvalRunRecord, EvalRunRow};
|
||||
pub use fts::rebuild_chunks_fts;
|
||||
pub use jobs::IngestRunRow;
|
||||
pub use store::SqliteStore;
|
||||
pub use store::{CountSummary, NotIndexed, SqliteStore};
|
||||
|
||||
@@ -11,11 +11,27 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use rusqlite::{Connection, OpenFlags, OptionalExtension, params};
|
||||
|
||||
use crate::error::StoreError;
|
||||
use crate::schema;
|
||||
|
||||
/// Signal: SQLite database file does not exist, or schema_version does
|
||||
/// not match the binary's expectation.
|
||||
///
|
||||
/// Distinct from generic I/O / SQL errors so kebab-cli can surface
|
||||
/// `code: "not_indexed"` with a hint to run `kebab init` / `kebab ingest`.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("not indexed: expected={expected}, found={found:?}")]
|
||||
pub struct NotIndexed {
|
||||
pub expected: String,
|
||||
/// When the DB file exists but the schema is incompatible, this holds
|
||||
/// the highest applied migration version string (e.g. `"V005"`).
|
||||
/// `None` means the file was absent entirely (current Task 3 behavior;
|
||||
/// schema-mismatch wrapping is a deferred follow-up).
|
||||
pub found: Option<String>,
|
||||
}
|
||||
|
||||
/// Monotonic counter used to namespace per-process temp file names so
|
||||
/// concurrent `put_asset_with_bytes` calls in the same millisecond cannot
|
||||
/// collide on `<final>.tmp.<pid>.<n>`.
|
||||
@@ -59,6 +75,47 @@ pub struct SqliteStore {
|
||||
}
|
||||
|
||||
impl SqliteStore {
|
||||
/// Open an existing SQLite DB at `path`.
|
||||
///
|
||||
/// Unlike [`Self::open`], this does NOT create the file — if it is
|
||||
/// missing, returns a [`NotIndexed`] signal suitable for `error.v1`
|
||||
/// translation. Opens read-write to support WAL pragmas; callers should
|
||||
/// not issue mutations through this connection — use [`Self::open`] for
|
||||
/// ingest paths.
|
||||
///
|
||||
/// **Does not run migrations** — call [`Self::run_migrations`] next if
|
||||
/// you need the schema initialised.
|
||||
pub fn open_existing(path: &std::path::Path) -> anyhow::Result<Self> {
|
||||
let conn = Connection::open_with_flags(
|
||||
path,
|
||||
OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_URI,
|
||||
)
|
||||
.map_err(|_| {
|
||||
anyhow::Error::new(NotIndexed {
|
||||
expected: path.to_string_lossy().to_string(),
|
||||
found: None,
|
||||
})
|
||||
})?;
|
||||
apply_pragmas(&conn)?;
|
||||
|
||||
let data_dir = path
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.to_path_buf();
|
||||
|
||||
tracing::debug!(
|
||||
target: "kebab-store-sqlite",
|
||||
db = %path.display(),
|
||||
"opened existing sqlite store"
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
data_dir,
|
||||
copy_threshold_bytes: 0,
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// Open (or create) the SQLite file under `config.storage.data_dir`,
|
||||
/// apply pragmas (foreign_keys / WAL / synchronous=NORMAL /
|
||||
/// temp_store=MEMORY), and create parent directories as needed.
|
||||
@@ -534,6 +591,61 @@ pub(crate) fn upsert_asset_row(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// p9-fb-27: aggregate counts for `SchemaV1.stats` block.
|
||||
///
|
||||
/// Returned by [`SqliteStore::count_summary`] and consumed by
|
||||
/// `kebab-app::schema_with_config` to populate the `stats` sub-object of the
|
||||
/// `schema.v1` wire record.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CountSummary {
|
||||
pub doc_count: u64,
|
||||
pub chunk_count: u64,
|
||||
pub asset_count: u64,
|
||||
/// ISO-8601 timestamp of the most-recently updated document row, or
|
||||
/// `None` when the store is empty.
|
||||
pub last_ingest_at: Option<String>,
|
||||
}
|
||||
|
||||
impl SqliteStore {
|
||||
/// Return aggregate counts from the three primary tables plus the
|
||||
/// most-recent `documents.updated_at` timestamp.
|
||||
///
|
||||
/// Uses `read_conn()` (no mutations) — mirrors the pattern used by
|
||||
/// [`Self::corpus_revision`].
|
||||
pub fn count_summary(&self) -> anyhow::Result<CountSummary> {
|
||||
let conn = self.read_conn();
|
||||
|
||||
let doc_count: u64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0))
|
||||
.context("count documents")?;
|
||||
|
||||
let chunk_count: u64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM chunks", [], |r| r.get(0))
|
||||
.context("count chunks")?;
|
||||
|
||||
let asset_count: u64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM assets", [], |r| r.get(0))
|
||||
.context("count assets")?;
|
||||
|
||||
let last_ingest_at: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT MAX(updated_at) FROM documents",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.context("max updated_at")?
|
||||
.flatten();
|
||||
|
||||
Ok(CountSummary {
|
||||
doc_count,
|
||||
chunk_count,
|
||||
asset_count,
|
||||
last_ingest_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the design §5 / task-spec pragmas. Called once per connection.
|
||||
/// Note: WAL is persistent (the journal-mode setting is sticky in the DB
|
||||
/// header) but `foreign_keys`, `synchronous`, and `temp_store` are
|
||||
@@ -548,3 +660,27 @@ fn apply_pragmas(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn open_fresh_store() -> (tempfile::TempDir, SqliteStore) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cfg = kebab_config::Config::defaults();
|
||||
cfg.storage.data_dir = dir.path().to_string_lossy().into_owned();
|
||||
let store = SqliteStore::open(&cfg).unwrap();
|
||||
store.run_migrations().unwrap();
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_summary_zero_on_fresh_store() {
|
||||
let (_dir, store) = open_fresh_store();
|
||||
let s = store.count_summary().unwrap();
|
||||
assert_eq!(s.doc_count, 0);
|
||||
assert_eq!(s.chunk_count, 0);
|
||||
assert_eq!(s.asset_count, 0);
|
||||
assert!(s.last_ingest_at.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ fn fixture_report() -> IngestReport {
|
||||
unchanged: 0,
|
||||
errors: 0,
|
||||
duration_ms: 187,
|
||||
skipped_by_extension: std::collections::BTreeMap::new(),
|
||||
items: Some(vec![
|
||||
IngestItem {
|
||||
kind: IngestItemKind::New,
|
||||
|
||||
27
crates/kebab-store-sqlite/tests/not_indexed.rs
Normal file
27
crates/kebab-store-sqlite/tests/not_indexed.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! Signal test: `SqliteStore::open_existing` emits `NotIndexed` when the DB
|
||||
//! file is absent.
|
||||
|
||||
use kebab_store_sqlite::{NotIndexed, SqliteStore};
|
||||
|
||||
#[test]
|
||||
fn not_indexed_signal_emitted_when_db_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nonexistent_db = dir.path().join("does-not-exist.sqlite");
|
||||
let res = SqliteStore::open_existing(&nonexistent_db);
|
||||
let err = match res {
|
||||
Ok(_) => panic!("opening a missing DB should fail"),
|
||||
Err(e) => e,
|
||||
};
|
||||
let signal = err
|
||||
.downcast_ref::<NotIndexed>()
|
||||
.expect("missing DB error should downcast to NotIndexed");
|
||||
assert_eq!(signal.expected, nonexistent_db.to_string_lossy().as_ref());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_existing_does_not_create_missing_db() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nonexistent_db = dir.path().join("does-not-exist.sqlite");
|
||||
let _ = SqliteStore::open_existing(&nonexistent_db);
|
||||
assert!(!nonexistent_db.exists(), "open_existing must NOT create the file");
|
||||
}
|
||||
@@ -28,4 +28,4 @@ mod arrow_batch;
|
||||
mod paths;
|
||||
mod store;
|
||||
|
||||
pub use store::LanceVectorStore;
|
||||
pub use store::{INDEX_VERSION_STR, LanceVectorStore};
|
||||
|
||||
@@ -44,6 +44,12 @@ const INDEX_KIND: &str = "flat";
|
||||
/// `v1` so re-runs produce stable IDs.
|
||||
const INDEX_VERSION: &str = "v1";
|
||||
|
||||
/// Public view of [`INDEX_VERSION`] for `kebab-app::schema_with_config`.
|
||||
/// The value is the same string — exposed as `pub const` so the schema
|
||||
/// facade can embed it in `SchemaV1.models.index_version` without
|
||||
/// reaching into a private constant.
|
||||
pub const INDEX_VERSION_STR: &str = INDEX_VERSION;
|
||||
|
||||
/// Lance VectorStore.
|
||||
///
|
||||
/// Holds a single `lancedb::Connection` opened against
|
||||
|
||||
@@ -36,8 +36,8 @@ pub fn start_ingest(app: &mut App) -> anyhow::Result<()> {
|
||||
let cfg = app.config.clone();
|
||||
let scope = SourceScope {
|
||||
root: std::path::PathBuf::from(&cfg.workspace.root),
|
||||
include: cfg.workspace.include.clone(),
|
||||
exclude: cfg.workspace.exclude.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let (tx, rx) = mpsc::channel::<IngestEvent>();
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
@@ -175,8 +175,9 @@ pub fn status_line(state: &IngestState) -> String {
|
||||
let elapsed = state.started_at.elapsed();
|
||||
let secs = elapsed.as_secs();
|
||||
if state.aborted {
|
||||
let skipped_breakdown = kebab_app::ingest_progress::render_skipped_breakdown(&state.counts.skipped_by_extension);
|
||||
return format!(
|
||||
"✗ ingest aborted at {}/{} after {}s (new={} updated={} unchanged={} skipped={} errors={})",
|
||||
"✗ ingest aborted at {}/{} after {}s (new={} updated={} unchanged={} skipped={}{} errors={})",
|
||||
state.counts.scanned.saturating_sub(state.counts.errors),
|
||||
state.counts.scanned,
|
||||
secs,
|
||||
@@ -184,16 +185,19 @@ pub fn status_line(state: &IngestState) -> String {
|
||||
state.counts.updated,
|
||||
state.counts.unchanged,
|
||||
state.counts.skipped,
|
||||
skipped_breakdown,
|
||||
state.counts.errors,
|
||||
);
|
||||
}
|
||||
let skipped_breakdown = kebab_app::ingest_progress::render_skipped_breakdown(&state.counts.skipped_by_extension);
|
||||
return format!(
|
||||
"✓ ingest: {} docs ({} new, {} updated, {} unchanged, {} skipped), {} chunks indexed in {}s",
|
||||
"✓ ingest: {} docs ({} new, {} updated, {} unchanged, {} skipped{}), {} chunks indexed in {}s",
|
||||
state.counts.scanned,
|
||||
state.counts.new,
|
||||
state.counts.updated,
|
||||
state.counts.unchanged,
|
||||
state.counts.skipped,
|
||||
skipped_breakdown,
|
||||
state.counts.chunks_indexed,
|
||||
secs,
|
||||
);
|
||||
@@ -288,7 +292,7 @@ mod tests {
|
||||
chunks_indexed: 50,
|
||||
..Default::default()
|
||||
};
|
||||
apply_event(&mut s, IngestEvent::Completed { counts: final_counts });
|
||||
apply_event(&mut s, IngestEvent::Completed { counts: final_counts.clone() });
|
||||
assert_eq!(s.counts, final_counts);
|
||||
assert!(s.terminal_at.is_some());
|
||||
assert!(!s.aborted);
|
||||
@@ -415,4 +419,44 @@ mod tests {
|
||||
// No worker to cancel — already terminated.
|
||||
assert!(!cancel_running_ingest(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_line_terminal_includes_skipped_breakdown() {
|
||||
let mut s = fresh_state();
|
||||
let skipped_by_extension = std::collections::BTreeMap::from([
|
||||
("docx".to_string(), 2u32),
|
||||
("txt".to_string(), 1u32),
|
||||
]);
|
||||
let counts = AggregateCounts {
|
||||
scanned: 10,
|
||||
skipped: 3,
|
||||
skipped_by_extension,
|
||||
..Default::default()
|
||||
};
|
||||
apply_event(&mut s, IngestEvent::Completed { counts });
|
||||
let line = status_line(&s);
|
||||
assert!(
|
||||
line.contains("3 skipped: 2 docx, 1 txt"),
|
||||
"breakdown must appear in: {line}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_line_aborted_includes_skipped_breakdown() {
|
||||
let mut s = fresh_state();
|
||||
let skipped_by_extension =
|
||||
std::collections::BTreeMap::from([("pdf".to_string(), 2u32)]);
|
||||
let counts = AggregateCounts {
|
||||
scanned: 5,
|
||||
skipped: 2,
|
||||
skipped_by_extension,
|
||||
..Default::default()
|
||||
};
|
||||
apply_event(&mut s, IngestEvent::Aborted { counts });
|
||||
let line = status_line(&s);
|
||||
assert!(
|
||||
line.contains("skipped=2: 2 pdf"),
|
||||
"breakdown must appear in: {line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ flowchart TB
|
||||
subgraph UI ["UI binary"]
|
||||
cli["kebab-cli"]
|
||||
tui["kebab-tui"]
|
||||
mcp["kebab-mcp<br/>(P9-FB-30)"]
|
||||
desktop["kebab-desktop<br/>(P9-5)"]
|
||||
end
|
||||
app["kebab-app<br/>(facade)"]
|
||||
@@ -71,6 +72,7 @@ flowchart TB
|
||||
|
||||
cli --> app
|
||||
tui --> app
|
||||
mcp --> app
|
||||
desktop --> app
|
||||
|
||||
app --> srcfs
|
||||
@@ -168,6 +170,7 @@ kebab/
|
||||
│ ├── kebab-parse-pdf/ # lopdf per-page text extractor (P7-1)
|
||||
│ ├── kebab-app/ # facade (P0 시그니처 + P3-5/P6-4/P7-3 본체)
|
||||
│ ├── kebab-tui/ # Ratatui shell + Library 패널 (P9-1)
|
||||
│ ├── kebab-mcp/ # stdio MCP server — tools: schema, doctor, search, ask (P9-FB-30)
|
||||
│ └── kebab-cli/ # binary (P0 → 핫픽스로 --config flag wiring 강화)
|
||||
├── migrations/ # SQLite refinery V001/V002/V003
|
||||
└── fixtures/ # 테스트 fixture 트리
|
||||
|
||||
486
docs/mcp-usage.md
Normal file
486
docs/mcp-usage.md
Normal file
@@ -0,0 +1,486 @@
|
||||
# MCP usage — agent integration guide
|
||||
|
||||
`kebab mcp` runs an MCP (Model Context Protocol) stdio JSON-RPC server. agent host (Claude Code / Cursor / OpenAI Agents / Copilot CLI 등) 가 본 binary 를 spawn 하여 KB 검색 / 답변 / ingest 를 호출.
|
||||
|
||||
shipped since **v0.3.1** (fb-30). 6 tool 으로 확장 (v0.3.2, fb-31).
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
binary 를 PATH 에 두고 (`cargo install --path crates/kebab-cli` 또는 release tarball), agent host 의 mcp config 에 등록:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kebab": {
|
||||
"command": "kebab",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
session 시작 시 host 가 `kebab mcp` 를 spawn — process 가 session 동안 살아 있어 SQLite / Lance / fastembed 가 hot. 첫 tool call 만 cold-start 비용, 이후 sub-100ms.
|
||||
|
||||
`--config` 옵션 thread:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kebab": {
|
||||
"command": "kebab",
|
||||
"args": ["--config", "/Users/me/.config/kebab/agent.toml", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Host config 예시
|
||||
|
||||
### Claude Code
|
||||
|
||||
`~/.claude/mcp.json` (또는 OS 별 동등 위치):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kebab": {
|
||||
"command": "kebab",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
session 재시작 후 `kebab` server 가 tool list 에 등장. agent 가 `mcp__kebab__search` / `mcp__kebab__ask` 등 호출 가능.
|
||||
|
||||
### Cursor
|
||||
|
||||
`~/.cursor/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kebab": {
|
||||
"command": "kebab",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Cursor 의 Composer / Agent 모드에서 활성화.
|
||||
|
||||
### OpenAI Agents (`agents-sdk`)
|
||||
|
||||
Python:
|
||||
|
||||
```python
|
||||
from openai_agents import Agent, MCPServerStdio
|
||||
|
||||
kebab = MCPServerStdio(
|
||||
name="kebab",
|
||||
params={"command": "kebab", "args": ["mcp"]},
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="researcher",
|
||||
mcp_servers=[kebab],
|
||||
)
|
||||
```
|
||||
|
||||
Node:
|
||||
|
||||
```ts
|
||||
import { Agent, MCPServerStdio } from "openai-agents";
|
||||
|
||||
const kebab = new MCPServerStdio({
|
||||
name: "kebab",
|
||||
params: { command: "kebab", args: ["mcp"] },
|
||||
});
|
||||
|
||||
const agent = new Agent({ name: "researcher", mcpServers: [kebab] });
|
||||
```
|
||||
|
||||
### Copilot CLI
|
||||
|
||||
`~/.config/copilot-cli/mcp.json` (or wherever the CLI looks):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kebab": {
|
||||
"command": "kebab",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 기타 host
|
||||
|
||||
stdio JSON-RPC MCP 표준을 따르는 모든 host 가 지원. 위 형식 (`command` + `args`) 만 맞추면 동작.
|
||||
|
||||
---
|
||||
|
||||
## Tool catalog (6 tools)
|
||||
|
||||
모든 tool 의 출력은 wire schema v1 JSON 을 MCP `text` content block 으로 직렬화. CLI `--json` 모드와 byte-동일 (single source of truth).
|
||||
|
||||
### `search` — corpus 검색
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Input | `{ "query": string, "mode"?: "lexical"\|"vector"\|"hybrid", "k"?: 1-100 }` |
|
||||
| Defaults | `mode = "hybrid"`, `k = 10` |
|
||||
| Output | `search_hit.v1` array, ranked |
|
||||
|
||||
예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "search",
|
||||
"arguments": {
|
||||
"query": "Kubernetes ingress controller setup",
|
||||
"mode": "hybrid",
|
||||
"k": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
응답 (한 hit 발췌):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"schema_version": "search_hit.v1",
|
||||
"rank": 1,
|
||||
"score": 0.847,
|
||||
"doc_id": "...",
|
||||
"chunk_id": "...",
|
||||
"doc_path": "k8s/ingress.md",
|
||||
"heading_path": ["Setup", "Ingress controller"],
|
||||
"snippet": "...",
|
||||
"citation": { ... }
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
**언제 사용**: 사용자가 \"문서 어디 있는지\" 묻거나, agent 가 답변 전 raw chunk 가 필요할 때.
|
||||
|
||||
### `ask` — RAG 답변
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Input | `{ "query": string, "session_id"?: string, "mode"?: "lexical"\|"vector"\|"hybrid" }` |
|
||||
| Defaults | `mode = "hybrid"` |
|
||||
| Output | `answer.v1` (single object) |
|
||||
|
||||
예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "ask",
|
||||
"arguments": {
|
||||
"query": "What's our internal Kubernetes ingress setup?",
|
||||
"session_id": "ops-onboarding-2026-05"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
응답:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "answer.v1",
|
||||
"answer": "...",
|
||||
"citations": [ ... ],
|
||||
"grounded": true,
|
||||
"refusal_reason": null,
|
||||
"model": { ... },
|
||||
"conversation_id": "...",
|
||||
"turn_index": 0
|
||||
}
|
||||
```
|
||||
|
||||
**`grounded: false` 처리**: KB 에 충분한 context 없음. `refusal_reason` 확인 후 사용자에게 \"KB 에 정보 없음\" 으로 안내, 본인 지식 fallback 또는 source 요청. **paraphrase 하면 안 됨** (hallucination 위험).
|
||||
|
||||
multi-turn 은 [Session 관리](#session-관리-multi-turn-ask) 참조.
|
||||
|
||||
### `schema` — capability discovery
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Input | `{}` (no args) |
|
||||
| Output | `schema.v1` |
|
||||
|
||||
예시:
|
||||
|
||||
```json
|
||||
{ "name": "schema", "arguments": {} }
|
||||
```
|
||||
|
||||
응답:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "schema.v1",
|
||||
"kebab_version": "0.3.2",
|
||||
"wire": { "schemas": ["answer.v1", "search_hit.v1", ...] },
|
||||
"capabilities": {
|
||||
"json_mode": true,
|
||||
"rag_multi_turn": true,
|
||||
"mcp_server": true,
|
||||
"streaming_ask": false,
|
||||
...
|
||||
},
|
||||
"models": { "parser_version": "...", "embedding_version": "...", ... },
|
||||
"stats": { "doc_count": 128, "chunk_count": 2147, "asset_count": 130, ... }
|
||||
}
|
||||
```
|
||||
|
||||
**언제 사용**: session 시작 시 한 번 — feature gate 결정 (`capabilities.streaming_ask` true 면 streaming 사용 등). cheap call (no LLM, no embedder), session 동안 1 회 충분.
|
||||
|
||||
### `doctor` — health check
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Input | `{}` (no args) |
|
||||
| Output | `doctor.v1` |
|
||||
|
||||
예시:
|
||||
|
||||
```json
|
||||
{ "name": "doctor", "arguments": {} }
|
||||
```
|
||||
|
||||
응답:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "doctor.v1",
|
||||
"ok": true,
|
||||
"checks": [
|
||||
{ "name": "config_loaded", "ok": true, "detail": "..." },
|
||||
{ "name": "ollama_reachable", "ok": true, "detail": "..." },
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**언제 사용**: 다른 tool 이 실패하거나 비정상 응답 줄 때 first triage. `ok: false` 면 `checks[]` 의 failed entry 가 원인 — 사용자에게 보고 후 stop (자동 retry 금지).
|
||||
|
||||
### `ingest_file` — 단일 파일 저장 (mutation)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Input | `{ "path": string }` |
|
||||
| Supported ext | `.md` / `.pdf` / `.png` / `.jpg` / `.jpeg` (`unsupported extension` error 그 외) |
|
||||
| Output | `ingest_report.v1` (single asset) |
|
||||
|
||||
예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "ingest_file",
|
||||
"arguments": { "path": "/Users/me/Downloads/article.md" }
|
||||
}
|
||||
```
|
||||
|
||||
응답:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "ingest_report.v1",
|
||||
"scanned": 1,
|
||||
"new": 1,
|
||||
"updated": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**언제 사용**: 사용자가 disk 의 file 을 KB 에 저장 의향 명시 시. workspace 외부 path OK — 파일은 `<workspace.root>/_external/<hash12>.<ext>` 으로 copy. 동일 content 재 ingest 면 idempotent (`unchanged: 1`).
|
||||
|
||||
**주의**: mutation tool — 사용자 명시 의도 없을 때 자동 호출 금지.
|
||||
|
||||
### `ingest_stdin` — stdin markdown 저장 (mutation)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Input | `{ "content": string, "title": string, "source_uri"?: string }` |
|
||||
| v1 scope | markdown only |
|
||||
| Output | `ingest_report.v1` (single asset) |
|
||||
|
||||
예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "ingest_stdin",
|
||||
"arguments": {
|
||||
"content": "## Article body\n\nMain text here.",
|
||||
"title": "Article X",
|
||||
"source_uri": "https://example.com/x"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
응답:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "ingest_report.v1",
|
||||
"scanned": 1,
|
||||
"new": 1,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**언제 사용**: agent 가 web fetch 한 markdown article 을 KB 에 저장. 사용자가 \"이거 나중에 또 보고 싶어\" 명시 시 또는 multi-turn 대화에서 자료 누적. content 가 이미 frontmatter (`---` 시작) 이면 error — `ingest_file` 사용.
|
||||
|
||||
`title` + `source_uri` 가 frontmatter 로 자동 prepend → `Document.metadata` 에 저장 → 후속 `search` 결과의 `doc_meta` 에 포함. agent 가 source URL 추적 가능.
|
||||
|
||||
**주의**: mutation tool. 같은 content 무한 ingest 안 함 (idempotent 보장이지만 embedding cost 낭비).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `isError: true` + `error.v1` content
|
||||
|
||||
tool dispatch 가 `Err` 반환 시. content 의 `error.v1` JSON 의 `code` 로 분기:
|
||||
|
||||
| code | 의미 | 조치 |
|
||||
|------|------|------|
|
||||
| `config_invalid` | `--config` path missing / TOML parse 실패 | path 확인 + `kebab schema` 로 검증. `details.path` + `details.cause` 확인. |
|
||||
| `not_indexed` | `kebab.sqlite` 미존재 / migration 미실행 | 사용자에게 `kebab init` + `kebab ingest` 실행 안내. retry 자동 금지. |
|
||||
| `model_unreachable` | Ollama endpoint 연결 실패 | Ollama 실행 확인 (`ollama serve`). `details.endpoint` 의 host 가 reachable 한지. retry 1-2 회 후 사용자 보고. |
|
||||
| `model_not_pulled` | Ollama model not found | 사용자에게 `ollama pull <model>` 안내 — `details.model` 표시. |
|
||||
| `timeout` | LLM stream / embed deadline 초과 | 일시적이면 retry 1 회. 재발 시 사용자 보고 (model 응답 느림 / Ollama load). |
|
||||
| `io_error` | filesystem / 권한 / disk full | `details.kind` 보고 사용자에게 disk space / permission 확인 안내. |
|
||||
| `generic` | catch-all | `details.chain` (verbose 시) 보고 사용자에게 그대로 전달. retry 금지. |
|
||||
|
||||
`hint` field 가 있으면 사용자에게 그대로 보여주기 (각 code 의 가장 빠른 조치).
|
||||
|
||||
### `grounded: false` (ask refusal)
|
||||
|
||||
`isError: false` (정상 응답). KB 에 충분한 context 없음. `refusal_reason` 확인 후:
|
||||
|
||||
- `NoChunks` — 검색 자체가 0 hit. 다른 표현 / 더 일반적인 query 시도.
|
||||
- `LowScores` — hit 있지만 score gate 미달. `kebab search` (별도) 로 raw hit 확인.
|
||||
- 그 외 — refusal 메시지 그대로 사용자에게 보고.
|
||||
|
||||
자동 paraphrase 금지. 사용자에게 \"KB 에 정보 없음\" 명시 후 본인 지식 또는 source 요청.
|
||||
|
||||
### `doctor` `ok: false`
|
||||
|
||||
다른 tool 호출 전 `doctor` 부터. `checks[]` 의 failed entry 원인 명시 — 사용자에게 보고 후 stop.
|
||||
|
||||
### empty `search` result
|
||||
|
||||
`isError: false`, content = `[]` (빈 array). KB 에 매칭 없음. `mode` 변경 (lexical → vector or vice versa) 또는 query 표현 다양화. 그래도 빈 결과면 KB coverage 부족 — 사용자에게 보고.
|
||||
|
||||
### tool not found
|
||||
|
||||
`tools/list` 에서 본 binary 의 6 tool 확인. 0.3.1 (fb-30) 은 4 tool, 0.3.2 (fb-31) 부터 6. binary version 확인:
|
||||
|
||||
```json
|
||||
{ "name": "schema", "arguments": {} }
|
||||
```
|
||||
|
||||
응답의 `kebab_version` 이 0.3.2+ 인지 확인.
|
||||
|
||||
---
|
||||
|
||||
## Session 관리 (multi-turn ask)
|
||||
|
||||
`ask` tool 의 `session_id` 가 multi-turn RAG context 활성화. 같은 `session_id` 로 연속 호출 시 이전 Q/A history 가 새 query 의 retrieval expansion + prompt context 에 포함.
|
||||
|
||||
### session_id 명명
|
||||
|
||||
`<topic>-<date>` 형식 권장 — 사용자 친화 + uniqueness:
|
||||
|
||||
- `ops-onboarding-2026-05`
|
||||
- `kubernetes-ingress-debug-2026-05-07`
|
||||
- `agent-research-session-1` (auto-numbered)
|
||||
|
||||
session_id 는 임의 string — kebab 이 처음 보는 id 면 새 session 생성, 기존 id 면 history append.
|
||||
|
||||
### 언제 새 session 시작?
|
||||
|
||||
- 주제 완전 전환 (KB 의 다른 도메인) — 이전 history 가 noise.
|
||||
- 사용자 명시 reset 요청.
|
||||
- Long session (50+ turn) 의 context bloat — 새 session 으로 fresh start.
|
||||
|
||||
### Session lifetime
|
||||
|
||||
session 데이터는 SQLite `chat_sessions` + `chat_turns` 에 영속. `kebab reset --data-only` 가 모두 wipe. session 별 삭제 명령은 없음 (P+).
|
||||
|
||||
### 예시 multi-turn flow
|
||||
|
||||
```json
|
||||
// turn 1
|
||||
{ "name": "ask", "arguments": {
|
||||
"query": "What's our internal Kubernetes ingress setup?",
|
||||
"session_id": "ops-2026-05"
|
||||
}}
|
||||
// → answer.v1 with conversation_id, turn_index: 0
|
||||
|
||||
// turn 2 — 이전 답변을 context 로 retrieval expansion
|
||||
{ "name": "ask", "arguments": {
|
||||
"query": "What about TLS?",
|
||||
"session_id": "ops-2026-05"
|
||||
}}
|
||||
// → kebab 가 "TLS" 만으로 retrieval 안 함, 이전 \"Kubernetes ingress\" history 포함 query 로 검색
|
||||
|
||||
// turn 3 — 명시적 reference
|
||||
{ "name": "ask", "arguments": {
|
||||
"query": "How does that compare to AWS ALB?",
|
||||
"session_id": "ops-2026-05"
|
||||
}}
|
||||
```
|
||||
|
||||
### Session vs single-shot
|
||||
|
||||
`session_id` 없이 `ask` 호출 = single-shot. agent host 자체가 conversation 추적하면 single-shot + agent-side context 도 OK. session 이 필요한 경우:
|
||||
|
||||
- KB 가 \"이전 질문\" 을 retrieval expansion 에 사용해야 정확 (e.g. follow-up 의 대명사).
|
||||
- 한 session 안에서 같은 chunk 반복 fetch 회피 (kebab 가 turn 간 chunk overlap 인지).
|
||||
|
||||
agent host 가 conversation 추적 + 충분한 context 보유면 session 불필요.
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
- **첫 tool call**: cold start ~1-2s (SQLite open + Lance dataset open + fastembed model load).
|
||||
- **이후 tool call (same session)**: hot — search ~50-200ms, ask ~수 초 (Ollama LLM dominant).
|
||||
- **session 종료** (host 가 process kill): 모든 cache lost. 다음 session 첫 call 다시 cold.
|
||||
- **`schema` / `doctor`**: cheap (no LLM / no embedder), 매 call ~ms.
|
||||
- **`ingest_file` / `ingest_stdin`**: 첫 call 시 fastembed cold start. 이후 file 당 ~수 백 ms (parse + chunk + embed).
|
||||
|
||||
cold-start 회피하려면 host 가 long-running session 유지 (Claude Code default).
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- stdio MCP — 외부 네트워크 노출 없음. agent host 만 access.
|
||||
- `kebab mcp` 가 호출하는 facade 는 `--config` 의 권한으로 동작. config 내 secret (Ollama API key 등) 은 process 환경에 한정.
|
||||
- mutation tool (`ingest_file` / `ingest_stdin`) 는 사용자 명시 의도 없이 자동 호출 금지 — agent 측 가드.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- CLI usage: `kebab --help` + [README.md](../README.md)
|
||||
- Wire schemas: `docs/wire-schema/v1/*.schema.json`
|
||||
- design contract: `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` §10.2
|
||||
- Claude Code 전용 skill: `integrations/claude-code/kebab/SKILL.md`
|
||||
- HOTFIXES (post-merge deviations): `tasks/HOTFIXES.md`
|
||||
@@ -0,0 +1,882 @@
|
||||
# p9-fb-25 — Config `workspace.include` 제거 + 지원 형식 가시성 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remove the dead `WorkspaceCfg.include` config field, surface skip reasons (unsupported media type) per file via `IngestItem.warnings`, and aggregate them into `IngestReport.skipped_by_extension` with CLI / TUI / README / `kebab init` template all calling out the four supported extensions (`.md`, `.png`, `.jpg/.jpeg`, `.pdf`).
|
||||
|
||||
**Architecture:** Two coordinated streams. (1) Config: drop `WorkspaceCfg.include` + emit a one-shot `tracing::warn!` when an old config still has it (raw TOML key probe). (2) Ingest pipeline: every Skipped per-asset return gains `warnings: vec!["unsupported media type: .ext"]` (or `"kb:// URI not yet supported"`); the asset loop bumps a new `aggregate.skipped_by_extension: BTreeMap<String, u32>` keyed by lowercase ext (`<no-ext>` sentinel for files without one). CLI summary + TUI status_line render the breakdown desc-sorted on terminal events.
|
||||
|
||||
**Tech Stack:** Rust 2024, serde, toml 0.8, tracing. `BTreeMap` for stable JSON key order. No new deps. No SQLite migration.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-05-p9-fb-25-config-include-removal-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Modified:**
|
||||
- `crates/kebab-config/src/lib.rs` — drop `WorkspaceCfg.include` field + update `WorkspaceCfg::defaults()` + add `from_file` deprecation probe.
|
||||
- `crates/kebab-app/src/lib.rs` — `init_workspace` header comment lists supported extensions + (no code change needed beyond default config no longer carrying include); ingest pipeline's three Skipped emit sites populate `warnings` + bump `skipped_by_extension`.
|
||||
- `crates/kebab-app/src/ingest_progress.rs` — `AggregateCounts.skipped_by_extension: BTreeMap<String, u32>`.
|
||||
- `crates/kebab-core/src/ingest.rs` — `IngestReport.skipped_by_extension: BTreeMap<String, u32>`.
|
||||
- `crates/kebab-cli/src/main.rs` — drop `include: cfg.workspace.include.clone()` from SourceScope construction; render breakdown in summary print.
|
||||
- `crates/kebab-tui/src/ingest_progress.rs` — drop `include: cfg.workspace.include.clone()` from SourceScope construction; render breakdown in `status_line` final / aborted.
|
||||
- `docs/wire-schema/v1/ingest_report.schema.json` — additive `skipped_by_extension` (object, additionalProperties integer ≥ 0).
|
||||
- `README.md` — `kebab tui` / `kebab ingest` cell appends supported-extension list + skip-reason mention.
|
||||
- `HANDOFF.md`, `tasks/HOTFIXES.md`, `tasks/INDEX.md`, `tasks/p9/p9-fb-25-config-include-removal.md`.
|
||||
|
||||
`SourceScope` (in `kebab-core/src/traits.rs`) keeps its `include: Vec<String>` field — it's a design-level abstraction (§7.1) that connectors / routers may use later. Removing it is a separate spec.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Drop `WorkspaceCfg.include` + add deprecation probe
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-config/src/lib.rs` (struct + defaults + from_file)
|
||||
|
||||
This task removes the field but keeps backward-compat: an old `config.toml` with `include = [...]` still loads (serde ignores unknown keys without `deny_unknown_fields`). On detection, emit a one-shot `tracing::warn!`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to the existing `#[cfg(test)] mod tests` block in `crates/kebab-config/src/lib.rs` (find via `grep -n "fn defaults_are_serde_roundtrip_stable" crates/kebab-config/src/lib.rs`):
|
||||
|
||||
```rust
|
||||
/// p9-fb-25: legacy config with `workspace.include = [...]` must
|
||||
/// still deserialize cleanly (silent unknown-field acceptance).
|
||||
#[test]
|
||||
fn legacy_include_field_is_ignored_silently() {
|
||||
let toml_text = r#"
|
||||
schema_version = 1
|
||||
|
||||
[workspace]
|
||||
root = "/tmp/kebab-legacy"
|
||||
include = ["**/*.md", "**/*.txt"]
|
||||
exclude = [".git/**"]
|
||||
|
||||
[storage]
|
||||
data_dir = "/tmp/kebab-data"
|
||||
sqlite = "{data_dir}/kebab.sqlite"
|
||||
vector_dir = "{data_dir}/lancedb"
|
||||
asset_dir = "{data_dir}/assets"
|
||||
artifact_dir = "{data_dir}/artifacts"
|
||||
model_dir = "{data_dir}/models"
|
||||
runs_dir = "{data_dir}/runs"
|
||||
copy_threshold_mb = 100
|
||||
|
||||
[indexing]
|
||||
max_parallel_extractors = 2
|
||||
max_parallel_embeddings = 1
|
||||
watch_filesystem = false
|
||||
"#;
|
||||
// NOTE: a real legacy config has many more sections (chunking,
|
||||
// models, etc.). For this test we rely on `#[serde(default)]`
|
||||
// on each top-level Config field — if any field is missing
|
||||
// a serde default at this point, we accept that as a separate
|
||||
// bug and adjust below. The point of THIS test is the
|
||||
// workspace.include field tolerance.
|
||||
let parsed: Result<Config, _> = toml::from_str(toml_text);
|
||||
assert!(parsed.is_ok(), "legacy include must not break load: {:?}", parsed.err());
|
||||
let cfg = parsed.unwrap();
|
||||
assert_eq!(cfg.workspace.root, "/tmp/kebab-legacy");
|
||||
assert_eq!(cfg.workspace.exclude, vec![".git/**".to_string()]);
|
||||
}
|
||||
|
||||
/// p9-fb-25: `WorkspaceCfg::defaults()` no longer carries `include`.
|
||||
#[test]
|
||||
fn workspace_defaults_have_no_include_field() {
|
||||
let ws = WorkspaceCfg::defaults();
|
||||
// We're not asserting include is absent (Rust struct field
|
||||
// doesn't have such a query). We assert the default has the
|
||||
// expected exclude shape and the struct definition reflects
|
||||
// the removal — this test will fail to compile if a stray
|
||||
// reference to ws.include lingers.
|
||||
assert!(!ws.exclude.is_empty(), "default exclude should retain `.git/**` etc.");
|
||||
}
|
||||
```
|
||||
|
||||
If `Config` doesn't have `#[serde(default)]` at the section level (chunking / models / etc.), the legacy-config test will fail because the abbreviated TOML omits required sections. In that case, expand the legacy TOML in the test to include all required sections — the goal is to verify `include` is ignored, not to test default fallback. Use `Config::defaults()` and serialize → modify → deserialize as a shortcut:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn legacy_include_field_is_ignored_silently() {
|
||||
let mut cfg = Config::defaults();
|
||||
cfg.workspace.root = "/tmp/kebab-legacy".to_string();
|
||||
let mut toml_text = toml::to_string(&cfg).expect("default round-trips");
|
||||
// Inject a legacy `include = [...]` line into the [workspace] block.
|
||||
toml_text = toml_text.replace(
|
||||
"[workspace]",
|
||||
"[workspace]\ninclude = [\"**/*.md\", \"**/*.txt\"]",
|
||||
);
|
||||
let parsed: Result<Config, _> = toml::from_str(&toml_text);
|
||||
assert!(parsed.is_ok(), "legacy include must not break load: {:?}", parsed.err());
|
||||
let cfg = parsed.unwrap();
|
||||
assert_eq!(cfg.workspace.root, "/tmp/kebab-legacy");
|
||||
}
|
||||
```
|
||||
|
||||
Run: `cargo test -p kebab-config --lib legacy_include_field_is_ignored_silently`
|
||||
Expected: FAIL — current `WorkspaceCfg` still has `include: Vec<String>` so deserialize SUCCEEDS but `workspace_defaults_have_no_include_field` test compiles and passes; the first test passes too because serde default for `Vec<String>` is empty. Wait — the FAIL precondition is wrong. Both tests pass against current code.
|
||||
|
||||
Reframe: write a **forward-looking** test that asserts the field is gone:
|
||||
|
||||
```rust
|
||||
/// p9-fb-25: `WorkspaceCfg` must NOT have an `include` field.
|
||||
/// Compile-time proof: this test references every field of
|
||||
/// `WorkspaceCfg` exhaustively. If a future commit re-introduces
|
||||
/// `include`, the destructure here breaks (refactor failure).
|
||||
#[test]
|
||||
fn workspace_cfg_has_only_root_and_exclude_fields() {
|
||||
let ws = WorkspaceCfg::defaults();
|
||||
// Exhaustive destructure — adding a new field would break
|
||||
// this on the next compile.
|
||||
let WorkspaceCfg { root: _, exclude: _ } = &ws;
|
||||
}
|
||||
```
|
||||
|
||||
This test will NOT compile against current code (because `WorkspaceCfg` still has `include`). The compile error IS the test failure.
|
||||
|
||||
Run: `cargo build -p kebab-config --tests`
|
||||
Expected: error[E0027] missing structure fields OR error mentioning `include`. **This is the failing test.**
|
||||
|
||||
- [ ] **Step 2: Drop the field + update defaults**
|
||||
|
||||
Open `crates/kebab-config/src/lib.rs`. Find `pub struct WorkspaceCfg` (around line 51). Remove the `pub include: Vec<String>` line:
|
||||
|
||||
```rust
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WorkspaceCfg {
|
||||
pub root: String,
|
||||
pub exclude: Vec<String>,
|
||||
}
|
||||
```
|
||||
|
||||
Find `WorkspaceCfg::defaults()` or the `Config::defaults()` body that constructs `WorkspaceCfg { root, include, exclude }` (around line 252). Drop the `include: vec!["**/*.md".to_string()],` line:
|
||||
|
||||
```rust
|
||||
workspace: WorkspaceCfg {
|
||||
root: "~/KnowledgeBase".to_string(),
|
||||
exclude: vec![
|
||||
".git/**".to_string(),
|
||||
"node_modules/**".to_string(),
|
||||
".obsidian/**".to_string(),
|
||||
],
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add deprecation probe in `from_file`**
|
||||
|
||||
In the same file, find `pub fn from_file` (around line 397). Replace the body to probe for the legacy `include` key BEFORE typed deserialize:
|
||||
|
||||
```rust
|
||||
pub fn from_file(path: &Path) -> anyhow::Result<Self> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
|
||||
// p9-fb-25: probe for the legacy `workspace.include` key — if
|
||||
// present, emit a one-shot deprecation warning. Detection uses
|
||||
// raw `toml::Value` lookup; the warning is fired via a
|
||||
// process-level `OnceLock` so a long-running TUI / CLI run
|
||||
// doesn't spam the log on every Config::load.
|
||||
if let Ok(value) = toml::from_str::<toml::Value>(&text) {
|
||||
if value
|
||||
.get("workspace")
|
||||
.and_then(|v| v.get("include"))
|
||||
.is_some()
|
||||
{
|
||||
static DEPRECATION_FIRED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
DEPRECATION_FIRED.get_or_init(|| {
|
||||
tracing::warn!(
|
||||
target: "kebab-config",
|
||||
config = %path.display(),
|
||||
"deprecated config: `workspace.include` 필드는 더 이상 사용되지 않습니다 (p9-fb-25). 처리 가능한 형식 (md / png / jpg / pdf) 은 extractor 가 자동 결정. 다음 버전부터 config 갱신 권장."
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut cfg: Self = toml::from_str(&text)?;
|
||||
cfg.source_dir = path.parent().map(Path::to_path_buf);
|
||||
Ok(cfg)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `cargo test -p kebab-config --lib`
|
||||
Expected: all pass. The forward-looking exhaustive-destructure test now compiles + asserts the struct shape.
|
||||
|
||||
- [ ] **Step 5: Build the workspace + surface compile errors**
|
||||
|
||||
Run: `cargo build --workspace`
|
||||
Expected: compile errors at every site that constructs `WorkspaceCfg { ..., include: ..., ... }` or reads `cfg.workspace.include`. The known sites:
|
||||
- `crates/kebab-cli/src/main.rs:329` — drop `include: cfg.workspace.include.clone(),` (Task 2 will add the proper SourceScope construction).
|
||||
- `crates/kebab-tui/src/ingest_progress.rs:39` — same.
|
||||
- Test fixtures inside `kebab-config` if any — drop the `include: vec![...]` literal.
|
||||
|
||||
For Task 1's commit, fix ONLY the kebab-config sites (the test passes fix). Other crates' compile errors roll into Task 2.
|
||||
|
||||
For now, in `crates/kebab-cli/src/main.rs` line 329 and `crates/kebab-tui/src/ingest_progress.rs` line 39, replace `include: cfg.workspace.include.clone()` with `include: Vec::new()` (Task 2 will use `..Default::default()` once we touch the structures more carefully).
|
||||
|
||||
- [ ] **Step 6: clippy + commit**
|
||||
|
||||
Run: `cargo clippy -p kebab-config --all-targets -- -D warnings`
|
||||
Expected: clean.
|
||||
|
||||
```bash
|
||||
git add crates/kebab-config/src/lib.rs crates/kebab-cli/src/main.rs crates/kebab-tui/src/ingest_progress.rs
|
||||
git commit -m "feat(kebab-config): p9-fb-25 task 1 — drop WorkspaceCfg.include + deprecation probe
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Switch SourceScope construction to `..Default::default()` for cleaner removal
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-cli/src/main.rs` (line 327)
|
||||
- Modify: `crates/kebab-tui/src/ingest_progress.rs` (line 38)
|
||||
|
||||
Task 1's quick fix (`include: Vec::new()`) is functional but ugly. This task replaces those with the idiomatic `..Default::default()` pattern.
|
||||
|
||||
- [ ] **Step 1: Update CLI ingest dispatch**
|
||||
|
||||
Open `crates/kebab-cli/src/main.rs`. Find the `Cmd::Ingest { ... }` arm (around line 321). Replace the SourceScope literal:
|
||||
|
||||
```rust
|
||||
let scope = kebab_core::SourceScope {
|
||||
root: root.clone().unwrap_or_else(|| PathBuf::from(&cfg.workspace.root)),
|
||||
exclude: cfg.workspace.exclude.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
```
|
||||
|
||||
(SourceScope derives `Default` per `kebab-core/src/traits.rs`; `..Default::default()` fills `include: Vec::new()` for now. If `include` is removed from `SourceScope` in a future spec, this site needs no change.)
|
||||
|
||||
- [ ] **Step 2: Update TUI ingest dispatch**
|
||||
|
||||
Open `crates/kebab-tui/src/ingest_progress.rs`. Find the SourceScope construction (around line 38):
|
||||
|
||||
```rust
|
||||
scope: kebab_core::SourceScope {
|
||||
root: PathBuf::from(&cfg.workspace.root),
|
||||
exclude: cfg.workspace.exclude.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build + test**
|
||||
|
||||
Run: `cargo build --workspace`
|
||||
Run: `cargo test -p kebab-cli -p kebab-tui --lib`
|
||||
Run: `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
Expected: all clean.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/kebab-cli/src/main.rs crates/kebab-tui/src/ingest_progress.rs
|
||||
git commit -m "refactor(kebab-cli, kebab-tui): p9-fb-25 task 2 — SourceScope via ..Default::default()
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `init_workspace` header — supported extensions
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-app/src/lib.rs` (`init_workspace`, around line 138)
|
||||
|
||||
- [ ] **Step 1: Update the header comment**
|
||||
|
||||
Open `crates/kebab-app/src/lib.rs`. Find `let header = "\` inside `init_workspace` (around line 138). Replace the entire `header` string with a version that adds the supported-extensions block AND removes any reference to `include`:
|
||||
|
||||
```rust
|
||||
let header = "\
|
||||
# kebab config — `~/.config/kebab/config.toml`.
|
||||
#
|
||||
# `workspace.root` accepts:
|
||||
# • absolute paths (`/home/me/KnowledgeBase`)
|
||||
# • tilde (`~/KnowledgeBase`) ← default
|
||||
# • env vars (`${XDG_DATA_HOME}/kebab`)
|
||||
# • relative paths (`./notes`, `notes`, `../shared/x`)
|
||||
# — relative paths resolve against the directory of THIS
|
||||
# config file, NOT the user's `cwd` at invocation time.
|
||||
#
|
||||
# 처리 가능한 형식 (extractor 가 자동 결정 — config 에 명시할 수 없음):
|
||||
# • Markdown: .md
|
||||
# • 이미지: .png .jpg .jpeg (OCR + caption)
|
||||
# • PDF: .pdf
|
||||
# 다른 확장자는 ingest 시 자동 skip + warning. 처리 대상 폴더의
|
||||
# 일부만 ingest 하고 싶으면 `kebab ingest <path>` 로 root 명시
|
||||
# 또는 `.kebabignore` 파일 / 본 `workspace.exclude` 로 denylist.
|
||||
#
|
||||
# Override individual keys at runtime with `KEBAB_*` env vars
|
||||
# (e.g. `KEBAB_WORKSPACE_ROOT=/tmp/test kebab ingest`).
|
||||
\n";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Smoke test by running `kebab init` against a temp config**
|
||||
|
||||
Add (or extend) a test in `crates/kebab-app/tests/`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn init_workspace_header_lists_supported_extensions() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// SAFETY: each test sets KEBAB env vars in a process-wide manner;
|
||||
// this test relies on init_workspace writing relative to the
|
||||
// current XDG_CONFIG_HOME. We override XDG_CONFIG_HOME to the
|
||||
// tmp dir so the produced config sits inside `tmp`.
|
||||
unsafe {
|
||||
std::env::set_var("XDG_CONFIG_HOME", tmp.path());
|
||||
}
|
||||
kebab_app::init_workspace(true).expect("init_workspace");
|
||||
let cfg_path = kebab_config::Config::xdg_config_path();
|
||||
let body = std::fs::read_to_string(&cfg_path).unwrap();
|
||||
assert!(body.contains("처리 가능한 형식"), "header lists supported types");
|
||||
assert!(body.contains("Markdown: .md"), "md listed");
|
||||
assert!(body.contains(".png .jpg .jpeg"), "image extensions listed");
|
||||
assert!(body.contains("PDF: .pdf"), "pdf listed");
|
||||
assert!(!body.contains("workspace.include"), "no leftover include reference");
|
||||
}
|
||||
```
|
||||
|
||||
If the existing kebab-app test infra already has an `init_workspace` test, extend it. Otherwise create a new file `crates/kebab-app/tests/init_template.rs`.
|
||||
|
||||
`unsafe`: Rust 2024 + recent toolchain may flag `set_var` as unsafe. Wrap in `unsafe { ... }` block per current Rust semantics.
|
||||
|
||||
- [ ] **Step 3: Run + commit**
|
||||
|
||||
```bash
|
||||
cargo test -p kebab-app --test init_template # or whatever filename
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
git add -u
|
||||
git commit -m "feat(kebab-app): p9-fb-25 task 3 — init_workspace header lists supported extensions
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add `skipped_by_extension` to `IngestReport` + `AggregateCounts` + wire schema
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-core/src/ingest.rs`
|
||||
- Modify: `crates/kebab-app/src/ingest_progress.rs`
|
||||
- Modify: `docs/wire-schema/v1/ingest_report.schema.json`
|
||||
|
||||
- [ ] **Step 1: Add field to `IngestReport`**
|
||||
|
||||
Open `crates/kebab-core/src/ingest.rs`. Replace the `IngestReport` struct (around lines 10-22) by adding the new field:
|
||||
|
||||
```rust
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct IngestReport {
|
||||
pub scope: SourceScope,
|
||||
pub scanned: u32,
|
||||
pub new: u32,
|
||||
pub updated: u32,
|
||||
pub skipped: u32,
|
||||
pub unchanged: u32,
|
||||
pub errors: u32,
|
||||
pub duration_ms: u32,
|
||||
/// p9-fb-25: per-extension skip count. Key = lowercase extension
|
||||
/// without leading dot (e.g. "docx", "txt"); files without an
|
||||
/// extension key under "<no-ext>". `BTreeMap` so the wire JSON
|
||||
/// has stable key order across runs.
|
||||
pub skipped_by_extension: std::collections::BTreeMap<String, u32>,
|
||||
pub items: Option<Vec<IngestItem>>,
|
||||
}
|
||||
```
|
||||
|
||||
The import for `BTreeMap` lives inside the field annotation via the full path; don't add a `use` at the top of the file.
|
||||
|
||||
- [ ] **Step 2: Add field to `AggregateCounts`**
|
||||
|
||||
Open `crates/kebab-app/src/ingest_progress.rs`. Replace the struct (around lines 25-37):
|
||||
|
||||
```rust
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AggregateCounts {
|
||||
pub scanned: u32,
|
||||
pub new: u32,
|
||||
pub updated: u32,
|
||||
pub skipped: u32,
|
||||
pub unchanged: u32,
|
||||
pub errors: u32,
|
||||
pub chunks_indexed: u32,
|
||||
pub embeddings_indexed: u32,
|
||||
/// p9-fb-25: per-extension skip count. See [`IngestReport.skipped_by_extension`].
|
||||
pub skipped_by_extension: std::collections::BTreeMap<String, u32>,
|
||||
}
|
||||
```
|
||||
|
||||
Note: removed `Copy` from the derive. `BTreeMap` is not `Copy`. Replaces `Copy + Eq + PartialEq` with `Eq + PartialEq` only. `Copy`-using callers (if any) need to switch to `clone()`.
|
||||
|
||||
Run: `cargo build -p kebab-app`. Look for `error: ... requires Copy` errors. Fix each by `.clone()`.
|
||||
|
||||
- [ ] **Step 3: Update wire schema**
|
||||
|
||||
Open `docs/wire-schema/v1/ingest_report.schema.json`. Inside `properties`, add:
|
||||
|
||||
```json
|
||||
"skipped_by_extension": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"description": "p9-fb-25: per-extension skip count. Key = lowercase extension without leading dot (e.g. 'docx'). Files without extension key under '<no-ext>'."
|
||||
},
|
||||
```
|
||||
|
||||
If `required` array exists, add `skipped_by_extension` to it (always present, even if empty `{}`).
|
||||
|
||||
- [ ] **Step 4: Build + fix construction sites**
|
||||
|
||||
Run: `cargo build --workspace`. The compiler will surface every `IngestReport { ... }` and `AggregateCounts { ... }` literal that omits the new field. Add `skipped_by_extension: BTreeMap::new()` (or `Default::default()`) at each.
|
||||
|
||||
Add `use std::collections::BTreeMap;` at the top of test fixture files where the literal is constructed (so the addition is concise — `BTreeMap::new()` rather than `std::collections::BTreeMap::new()`).
|
||||
|
||||
Snapshot fixtures (`crates/kebab-store-sqlite/snapshots/ingest_report.snapshot.json`) — add `"skipped_by_extension": {}` between two existing fields (alphabetic / logical order — between `skipped` and `errors` to match struct declaration order).
|
||||
|
||||
- [ ] **Step 5: Run + commit**
|
||||
|
||||
```bash
|
||||
cargo test --workspace --no-fail-fast -j 1 2>&1 | grep "^test result:"
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
git add -u
|
||||
git commit -m "feat(kebab-core, kebab-app): p9-fb-25 task 4 — IngestReport.skipped_by_extension + wire schema additive
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Populate `IngestItem.warnings` for Skipped paths + bump `skipped_by_extension`
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-app/src/lib.rs` (three Skipped emit sites + one asset loop)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `crates/kebab-app/tests/skip_reason.rs`:
|
||||
|
||||
```rust
|
||||
//! p9-fb-25: skipped per-asset items must carry a human-readable reason
|
||||
//! in `warnings`, and the report's `skipped_by_extension` must aggregate
|
||||
//! by lowercase extension.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::TestEnv;
|
||||
|
||||
#[test]
|
||||
fn unsupported_extension_skip_carries_warning_and_is_aggregated() {
|
||||
let env = TestEnv::lexical_only();
|
||||
// Workspace already populated by TestEnv; add a `.docx` and a
|
||||
// file with no extension to trigger Skipped paths.
|
||||
let workspace_root = std::path::PathBuf::from(&env.config.workspace.root);
|
||||
std::fs::write(workspace_root.join("legacy.docx"), b"unsupported").unwrap();
|
||||
std::fs::write(workspace_root.join("Makefile"), b"unsupported").unwrap();
|
||||
|
||||
let report = kebab_app::ingest_with_config(
|
||||
env.config.clone(),
|
||||
env.scope(),
|
||||
false,
|
||||
).unwrap();
|
||||
|
||||
let items = report.items.as_ref().expect("items array populated");
|
||||
let docx_item = items.iter().find(|i| i.doc_path.0.ends_with("legacy.docx")).unwrap();
|
||||
assert_eq!(docx_item.kind, kebab_core::IngestItemKind::Skipped);
|
||||
assert_eq!(
|
||||
docx_item.warnings,
|
||||
vec!["unsupported media type: .docx".to_string()],
|
||||
);
|
||||
let makefile_item = items.iter().find(|i| i.doc_path.0.ends_with("Makefile")).unwrap();
|
||||
assert_eq!(makefile_item.kind, kebab_core::IngestItemKind::Skipped);
|
||||
assert_eq!(
|
||||
makefile_item.warnings,
|
||||
vec!["unsupported media type: <no-ext>".to_string()],
|
||||
);
|
||||
assert_eq!(report.skipped_by_extension.get("docx").copied(), Some(1));
|
||||
assert_eq!(report.skipped_by_extension.get("<no-ext>").copied(), Some(1));
|
||||
}
|
||||
```
|
||||
|
||||
If `TestEnv::lexical_only()` populates a workspace with markdown fixtures already, the docx + Makefile additions are extra. If not, build a fresh `TempDir` workspace following the pattern other kebab-app tests use.
|
||||
|
||||
Run: `cargo test -p kebab-app --test skip_reason`
|
||||
Expected: FAIL — current `warnings: Vec::new()` for Skipped + `skipped_by_extension` is always empty.
|
||||
|
||||
- [ ] **Step 2: Update the three `IngestItemKind::Skipped` emit sites**
|
||||
|
||||
Open `crates/kebab-app/src/lib.rs`. Three sites currently return `IngestItem { kind: Skipped, ..., warnings: Vec::new(), ... }`:
|
||||
|
||||
- One at the top of `ingest_one_asset` (the `_ =>` fallback when MediaType doesn't match).
|
||||
- One when `SourceUri::Kb` (kb:// URI not yet supported).
|
||||
- One in `ingest_one_image_asset` when SourceUri is kb://.
|
||||
- One in `ingest_one_pdf_asset` when SourceUri is kb://.
|
||||
|
||||
For each Skipped emit, replace `warnings: Vec::new()` with the appropriate reason.
|
||||
|
||||
For the **media-type fallback** at the top of `ingest_one_asset`: extract the extension from `asset.workspace_path.0` (lowercase, no dot, `<no-ext>` sentinel) and emit:
|
||||
|
||||
```rust
|
||||
let ext = ext_for_skip_warning(&asset.workspace_path.0);
|
||||
return Ok(kebab_core::IngestItem {
|
||||
kind: kebab_core::IngestItemKind::Skipped,
|
||||
doc_id: None,
|
||||
doc_path: asset.workspace_path.clone(),
|
||||
asset_id: Some(asset.asset_id.clone()),
|
||||
byte_len: Some(asset.byte_len),
|
||||
block_count: None,
|
||||
chunk_count: None,
|
||||
parser_version: None,
|
||||
chunker_version: None,
|
||||
warnings: vec![format!("unsupported media type: .{ext}")],
|
||||
error: None,
|
||||
});
|
||||
```
|
||||
|
||||
For the **kb:// URI** sites:
|
||||
|
||||
```rust
|
||||
warnings: vec!["kb:// URI not yet supported".to_string()],
|
||||
```
|
||||
|
||||
Add the helper near the other per-asset helpers:
|
||||
|
||||
```rust
|
||||
/// p9-fb-25: extract the lowercase extension (no leading dot) from a
|
||||
/// workspace path for use in the `unsupported media type: .X`
|
||||
/// warning + `IngestReport.skipped_by_extension` key. Returns
|
||||
/// `"<no-ext>"` for paths with no extension. Always lowercase so
|
||||
/// `Foo.DOCX` and `bar.docx` aggregate under the same key.
|
||||
fn ext_for_skip_warning(path: &str) -> String {
|
||||
std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.unwrap_or_else(|| "<no-ext>".to_string())
|
||||
}
|
||||
```
|
||||
|
||||
Note: for the `<no-ext>` case the warning should read `"unsupported media type: <no-ext>"` (no leading dot — sentinel). Adjust the format! call:
|
||||
|
||||
```rust
|
||||
let ext = ext_for_skip_warning(&asset.workspace_path.0);
|
||||
let warning = if ext == "<no-ext>" {
|
||||
"unsupported media type: <no-ext>".to_string()
|
||||
} else {
|
||||
format!("unsupported media type: .{ext}")
|
||||
};
|
||||
warnings: vec![warning],
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Bump `aggregate.skipped_by_extension` in the asset loop**
|
||||
|
||||
Find the asset loop in `ingest_with_config_opts` (search for `IngestItemKind::Skipped =>` arms in the per-result match). Where the loop currently does `aggregate.skipped += 1`, also bump the per-extension counter using the same `ext_for_skip_warning` helper:
|
||||
|
||||
```rust
|
||||
IngestItemKind::Skipped => {
|
||||
aggregate.skipped += 1;
|
||||
let ext = ext_for_skip_warning(&item.doc_path.0);
|
||||
*aggregate.skipped_by_extension.entry(ext).or_insert(0) += 1;
|
||||
}
|
||||
```
|
||||
|
||||
After the loop, when building the final `IngestReport`, populate `skipped_by_extension: aggregate.skipped_by_extension.clone()`.
|
||||
|
||||
- [ ] **Step 4: Run the test**
|
||||
|
||||
Run: `cargo test -p kebab-app --test skip_reason`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Run the full kebab-app suite for regressions**
|
||||
|
||||
Run: `cargo test -p kebab-app`
|
||||
Expected: existing tests pass. The change is additive — Skipped items previously had empty `warnings` and `skipped_by_extension` was 0. Tests that asserted `warnings.is_empty()` may need updating (search):
|
||||
|
||||
```bash
|
||||
grep -rn 'warnings.is_empty\|warnings, vec!\[\]\|warnings == Vec::new' crates/kebab-app/tests/
|
||||
```
|
||||
|
||||
Update any failing test to either skip the warnings assertion (if not the focus) or assert the new content.
|
||||
|
||||
- [ ] **Step 6: clippy + commit**
|
||||
|
||||
```bash
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
git add -u
|
||||
git commit -m "feat(kebab-app): p9-fb-25 task 5 — Skipped warnings + skipped_by_extension aggregation
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: CLI summary + TUI status_line render breakdown
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-cli/src/main.rs` (ingest summary print)
|
||||
- Modify: `crates/kebab-tui/src/ingest_progress.rs` (status_line)
|
||||
|
||||
- [ ] **Step 1: Update CLI summary**
|
||||
|
||||
Open `crates/kebab-cli/src/main.rs`. Find the human-mode summary print (search for `"new" =>` or similar around the ingest-finished branch). The current format is `"... {N} skipped, ..."`. Replace with:
|
||||
|
||||
```rust
|
||||
let skipped_breakdown = if report.skipped_by_extension.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let mut entries: Vec<_> = report.skipped_by_extension.iter().collect();
|
||||
// desc by count, ties broken by key for stable output.
|
||||
entries.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
|
||||
let parts: Vec<String> = entries.iter().map(|(k, v)| format!("{v} {k}")).collect();
|
||||
format!(": {}", parts.join(", "))
|
||||
};
|
||||
println!(
|
||||
"✓ ingest: {} docs ({} new, {} updated, {} unchanged, {} skipped{}), {} chunks indexed in {}s",
|
||||
report.scanned,
|
||||
report.new,
|
||||
report.updated,
|
||||
report.unchanged,
|
||||
report.skipped,
|
||||
skipped_breakdown,
|
||||
/* chunks_indexed: derive from items or store in IngestReport */ 0, // adapt to actual
|
||||
report.duration_ms / 1000,
|
||||
);
|
||||
```
|
||||
|
||||
Adapt to the actual print site — the existing print may use `report.duration_ms` directly or have a `chunks_indexed` already plumbed. Match the existing surrounding pattern.
|
||||
|
||||
- [ ] **Step 2: Update TUI status_line**
|
||||
|
||||
Open `crates/kebab-tui/src/ingest_progress.rs`. Find the success branch in `pub fn status_line` (around line 170+). Apply the same breakdown logic:
|
||||
|
||||
```rust
|
||||
let skipped_breakdown = if state.counts.skipped_by_extension.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let mut entries: Vec<_> = state.counts.skipped_by_extension.iter().collect();
|
||||
entries.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
|
||||
let parts: Vec<String> = entries.iter().map(|(k, v)| format!("{v} {k}")).collect();
|
||||
format!(": {}", parts.join(", "))
|
||||
};
|
||||
return format!(
|
||||
"✓ ingest: {} docs ({} new, {} updated, {} unchanged, {} skipped{}), {} chunks indexed in {}s",
|
||||
state.counts.scanned,
|
||||
state.counts.new,
|
||||
state.counts.updated,
|
||||
state.counts.unchanged,
|
||||
state.counts.skipped,
|
||||
skipped_breakdown,
|
||||
state.counts.chunks_indexed,
|
||||
secs,
|
||||
);
|
||||
```
|
||||
|
||||
Apply the same to the aborted branch:
|
||||
|
||||
```rust
|
||||
return format!(
|
||||
"✗ ingest aborted at {}/{} after {}s (new={} updated={} unchanged={} skipped={}{} errors={})",
|
||||
state.counts.scanned.saturating_sub(state.counts.errors),
|
||||
state.counts.scanned,
|
||||
secs,
|
||||
state.counts.new,
|
||||
state.counts.updated,
|
||||
state.counts.unchanged,
|
||||
state.counts.skipped,
|
||||
skipped_breakdown,
|
||||
state.counts.errors,
|
||||
);
|
||||
```
|
||||
|
||||
In-flight branch unchanged.
|
||||
|
||||
- [ ] **Step 3: Update existing status_line tests**
|
||||
|
||||
Find `#[cfg(test)] mod tests` in `crates/kebab-tui/src/ingest_progress.rs`. Existing tests construct `AggregateCounts` literals. After Task 4 they already have `skipped_by_extension: BTreeMap::new()`. For tests that exercise the breakdown, build an `AggregateCounts` with a populated map:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn status_line_includes_skipped_breakdown() {
|
||||
use std::collections::BTreeMap;
|
||||
let mut counts = AggregateCounts::default();
|
||||
counts.scanned = 10;
|
||||
counts.skipped = 3;
|
||||
counts.skipped_by_extension.insert("docx".into(), 2);
|
||||
counts.skipped_by_extension.insert("txt".into(), 1);
|
||||
let state = IngestState { /* fill mandatory fields */ };
|
||||
state.counts = counts;
|
||||
state.terminal_at = Some(std::time::Instant::now()); // make `if state.terminal_at.is_some()` true
|
||||
let line = status_line(&state);
|
||||
assert!(line.contains("3 skipped: 2 docx, 1 txt"), "got: {line}");
|
||||
}
|
||||
```
|
||||
|
||||
Adapt to the actual `IngestState` struct fields.
|
||||
|
||||
- [ ] **Step 4: Run + commit**
|
||||
|
||||
```bash
|
||||
cargo test -p kebab-cli -p kebab-tui --lib
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
git add -u
|
||||
git commit -m "feat(kebab-cli, kebab-tui): p9-fb-25 task 6 — render skipped-by-extension breakdown
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Docs sync
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`, `HANDOFF.md`, `tasks/HOTFIXES.md`, `tasks/INDEX.md`
|
||||
- Create: `tasks/p9/p9-fb-25-config-include-removal.md`
|
||||
|
||||
- [ ] **Step 1: README**
|
||||
|
||||
Open `README.md`. Find the `kebab ingest` row (or paragraph if it's not a row). Append:
|
||||
|
||||
```
|
||||
**지원 형식** (extractor 자동 결정 — config 에 명시 불가): Markdown (`.md`), 이미지 (`.png` / `.jpg` / `.jpeg`, OCR + caption), PDF (`.pdf`). 다른 확장자는 자동 skip — `IngestItem.warnings` 에 사유 (`"unsupported media type: .docx"` 등), `IngestReport.skipped_by_extension` 에 카운트 분류, CLI / TUI summary 에 breakdown 표시.
|
||||
```
|
||||
|
||||
If the `kebab tui` row already mentions some of this, integrate into existing text instead of duplicating.
|
||||
|
||||
- [ ] **Step 2: HANDOFF.md**
|
||||
|
||||
Add a new entry directly above the most recent `p9-fb-24` row (or wherever the dated list begins):
|
||||
|
||||
```
|
||||
- **2026-05-05 P9 post-도그푸딩 (p9-fb-25)** — Config 의 `workspace.include` 필드 제거 + 지원 형식 가시성. 사용자 도그푸딩 피드백: include + exclude 동시 존재가 case 4 (둘 다 매치 안 함) 의미 모호 + 어차피 처리 가능 형식 (md / png / jpg / pdf) 이 정해져 있으니 명시 필요. `WorkspaceCfg.include` 제거 (옛 config 의 `include = [...]` 은 silently 무시 + 단발 deprecation warning). `IngestItem.warnings` 가 Skipped 시 사유 (`"unsupported media type: .docx"` 등) 채움. `IngestReport.skipped_by_extension: BTreeMap<String, u32>` 신규 (additive wire — release 트리거 안 됨). CLI / TUI summary 에 breakdown 표시 (`"5 skipped: 3 docx, 1 txt, 1 epub"`). README + `kebab init` 헤더 주석에 지원 형식 명시. spec: `tasks/p9/p9-fb-25-config-include-removal.md`. HOTFIXES `2026-05-05 — p9-fb-25` 가 source of truth.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: HOTFIXES.md**
|
||||
|
||||
Open `tasks/HOTFIXES.md`. Add new section above `## 2026-05-04 — p9-fb-24`:
|
||||
|
||||
```markdown
|
||||
## 2026-05-05 — p9-fb-25 (post-dogfooding): config workspace.include 제거 + 지원 형식 가시성
|
||||
|
||||
**Source feedback**: 사용자 도그푸딩 2026-05-05 — config 의 `workspace.include` + `workspace.exclude` 동시 존재가 case 4 (둘 다 매치 안 함) 의미 모호 + 어차피 처리 가능 형식 (md / png / jpg / pdf) 이 정해져 있으니 사용자에게 명시 필요.
|
||||
|
||||
**Live binding 변경**:
|
||||
|
||||
- `kebab-config::WorkspaceCfg.include: Vec<String>` 제거. denylist-only 모델. 옛 config 의 `include = [...]` 은 serde 가 silently 무시 + `Config::from_file` 가 단발 `tracing::warn!` 으로 deprecation 안내 (`std::sync::OnceLock` — 같은 process 안에서 한 번만).
|
||||
- `kebab-core::IngestItem.warnings` 가 Skipped 시 사유 채움: `"unsupported media type: .{ext}"` (ext 없으면 `"unsupported media type: <no-ext>"`) / `"kb:// URI not yet supported"`.
|
||||
- `kebab-core::IngestReport.skipped_by_extension: BTreeMap<String, u32>` + `kebab-app::AggregateCounts.skipped_by_extension` 신규. key = lowercase ext (`docx`, `txt`), no-ext sentinel = `<no-ext>`. wire schema `ingest_report.v1` 에 additive 추가 (v1 호환 유지 — release 트리거 안 됨 per CLAUDE.md release 규약).
|
||||
- CLI summary + TUI status_line final / aborted: `5 skipped: 3 docx, 1 txt, 1 epub` 형식. desc 정렬 + 모두 표시.
|
||||
- `kebab-app::init_workspace` 헤더 주석에 지원 형식 명시 (Markdown / 이미지 / PDF + 각 확장자).
|
||||
- README `kebab ingest` 설명에 지원 형식 + skip 사유 + breakdown 표시 명시.
|
||||
|
||||
**Spec contract impact**: design §6.2 의 `workspace.include` 항목 invalidate (frozen 그대로 두고 본 항목 + spec `tasks/p9/p9-fb-25-config-include-removal.md` 가 source of truth). design §3.x `IngestReport` + §2.4a `IngestEvent` 에 새 필드 / 새 warning 의미 추가 (additive).
|
||||
|
||||
**Tests added**: 약 6 신규 (kebab-config 단위 2: legacy include 무시 + WorkspaceCfg 필드 destructure / kebab-app 통합 1: skip_reason / kebab-tui 단위 1: breakdown 라인 / kebab-app 단위 1: init template 헤더 / kebab-app 단위 1: ext_for_skip_warning helper). 기존 723 워크스페이스 테스트 무수정 통과.
|
||||
|
||||
**Known limitation (deferred)**:
|
||||
|
||||
- `SourceScope.include` (`kebab-core::traits`) 는 그대로 — design §7.1 abstraction 이라 별 spec 으로 다룰 수 있음. 본 PR 은 config 단의 `WorkspaceCfg.include` 만 정리.
|
||||
- 새 extractor (txt / docx / epub 등) 도입은 별 spec.
|
||||
- `kebab doctor` 가 unsupported 파일 카운트 분석은 후속 task.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: INDEX.md**
|
||||
|
||||
Open `tasks/INDEX.md`. Append to the p9-fb section:
|
||||
|
||||
```
|
||||
- [p9-fb-25 config workspace.include 제거 + 지원 형식 가시성 (post-도그푸딩)](p9/p9-fb-25-config-include-removal.md)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Per-task spec file**
|
||||
|
||||
Create `tasks/p9/p9-fb-25-config-include-removal.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-config
|
||||
task_id: p9-fb-25
|
||||
title: "Config workspace.include 제거 + 지원 형식 가시성 (post-merge dogfooding)"
|
||||
status: completed
|
||||
depends_on: [p9-fb-23]
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§6.2 Workspace, §3.x IngestReport, §2.4a IngestEvent]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-05 — include + exclude 의미 모호 + 지원 형식 가시성 부족.
|
||||
---
|
||||
|
||||
# p9-fb-25 — Config `workspace.include` 제거 + 지원 형식 가시성
|
||||
|
||||
상세 설계: `docs/superpowers/specs/2026-05-05-p9-fb-25-config-include-removal-design.md`.
|
||||
구현 계획: `docs/superpowers/plans/2026-05-05-p9-fb-25-config-include-removal.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
- `WorkspaceCfg.include` 필드 제거 (denylist-only 모델 정착).
|
||||
- 사용자가 ingest 결과에서 어떤 파일이 왜 skip 됐는지 즉시 파악.
|
||||
- 지원 형식 (md / png / jpg / pdf) 을 README + `kebab init` config 주석에 명시.
|
||||
|
||||
## Behavior contract
|
||||
|
||||
- 옛 config 의 `include = [...]` 은 silently 무시 + 단발 deprecation warning.
|
||||
- Skipped 시 `IngestItem.warnings` = `["unsupported media type: .ext"]` 또는 `["unsupported media type: <no-ext>"]` 또는 `["kb:// URI not yet supported"]`.
|
||||
- `IngestReport.skipped_by_extension` = `BTreeMap<lowercase-ext, count>`. no-ext 키 = `<no-ext>`.
|
||||
- CLI / TUI summary final / aborted 라인에 `"N skipped: A docx, B txt, ..."` (desc 정렬, 모두).
|
||||
|
||||
## Tests
|
||||
|
||||
- legacy include 무시 + 새 WorkspaceCfg 필드 destructure (kebab-config).
|
||||
- skip_reason 통합 (kebab-app): docx + Makefile 두 파일 ingest → warnings + skipped_by_extension 채워짐.
|
||||
- status_line breakdown (kebab-tui).
|
||||
- init template 헤더 (kebab-app).
|
||||
- ext_for_skip_warning helper (kebab-app).
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- 옛 config 가 narrow allowlist (예: `include = ["**/*.md"]`) 면 본 변경 후 `.png` 등이 자동 ingest 시작 — deprecation warning + README 가 alarm.
|
||||
- `SourceScope.include` (kebab-core) 는 그대로.
|
||||
|
||||
Live deviations 반영 위치: `tasks/HOTFIXES.md` `2026-05-05 — p9-fb-25` 항목.
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Final commit**
|
||||
|
||||
```bash
|
||||
git add README.md HANDOFF.md tasks/HOTFIXES.md tasks/INDEX.md tasks/p9/p9-fb-25-config-include-removal.md
|
||||
git commit -m "docs(p9-fb-25): README + HANDOFF + HOTFIXES + INDEX + per-task spec
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes (writer)
|
||||
|
||||
**Spec coverage:**
|
||||
- `WorkspaceCfg.include` 제거 + deprecation warning → Task 1.
|
||||
- SourceScope construction cleanup → Task 2.
|
||||
- `kebab init` header supported-extensions → Task 3.
|
||||
- `IngestReport.skipped_by_extension` + AggregateCounts + wire schema → Task 4.
|
||||
- `IngestItem.warnings` populate + asset loop bumps → Task 5.
|
||||
- CLI / TUI summary breakdown render → Task 6.
|
||||
- README + docs sync → Task 7.
|
||||
|
||||
**Type / API consistency:**
|
||||
- `BTreeMap<String, u32>` used in both `IngestReport.skipped_by_extension` (Task 4) and `AggregateCounts.skipped_by_extension` (Task 4) — same type.
|
||||
- `<no-ext>` sentinel used in BOTH `IngestItem.warnings` (Task 5) and `BTreeMap` key (Task 5).
|
||||
- `ext_for_skip_warning` helper defined in Task 5, consumed by Tasks 5 + 6 (CLI + TUI consume the resulting `BTreeMap`, not the helper directly).
|
||||
- `Copy` derive removed from `AggregateCounts` (Task 4) — callers using `let counts = state.counts;` continue to compile because `Clone` still works (assignment of non-Copy type via move; Rust borrow checker handles).
|
||||
|
||||
**Placeholder scan:** Each step has full code. Adapter-language ("adapt to actual existing helper") is reserved for genuine ambiguity (CLI summary print site, init template test fixture pattern) — the engineer must inspect 5-10 lines of context.
|
||||
|
||||
**Risks documented:**
|
||||
- `Copy` removal on `AggregateCounts` may surface compile errors at call sites that rely on `Copy`. Plan flags this in Task 4 step 2 with grep instruction.
|
||||
- Deprecation warning might fire from the `kebab init` test if it produces a config with `include = [...]` first. Task 3's test uses `force=true` on a fresh dir → no `include` in default → no warning. Acceptable.
|
||||
- `set_var(XDG_CONFIG_HOME)` in init test relies on Rust 2024 `unsafe`. Plan flags the wrapping requirement.
|
||||
File diff suppressed because it is too large
Load Diff
1644
docs/superpowers/plans/2026-05-07-p9-fb-30-mcp-server.md
Normal file
1644
docs/superpowers/plans/2026-05-07-p9-fb-30-mcp-server.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1206,6 +1206,12 @@ hint edit ~/.config/kebab/config.toml then `kebab ingest ~/KnowledgeBase`
|
||||
- 항상 POSIX path 정규화 후 DB 저장. `to_posix` 단일 함수.
|
||||
- 심볼릭 링크: 1차 follow + 무한루프 detect (`canonicalize` 후 set 추적).
|
||||
|
||||
### 6.7 `_external/` subdirectory (fb-31)
|
||||
|
||||
`<workspace.root>/_external/` 가 single-file / stdin ingest 의 destination. 명명: `<blake3-12>.<ext>` (12-char hex prefix of content hash + 원래 extension). deterministic — 동일 content 재 ingest 면 idempotent.
|
||||
|
||||
첫 생성 시 `<workspace.root>/.kebabignore` 에 `_external/` line 자동 append — 향후 `kebab ingest` 전체 walk 가 이 디렉토리 재 walk 안 함 (re-ingestion 무한 루프 방지).
|
||||
|
||||
---
|
||||
|
||||
## 7. Trait contracts (kebab-core)
|
||||
@@ -1426,6 +1432,34 @@ $ kebab doctor
|
||||
1 check failed.
|
||||
```
|
||||
|
||||
### 10.1 Capability matrix + introspection (fb-27)
|
||||
|
||||
`kebab schema [--json]` 가 binary 의 capability set 을 노출한다.
|
||||
`schema.v1` wire schema 가 `wire.schemas` (지원 wire id 목록), `capabilities`
|
||||
(bool flag, 미래 surface 의 placeholder 도 항상 포함), `models` (cascade
|
||||
version 6축), `stats` (doc/chunk/asset count + last_ingest_at) 를 한 호출로 반환한다.
|
||||
|
||||
`error.v1` wire schema 가 `--json` 모드에서 fatal error 를 stderr ndjson 으로
|
||||
emit. code 7개 initial set: `config_invalid` / `not_indexed` /
|
||||
`model_unreachable` / `model_not_pulled` / `timeout` / `io_error` /
|
||||
`generic`. exit code 0/1/2/3 unchanged — `error.v1.code` 가 fine-grained
|
||||
agent 분기 source. 자세한 details shape per code 는
|
||||
[docs/wire-schema/v1/error.schema.json](../../wire-schema/v1/error.schema.json).
|
||||
HOTFIXES 의 `2026-05-07 — p9-fb-27` 항목이 details shape 의
|
||||
interim deviation (IoFailure / OpTimeout 신규 typed signal 도입 전까지의
|
||||
transitional 형태) 의 source of truth.
|
||||
|
||||
### 10.2 MCP server transport (fb-30)
|
||||
|
||||
`kebab mcp` 가 stdio JSON-RPC server. Rust SDK = `rmcp 1.6`. Tool surface
|
||||
v1: `search` / `ask` / `schema` / `doctor` (4 read-only). Resources /
|
||||
Prompts / Sampling 미선언. Output 은 wire schema v1 JSON 을 MCP `text`
|
||||
content block 으로 직렬화. Tool dispatch 실패는 `isError: true` + error.v1
|
||||
content; refusal / no-hit / unhealthy 는 정상 응답 (semantic flag 으로
|
||||
agent 가 분기). HTTP-SSE transport 는 fb-29 deferral 따라 P+. classify
|
||||
모듈은 `kebab-app::error_wire` 에 single source — kebab-cli + kebab-mcp
|
||||
공유.
|
||||
|
||||
---
|
||||
|
||||
## 11. 동결 범위 / 변경 정책
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# p9-fb-25 — Config `workspace.include` 제거 + 지원 형식 가시성
|
||||
|
||||
**Date**: 2026-05-05
|
||||
**Status**: planned
|
||||
**Audience**: kebab-config / kebab-app / kebab-cli / kebab-tui implementer.
|
||||
**Source feedback**: 사용자 도그푸딩 2026-05-05 — config 의 `workspace.include` + `workspace.exclude` 가 동시에 있으면 case 4 (둘 다 매치 안 함) 의미 모호 + 어차피 처리 가능 형식이 정해져 있으니 사용자에게 명시 필요.
|
||||
|
||||
## Goal
|
||||
|
||||
- `WorkspaceCfg.include` 필드 제거. dead config field 제거 + denylist-only 모델 정착.
|
||||
- 사용자가 ingest 결과에서 \*\*어떤 파일이 왜 skip 됐는지\*\* 즉시 파악.
|
||||
- 지원 형식 (md / png / jpg / pdf) 을 README + `kebab init` config 주석에 명시.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- include 의 enforce 로직 추가 (반대 방향).
|
||||
- 새 extractor (txt / docx / epub 등) 도입 — 별 spec.
|
||||
- `kebab doctor` 가 unsupported 파일 카운트 분석 — 별 task (간단 follow-up 가능).
|
||||
|
||||
## Allowed dependencies
|
||||
|
||||
- 기존 crate 만. 신규 crate 없음. 신규 SQLite migration 없음.
|
||||
|
||||
## Storage 변경
|
||||
|
||||
없음.
|
||||
|
||||
## API / Wire 변경
|
||||
|
||||
### `kebab-config::WorkspaceCfg`
|
||||
|
||||
`include: Vec<String>` 필드 제거. `exclude: Vec<String>` 만 유지.
|
||||
|
||||
backward-compat: serde default `deny_unknown_fields` 미사용이라 옛 config 의 `include = [...]` 은 silently deserialize 통과 + 무시. `Config::load` 가 옛 키 발견 시 `tracing::warn!` 로 deprecation 경고 emit (단발 — 같은 process 안에서 한 번만):
|
||||
|
||||
```
|
||||
deprecated config: `workspace.include` 필드는 더 이상 사용되지 않습니다 (p9-fb-25). 처리 가능한 형식 (md / png / jpg / pdf) 은 extractor 가 자동 결정. 다음 버전부터 config 갱신 권장.
|
||||
```
|
||||
|
||||
검출 방법: `Config::load` 가 raw TOML 파싱 후 `workspace` 테이블의 키 이름을 살펴 `include` 존재 여부 확인. `serde_ignored` crate 미도입 (YAGNI) — `toml::Value` 로 raw lookup 한 번.
|
||||
|
||||
### `kebab-core::IngestItem.warnings`
|
||||
|
||||
Skipped path 가 빈 `Vec` 대신 사유 한 줄 채움. 두 case:
|
||||
|
||||
- media-type filter (extractor 미지원): `format!("unsupported media type: .{ext}")` (e.g. `"unsupported media type: .docx"`). extension 이 없으면 `"unsupported media type: <no-ext>"`.
|
||||
- `kb://` URI: `"kb:// URI not yet supported"`.
|
||||
|
||||
### `kebab-core::IngestReport.skipped_by_extension`
|
||||
|
||||
신규 필드:
|
||||
|
||||
```rust
|
||||
pub skipped_by_extension: std::collections::BTreeMap<String, u32>,
|
||||
```
|
||||
|
||||
key = lowercase extension without leading dot (`"docx"`, `"txt"`, `"epub"`). 확장자 없는 파일 = `"<no-ext>"` sentinel (꺾쇠로 일반 ext 와 시각 구분).
|
||||
|
||||
`BTreeMap` 사용 — wire JSON 안에서 key 정렬 안정. `HashMap` 은 매 직렬화마다 순서 바뀌어 diff / snapshot 테스트 noisy.
|
||||
|
||||
`AggregateCounts` 도 동일 필드 추가 — TUI / CLI 가 in-flight 와 final 모두에서 일관 표시.
|
||||
|
||||
### Wire schema `ingest_report.v1`
|
||||
|
||||
`skipped_by_extension` 필드 additive 추가:
|
||||
|
||||
```json
|
||||
"skipped_by_extension": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"description": "p9-fb-25: per-extension skip count. Key = lowercase extension without leading dot (e.g. 'docx'). Files without extension key under '<no-ext>'."
|
||||
}
|
||||
```
|
||||
|
||||
CLAUDE.md 의 release 규약 (additive minor) 에 따라 release bump 트리거 안 됨.
|
||||
|
||||
## TUI / CLI 노출
|
||||
|
||||
### CLI summary
|
||||
|
||||
기존:
|
||||
|
||||
```
|
||||
✓ ingest: 100 docs (5 new, 3 updated, 2 unchanged, 90 skipped), 142 chunks indexed in 12s
|
||||
```
|
||||
|
||||
변경 (skipped > 0 + breakdown 있을 때만 괄호 안):
|
||||
|
||||
```
|
||||
✓ ingest: 100 docs (5 new, 3 updated, 2 unchanged, 90 skipped: 80 docx, 5 txt, 5 epub), 142 chunks indexed in 12s
|
||||
```
|
||||
|
||||
extension 카운트 desc 정렬 (큰 거 먼저). 모두 표시 (top-3 제한 없음). Line 길어질 우려가 있으나 사용자 원함 — line wrap 은 terminal 책임.
|
||||
|
||||
### TUI
|
||||
|
||||
`kebab-tui::ingest_progress::status_line` 의 final / aborted 라인 동일 포맷. in-flight 진행 중에는 breakdown 표시 안 함 (idx 진행 중 계속 변동, 불필요 noise).
|
||||
|
||||
## 사용자 안내 (docs)
|
||||
|
||||
### README
|
||||
|
||||
`kebab ingest` row 의 cell 끝에 추가:
|
||||
|
||||
```
|
||||
**지원 형식** (extractor 자동 결정): Markdown (`.md`) / 이미지 (`.png`, `.jpg`, `.jpeg`, OCR + caption) / PDF (`.pdf`). 다른 확장자는 자동 skip — `--json` / TUI 의 `IngestItem.warnings` 에 사유 (`unsupported media type: .docx` 등). 카운트 분류는 `IngestReport.skipped_by_extension`.
|
||||
```
|
||||
|
||||
### `kebab init` config.toml 주석
|
||||
|
||||
`[workspace]` section 위에 주석 추가:
|
||||
|
||||
```toml
|
||||
# [workspace] — 색인 대상 디렉토리 + denylist.
|
||||
#
|
||||
# 지원 형식 (extractor 가 자동 결정 — config 에 명시할 수 없음):
|
||||
# - Markdown: .md
|
||||
# - 이미지: .png .jpg .jpeg (OCR + caption)
|
||||
# - PDF: .pdf
|
||||
#
|
||||
# 다른 확장자는 ingest 시 자동 skip + warning. 처리 대상 폴더의
|
||||
# 일부만 ingest 하고 싶으면 `kebab ingest <path>` 로 root 명시
|
||||
# 또는 `.kebabignore` 파일 / 본 `exclude` 로 denylist.
|
||||
[workspace]
|
||||
root = "..."
|
||||
exclude = [...]
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
### 신규 단위
|
||||
|
||||
- `kebab-config`: `Config::load` 가 옛 `include = [...]` 발견 시 warning emit + 정상 deserialize. snapshot test (in-memory string TOML).
|
||||
- `kebab-core`: `IngestItem` JSON serde — `warnings` 가 `["unsupported media type: .docx"]` round-trip.
|
||||
- `kebab-core`: `IngestReport.skipped_by_extension` JSON serde — `BTreeMap` 정렬 stable.
|
||||
|
||||
### 신규 통합
|
||||
|
||||
- `kebab-app`: 다양한 확장자 mix (`.md`, `.docx`, `.txt`, no-ext 파일) workspace 에서 ingest → `report.skipped_by_extension == {"docx": 1, "txt": 1, "<no-ext>": 1}` + 각 skipped 의 `warnings` 채워짐.
|
||||
- `kebab-tui`: `status_line` 가 `90 skipped: 80 docx, 5 txt, 5 epub` 형식.
|
||||
- `kebab-cli`: `kebab ingest --json` 출력에 `skipped_by_extension` 필드.
|
||||
|
||||
### 기존 영향
|
||||
|
||||
- 기존 `IngestReport` 구성 site (테스트 fixture 등) 가 새 필드 default 로 채움 (`BTreeMap::new()`).
|
||||
- `WorkspaceCfg` 의 `include` 필드 제거로 컴파일 에러 → 매 site 정리 (기존 default 가 `vec!["**/*.md"]` 였으니 모두 제거).
|
||||
|
||||
## Spec contract impact
|
||||
|
||||
- design §6.2 의 `workspace.include` 항목 invalidate. frozen spec 그대로 두고 본 spec + HOTFIXES `2026-05-05 — p9-fb-25` 가 source of truth.
|
||||
- design §3.x `IngestReport` 에 `skipped_by_extension` 필드 추가 (additive).
|
||||
- design §2.4a `IngestEvent::AssetFinished` 에 새로 emit 되는 warnings 의미 추가 (variant 변경 없음, content 풍부화).
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- **옛 config 가 `include = ["**/*.md"]` 같은 narrow 한 allowlist** 면 본 변경 후 그 이상의 확장자 (예: 파일 추가된 `.png`) 가 자동 ingest 시작. 사용자 의도와 어긋날 수 있음. 완화: deprecation warning 의 문구가 \"처리 가능 형식 자동 결정\" 명시 → 사용자가 alarm 받음. + README 변경. 경계 case 라 design accepted.
|
||||
- **`skipped_by_extension` 용량**: workspace 가 1만 파일이면 dict size 작음 (extension 종류는 보통 < 50). wire 영향 무시.
|
||||
- **deprecation warning 단발 vs every-load**: `Config::load` 가 매 CLI 호출마다 발생. 단발 (`std::sync::Once`) 이 깔끔. 본 spec 은 단발 채택.
|
||||
- **release 트리거**: wire schema additive + serde backward-compat → CLAUDE.md release 규약 의 minor 트리거에 해당 안 됨 (additive 만으로 release 안 찍음). 사용자 explicit 도그푸딩 요청 시 bump.
|
||||
|
||||
## Live deviations
|
||||
|
||||
추후 발견되는 deviation 은 `tasks/HOTFIXES.md` `2026-05-05 — p9-fb-25` 항목에 기록.
|
||||
@@ -0,0 +1,374 @@
|
||||
---
|
||||
title: "p9-fb-27 — Introspection (`kebab schema`) + structured error wire"
|
||||
date: 2026-05-07
|
||||
status: design (brainstorm 완료, plan 단계 대기)
|
||||
target_version: 0.3.0
|
||||
task_spec: ../../../tasks/p9/p9-fb-27-introspection-and-error-wire.md
|
||||
contract_source: ../specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§10 에러 모델 + exit codes, wire-schema 전반]
|
||||
unblocks: [p9-fb-30]
|
||||
---
|
||||
|
||||
# Introspection + structured error wire — 설계
|
||||
|
||||
## 동기
|
||||
|
||||
agent (Claude Code skill, 미래 fb-30 MCP, fb-29 daemon) 가 kebab 인스턴스의 wire 버전 / 기능 / 모델 / 인덱스 통계를 한 번의 호출로 알아내야 통합이 안전하다. 현재는 README / 코드 / `kebab doctor` 출력을 따로 봐야 하고, agent 입장에서 parsable 한 path 가 없다.
|
||||
|
||||
또한 error 가 stderr text (`error: <msg>\n hint: <h>`) — agent 가 substring 으로 분기 (timeout vs config-missing vs not-indexed) 해야 하는데 i18n / 메시지 변경에 깨진다.
|
||||
|
||||
본 설계는 다음 두 surface 를 도입한다:
|
||||
|
||||
1. `kebab schema [--json]` — 정적 (wire / capabilities / models) + 동적 (stats) introspection 한 명령.
|
||||
2. `error.v1` wire schema — `--json` 모드에서 fatal error 가 stderr 에 ndjson 으로 emit. 비 `--json` 은 기존 stderr text 그대로.
|
||||
|
||||
## Surface 1 — `kebab schema`
|
||||
|
||||
### CLI 형태
|
||||
|
||||
| flag | 동작 |
|
||||
|------|------|
|
||||
| (없음) | 사람 친화 텍스트 (doctor 풍) — stdout |
|
||||
| `--json` | `schema.v1` JSON object 한 줄 — stdout |
|
||||
|
||||
`--config <path>` honor (P3-5 / P4-3 회귀 패턴 회피 — `kebab_app::schema_with_config` 사용).
|
||||
|
||||
### Wire schema (`schema.v1`)
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "schema.v1",
|
||||
"kebab_version": "0.2.1",
|
||||
"wire": {
|
||||
"schemas": [
|
||||
"answer.v1", "search_hit.v1", "doc_summary.v1",
|
||||
"chunk_inspection.v1", "doctor.v1",
|
||||
"ingest_report.v1", "ingest_progress.v1",
|
||||
"reset_report.v1", "citation.v1",
|
||||
"schema.v1", "error.v1"
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"json_mode": true,
|
||||
"ingest_progress": true,
|
||||
"ingest_cancellation": true,
|
||||
"rag_multi_turn": true,
|
||||
"search_cache": true,
|
||||
"incremental_ingest": true,
|
||||
"streaming_ask": false,
|
||||
"http_daemon": false,
|
||||
"mcp_server": false,
|
||||
"single_file_ingest": false
|
||||
},
|
||||
"models": {
|
||||
"parser_version": "md-frontmatter-v2",
|
||||
"chunker_version": "md-heading-v1",
|
||||
"embedding_version": "fastembed-mle5small-384-v1",
|
||||
"prompt_template_version": "rag-v1",
|
||||
"index_version": "lance-flat-l2-384-v1",
|
||||
"corpus_revision": 42
|
||||
},
|
||||
"stats": {
|
||||
"doc_count": 128,
|
||||
"chunk_count": 2147,
|
||||
"asset_count": 130,
|
||||
"last_ingest_at": "2026-05-07T03:14:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**필드 의미**:
|
||||
|
||||
- `kebab_version` — `env!("CARGO_PKG_VERSION")` (workspace `Cargo.toml` 의 `version`, kebab-cli 빌드 시 compile-in).
|
||||
- `wire.schemas` — 본 binary 가 emit 가능한 모든 wire schema 의 fully-qualified id list. parsing 시 v1 / v2 분기 지표.
|
||||
- `capabilities` — bool 만. 미래 surface (streaming_ask / http_daemon / mcp_server / single_file_ingest) 의 placeholder 도 항상 포함. 해당 fb 머지 시 false → true flip. agent 가 한 호출로 "이 binary 가 streaming 지원하나" 결정.
|
||||
- `models.parser_version` — `kebab-parse-md` / `kebab-parse-image` / `kebab-parse-pdf` 의 active const (현재 markdown 만 표시 — multi-medium 동시 표시는 plan 단계). 또는 `Config::active_parser_version()` helper.
|
||||
- `models.chunker_version` — `Config::chunking.chunker_version` (markdown). PDF 는 항상 `pdf-page-v1` hardcode (P7-3 deviation).
|
||||
- `models.embedding_version` — `Config::models.embedding.id` (config 의 사용자 지정 model id).
|
||||
- `models.prompt_template_version` — `kebab-rag::PROMPT_TEMPLATE_VERSION` const.
|
||||
- `models.index_version` — `kebab-store-vector::INDEX_VERSION` const (lance flat L2 384d).
|
||||
- `models.corpus_revision` — `kv.corpus_revision` (p9-fb-19 V004) 를 u64 로 read.
|
||||
- `stats.doc_count` / `chunk_count` / `asset_count` — `SELECT COUNT(*) FROM documents | chunks | assets`.
|
||||
- `stats.last_ingest_at` — `SELECT MAX(updated_at) FROM documents`. RFC3339 string. KB 비어 있으면 `null`.
|
||||
|
||||
`stats.last_ingest_at` 은 별도 stamp 안 함 — 기존 `documents.updated_at` 가 idempotent ingest 의 source of truth.
|
||||
|
||||
### 사람 친화 출력 (비 `--json`)
|
||||
|
||||
```text
|
||||
$ kebab schema
|
||||
kebab v0.2.1
|
||||
|
||||
wire schemas
|
||||
answer.v1, search_hit.v1, doc_summary.v1, ...
|
||||
|
||||
capabilities
|
||||
✓ json_mode
|
||||
✓ ingest_progress
|
||||
✓ ingest_cancellation
|
||||
✓ rag_multi_turn
|
||||
✓ search_cache
|
||||
✓ incremental_ingest
|
||||
✗ streaming_ask
|
||||
✗ http_daemon
|
||||
✗ mcp_server
|
||||
✗ single_file_ingest
|
||||
|
||||
models
|
||||
parser_version md-frontmatter-v2
|
||||
chunker_version md-heading-v1
|
||||
embedding_version fastembed-mle5small-384-v1
|
||||
prompt_template_version rag-v1
|
||||
index_version lance-flat-l2-384-v1
|
||||
corpus_revision 42
|
||||
|
||||
stats
|
||||
doc_count 128
|
||||
chunk_count 2147
|
||||
asset_count 130
|
||||
last_ingest_at 2026-05-07T03:14:00Z
|
||||
```
|
||||
|
||||
doctor 와 시각 일관 — 체크/엑스 마크 + key-value padding.
|
||||
|
||||
## Surface 2 — `error.v1` wire
|
||||
|
||||
### Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "error.v1",
|
||||
"code": "model_not_pulled",
|
||||
"message": "Ollama model not pulled: gemma4:e4b",
|
||||
"details": {
|
||||
"model": "gemma4:e4b",
|
||||
"endpoint": "http://127.0.0.1:11434",
|
||||
"operation": "ask"
|
||||
},
|
||||
"hint": "ollama pull gemma4:e4b"
|
||||
}
|
||||
```
|
||||
|
||||
### Field 규약
|
||||
|
||||
- `schema_version` — literal `"error.v1"`.
|
||||
- `code` — machine-readable enum string (catalog 아래).
|
||||
- `message` — 한 줄 사람 메시지 (anyhow root cause + 짧은 context).
|
||||
- `details` — code 별 free-form object. 모든 code 가 자체 schema. agent 는 `code` 보고 `details` 의 field 안다.
|
||||
- `hint` — string. 다음 단계 한 줄. hint 없으면 `null` 또는 omit.
|
||||
|
||||
### Emission 정책
|
||||
|
||||
- `--json` 일 때 `Cli::run` 의 `Err(e)` 도달 시 `serde_json::to_writer(stderr, &error_v1)?; stderr.write_all(b"\n")?;`. stderr text 는 emit 안 함.
|
||||
- 비 `--json` 일 때 기존 그대로 (`error: <msg>\n hint: <h>` + verbose chain).
|
||||
- **refusal** (`RefusalSignal`) → `answer.v1` 의 `grounded: false`. stdout JSON, exit 1. error.v1 으로 가지 않음.
|
||||
- **no-hit** (`NoHitSignal`) → `search_hit.v1` 빈 list. stdout JSON, exit 1. error.v1 으로 가지 않음.
|
||||
- **doctor unhealthy** (`DoctorUnhealthy`) → `doctor.v1` 의 `healthy: false`. stdout JSON, exit 3. error.v1 으로 가지 않음.
|
||||
|
||||
### Error code catalog
|
||||
|
||||
초기 7개. 각 code 가 typed signal 또는 anyhow chain root 에 매핑.
|
||||
|
||||
| code | trigger | details fields | exit | source |
|
||||
|------|---------|----------------|------|--------|
|
||||
| `config_invalid` | `Config::load` 실패, `--config` 경로 누락, TOML 파싱 / validation 실패 | `path: String`, `cause: String` | 2 | `ConfigInvalid` 신규 signal |
|
||||
| `not_indexed` | `kebab.sqlite` 미존재 / migration 미실행 / V00X mismatch | `data_dir: String`, `expected: String`, `found: Option<String>` | 3 | `DoctorUnhealthy` extension |
|
||||
| `model_unreachable` | Ollama endpoint 연결 실패 (TCP refused / DNS / connect timeout) | `endpoint: String`, `operation: "ask"\|"caption"\|"ocr"` | 2 | `ModelUnreachable` 신규 signal |
|
||||
| `model_not_pulled` | Ollama 200 응답이 "model not found" body | `model: String`, `endpoint: String`, `operation: ...` | 2 | `ModelNotPulled` 신규 signal |
|
||||
| `timeout` | LLM stream / embed batch deadline 초과 | `operation: String`, `elapsed_ms: u64`, `deadline_ms: u64` | 2 | `OpTimeout` 신규 signal |
|
||||
| `io_error` | filesystem / 권한 / disk full | `path: String`, `op: "read"\|"write"\|"create"` | 2 | `IoFailure` 신규 signal |
|
||||
| `generic` | 위 catalog 외 모든 anyhow | `chain: Vec<String>` (verbose 시) | 2 | catch-all |
|
||||
|
||||
**확장 정책**:
|
||||
|
||||
- 새 code 추가 = additive — `error.v1` major bump 불필요.
|
||||
- code 제거 / 의미 변경 = `error.v2` breaking.
|
||||
- fb-29/30/33 머지 시 자체 code 추가 가능 (예 `daemon_locked`, `mcp_protocol_error`, `stream_aborted`).
|
||||
|
||||
## Internal architecture
|
||||
|
||||
### 새 typed signal 모듈
|
||||
|
||||
```rust
|
||||
// crates/kebab-app/src/error_signal.rs (신규)
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ConfigInvalid {
|
||||
pub path: PathBuf,
|
||||
pub cause: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ModelUnreachable {
|
||||
pub endpoint: String,
|
||||
pub operation: &'static str, // "ask" | "caption" | "ocr"
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ModelNotPulled {
|
||||
pub model: String,
|
||||
pub endpoint: String,
|
||||
pub operation: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct OpTimeout {
|
||||
pub operation: &'static str,
|
||||
pub elapsed_ms: u64,
|
||||
pub deadline_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct IoFailure {
|
||||
pub path: PathBuf,
|
||||
pub op: &'static str, // "read" | "write" | "create"
|
||||
}
|
||||
```
|
||||
|
||||
각 signal 은 `std::error::Error + Send + Sync` 자동 derive 또는 thiserror impl. 발생지 (`kebab-config`, `kebab-llm-local`, `kebab-store-sqlite`) 가 `anyhow::Error::new(signal).context(...)` 로 wrap. `classify` 가 downcast 로 분기.
|
||||
|
||||
기존 signal — `RefusalSignal` (kebab-rag), `NoHitSignal` (kebab-app), `DoctorUnhealthy` (kebab-app) — 변경 없음.
|
||||
|
||||
### `classify` 함수
|
||||
|
||||
```rust
|
||||
// crates/kebab-cli/src/error_classify.rs (신규)
|
||||
use kebab_app::error_signal::*;
|
||||
use crate::wire::ErrorV1;
|
||||
|
||||
pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 {
|
||||
if let Some(s) = err.downcast_ref::<ConfigInvalid>() {
|
||||
return ErrorV1::config_invalid(&s.path, &s.cause);
|
||||
}
|
||||
if let Some(s) = err.downcast_ref::<ModelUnreachable>() {
|
||||
return ErrorV1::model_unreachable(&s.endpoint, s.operation);
|
||||
}
|
||||
if let Some(s) = err.downcast_ref::<ModelNotPulled>() {
|
||||
return ErrorV1::model_not_pulled(&s.model, &s.endpoint, s.operation);
|
||||
}
|
||||
if let Some(s) = err.downcast_ref::<OpTimeout>() {
|
||||
return ErrorV1::timeout(s.operation, s.elapsed_ms, s.deadline_ms);
|
||||
}
|
||||
if let Some(s) = err.downcast_ref::<IoFailure>() {
|
||||
return ErrorV1::io_error(&s.path, s.op);
|
||||
}
|
||||
// not_indexed 는 DoctorUnhealthy 가 아닌 별 signal? — skeleton 단계
|
||||
// store-sqlite 의 schema mismatch 는 별 signal type 정의하거나 anyhow context 매칭
|
||||
ErrorV1::generic(err, verbose)
|
||||
}
|
||||
```
|
||||
|
||||
`not_indexed` 의 매핑은 plan 단계 결정 — `DoctorUnhealthy` 의 reason 분류 또는 `kebab-store-sqlite` 의 schema-mismatch 별 signal.
|
||||
|
||||
### CLI main.rs 변경
|
||||
|
||||
`Cmd::Schema` arm 신규:
|
||||
|
||||
```rust
|
||||
Cmd::Schema => {
|
||||
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
|
||||
let report = kebab_app::schema_with_config(&cfg)?;
|
||||
if cli.json {
|
||||
let v = serde_json::to_value(&report)?;
|
||||
let v = wire::tag_object(v, "schema.v1");
|
||||
println!("{}", serde_json::to_string(&v)?);
|
||||
} else {
|
||||
wire::print_schema_text(&report);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
`main()` 의 `Err(e)` arm 분기:
|
||||
|
||||
```rust
|
||||
match run(&cli) {
|
||||
Ok(()) => ExitCode::from(0),
|
||||
Err(e) => {
|
||||
let code = exit_code(&e);
|
||||
if code != 1 {
|
||||
if cli.json {
|
||||
let err_v1 = error_classify::classify(&e, cli.verbose);
|
||||
let v = serde_json::to_value(&err_v1).unwrap();
|
||||
let v = wire::tag_object(v, "error.v1");
|
||||
eprintln!("{}", serde_json::to_string(&v).unwrap());
|
||||
} else {
|
||||
eprintln!("error: {e}");
|
||||
if cli.verbose {
|
||||
for cause in e.chain().skip(1) {
|
||||
eprintln!(" caused by: {cause}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ExitCode::from(code)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`exit_code()` 함수 unchanged — typed signal 3개 (`RefusalSignal`, `NoHitSignal`, `DoctorUnhealthy`) 만 보고 1/3 결정. 신규 5 signal 모두 fall-through → 2.
|
||||
|
||||
### Facade (kebab-app) 변경
|
||||
|
||||
- `pub fn schema_with_config(cfg: &Config) -> Result<SchemaV1>` 신규 — wire / capabilities / models / stats 빌드.
|
||||
- `pub mod error_signal` — public, kebab-cli 가 import.
|
||||
- 기존 facade 시그니처 무영향.
|
||||
|
||||
### 의존 경계
|
||||
|
||||
- `error_signal` 모듈 = `kebab-app` 내. UI crate (`kebab-cli`) 만 import.
|
||||
- `kebab-core` 침범 없음.
|
||||
- `kebab-store-sqlite` / `kebab-llm-local` / `kebab-config` 가 발생지에서 signal 받아 anyhow wrap — 각자 `kebab-app` 의존 없이 `kebab-core` extension trait 또는 별 sub-crate 로 import. plan 단계 결정.
|
||||
|
||||
**대안 1**: `error_signal` 을 `kebab-core` 에 두고 모든 발생지가 kebab-core 만 의존 (이미 의존 중). 단순. 하지만 §8 의존 경계 룰: `kebab-core` 는 도메인 타입만. signal 이 도메인 타입인가? 모호. plan 단계 brainstorm.
|
||||
**대안 2**: 신규 crate `kebab-error` — signal type 만 보유. 모든 crate 가 의존. 새 crate 도입 비용.
|
||||
**대안 3 (recommended)**: signal type 을 발생지 crate (kebab-config / kebab-llm / kebab-llm-local / kebab-store-sqlite) 자체에 정의. kebab-cli 의 `classify` 가 모두 import. kebab-app 은 re-export 만.
|
||||
|
||||
## Testing 전략
|
||||
|
||||
| crate | test type | 파일 | 검증 |
|
||||
|-------|-----------|------|------|
|
||||
| `kebab-app` | unit | `tests/schema_report.rs` | TempDir KB ingest 후 `schema_with_config` — `models.parser_version == "md-frontmatter-v2"`, `stats.doc_count == 3`, `stats.last_ingest_at == max(documents.updated_at)`, 빈 KB → `last_ingest_at: None` |
|
||||
| `kebab-app::error_signal` | unit | `src/error_signal.rs::tests` | 5 신규 signal 의 `Display` + `std::error::Error::source` chain 안정 |
|
||||
| `kebab-cli::wire` | unit | `src/wire.rs::tests` | `SchemaV1` / `ErrorV1` round-trip — `tag_object` 가 `schema_version` 정확 wrap, `serde_json::from_str` 으로 다시 파싱 |
|
||||
| `kebab-cli::error_classify` | unit | `src/error_classify.rs::tests` | 7 mock anyhow chain → 7 code 일대일 매핑, 8th anyhow → `code == "generic"`, verbose=true 시 `details.chain` 채움 |
|
||||
| 통합 | binary | `tests/cli_schema.rs` | `kebab schema --json` exit 0 + stdout parse 가능 + `schema_version == "schema.v1"` |
|
||||
| 통합 | binary | `tests/cli_error_wire.rs` | `kebab --json --config /nonexistent ingest` → exit 2 + stderr ndjson `code == "config_invalid"` |
|
||||
| 회귀 | binary | 기존 smoke 6+ | 비 `--json` 모드 stderr text 포맷 unchanged — snapshot |
|
||||
|
||||
## Migration / 호환성
|
||||
|
||||
- 모든 변경 additive. wire schema v1 major bump 없음.
|
||||
- 기존 9 wire schema literal 동일.
|
||||
- `--json` 모드 에러 emit 은 신규 surface — 이전 binary 의 `--json` 사용자 (claude-code skill) 가 stderr 무시했던 패턴 그대로 동작. 추가 정보만 늘어남.
|
||||
- exit code 매핑 동일 — 0/1/2/3.
|
||||
|
||||
## Spec / doc sync (PR 같은 commit)
|
||||
|
||||
1. **frozen design §10** — wire schema list 에 `schema.v1` / `error.v1` 추가, capability matrix 절 신설.
|
||||
2. **`docs/wire-schema/v1/schema.schema.json`** + **`error.schema.json`** 신규.
|
||||
3. **README.md** — 명령 표 에 `kebab schema` row, 짧은 capability flag 안내.
|
||||
4. **HANDOFF.md** — "머지 후 발견된 결정" 한 줄.
|
||||
5. **HOTFIXES.md** — 의도적 deviation 없으면 짧은 entry.
|
||||
6. **CLAUDE.md** — wire schema 절에 두 신규 추가.
|
||||
7. **integrations/claude-code/kebab/SKILL.md** — `kebab schema` 활용 안내 (additive).
|
||||
8. **`tasks/p9/p9-fb-27-introspection-and-error-wire.md`** — frontmatter `status: open` → `in_progress` 또는 `completed`.
|
||||
|
||||
## Release trigger
|
||||
|
||||
0.3.0 minor bump — fb-27 머지 = "agent foundation" 첫 component. wire 추가 additive 라 release 의무 아님이지만 fb-26~31 묶어 0.3.0 한 번에 cut.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- fb-30 MCP 의 `initialize` response 가 `capabilities` 재사용 — fb-30 spec 에서 import.
|
||||
- fb-37 trace + stats 가 `error.v1.details.trace_id` 추가 — additive.
|
||||
- error code 확장 (예 `embedding_dim_mismatch`) — 발생지 추가 시점 case-by-case.
|
||||
- `not_indexed` 의 정확한 source signal 결정 (`DoctorUnhealthy` extension vs 별 signal) — plan 단계.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- Cascade: capability flag 추가 / 제거 = wire schema additive — 기존 agent 가 새 flag 무시하면 OK, false 인 flag 의존 코드는 반드시 default 처리 필요.
|
||||
- error code enumeration 의 i18n: `message` 필드는 영어 또는 한국어? — plan 단계 결정. agent 는 `code` 로만 분기, `message` 는 사람용. 현 stderr text 는 한국어 우세 → 동일.
|
||||
- `not_indexed` 의 매핑이 `DoctorUnhealthy` 와 겹침. `DoctorUnhealthy` 가 wider scope (multiple subsystem) — `not_indexed` 만 별 signal 로 분리 vs reason field 로 구분.
|
||||
- `last_ingest_at` 이 incremental ingest (fb-23) 의 `Unchanged` 도 `updated_at` bump 시키면 의미 모호 — code 확인 후 plan 단계 명시 (현재 idempotent UPSERT 가 항상 bump 라면 `last_change_at` 이 더 정확).
|
||||
222
docs/superpowers/specs/2026-05-07-p9-fb-30-mcp-server-design.md
Normal file
222
docs/superpowers/specs/2026-05-07-p9-fb-30-mcp-server-design.md
Normal file
@@ -0,0 +1,222 @@
|
||||
---
|
||||
title: "p9-fb-30 — MCP server (stdio) — agent host 무관 protocol surface"
|
||||
date: 2026-05-07
|
||||
status: design (brainstorm 완료, plan 단계 대기)
|
||||
target_version: 0.4.0
|
||||
task_spec: ../../../tasks/p9/p9-fb-30-mcp-server.md
|
||||
contract_source: ../specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§7 RAG, §10 UX]
|
||||
depends_on: [p9-fb-27]
|
||||
unblocks: []
|
||||
---
|
||||
|
||||
# MCP server (stdio) — 설계
|
||||
|
||||
## 동기
|
||||
|
||||
현재 외부 AI 통합은 `integrations/claude-code/kebab/` skill 한 종류 — Claude Code subprocess wrapper. Cursor / OpenAI Agents / Copilot CLI 등 다른 host 는 별도 wrapper 작성 필요.
|
||||
|
||||
MCP (Model Context Protocol) 가 표준 — 한 번 server 구현하면 MCP-aware host 모두 지원. 본 task 는 stdio MCP server 도입. fb-29 HTTP daemon 은 deferred (single-user local-first 환경에서 daemon 복잡도 비대 — fb-30 stdio 가 동일 사용자 가치 제공).
|
||||
|
||||
fb-27 (introspection + error wire) 의 capability matrix + error.v1 wire 가 본 task 의 prerequisite ✅.
|
||||
|
||||
## 결정 요약
|
||||
|
||||
| 결정 | 선택 |
|
||||
|------|------|
|
||||
| Dispatch | `kebab mcp` subcommand (kebab-cli 내) |
|
||||
| Tool surface (v1) | `search` / `ask` / `schema` / `doctor` (read-only, 4 개) |
|
||||
| Resources / Prompts | 모두 skip (tools only) |
|
||||
| 구현 | Rust MCP SDK (`rmcp` 또는 plan 단계 채택) |
|
||||
| Transport | stdio 단일 (HTTP-SSE 는 fb-29 deferral 따라 P+) |
|
||||
| Output | 모든 tool 이 wire schema v1 JSON 을 text content 로 반환 |
|
||||
| Multi-turn `ask` | optional `session_id` (kebab-app 의 `ask_with_session_with_config` 활용) |
|
||||
|
||||
## Surface 1 — `kebab mcp` 신규 subcommand
|
||||
|
||||
### CLI
|
||||
|
||||
```
|
||||
kebab mcp # stdio JSON-RPC server 시작
|
||||
kebab mcp --config <path> # config 명시 (P3-5 / P4-3 패턴)
|
||||
```
|
||||
|
||||
`--config` 외 추가 flag 없음. agent host 가 spawn 명령에서 환경 변수로 추가 설정 주입.
|
||||
|
||||
### Crate boundary
|
||||
|
||||
새 crate `crates/kebab-mcp/` (lib only). `kebab-cli` 의 `Cmd::Mcp` arm 이 한 줄 entry — `kebab_mcp::serve_stdio(cfg)?`.
|
||||
|
||||
```
|
||||
kebab-cli ──► kebab-mcp ──► kebab-app ──► kebab-store-* / kebab-llm-* / kebab-parse-*
|
||||
│ │
|
||||
└─ rmcp ─────┴─ kebab-config / kebab-core
|
||||
```
|
||||
|
||||
CLAUDE.md facade 룰 준수:
|
||||
- `kebab-mcp` 는 `kebab-app` facade + `kebab-config` + `kebab-core` 만 import. 구현 crate 직접 금지.
|
||||
- `kebab-cli` 는 `kebab-mcp` 만 알고 MCP 내부 미인지 — 다른 UI crate (TUI / desktop) 가 mcp surface 필요해지면 동일하게 import.
|
||||
- `rmcp` (또는 채택 SDK) 는 `kebab-mcp` 의 `[dependencies]` 만 — kebab-cli 는 transitive.
|
||||
|
||||
CLAUDE.md 의 "UI crates" 카테고리에 `kebab-mcp` 추가 (의존 경계 절).
|
||||
|
||||
## Surface 2 — Tool catalog (4 tools)
|
||||
|
||||
`tools/list` response 가 4 tool 을 노출. 각 tool 은 inputSchema (JSON Schema) 가 inline.
|
||||
|
||||
### `search`
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| description | "Lexical / vector / hybrid retrieval over indexed corpus." |
|
||||
| input | `{ query: string (required), mode?: "lexical" \| "vector" \| "hybrid" (default "hybrid"), k?: integer (default 10, range 1-100) }` |
|
||||
| facade | `kebab_app::search_with_config(&cfg, query, mode, k)` |
|
||||
| output | text content = `serde_json::to_string(wire::wire_search_hits(&hits))` |
|
||||
|
||||
빈 결과 = 정상 응답 (empty `search_hit.v1` array). NoHitSignal 의 exit-code 분기는 stdio 무관.
|
||||
|
||||
### `ask`
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| description | "Grounded RAG answer with citations. Returns answer.v1 with grounded=false when KB lacks context." |
|
||||
| input | `{ query: string (required), session_id?: string }` |
|
||||
| facade | `session_id` 있으면 `ask_with_session_with_config`, 없으면 `ask_with_config` |
|
||||
| output | text content = `serde_json::to_string(wire::wire_answer(&answer))` |
|
||||
|
||||
Refusal (`grounded: false`) = 정상 응답. agent 가 wire payload 의 `grounded` flag 로 분기. `refusal_reason` 도 답변에 포함.
|
||||
|
||||
### `schema`
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| description | "Introspection — wire schemas, capabilities, model versions, index stats." |
|
||||
| input | `{}` (no args) |
|
||||
| facade | `schema_with_config(&cfg)` |
|
||||
| output | text content = `serde_json::to_string(wire::wire_schema(&schema))` |
|
||||
|
||||
`capabilities.mcp_server` 가 `true` (본 PR 에서 `capabilities_snapshot()` 갱신).
|
||||
|
||||
### `doctor`
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| description | "Health check — config / data dir / Ollama reachability." |
|
||||
| input | `{}` (no args) |
|
||||
| facade | `doctor_with_config_path(cli.config.as_deref())` (or equivalent) |
|
||||
| output | text content = `serde_json::to_string(wire::wire_doctor(&report))` |
|
||||
|
||||
DoctorUnhealthy = 정상 응답 (doctor.v1 with `ok: false`). agent 가 검사.
|
||||
|
||||
## Surface 3 — Lifecycle / capabilities / error mapping
|
||||
|
||||
### Initialize handshake
|
||||
|
||||
server `initialize` 응답:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"protocolVersion": "<rmcp 가 pin 하는 stable version, 예: 2025-03-26>",
|
||||
"capabilities": {
|
||||
"tools": { "listChanged": false }
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "kebab",
|
||||
"version": "<env!(\"CARGO_PKG_VERSION\")>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
resources / prompts / sampling / notifications — 모두 미선언.
|
||||
|
||||
### Tool error envelope
|
||||
|
||||
| 시나리오 | MCP 응답 | content |
|
||||
|----------|----------|---------|
|
||||
| facade `Err(e)` | `{ isError: true, content: [{ type: "text", text: <error.v1 JSON> }] }` | `error_wire::classify(&e, false)` 결과 |
|
||||
| facade `Ok(...)` | `{ isError: false, content: [{ type: "text", text: <wire JSON> }] }` | search_hit.v1 / answer.v1 / schema.v1 / doctor.v1 |
|
||||
|
||||
protocol-level error (invalid method / malformed params / panic) 는 SDK 가 JSON-RPC error envelope 으로 자동 처리.
|
||||
|
||||
### Refusal / no-hit / unhealthy 가 isError 아님
|
||||
|
||||
CLI 의 exit code 1 (refusal/no-hit) / 3 (doctor unhealthy) 는 stdio 환경에 의미 없음. 모두 `isError: false` 정상 응답으로 반환 — agent 가 wire payload 의 semantic flag (`grounded` / 빈 array / `ok: false`) 로 분기. 이게 MCP 표준 패턴 — error envelope 은 protocol 실패 전용.
|
||||
|
||||
### classify 모듈 이전 (load-bearing 구조 변경)
|
||||
|
||||
`kebab-cli::error_classify` (fb-27 도입) 를 `kebab-app::error_wire` 로 promotion. 본 PR 에 포함:
|
||||
|
||||
- `crates/kebab-app/src/error_wire.rs` 신규 — `ErrorV1` struct + `classify(&anyhow::Error, verbose: bool) -> ErrorV1` 함수 + `classify_llm` helper. fb-27 commit `c91228e` 의 `error_classify.rs` 코드 그대로 이전.
|
||||
- `crates/kebab-app/src/lib.rs` `pub mod error_wire;` + re-export.
|
||||
- `crates/kebab-cli/src/error_classify.rs` 삭제 — `kebab-cli::main` 의 import 가 `kebab_app::error_wire::*` 로 변경.
|
||||
- 기존 7 unit test 도 함께 이전 (`kebab-app/src/error_wire.rs::tests`).
|
||||
- `kebab-cli::wire::wire_error_v1` 의 `&crate::error_classify::ErrorV1` → `&kebab_app::ErrorV1` 1 줄 변경.
|
||||
- kebab-cli 의 reqwest dev-dep 는 유지 (`llm_unreachable_classifies` 가 함께 이동) — 또는 reqwest dev-dep 도 kebab-app 으로 이전.
|
||||
|
||||
근거: kebab-cli + kebab-mcp 둘 다 동일 classify 사용. UI crate (kebab-cli) 가 다른 UI crate import 는 facade 룰 위반. kebab-app 으로 promotion 이 정공법.
|
||||
|
||||
### Concurrency
|
||||
|
||||
stdio JSON-RPC 는 클라이언트 측 순차 호출 default 지만 MCP spec 은 동시 호출 허용. tokio runtime (kebab-app 의 `rt-multi-thread` feature 활용):
|
||||
|
||||
- 각 `tools/call` request 가 독립 task — `tokio::task::spawn`.
|
||||
- facade 가 sync API (현재 대부분) → `tokio::task::spawn_blocking` wrap.
|
||||
- 한 process 안에 SQLite / Lance / fastembed connection 공유 — 한 번 init 후 모든 tool call 이 hot. `kebab-app` 의 facade 가 매 호출 마다 `Config` load + store open 시 cold-start 절감 효과 약화 — plan 단계에서 server-scope `App` 인스턴스 (혹은 connection pool) 도입 검토.
|
||||
|
||||
세부 async pattern + connection lifetime 은 plan 단계 결정 (rmcp SDK 의 dispatch 모델에 의존).
|
||||
|
||||
## Out of scope (defer)
|
||||
|
||||
- **HTTP-SSE transport** — fb-29 P+ 와 묶어 진행. 본 task 는 stdio 단일.
|
||||
- **Resources** — `kebab://chunk/<id>` / `kebab://doc/<id>` URI scheme. fb-35 verbatim fetch 와 함께 v2.
|
||||
- **Prompts** — reusable prompt template. RAG 자체가 prompt template 내장 — 사용자 가치 약함, defer.
|
||||
- **Streaming `ask`** — fb-33 streaming ask 와 함께 ndjson delta tool 결과.
|
||||
- **`ingest_file` / `ingest_stdin` tools** — fb-31 single-file ingest 머지 시 추가.
|
||||
- **`fetch` (verbatim doc/chunk)** — fb-35 verbatim fetch 머지 시 추가.
|
||||
- **`list_docs` / `inspect_chunk` tools** — demand 발생 시.
|
||||
- **Server logging notifications** (`notifications/message`) — SDK 자동 처리만.
|
||||
- **Sampling capability** — 본 server 는 sampling 미수행.
|
||||
|
||||
## Testing 전략
|
||||
|
||||
| crate | type | 파일 | 검증 |
|
||||
|-------|------|------|------|
|
||||
| `kebab-app` | unit | `src/error_wire.rs::tests` | 기존 7 classify test (fb-27 에서 promotion) |
|
||||
| `kebab-mcp` | unit | `src/lib.rs::tests` | tool input schema parse + dispatch (mock or TempDir) |
|
||||
| `kebab-mcp` | integration | `tests/initialize.rs` | initialize handshake — protocolVersion / serverInfo / capabilities.tools 정확 |
|
||||
| `kebab-mcp` | integration | `tests/tools_list.rs` | `tools/list` 가 4 tool name + inputSchema 정확 반환 |
|
||||
| `kebab-mcp` | integration | `tests/tools_call_search.rs` | search tool call → text content = search_hit.v1 array, isError=false |
|
||||
| `kebab-mcp` | integration | `tests/tools_call_ask.rs` | ask tool call → answer.v1 (refusal 시 grounded=false 정상) |
|
||||
| `kebab-mcp` | integration | `tests/tools_call_schema.rs` | schema.v1 정확 + capabilities.mcp_server=true 검증 |
|
||||
| `kebab-mcp` | integration | `tests/tools_call_doctor.rs` | doctor.v1 정확 |
|
||||
| `kebab-mcp` | integration | `tests/error_mapping.rs` | bad config 로 호출 → tool error with error.v1 + isError=true |
|
||||
| `kebab-cli` | integration | `tests/cli_mcp_smoke.rs` | `target/debug/kebab mcp` spawn + 1 round-trip JSON-RPC |
|
||||
| `kebab-app` | unit | `tests/schema_report.rs` (기존) | `capabilities.mcp_server == true` assertion 1 줄 추가 |
|
||||
|
||||
JSON-RPC client = rmcp 의 in-process test harness (지원 시) 또는 hand-roll line write/read 헬퍼.
|
||||
|
||||
## Spec / doc sync (PR 같은 commit)
|
||||
|
||||
1. **frozen design §10.1** — MCP transport 절 추가 (또는 §10.2 신설). stdio-only / 4 tool / capability flag flip 명시.
|
||||
2. **README.md** — 명령 표 에 `kebab mcp` row + MCP usage section (Claude Code `~/.claude/mcp.json` config 예시).
|
||||
3. **HANDOFF.md** — `2026-05-?? P9 post-도그푸딩 (p9-fb-30)` 한 줄.
|
||||
4. **CLAUDE.md** — facade 룰 절 에 `kebab-mcp` UI crate 카테고리 추가. 새 crate 카운트 갱신 (~20 → ~21).
|
||||
5. **integrations/claude-code/kebab/SKILL.md** — MCP 사용 권장 + Claude Code `~/.claude/mcp.json` 예시 한 블록 추가. 기존 subprocess wrapper 형태도 backwards-compat 유지 (일부 사용자가 MCP 미지원 host 에서 호출).
|
||||
6. **HOTFIXES.md** — `2026-05-?? — fb-30` entry. classify 모듈 이전 + capability flag flip + 기타 deviation 명시.
|
||||
7. **`tasks/p9/p9-fb-30-mcp-server.md`** — status `open` → `completed`, banner 갱신, depends_on 갱신 (이미 fb-29 제거됨 from 2026-05-07 commit).
|
||||
|
||||
## Release trigger
|
||||
|
||||
0.3.0 → **0.4.0** minor bump — fb-30 머지 = 신규 CLI surface (`kebab mcp`) + new crate (`kebab-mcp`) + capability flag flip (`mcp_server: true`) + design §10 변경 (3 trigger 모두 발동).
|
||||
|
||||
agent integration "MVP" 완성 신호. release notes 에 강조: "MCP 표준 protocol 으로 Claude Code / Cursor / OpenAI Agents 등 host-agnostic 사용 가능."
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- **rmcp version maturity**: plan 단계 verify 필요. 미존재 / 미성숙 시 hand-roll JSON-RPC 또는 hybrid (transport hand-roll + spec literal struct serde) fallback. rmcp 채택 가정 하 spec 작성됨 — 심각한 호환성 문제 발생 시 spec 갱신 + HOTFIXES.
|
||||
- **classify 이전의 회귀 위험**: kebab-cli 의 7 test + 1 wire test 가 import path 변경. mechanical 이지만 누락 시 컴파일 실패로 catch.
|
||||
- **`Config` resolution per call**: 매 tool call 마다 `Config::load(...)` + `App` open 하면 daemon 의 hot-cache 효과 미미. 첫 call 시 server-scope `App` 인스턴스 만들고 이후 재사용 — plan 단계 concrete 설계.
|
||||
- **MCP version evolution**: spec 가 진화 중. SDK pin 따르고 README 에 명시. major change 발생 시 별 task.
|
||||
- **ask 의 multi-turn session 의 정합**: kebab session 은 kebab 의 RAG history. agent host 도 자체 conversation 추적. 둘이 다른 식별자 — sync 필요 시 사용자가 명시적으로 `session_id` 매핑. 본 PR scope 밖 — agent 사용 가이드에 명시.
|
||||
- **Tool error 에 hint 손실 위험**: `error_wire::classify` 가 `hint: Option<String>` 채움. MCP 응답에서 `hint` 가 보존되는지 — text content 의 JSON 이 `hint` field 그대로 가짐. agent 가 parse 하면 readable. OK.
|
||||
- **stdin/stdout 충돌**: kebab-mcp 가 stdin/stdout 으로 JSON-RPC 통신. `tracing` log 가 stdout 으로 쓰면 protocol 깨짐. 모든 log 는 stderr 또는 file (`~/.local/state/kebab/logs/`) — kebab-app 의 logging init 가 이미 stderr 기본. 명시 verify.
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
title: "p9-fb-31 — Single-file / stdin ingest — agent on-demand 저장"
|
||||
date: 2026-05-07
|
||||
status: design (brainstorm 완료, plan 단계 대기)
|
||||
target_version: 0.3.x
|
||||
task_spec: ../../../tasks/p9/p9-fb-31-single-file-stdin-ingest.md
|
||||
contract_source: ../specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§3 ingest, §6 filesystem, §10 UX]
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
---
|
||||
|
||||
# Single-file / stdin ingest — 설계
|
||||
|
||||
## 동기
|
||||
|
||||
agent (Claude Code via MCP, fb-30) 가 web 에서 fetch 한 markdown / pdf 를 KB 에 저장하려면 현재는:
|
||||
|
||||
1. agent 가 workspace 디렉토리에 file 쓰기.
|
||||
2. `kebab ingest` 전체 walk 재실행.
|
||||
|
||||
(2) 가 비효율 — 100+ doc workspace 면 모든 doc 의 incremental check 비용. agent 메모리상 string contents 면 임시 file 거치는 우회.
|
||||
|
||||
본 task 는 두 신규 명령 도입:
|
||||
|
||||
- `kebab ingest-file <path>` — 단일 file (workspace 외부 포함) 만 ingest.
|
||||
- `kebab ingest-stdin --title <T> [--source-uri <URI>]` — stdin 에서 markdown 본문 read 후 ingest.
|
||||
|
||||
MCP tool `ingest_file` + `ingest_stdin` 도 동시 추가 — agent 가 CLI 우회 없이 직접 호출.
|
||||
|
||||
## 결정 요약
|
||||
|
||||
| 결정 | 선택 |
|
||||
|------|------|
|
||||
| 외부 file 저장 정책 | Copy in (`<workspace.root>/_external/<hash12>.<ext>`) |
|
||||
| CLI surface | 신규 subcommand 2개 (`ingest-file` + `ingest-stdin`) |
|
||||
| MCP tool | 동시 추가 (4 → 6 tool) — `ingest_file` + `ingest_stdin` |
|
||||
| .kebabignore | bypass + warn (explicit ingest 가 default bypass intent) |
|
||||
| stdin v1 scope | markdown 전용 + flag → frontmatter 자동 주입 |
|
||||
|
||||
## Surface 1 — `kebab ingest-file`
|
||||
|
||||
### CLI
|
||||
|
||||
```
|
||||
kebab ingest-file <path> [--config <path>]
|
||||
```
|
||||
|
||||
- `path`: positional, absolute / relative file path. workspace 외부 가능.
|
||||
- `--config <path>`: 기존 facade rule 일관 (P3-5 / P4-3 패턴).
|
||||
- 추가 flag 없음 — 명시 ingest 자체가 .kebabignore bypass intent.
|
||||
|
||||
### Behavior
|
||||
|
||||
1. file 존재 여부 + 크기 + media type (extension) 검증.
|
||||
2. workspace.root 의 `.kebabignore` pattern 과 source path 매치 검사 — 매치 시 stderr warn (`warn: <path> matches .kebabignore patterns; proceeding (explicit ingest bypasses ignore)`). 진행은 계속.
|
||||
3. blake3 content hash 계산 → `_external/<hash12>.<ext>` workspace 상대 경로 derive.
|
||||
4. `<workspace.root>/_external/` 디렉토리 자동 생성 (없으면). 첫 생성 시 `<workspace.root>/.kebabignore` 에 `_external/` line 자동 append (없으면) — 향후 walk 중복 방지.
|
||||
5. file content → `<workspace.root>/_external/<hash12>.<ext>` 로 copy. 동일 hash 면 skip (idempotent).
|
||||
6. 단일 asset 으로 기존 ingest pipeline 재사용 (parse → chunk → embed → vector store + SQLite upsert). incremental ingest (fb-23) 가 동일 hash 면 unchanged 처리.
|
||||
7. `IngestReport` (`ingest_report.v1`) 반환 — single asset count.
|
||||
|
||||
### Output
|
||||
|
||||
stdout 은 기존 `kebab ingest` 와 동일 — 사람 모드는 한 줄 summary, `--json` 은 `ingest_report.v1` JSON.
|
||||
|
||||
```text
|
||||
$ kebab ingest-file ~/Downloads/article.md
|
||||
ingested 1 new (~/Downloads/article.md → _external/a3f7b9e2c1d4.md)
|
||||
```
|
||||
|
||||
`--json`:
|
||||
|
||||
```json
|
||||
{"schema_version":"ingest_report.v1","scope":{"root":".../_external/a3f7b9e2c1d4.md","include":[],"exclude":[]},"scanned":1,"new":1,"updated":0,"skipped":0,"unchanged":0,"errors":0,...}
|
||||
```
|
||||
|
||||
(`scope.root` 표현은 plan 단계 결정 — 단일 file path 또는 fake scope.)
|
||||
|
||||
## Surface 2 — `kebab ingest-stdin`
|
||||
|
||||
### CLI
|
||||
|
||||
```
|
||||
kebab ingest-stdin --title <T> [--source-uri <URI>] [--config <path>]
|
||||
```
|
||||
|
||||
- `--title <T>`: 필수. frontmatter `title` field 채움.
|
||||
- `--source-uri <URI>`: 옵션. 제공 시 frontmatter `source_uri` field 채움.
|
||||
- v1 markdown 전용 — `--media` flag 없음.
|
||||
|
||||
### Behavior
|
||||
|
||||
1. stdin 전체 read → `String content`.
|
||||
2. **Frontmatter pre-check**: `content.trim_start().starts_with("---\n")` 면 — `Err`: `"stdin already has frontmatter; use \`kebab ingest-file\` for files with metadata"`. exit 2.
|
||||
3. 그 외 frontmatter block prepend:
|
||||
|
||||
```md
|
||||
---
|
||||
title: "<T>"
|
||||
source_uri: "<URI>" # only if --source-uri provided
|
||||
---
|
||||
|
||||
<stdin contents>
|
||||
```
|
||||
|
||||
(YAML escaping for title — `serde_yaml::to_string` 또는 inline quote escape. plan 단계 결정.)
|
||||
|
||||
4. 합친 markdown 의 blake3 hash → `<workspace.root>/_external/<hash12>.md` 로 write.
|
||||
5. ingest-file path 의 5-7 단계 재사용.
|
||||
|
||||
### Output
|
||||
|
||||
```text
|
||||
$ echo "## Body" | kebab ingest-stdin --title "Article X" --source-uri "https://example.com/x"
|
||||
ingested 1 new (stdin → _external/7c8e1f3a2b9d.md)
|
||||
```
|
||||
|
||||
`--json` 동일 `ingest_report.v1`.
|
||||
|
||||
### source_uri metadata 흐름
|
||||
|
||||
- frontmatter `source_uri` 는 markdown parser 가 `Document.metadata` 의 free-form map 에 string field 로 저장 (이미 처리됨 — frontmatter 의 모든 key 가 metadata 로 흘러감).
|
||||
- `kebab inspect` / `kebab search --json` 의 `doc_meta` 에 자동 포함. agent 가 search 결과의 source_uri 로 원본 web URL 추적 가능.
|
||||
- v1 wire schema 추가 변경 없음 — `metadata` 가 이미 free-form map.
|
||||
|
||||
## Surface 3 — MCP tools `ingest_file` + `ingest_stdin`
|
||||
|
||||
### `ingest_file`
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct IngestFileInput {
|
||||
/// Absolute or relative path to the file to ingest.
|
||||
pub path: String,
|
||||
}
|
||||
```
|
||||
|
||||
facade: `kebab_app::ingest_file_with_config(cfg, &Path) -> Result<IngestReport>`.
|
||||
|
||||
handle: `spawn_blocking` wrap (touches embedder + SqliteStore). text content = `ingest_report.v1` JSON.
|
||||
|
||||
### `ingest_stdin`
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct IngestStdinInput {
|
||||
/// Markdown body content. v1 supports markdown only.
|
||||
pub content: String,
|
||||
/// Title for frontmatter injection.
|
||||
pub title: String,
|
||||
/// Optional source URI (e.g. https URL agent fetched from).
|
||||
pub source_uri: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
facade: `kebab_app::ingest_stdin_with_config(cfg, content, title, source_uri) -> Result<IngestReport>`.
|
||||
|
||||
handle: `spawn_blocking` wrap. text content = `ingest_report.v1` JSON.
|
||||
|
||||
### `KebabHandler` 변경
|
||||
|
||||
- `build_tools_vec()` 가 4 → 6 entries 반환.
|
||||
- `call_tool` match 에 `"ingest_file"` + `"ingest_stdin"` arm 추가 (spawn_tool helper 재사용).
|
||||
- 신규 module `crates/kebab-mcp/src/tools/ingest_file.rs` + `ingest_stdin.rs`.
|
||||
|
||||
### Mutation tool 첫 도입
|
||||
|
||||
fb-30 v1 은 read-only 4 tool. fb-31 머지로 mutation surface 등장 — agent 가 KB 에 직접 write 가능. 의도된 진화 — agent flow 의 자연스러운 다음 단계. HOTFIXES entry 명시.
|
||||
|
||||
## `_external/` 디렉토리 정책
|
||||
|
||||
- 위치: `<workspace.root>/_external/`.
|
||||
- 첫 ingest-file / ingest-stdin 호출 시 자동 생성.
|
||||
- 생성과 동시에 `<workspace.root>/.kebabignore` 에 `_external/` line append (없으면) — 향후 `kebab ingest` 전체 walk 가 이 디렉토리 재 walk 안 함 (re-ingestion 무한 루프 방지).
|
||||
- 파일명 = `blake3(content) 12-char prefix + 원래 ext`. deterministic — 동일 content 재 ingest 면 같은 파일명, idempotent (incremental ingest 가 unchanged 처리).
|
||||
- 사용자가 `_external/` 안 파일 직접 수정해도 OK — explicit `ingest-file` 또는 manual `kebab ingest` (`.kebabignore` 우회 시) 가 incremental 변경 감지.
|
||||
|
||||
## 의존 경계 + 신규 facade
|
||||
|
||||
- `kebab-app::ingest_file_with_config(cfg, &Path) -> Result<IngestReport>` — 신규 facade fn.
|
||||
- `kebab-app::ingest_stdin_with_config(cfg, content, title, source_uri) -> Result<IngestReport>` — 신규.
|
||||
- 둘 모두 내부적으로 기존 `ingest_with_config_opts` 의 single-asset 변종 OR 별 helper. plan 단계 구체화.
|
||||
- frontmatter injection helper (`kebab-app::frontmatter::inject(content, title, source_uri) -> String`) — kebab-app 안. kebab-mcp + kebab-cli 둘 다 facade 통해 호출.
|
||||
- `_external/` 디렉토리 + `.kebabignore` 자동 추가도 `kebab-app` 책임 (ingest_file_with_config 안에서).
|
||||
|
||||
## Wire schema impact
|
||||
|
||||
**없음**. 모두 기존 schema 재사용:
|
||||
|
||||
- `ingest_report.v1` — single-asset count. 기존 shape 그대로.
|
||||
- `error.v1` — file not found / frontmatter precheck 등 facade error 가 기존 code 로 매핑 (`io_error`, `generic`).
|
||||
|
||||
`source_uri` 는 `Document.metadata` 의 free-form map 안 — `inspect` / `search_hit.v1` / `answer.v1` 의 `doc_meta` 에 자동 포함.
|
||||
|
||||
## Testing 전략
|
||||
|
||||
| crate | type | 파일 | 검증 |
|
||||
|-------|------|------|------|
|
||||
| `kebab-app` | unit | `tests/ingest_file.rs` | external file → `_external/<hash>.md` copy + IngestReport new=1, 두 번째 호출 unchanged=1, .kebabignore match warn (stderr capture), file-not-found Err |
|
||||
| `kebab-app` | unit | `tests/ingest_stdin.rs` | content + title → frontmatter prepend + ingest, source_uri 옵션 처리, stdin already-frontmatter Err |
|
||||
| `kebab-mcp` | integration | `tests/tools_call_ingest_file.rs` | tool call → ingest_report.v1 (isError=false), idempotent 두 번째 호출 unchanged=1 |
|
||||
| `kebab-mcp` | integration | `tests/tools_call_ingest_stdin.rs` | content + title input → ingest_report.v1, frontmatter precheck error 시 isError=true + error.v1 |
|
||||
| `kebab-mcp` | integration | `tests/tools_list.rs` (기존) | 4 → 6 tool 검증 (assertion update) |
|
||||
| `kebab-cli` | integration | `tests/cli_ingest_file.rs` | spawn `kebab ingest-file <tempfile>` → ingest_report.v1 stdout, exit 0 |
|
||||
| `kebab-cli` | integration | `tests/cli_ingest_stdin.rs` | spawn `kebab ingest-stdin --title X` + stdin pipe → ingest_report.v1, exit 0 |
|
||||
|
||||
## Spec / doc sync (PR 같은 commit)
|
||||
|
||||
1. **frozen design §3 / §6** — `_external/` 디렉토리 + .kebabignore auto-add 정책 명시.
|
||||
2. **README** — 명령 표 에 `kebab ingest-file` + `kebab ingest-stdin` 두 row + MCP usage section 의 tool list 4 → 6 update.
|
||||
3. **HANDOFF** — post-도그푸딩 entry.
|
||||
4. **CLAUDE.md** — wire schema 목록 변경 없음 (`ingest_report.v1` 재사용). `_external/` 디렉토리 + naming convention 한 줄.
|
||||
5. **integrations/claude-code/kebab/SKILL.md** — `ingest_file` / `ingest_stdin` MCP tool 사용 안내 + agent fetch flow 예시.
|
||||
6. **HOTFIXES** — 신규 entry. fb-30 v1 read-only 정책 변경 (mutation tool 도입) 명시.
|
||||
7. **`tasks/p9/p9-fb-31-single-file-stdin-ingest.md`** — status `open` → `completed`.
|
||||
|
||||
## Release trigger
|
||||
|
||||
0.3.1 → **0.3.2** patch — additive only (신규 subcommand + 신규 MCP tool, 기존 surface 동작 무영향, wire schema 변경 없음). pre-1.0 patch 정책 일관 (fb-30 도 0.3.1 patch 였음).
|
||||
|
||||
## Out of scope (defer)
|
||||
|
||||
- **PDF / image stdin** — binary stream + base64 처리 v2.
|
||||
- **다른 metadata field** (tags, language hint, custom kv) — `--title` + `--source-uri` 외 v2.
|
||||
- **자동 dedup by source_uri** — content hash 기반 dedup 은 incremental ingest 가 이미 처리. URI 별 lookup 은 별 task.
|
||||
- **Storage quota / TTL** — agent 무한 ingest 시 KB 비대 우려. monitor + 별 task.
|
||||
- **Frontmatter merge** (stdin 이 이미 frontmatter 보유 시 머지) — v1 은 error. user 가 경우에 맞게 ingest-file 사용.
|
||||
- **`--force-ignore` flag** — 명시 ingest 가 default bypass 라 flag 불필요.
|
||||
- **MCP `ingest_file` 의 multi-file batch** (`paths: Vec<String>`) — v1 single path. 여러 file 호출은 agent 가 N 회.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- **`_external/` 디렉토리 명명**: underscore prefix 가 dotfile 만큼 강한 hide 신호 아님. 사용자 workspace listing 시 보임. README 에 명시.
|
||||
- **.kebabignore auto-append 의 idempotency**: file 이 이미 `_external/` line 보유 시 중복 append 안 함. 정확한 정합 검사 필요.
|
||||
- **YAML escaping**: title 에 quote / special char 포함 시 frontmatter parse 실패 위험. `serde_yaml` 사용 또는 strict escape.
|
||||
- **Mutation tool 의 input validation**: `ingest_stdin` 의 `content` 가 매우 클 경우 (수 MB markdown) 메모리 압박. v1 size limit 없음 — agent 책임. monitor + 별 task.
|
||||
- **agent 의 무한 ingest**: KB 비대 + cost (embedding). 사용자 쪽 monitoring + storage quota 별 task.
|
||||
- **`_external/` workspace 외부 이동 / 백업 정책**: workspace 안 일반 파일과 동일 — 사용자 백업 정책 일관.
|
||||
- **hash collision 확률**: blake3 12-char prefix = 48 bit. ~16M files 까지 안전 (birthday bound). single-user KB 에 충분. 충돌 시 first-write-wins (idempotency 와 동일 동작).
|
||||
35
docs/wire-schema/v1/error.schema.json
Normal file
35
docs/wire-schema/v1/error.schema.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://kebab.local/wire-schema/v1/error.schema.json",
|
||||
"title": "error.v1",
|
||||
"description": "Structured fatal error emitted on stderr in --json mode. The `details` shape varies per `code`; consumers should branch on `code` and treat `details` as best-effort context.",
|
||||
"type": "object",
|
||||
"required": ["schema_version", "code", "message", "details"],
|
||||
"properties": {
|
||||
"schema_version": { "const": "error.v1" },
|
||||
"code": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"config_invalid",
|
||||
"not_indexed",
|
||||
"model_unreachable",
|
||||
"model_not_pulled",
|
||||
"timeout",
|
||||
"io_error",
|
||||
"generic"
|
||||
]
|
||||
},
|
||||
"message": { "type": "string" },
|
||||
"details": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Per-code free-form context. config_invalid: { path, cause }. not_indexed: { expected, found }. model_unreachable: { endpoint, source }. model_not_pulled: { model }. timeout: { source }. io_error: { kind }. generic: { chain (when --verbose) }."
|
||||
},
|
||||
"hint": {
|
||||
"anyOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "null" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@
|
||||
"skipped",
|
||||
"unchanged",
|
||||
"errors",
|
||||
"duration_ms"
|
||||
"duration_ms",
|
||||
"skipped_by_extension"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": { "const": "ingest_report.v1" },
|
||||
@@ -29,6 +30,14 @@
|
||||
},
|
||||
"errors": { "type": "integer", "minimum": 0 },
|
||||
"duration_ms": { "type": "integer", "minimum": 0 },
|
||||
"skipped_by_extension": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"description": "p9-fb-25: per-extension skip count. Key = lowercase extension without leading dot (e.g. 'docx'). Files without extension key under '<no-ext>'."
|
||||
},
|
||||
"items": { "type": ["array", "null"] }
|
||||
}
|
||||
}
|
||||
|
||||
61
docs/wire-schema/v1/schema.schema.json
Normal file
61
docs/wire-schema/v1/schema.schema.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://kebab.local/wire-schema/v1/schema.schema.json",
|
||||
"title": "schema.v1",
|
||||
"description": "kebab introspection report — wire schemas, capabilities, model versions, and index stats.",
|
||||
"type": "object",
|
||||
"required": ["schema_version", "kebab_version", "wire", "capabilities", "models", "stats"],
|
||||
"properties": {
|
||||
"schema_version": { "const": "schema.v1" },
|
||||
"kebab_version": { "type": "string" },
|
||||
"wire": {
|
||||
"type": "object",
|
||||
"required": ["schemas"],
|
||||
"properties": {
|
||||
"schemas": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "pattern": "^[a-z_]+\\.v[0-9]+$" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "boolean" },
|
||||
"required": [
|
||||
"json_mode", "ingest_progress", "ingest_cancellation",
|
||||
"rag_multi_turn", "search_cache", "incremental_ingest",
|
||||
"streaming_ask", "http_daemon", "mcp_server", "single_file_ingest"
|
||||
]
|
||||
},
|
||||
"models": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"parser_version", "chunker_version", "embedding_version",
|
||||
"prompt_template_version", "index_version", "corpus_revision"
|
||||
],
|
||||
"properties": {
|
||||
"parser_version": { "type": "string" },
|
||||
"chunker_version": { "type": "string" },
|
||||
"embedding_version": { "type": "string" },
|
||||
"prompt_template_version": { "type": "string" },
|
||||
"index_version": { "type": "string" },
|
||||
"corpus_revision": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"type": "object",
|
||||
"required": ["doc_count", "chunk_count", "asset_count", "last_ingest_at"],
|
||||
"properties": {
|
||||
"doc_count": { "type": "integer", "minimum": 0 },
|
||||
"chunk_count": { "type": "integer", "minimum": 0 },
|
||||
"asset_count": { "type": "integer", "minimum": 0 },
|
||||
"last_ingest_at": {
|
||||
"anyOf": [
|
||||
{ "type": "string", "format": "date-time" },
|
||||
{ "type": "null" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
integrations/claude-code/README.md
Normal file
57
integrations/claude-code/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Claude Code integration
|
||||
|
||||
Skill packages that let [Claude Code](https://claude.ai/claude-code) call `kebab` automatically when a question would benefit from the user's local KB.
|
||||
|
||||
## Available skills
|
||||
|
||||
| Skill | Trigger | What it does |
|
||||
|-------|---------|--------------|
|
||||
| [`kebab`](kebab/SKILL.md) | Internal / org-specific questions, runbooks, indexed-doc lookups | Calls `kebab search --json` / `kebab ask --json` and folds the results into the answer with citations |
|
||||
|
||||
## Install
|
||||
|
||||
User-level (every Claude Code session on this machine):
|
||||
|
||||
```bash
|
||||
# from a kebab repo checkout
|
||||
cp -r integrations/claude-code/kebab ~/.claude/skills/
|
||||
|
||||
# verify
|
||||
ls ~/.claude/skills/kebab/SKILL.md
|
||||
```
|
||||
|
||||
Or symlink so `git pull` in the repo updates the skill in place:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude/skills
|
||||
ln -s "$(pwd)/integrations/claude-code/kebab" ~/.claude/skills/kebab
|
||||
```
|
||||
|
||||
Project-level (only loads when Claude Code runs in a specific project):
|
||||
|
||||
```bash
|
||||
mkdir -p <project>/.claude/skills
|
||||
cp -r integrations/claude-code/kebab <project>/.claude/skills/
|
||||
```
|
||||
|
||||
After install, start a fresh Claude Code session — the skill self-registers from its frontmatter `description` and is invoked automatically when a matching question shows up. No config edit needed.
|
||||
|
||||
## Customization
|
||||
|
||||
The shipped `SKILL.md` is generic on purpose — it triggers on any "internal / org-specific" cue. To make Claude Code more eager (or less) for **your** corpus, edit the frontmatter `description` of your local copy and add the team / system / acronym keywords that should trigger the skill (e.g. `MLOps`, `DMQ`, `AiSuite`). Don't PR those keywords back into this repo — they're per-user.
|
||||
|
||||
A symlink install + a `~/.claude/skills/kebab/SKILL.md.local` patch script is one pattern; another is to keep a fork branch with personalized frontmatter and rebase on `main`.
|
||||
|
||||
## Update policy
|
||||
|
||||
The skill consumes `kebab`'s wire schema v1 (`schema_version` fields like `search_hit.v1`, `answer.v1`). When the wire schema major-bumps to v2, this skill is updated in the same PR — see the project root [`CLAUDE.md`](../../CLAUDE.md#wire-schema-v1) §Wire schema v1.
|
||||
|
||||
## Other hosts
|
||||
|
||||
`kebab` exposes the same `--json` contract to any agent host. To add a new integration:
|
||||
|
||||
1. Drop a directory under `integrations/<host>/` mirroring the structure here.
|
||||
2. Reference `docs/wire-schema/v1/` for the JSON shapes.
|
||||
3. Link from this `README.md` table.
|
||||
|
||||
A native MCP server (`kebab serve --mcp`) and an HTTP wrapper are listed in the root [README §외부 AI 통합](../../README.md#외부-ai-통합) as future options.
|
||||
134
integrations/claude-code/kebab/SKILL.md
Normal file
134
integrations/claude-code/kebab/SKILL.md
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
name: kebab
|
||||
description: Local knowledge base + RAG over the user's pre-indexed documents (wiki crawls, Markdown notes, PDFs, images). Use when answering questions that need internal context the user has indexed locally — e.g. team-specific procedures, internal runbooks, infrastructure docs, credentials registries, project-specific conventions. Also use when a domain question (Kubernetes, MLOps, internal tooling, etc.) needs additional grounding from indexed docs before answering. Do NOT use for general public questions, code in the working directory, or anything obviously outside the indexed corpus.
|
||||
---
|
||||
|
||||
# kebab — local KB / RAG access
|
||||
|
||||
`kebab` is a CLI installed at `~/.cargo/bin/kebab` (binary name: `kebab`). It indexes the user's personal documents and exposes them via lexical / vector / hybrid search and a local-LLM RAG answer. All output speaks frozen wire schema v1 — every JSON record carries a `schema_version` field.
|
||||
|
||||
## When to invoke
|
||||
|
||||
Trigger when the user's question matches **any** of:
|
||||
|
||||
- Refers to internal/organization-specific systems, procedures, or jargon that a generic public answer would miss.
|
||||
- Names a runbook or procedure the user is likely to have indexed ("how do I X", "what's our policy on Y", "where's the doc for Z").
|
||||
- Domain-technical question where additional internal context (custom CRDs, internal naming, team conventions) would change the answer vs. a generic public answer.
|
||||
- User explicitly references "the wiki", "내부 문서", "kb", or asks "do we have docs on X".
|
||||
|
||||
**Skip** when:
|
||||
|
||||
- The question is about public OSS, language semantics, or anything in the current working directory.
|
||||
- The user is editing kebab's own source — that's a code task, not a KB query.
|
||||
- A previous `kebab` call in this session already returned `grounded: false` on a near-identical query (don't loop).
|
||||
|
||||
User-specific trigger keywords (team names, system names, internal acronyms) belong in a per-user override of this SKILL.md, not in this repo-shipped version.
|
||||
|
||||
## Two surfaces, pick the right one
|
||||
|
||||
### `kebab search` — when you need the source
|
||||
|
||||
Use when the user wants to **find** a doc, or when you (the model) need raw chunks to reason from before answering.
|
||||
|
||||
```bash
|
||||
kebab search "<query>" --mode hybrid --json
|
||||
```
|
||||
|
||||
- `--mode hybrid` is the default-correct choice. Use `vector` for semantic-only ("docs about X concept"), `lexical` for exact strings ("the literal flag `--foo-bar`").
|
||||
- Output is a JSON array of `search_hit.v1` objects. Key fields: `rank`, `score`, `doc_path`, `heading_path[]`, `section_label`, `snippet`, `citation` (has line range / page), `chunk_id`.
|
||||
- Cite back to the user as `doc_path § heading_path[-1]` so they can open the source.
|
||||
|
||||
### `kebab ask` — when you need the answer
|
||||
|
||||
Use when the user wants a synthesized answer, not a list of links.
|
||||
|
||||
```bash
|
||||
kebab ask "<question>" --json
|
||||
```
|
||||
|
||||
- Returns one `answer.v1` object: `answer` (markdown), `citations[]`, `grounded` (bool), `refusal_reason`, `model`.
|
||||
- **If `grounded == false`** → the KB doesn't have enough context. Don't paraphrase the refusal as if it were an answer. Tell the user the KB came up dry and fall back to your own knowledge or ask for the source.
|
||||
- For follow-up turns on the same topic, pass `--session <stable-id>` so kebab gets prior history. Pick a slug (`team-onboarding-2026-05`) and reuse it across the conversation. Sessions persist across Claude sessions until `kebab reset --data-only`.
|
||||
|
||||
## Parsing tips
|
||||
|
||||
- Both commands print **one JSON value to stdout**, progress / warnings to stderr. Capture stdout only: `kebab search ... --json 2>/dev/null`.
|
||||
- `search --json` output can be large for broad queries. Pipe through `jq` to project: `jq '.[] | {rank, doc_path, heading: .heading_path[-1], snippet}'`.
|
||||
- `ask --json`'s `citations[]` mirrors `search_hit.v1` minus retrieval internals — same `doc_path` / `citation` shape.
|
||||
- Schema reference lives in the kebab repo at `docs/wire-schema/v1/*.schema.json` if a field is unclear.
|
||||
|
||||
## Capability discovery
|
||||
|
||||
Before using streaming or multi-turn features, you can probe what this binary supports:
|
||||
|
||||
```bash
|
||||
kebab schema --json
|
||||
```
|
||||
|
||||
Returns a `schema.v1` object with: `wire.schemas` (supported wire ids), `capabilities` (bool flags — e.g. `streaming_ask`, `rag_multi_turn`), `models` (version cascade 6-axis), and `stats` (doc/chunk/asset count + last_ingest_at). Gate streaming / session flows on `capabilities.streaming_ask` / `capabilities.rag_multi_turn` being `true`. This call is cheap (no LLM) and can be run once per session.
|
||||
|
||||
## Quick health check
|
||||
|
||||
If a call fails or returns suspicious output, run `kebab doctor` first — it surfaces config-load / data-dir / Ollama-reachability problems in one line each. Don't silently retry on errors; report the doctor output.
|
||||
|
||||
## MCP server (recommended over CLI subprocess wrapping)
|
||||
|
||||
Since v0.3.1, `kebab` exposes an MCP (Model Context Protocol) stdio server. Configure once in `~/.claude/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kebab": {
|
||||
"command": "kebab",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Claude Code spawns `kebab mcp` at session start; the process stays alive across all tool calls so SQLite / Lance / fastembed are hot after the first call. 6 tools available: `search` / `ask` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`. Same wire shapes as the CLI `--json` mode — see `Two surfaces, pick the right one` above for the same guidance.
|
||||
|
||||
If your host doesn't support MCP, the CLI subprocess pattern (`kebab search --json` / `kebab ask --json`) above continues to work.
|
||||
|
||||
For per-tool input/output examples, error code reference, multi-turn ask + session management, and host config beyond Claude Code (Cursor / OpenAI Agents / Copilot CLI), see [docs/mcp-usage.md](../../../docs/mcp-usage.md) in the kebab repo.
|
||||
|
||||
## Recipe D — agent fetched a web doc, save to KB
|
||||
|
||||
When you've fetched a markdown article (e.g. via WebFetch) that the user might query later:
|
||||
|
||||
1. Call MCP tool `ingest_stdin` with:
|
||||
- `content`: the markdown body
|
||||
- `title`: a stable title (article H1 or page title)
|
||||
- `source_uri`: the URL you fetched from
|
||||
|
||||
The doc lands in `<workspace.root>/_external/<hash>.md` and is indexed for `search` / `ask` immediately. Subsequent calls with identical content are no-ops (incremental ingest detects unchanged hash).
|
||||
|
||||
Don't loop ingest the same article — content-hash dedup makes it safe but wastes embedding cost.
|
||||
|
||||
For files already on disk that the user references, prefer `ingest_file` with the path — kebab handles the copy + dedup.
|
||||
|
||||
## Workflow recipes
|
||||
|
||||
**Recipe A — user asks an internal-context question, you want grounded answer:**
|
||||
|
||||
1. `kebab ask "<question>" --json`
|
||||
2. If `grounded`, cite `citations[].doc_path` in your reply and quote the user's `answer` (translate / condense as needed).
|
||||
3. If `!grounded`, switch to `kebab search "<question>" --mode hybrid --json` and look at top 3 hits — sometimes content exists but RAG threshold rejected it. If hits look relevant, summarize from snippets and cite. If still nothing, tell the user.
|
||||
|
||||
**Recipe B — domain question where internal context might exist:**
|
||||
|
||||
1. Run `kebab search "<key terms>" --mode hybrid --json` quickly (cheap, no LLM).
|
||||
2. If top hit's `score` is low (< ~0.3) or no hits, answer from general knowledge without mentioning the KB.
|
||||
3. If top hit is relevant, fold its content into your answer and cite `doc_path`.
|
||||
|
||||
**Recipe C — user wants to know "what's in the KB about X":**
|
||||
|
||||
1. `kebab search "X" --mode hybrid --json | jq '.[] | {doc_path, heading: .heading_path[-1]}'`
|
||||
2. List unique `doc_path`s back to the user as a discovery surface.
|
||||
|
||||
## Don't
|
||||
|
||||
- Don't run `kebab ingest` / `kebab reset` / `kebab init` automatically. Those mutate state — the user runs them.
|
||||
- Don't pass user-supplied raw text into the query without trimming — long queries (> a few hundred chars) waste embedding budget. Extract the question.
|
||||
- Don't fabricate `doc_path`s. If you didn't see a doc in `search` / `ask` output, it's not in the KB.
|
||||
- Don't use `kebab tui` from a skill — it's interactive only.
|
||||
@@ -14,6 +14,141 @@ historical contract that was implemented; this file accumulates the
|
||||
deltas so phase 5+ readers can find the live behavior without diffing
|
||||
git history.
|
||||
|
||||
## 2026-05-07 — p9-fb-31 (post-dogfooding): single-file / stdin ingest
|
||||
|
||||
**Source feedback**: 사용자 도그푸딩 2026-05-06 — agent (Claude Code via MCP, fb-30) 가 web fetch 한 markdown / 단일 외부 file 을 KB 에 저장하려면 `kebab ingest` 전체 walk 재실행 비효율. agent 메모리상 string contents 도 stdin ingest 가능해야.
|
||||
|
||||
**Live binding 변경**:
|
||||
|
||||
- 신규 subcommand `kebab ingest-file <path>` — 단일 file ingest, workspace 외부 path 가능.
|
||||
- 신규 subcommand `kebab ingest-stdin --title <T> [--source-uri <URI>]` — stdin 의 markdown 본문 ingest, v1 markdown only.
|
||||
- 신규 MCP tool `ingest_file` + `ingest_stdin` — fb-30 v1 read-only 정책 변경, 첫 mutation surface 도입 (의도된 진화). tools/list 4 → 6.
|
||||
- 외부 file 저장 정책: `<workspace.root>/_external/<blake3-12>.<ext>` 로 copy. deterministic 명명 → idempotent. `_external/` 첫 생성 시 `.kebabignore` 자동 append (walk 무한 루프 방지).
|
||||
- `.kebabignore` 매치 시 stderr warn (`warn: <path> matches .kebabignore patterns; proceeding (explicit ingest bypasses ignore)`) 후 진행. `--force-ignore` flag 불필요 — explicit ingest 가 default bypass intent.
|
||||
- stdin frontmatter 처리: 본문이 `---` 으로 시작하면 error (`use kebab ingest-file`); 그 외 frontmatter block prepend (title + 옵션 source_uri, YAML 더블쿼트 escape).
|
||||
- `kebab-app::external` 신규 모듈 — `ensure_external_dir`, `ensure_kebabignore_entry`, `copy_to_external`, `inject_frontmatter` helper. kebab-cli + kebab-mcp 둘 다 facade 통해 호출.
|
||||
- `kebab-app::ingest_file_with_config` + `ingest_stdin_with_config` 신규 facade fn.
|
||||
|
||||
**Spec contract impact**: design §6 에 `_external/` subdirectory 절 추가 (실제 §6.7 — 기존 §6 sub-section 이 6.6 까지 채워져 있어 §6.7 로 부착됨; spec stub 의 §6.3 명시는 deviation).
|
||||
|
||||
**Tests added**: kebab-app external::tests (14: dir / kebabignore append / copy / inject_frontmatter / yaml_quote), kebab-app integration (3 + 3: ingest_file + ingest_stdin), kebab-cli integration (2: cli_ingest_file + cli_ingest_stdin spawn-based), kebab-mcp integration (1 + 2: tools_call_ingest_file + tools_call_ingest_stdin), tools_list assertion update (4 → 6).
|
||||
|
||||
**Known limitation (deferred)**:
|
||||
|
||||
- PDF / image stdin — binary stream + base64 처리 v2.
|
||||
- `--title` + `--source-uri` 외 metadata field (tags, language, custom kv) — v2.
|
||||
- 자동 dedup by source_uri — content hash 기반 dedup 만 (incremental ingest). URI lookup 별 task.
|
||||
- Storage quota / TTL — agent 무한 ingest 시 KB 비대 우려. monitor + 별 task.
|
||||
- frontmatter merge (stdin 이 이미 frontmatter 보유 시 머지) — v1 은 error.
|
||||
- MCP `ingest_file` 의 multi-file batch 입력 — v1 single path. 여러 file 호출은 agent 가 N 회.
|
||||
|
||||
**Amends**:
|
||||
- design §6 (`_external/` subdirectory subsection 추가, §6.7 위치).
|
||||
- spec `tasks/p9/p9-fb-31-single-file-stdin-ingest.md` (status `open` → `completed`).
|
||||
- spec stub 의 §6.3 명시 → 실제 §6.7 (기존 §6 구조 우선).
|
||||
|
||||
## 2026-05-07 — p9-fb-30 (post-dogfooding): MCP server (stdio) — agent integration MVP
|
||||
|
||||
**Source feedback**: 사용자 도그푸딩 2026-05-06 — Claude Code 같은 AI agent 가 kebab CLI 를 사용하는 것이 궁극 목표. 현재 surface 는 Claude Code 전용 skill (subprocess wrapper) 만 — host 무관 표준 통신 없음. fb-29 HTTP daemon 은 single-user local-first 환경 대비 비대로 deferred (2026-05-07), fb-30 stdio MCP 가 동일 사용자 가치 (agent integration + session 동안 hot cache) 를 daemon 복잡도 없이 제공.
|
||||
|
||||
**Live binding 변경**:
|
||||
|
||||
- 신규 subcommand `kebab mcp` — stdio JSON-RPC server, `--config <path>` honor.
|
||||
- 신규 crate `kebab-mcp` (lib only) — `serve_stdio(Config, Option<PathBuf>)` entry. UI crate 카테고리 (kebab-cli + kebab-tui + kebab-mcp 가 facade 룰 동일 적용 — `kebab-app` facade 만 import).
|
||||
- Tool surface v1 (read-only 4): `search` (lexical/vector/hybrid 검색, default Hybrid), `ask` (RAG 답변, default mode Hybrid, optional `session_id` for multi-turn + optional `mode` override), `schema` (introspection), `doctor` (health check). `ingest_*` / `fetch` / `list_docs` / `inspect_chunk` 는 fb-31 / fb-35 / 후속 task 머지 시 추가.
|
||||
- Resources / Prompts / Sampling — 모두 미선언 (tools-only v1).
|
||||
- Output: 모든 tool 이 wire schema v1 JSON 을 MCP `text` content block 으로 직렬화. CLI `--json` 모드와 동일 wire — single source.
|
||||
- Error mapping: tool dispatch `Err(e)` 만 `isError: true` + error.v1 content. Refusal (`grounded: false`) / no-hit (empty array) / unhealthy (`ok: false`) 는 모두 정상 응답 — agent 가 wire payload semantic flag 으로 분기.
|
||||
- `kebab-app::error_wire` 신규 — fb-27 의 `kebab-cli::error_classify` 코드 그대로 promotion (struct + classify + classify_llm + 7 unit test). kebab-cli + kebab-mcp 둘 다 동일 모듈 사용. reqwest dev-dep 도 함께 이동. 부수 변경: `ErrorV1` 에 `schema_version: String` 필드 추가 — kebab-mcp 의 직접 serialize 경로에서도 wire 정합 (kebab-cli 의 `wire_error_v1` 의 `tag_object` 는 idempotent 로 작동, 동작 무영향).
|
||||
- `kebab-app::Capabilities::mcp_server`: `false` → `true`. `schema_report` 통합 테스트 + `cli_schema` 통합 테스트 assertion 갱신.
|
||||
- Initialize handshake: `protocolVersion = "2025-03-26"` (rmcp 1.6 default), `capabilities.tools = { listChanged: false }`, `serverInfo = { name: "kebab", version: <CARGO_PKG_VERSION> }`.
|
||||
- `KebabAppState` 가 `(Config, Option<PathBuf>)` carry — `kebab_app::doctor_with_config_path` 는 `Option<&Path>` 만 받기 때문 (`doctor_with_config(&Config)` 미존재). path 없으면 `None` (XDG default 동작).
|
||||
- `tokio::task::spawn_blocking` wrap on `call_tool` arms for `ask` + `search` — `OllamaLanguageModel` 의 `reqwest::blocking::Client::build()` 가 내부적으로 tokio runtime create+drop 하므로 async 안에서 panic. spawn_blocking 으로 우회. schema / doctor 는 cheap reads 라 wrap 불필요.
|
||||
- `tools/list` 의 list construction 을 `pub fn build_tools_vec()` 로 추출 — rmcp 1.6 가 in-memory test transport 미노출이라 spawn 없이 unit-level 검증 위함.
|
||||
|
||||
**Spec contract impact**: design §10 에 §10.2 MCP transport 절 추가.
|
||||
|
||||
**Tests added**: kebab-mcp integration (5: tools_call_search / tools_call_ask / tools_call_schema / tools_call_doctor / tools_list / error_mapping + initialize), kebab-cli integration (1: cli_mcp_smoke spawn + initialize + tools/list round-trip). 약 8 신규 테스트.
|
||||
|
||||
**Known limitation (deferred)**:
|
||||
|
||||
- HTTP-SSE transport — fb-29 P+ deferral 따라 stdio 단일. browser agent / remote 시나리오 등장 시 재개.
|
||||
- Resources (`kebab://chunk/<id>` URI) — fb-35 verbatim fetch 와 함께 v2.
|
||||
- Prompts — RAG 자체 prompt template 내장으로 사용자 가치 약함, defer.
|
||||
- Streaming `ask` — fb-33 streaming ask 와 함께.
|
||||
- `ingest_*` / `fetch` / `list_docs` / `inspect_chunk` tools — 후속 task 별로 추가.
|
||||
- Server-scope state caching — 현재 매 tool call 마다 store open. 첫 call 시 `KebabAppState` 에 `OnceLock<SqliteStore>` 도입 검토 (post-merge 후속 PR).
|
||||
- rmcp SDK API 호환성 — 1.6 채택, 미래 major bump 시 별 task.
|
||||
- Manual `tools/list` + `tools/call` dispatch 채택 — rmcp 1.6 의 `#[tool_router]` 매크로보다 명시적, 디버깅 쉬움. 하지만 새 tool 추가 시 두 곳 (list_tools 의 vec + call_tool 의 match) 동시 갱신 필요. 후속 task 가 5개 이상 tool 추가하면 매크로 도입 재검토.
|
||||
- `AskOpts` 가 `Default` 미도입 — kebab-cli + kebab-tui + kebab-mcp 의 모든 호출 site 가 9 field 를 명시적으로 초기화. 새 field 추가 시 모든 site 동시 갱신 필요. `impl Default for AskOpts` 또는 builder 패턴 도입은 별 PR.
|
||||
|
||||
**Amends**:
|
||||
- design §10 (MCP transport subsection 추가).
|
||||
- spec `tasks/p9/p9-fb-30-mcp-server.md` (status `open` → `completed`).
|
||||
- spec stub 의 `transport: stdio default + http (fb-29 daemon) 위에 SSE 옵션` → 실제 채택 stdio 단일 (fb-29 deferral 결과, 2026-05-07 commit `2e8de14` 의 spec 갱신과 일관).
|
||||
|
||||
## 2026-05-07 — p9-fb-27 (post-dogfooding): introspection (`kebab schema`) + structured error wire
|
||||
|
||||
**Source feedback**: 사용자 도그푸딩 2026-05-06 — agent 가 kebab 인스턴스의 wire 버전 / 기능 / 모델 / 인덱스 통계 introspect 못 함; error 가 stderr text 라 substring 분기 필요.
|
||||
|
||||
**Live binding 변경**:
|
||||
|
||||
- 신규 명령 `kebab schema [--json]` — text / `schema.v1` JSON. `--config <path>` honor.
|
||||
- 신규 wire `schema.v1` — `kebab_version` (`env!("CARGO_PKG_VERSION")`) / `wire.schemas` / `capabilities` (10 bool, 4 미래 surface 포함) / `models` (parser/chunker/embedding/prompt_template/index/corpus_revision 6축) / `stats` (doc/chunk/asset count + last_ingest_at). `SchemaV1` 가 자체 `schema_version: "schema.v1"` 필드 carry — `wire_doctor` 와 동일 idempotent re-tag pattern.
|
||||
- 신규 wire `error.v1` — `--json` 모드에서 fatal error 가 stderr ndjson 으로 emit. 비 `--json` 은 기존 stderr text 유지.
|
||||
- error code 7개 initial set: `config_invalid` (`ConfigInvalid` signal in kebab-config, `cause` prefix `read_failed:` / `parse_failed:` underscore-slugged for stable agent matching) / `not_indexed` (`NotIndexed` in kebab-store-sqlite, `SqliteStore::open_existing` API 신규 — `OpenFlags::SQLITE_OPEN_READ_WRITE | SQLITE_OPEN_URI` 로 silent CREATE 방지) / `model_unreachable` (`LlmError::Unreachable`) / `model_not_pulled` (`LlmError::ModelNotPulled`) / `timeout` (`LlmError::Timeout`) / `io_error` (`std::io::Error` chain detection) / `generic` (catch-all, verbose 시 `details.chain` 채움).
|
||||
- exit code 0/1/2/3 unchanged — `RefusalSignal` / `NoHitSignal` / `DoctorUnhealthy` 만 보고 1/1/3 결정. 신규 5 typed signal 모두 fall-through → 2.
|
||||
- `kebab-app::error_signal` 모듈 신규 — `doctor_signal` 의 3 signal 과 신규 typed error 들 한 곳에서 re-export.
|
||||
- `kebab-store-sqlite::SqliteStore::count_summary` 메서드 신규 — `schema.v1.stats` block backing.
|
||||
- `kebab_parse_md::PARSER_VERSION` + `kebab_store_vector::INDEX_VERSION_STR` `pub const` 노출 — kebab-app 의 `Models` block 이 single source of truth (cascade 규약 충족).
|
||||
|
||||
**Spec contract impact**: design §10 에 §10.1 capability matrix subsection 추가 — `schema.v1` / `error.v1` wire 명시.
|
||||
|
||||
**Tests added**: kebab-config fb27_tests (2: ConfigInvalid downcast / malformed TOML), kebab-store-sqlite (3: NotIndexed signal + open_existing no-create regression + count_summary zero state), kebab-cli error_classify::tests (7: 7 code 분류 + verbose chain), kebab-cli wire::tests (2: schema.v1 / error.v1 round-trip), kebab-app schema_report integration (2: ingested KB stats + empty KB), kebab-cli cli_schema integration (2: --json + text), kebab-cli cli_error_wire integration (2: --json error.v1 + legacy text). 약 20 신규 테스트.
|
||||
|
||||
**Known limitation (deferred — interim wire shape)**:
|
||||
|
||||
- `error.v1.details` shape per code 가 frozen design literal 과 일부 일탈 — 신규 typed signal 도입 deferred 라 발생:
|
||||
- `io_error.details` = `{ "kind": "<ErrorKind debug string>" }` (spec literal 의 `{ path, op }` 아님 — `IoFailure` typed signal 추가 시 정정).
|
||||
- `timeout.details` = `{ "source": "<error display>" }` (spec literal 의 `{ operation, elapsed_ms, deadline_ms }` 아님 — `OpTimeout` typed signal + per-callsite stamping 추가 시 정정).
|
||||
- `model_unreachable.details` = `{ endpoint, source }` (spec literal 의 `{ endpoint, operation }` — `LlmError::Unreachable` 가 `operation` field 없음).
|
||||
- `model_not_pulled.details` = `{ model }` (spec literal 의 `{ model, endpoint, operation }` — `LlmError::ModelNotPulled` 가 model id 만 carry).
|
||||
- JSON Schema literal `docs/wire-schema/v1/error.schema.json` 의 `details` block 은 `additionalProperties: true` + `required: []` 로 permissive — 실제 emit shape 반영. 후속 task 가 typed signal 추가 시 schema 의 description 갱신.
|
||||
- `Config::load(Some(/nonexistent))` 가 silent default fallback — agent 가 `--config /wrong` 으로 호출 시 `config_invalid` 가 아닌 default config 적용 + 후속 명령이 default 동작. fb-28 (`--readonly`/`--quiet`) 또는 별 follow-up 에서 `--config` strict mode 도입 검토 필요.
|
||||
- `Config::from_file` 의 schema-mismatch (DB 마이그레이션 버전 안 맞음) 는 `NotIndexed.found = None` 으로만 보고 — `_refinery_schema_history` 의 max version 을 read 하는 후속 PR 에서 `found: Some("V005")` 같은 정확한 값 채움.
|
||||
- `LlmError::Stream` / `Malformed` 가 `code: "generic"` fallback — 후속 task 에서 `stream_aborted` / `malformed_response` 같은 dedicated code 도입 검토 (design §10.1 future-extensions 절 참조).
|
||||
- `not_indexed.details` 가 `{ expected, found }` 만 emit (spec literal 의 `{ data_dir, expected, found }` 아님 — `expected` 가 full DB path 라 data_dir 은 caller 에서 derive 해야 함, NotIndexed signal 자체는 path 한 개만 carry).
|
||||
- README 의 wire schema 목록과 CLAUDE.md 의 wire schema 목록이 fb-27 머지 시점에 약간 일치 안 함 (CLAUDE.md 가 `eval_run.v1`/`eval_compare.v1`/`list_docs.v1` 포함, 실제 docs/wire-schema/v1/ 에 해당 파일 없음). 별 follow-up 에서 doc / 실제 wire 동기화 sweep 진행.
|
||||
- `SqliteStore::open_existing` 가 `SQLITE_OPEN_READ_WRITE` 로 열고 doc 으로만 "callers should not issue mutations" 명시 — 컴파일러 enforcement 없음. 후속 PR 에서 `apply_pragmas` 의 WAL 라인을 분리한 `apply_read_pragmas` + `SQLITE_OPEN_READ_ONLY` 변형 도입 검토 (WAL mode 는 DB 헤더에 영속이라 RO 도 동작 가능).
|
||||
|
||||
**Amends**:
|
||||
- design §10 (capability matrix subsection 추가).
|
||||
- spec `tasks/p9/p9-fb-27-introspection-and-error-wire.md` (status `open` → `completed`).
|
||||
- spec stub 의 `Goal (skeleton)` 의 6 exit code (`0/1/2/3/4/5`) 제안 → 실제 채택 0/1/2/3 only.
|
||||
|
||||
## 2026-05-05 — p9-fb-25 (post-dogfooding): config workspace.include 제거 + 지원 형식 가시성
|
||||
|
||||
**Source feedback**: 사용자 도그푸딩 2026-05-05 — config 의 `workspace.include` + `workspace.exclude` 동시 존재가 case 4 (둘 다 매치 안 함) 의미 모호 + 어차피 처리 가능 형식 (md / png / jpg / pdf) 이 정해져 있으니 사용자에게 명시 필요.
|
||||
|
||||
**Live binding 변경**:
|
||||
|
||||
- `kebab-config::WorkspaceCfg.include: Vec<String>` 제거. denylist-only 모델. 옛 config 의 `include = [...]` 은 serde 가 silently 무시 + `Config::from_file` 가 단발 `tracing::warn!` 으로 deprecation 안내 (`std::sync::OnceLock` — 같은 process 안에서 한 번만).
|
||||
- `kebab-core::IngestItem.warnings` 가 Skipped 시 사유 채움: `"unsupported media type: .{ext}"` (ext 없으면 `"unsupported media type: <no-ext>"`) / `"kb:// URI not yet supported"`.
|
||||
- `kebab-core::IngestReport.skipped_by_extension: BTreeMap<String, u32>` + `kebab-app::AggregateCounts.skipped_by_extension` 신규. key = lowercase ext (`docx`, `txt`), no-ext sentinel = `<no-ext>`. wire schema `ingest_report.v1` 에 additive 추가 (v1 호환 유지 — release 트리거 안 됨 per CLAUDE.md release 규약).
|
||||
- CLI summary + TUI status_line final / aborted: `5 skipped: 3 docx, 1 txt, 1 epub` 형식. desc 정렬 (count) + ties by key alphabetic + 모두 표시.
|
||||
- `kebab-app::init_workspace` 헤더 주석에 지원 형식 명시 (Markdown / 이미지 / PDF + 각 확장자).
|
||||
- README `kebab ingest` 설명에 지원 형식 + skip 사유 + breakdown 표시 명시.
|
||||
|
||||
**Spec contract impact**: design §6.2 의 `workspace.include` 항목 invalidate (frozen 그대로 두고 본 항목 + spec `tasks/p9/p9-fb-25-config-include-removal.md` 가 source of truth). design §3.x `IngestReport` + §2.4a `IngestEvent` 에 새 필드 / 새 warning 의미 추가 (additive).
|
||||
|
||||
**Tests added**: 5 신규 (kebab-config 단위 2: legacy include 무시 + WorkspaceCfg 필드 destructure / kebab-app 통합 1: skip_reason / kebab-app 통합 1: init_template 헤더 / kebab-tui 단위 2: status_line breakdown 완료/abort) + 1 unit (kebab-app 의 render_skipped_breakdown). 기존 fixture 6 개 mechanical adapter 수정 (`tests/common/mod.rs` SourceScope, `tests/image_pipeline.rs` × 2 + `tests/pdf_pipeline.rs` 의 dead `include.push` 제거, `tests/ingest_report_snapshot.rs` + `kebab-cli/src/wire.rs` literal 에 `BTreeMap::new()` 추가, snapshot JSON 의 `skipped_by_extension` 필드). assertion 의미 변경 없음.
|
||||
|
||||
**Known limitation (deferred)**:
|
||||
|
||||
- `SourceScope.include` (`kebab-core::traits`) 는 그대로 — design §7.1 abstraction 이라 별 spec 으로 다룰 수 있음. 본 PR 은 config 단의 `WorkspaceCfg.include` 만 정리.
|
||||
- 새 extractor (txt / docx / epub 등) 도입은 별 spec.
|
||||
- `kebab doctor` 가 unsupported 파일 카운트 분석은 후속 task.
|
||||
|
||||
## 2026-05-04 — p9-fb-23 (post-dogfooding): Incremental ingest
|
||||
|
||||
**Source feedback**: 사용자 도그푸딩 2026-05-04 — "새 문서들이 폴더에 추가되면 ingest 시 변하지 않은 문서는 다시 ingest 하지 않고 변하거나 새로 추가된 문서만 처리하고 싶어."
|
||||
|
||||
@@ -108,6 +108,33 @@ P0~P5 는 직렬. P6~P9 는 P5 이후 병렬 가능.
|
||||
- [p9-fb-22 cursor mid-string editing + Ask follow-tail (post-도그푸딩)](p9/p9-fb-22-tui-cursor-and-autoscroll.md)
|
||||
- [p9-fb-23 incremental ingest (post-도그푸딩)](p9/p9-fb-23-incremental-ingest.md)
|
||||
- [p9-fb-24 status bar + Library header + page scroll (post-도그푸딩)](p9/p9-fb-24-tui-affordances.md)
|
||||
- [p9-fb-25 config workspace.include 제거 + 지원 형식 가시성 (post-도그푸딩)](p9/p9-fb-25-config-include-removal.md)
|
||||
- **⏳ fb-26 ~ fb-42: 백로그 only — 미구현 + brainstorm 선행 필요.** spec 작성 시 [superpowers:brainstorming](../docs/superpowers/) 부터 시작. status: open. 다른 세션에서 이 그룹 손대기 전 사용자 확인 필요. **번호 = release 순서** — 작은 번호일수록 먼저 작업 (2026-05-06 renumber).
|
||||
|
||||
### 🎯 0.3.0+ — agent foundation (MCP + introspection)
|
||||
- [p9-fb-26 ingest 로그 출력 일관성](p9/p9-fb-26-ingest-log-consistency.md) — ⏳ 미구현, brainstorm 필요
|
||||
- [p9-fb-27 introspection + structured error wire](p9/p9-fb-27-introspection-and-error-wire.md) — ✅ 머지 + v0.3.0 cut (2026-05-07)
|
||||
- [p9-fb-28 agent invocation flags (--readonly / --quiet)](p9/p9-fb-28-agent-invocation-flags.md) — ⏳ 미구현, brainstorm 필요
|
||||
- [p9-fb-29 HTTP daemon (`kebab serve`)](p9/p9-fb-29-http-daemon.md) — 🚫 deferred (2026-05-07) — fb-30 stdio MCP 가 동일 가치 제공, daemon 복잡도 회피. P+ 재개 trigger 는 spec 참조.
|
||||
- [p9-fb-30 MCP server](p9/p9-fb-30-mcp-server.md) — ⏳ 미구현, brainstorm 필요 (depends_on 27 ✅, stdio-only)
|
||||
- [p9-fb-31 single-file / stdin ingest](p9/p9-fb-31-single-file-stdin-ingest.md) — ⏳ 미구현, brainstorm 필요
|
||||
|
||||
### 🎯 0.4.0 — agent surface refinement (additive only)
|
||||
- [p9-fb-32 stale doc indicator](p9/p9-fb-32-stale-doc-indicator.md) — ⏳ 미구현, brainstorm 필요
|
||||
- [p9-fb-33 streaming ask (ndjson delta)](p9/p9-fb-33-streaming-ask.md) — ⏳ 미구현, brainstorm 필요
|
||||
- [p9-fb-34 output budget controls](p9/p9-fb-34-output-budget-controls.md) — ⏳ 미구현, brainstorm 필요
|
||||
- [p9-fb-35 verbatim fetch](p9/p9-fb-35-verbatim-fetch.md) — ⏳ 미구현, brainstorm 필요
|
||||
- [p9-fb-36 search filter args](p9/p9-fb-36-search-filters.md) — ⏳ 미구현, brainstorm 필요
|
||||
- [p9-fb-37 trace + stats](p9/p9-fb-37-trace-and-stats.md) — ⏳ 미구현, brainstorm 필요 (depends_on 27)
|
||||
|
||||
### 🎯 0.5.0 — RAG quality (cascade 동반: V00X + reindex)
|
||||
- [p9-fb-38 score semantics](p9/p9-fb-38-score-semantics.md) — ⏳ 미구현, brainstorm 필요
|
||||
- [p9-fb-39 retrieval precision 튜닝](p9/p9-fb-39-retrieval-precision-tuning.md) — ⏳ 미구현, brainstorm 필요 (embedding_version cascade)
|
||||
- [p9-fb-40 fact-grounded answer](p9/p9-fb-40-fact-grounded-answer.md) — ⏳ 미구현, brainstorm 필요 (prompt_template_version cascade)
|
||||
|
||||
### 🎯 0.6.0 또는 P+ — reasoning
|
||||
- [p9-fb-41 multi-hop reasoning](p9/p9-fb-41-multi-hop-reasoning.md) — ⏳ 미구현, brainstorm 필요 (XL, eval 인프라 선행)
|
||||
- [p9-fb-42 bulk multi-query + re-rank hint](p9/p9-fb-42-bulk-multi-query-rerank.md) — ⏳ 미구현, brainstorm 필요 (Nice)
|
||||
|
||||
## Post-merge 핫픽스
|
||||
|
||||
|
||||
44
tasks/p9/p9-fb-25-config-include-removal.md
Normal file
44
tasks/p9/p9-fb-25-config-include-removal.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-config
|
||||
task_id: p9-fb-25
|
||||
title: "Config workspace.include 제거 + 지원 형식 가시성 (post-merge dogfooding)"
|
||||
status: completed
|
||||
depends_on: [p9-fb-23]
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§6.2 Workspace, §3.x IngestReport, §2.4a IngestEvent]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-05 — include + exclude 의미 모호 + 지원 형식 가시성 부족.
|
||||
---
|
||||
|
||||
# p9-fb-25 — Config `workspace.include` 제거 + 지원 형식 가시성
|
||||
|
||||
상세 설계: `docs/superpowers/specs/2026-05-05-p9-fb-25-config-include-removal-design.md`.
|
||||
구현 계획: `docs/superpowers/plans/2026-05-05-p9-fb-25-config-include-removal.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
- `WorkspaceCfg.include` 필드 제거 (denylist-only 모델 정착).
|
||||
- 사용자가 ingest 결과에서 어떤 파일이 왜 skip 됐는지 즉시 파악.
|
||||
- 지원 형식 (md / png / jpg / pdf) 을 README + `kebab init` config 주석에 명시.
|
||||
|
||||
## Behavior contract
|
||||
|
||||
- 옛 config 의 `include = [...]` 은 silently 무시 + 단발 deprecation warning.
|
||||
- Skipped 시 `IngestItem.warnings` = `["unsupported media type: .ext"]` 또는 `["unsupported media type: <no-ext>"]` 또는 `["kb:// URI not yet supported"]`.
|
||||
- `IngestReport.skipped_by_extension` = `BTreeMap<lowercase-ext, count>`. no-ext 키 = `<no-ext>`.
|
||||
- CLI / TUI summary final / aborted 라인에 `"N skipped: A docx, B txt, ..."` (desc 정렬, 모두 표시, ties by key alphabetic).
|
||||
|
||||
## Tests
|
||||
|
||||
- legacy include 무시 + 새 WorkspaceCfg 필드 destructure (kebab-config).
|
||||
- skip_reason 통합 (kebab-app): docx + Makefile 두 파일 ingest → warnings + skipped_by_extension 채워짐.
|
||||
- init_template 헤더 (kebab-app).
|
||||
- status_line breakdown 완료 / abort (kebab-tui).
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- 옛 config 가 narrow allowlist (예: `include = ["**/*.md"]`) 면 본 변경 후 `.png` 등이 자동 ingest 시작 — deprecation warning + README 가 alarm.
|
||||
- `SourceScope.include` (kebab-core) 는 그대로.
|
||||
|
||||
Live deviations 반영 위치: `tasks/HOTFIXES.md` `2026-05-05 — p9-fb-25` 항목.
|
||||
73
tasks/p9/p9-fb-26-ingest-log-consistency.md
Normal file
73
tasks/p9/p9-fb-26-ingest-log-consistency.md
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-cli
|
||||
task_id: p9-fb-26
|
||||
title: "Ingest 로그 출력 일관성 (in-place vs 새 줄 혼재)"
|
||||
status: open
|
||||
target_version: 0.3.0
|
||||
depends_on: [p9-fb-02]
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§7 ingest, §10 UX, §2.4a IngestEvent]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-05 — `kebab ingest` 진행 로그가 어떨 땐 새 라인으로, 어떨 땐 기존 라인 in-place 갱신으로 나와 일관성 떨어짐.
|
||||
---
|
||||
|
||||
# p9-fb-26 — Ingest 로그 출력 일관성
|
||||
|
||||
> ⏳ **백로그 only — 미구현.** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. 옵션 A/B/C 중 결정 + behavior contract 확정 후 implementation PR 진행.
|
||||
|
||||
## 증상
|
||||
|
||||
`kebab ingest` 실행 중 진행 출력이 두 패턴 혼재:
|
||||
|
||||
- **TTY**: indicatif `ProgressBar` 단일 라인 in-place 갱신 (`ScanStarted` / `ScanCompleted` / `AssetStarted` / `AssetFinished`). 마지막 `Completed` / `Aborted` summary 만 별도 새 라인.
|
||||
- **non-TTY (pipe / less / CI)**: 매 event 마다 `writeln!` 새 라인.
|
||||
- 같은 세션 안에서도 환경 변경 (예: `kebab ingest 2>&1 | tee log`) 시 출력 형식 바뀜.
|
||||
|
||||
사용자 인지: "어떨 땐 새 라인, 어떨 땐 기존 라인 업데이트". 시각적 노이즈 + 스크롤백에 진행 흔적 일부만 남거나 전체 남는 비대칭.
|
||||
|
||||
## 원인
|
||||
|
||||
[crates/kebab-cli/src/progress.rs:99-188](../../crates/kebab-cli/src/progress.rs#L99-L188):
|
||||
|
||||
- TTY branch 는 `bar.set_message` / `bar.set_position` 으로 단일 라인 갱신.
|
||||
- non-TTY branch (`if !tty { writeln!(err, ...) }`) 가 모든 event 마다 추가 라인.
|
||||
- Completed / Aborted 는 TTY 에서도 `writeln!` 항상 호출 — bar finish 후 summary 한 줄.
|
||||
|
||||
## Goal
|
||||
|
||||
진행 로그의 출력 형식을 환경 무관하게 예측 가능하게 만든다. 사용자는 한 가지 형식을 보고 다른 환경에서도 같은 형식을 기대해야 함.
|
||||
|
||||
## Behavior contract (제안 — brainstorming 단계, 머지 전 사용자 확인)
|
||||
|
||||
옵션 A — **TTY = in-place, non-TTY = append-only, 둘 다 명시적**:
|
||||
- TTY: 진행 라인은 in-place, summary 도 마지막에 같은 라인 commit (현재 처럼 새 줄 X) 또는 한 줄 띄우고 명시적 final.
|
||||
- non-TTY: 매 event 한 줄 (현재 동작 유지) — pipe / log redirect 에서 진행 흔적 남는 게 정답.
|
||||
- summary 라인은 두 모드 동일한 prefix (`ingest: complete (...)` / `ingest: aborted (...)`) 로 통일.
|
||||
|
||||
옵션 B — **항상 append-only**:
|
||||
- TTY 에서도 spinner 끄고 매 event 새 라인. 단순, 진행 흔적 보존.
|
||||
- 단점: bar UX 손실 — long ingest 에서 화면 가득.
|
||||
|
||||
옵션 C — **항상 in-place (TTY 만)**:
|
||||
- non-TTY 면 마지막 summary 한 줄만, 중간 event silent.
|
||||
- 단점: CI / log redirect 에서 진행 알 수 없음.
|
||||
|
||||
권장: **옵션 A** — 환경별 의미 명확, 두 형식 다 의도된 것임을 README 에 명시.
|
||||
|
||||
## 검증 / 테스트
|
||||
|
||||
- `kebab ingest` TTY 모드: spinner 한 라인만 차지, 종료 후 summary 한 줄.
|
||||
- `kebab ingest 2>&1 | cat`: append-only 형식, 매 asset / scan event 한 줄.
|
||||
- `kebab ingest --json`: 기존 ndjson 동작 유지 (영향 없음).
|
||||
- snapshot test: non-TTY 스트림 포맷 안정.
|
||||
|
||||
## 관련 항목
|
||||
|
||||
- p9-fb-01 / p9-fb-02 (progress 인프라). 본 항목은 그 위의 일관성 follow-up.
|
||||
- 사용자 visible surface — README **명령** 표 / Quick start 의 ingest 출력 예시 갱신 필요.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- indicatif `ProgressDrawTarget::stderr()` 가 일부 터미널 (TUI multiplexer, 일부 ssh client) 에서 in-place 갱신 fallback 으로 새 라인 그릴 수 있음 — 조사 필요.
|
||||
- CI 가 TTY-emulating wrapper (예: GitHub Actions 일부) 면 의도 안 한 in-place 모드 진입 가능. 명시적 `KEBAB_PROGRESS=plain` env override 검토.
|
||||
42
tasks/p9/p9-fb-27-introspection-and-error-wire.md
Normal file
42
tasks/p9/p9-fb-27-introspection-and-error-wire.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-cli + kebab-app + wire-schema
|
||||
task_id: p9-fb-27
|
||||
title: "Introspection (`kebab schema`) + structured error wire"
|
||||
status: completed
|
||||
target_version: 0.3.0
|
||||
depends_on: []
|
||||
unblocks: [p9-fb-30]
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§10 UX, wire-schema 전반]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-06 — agent 가 kebab 인스턴스의 wire 버전 / 기능 / 모델 / 인덱스 통계 알아야 통합 안전. error 도 stderr text 가 아닌 structured JSON 필요.
|
||||
---
|
||||
|
||||
# p9-fb-27 — Introspection + structured error wire
|
||||
|
||||
> ✅ **구현 완료.** 본 spec 은 구현 시점의 frozen 상태. post-merge deviation (특히 `error.v1.details` 의 interim shape) 은 [HOTFIXES.md](../HOTFIXES.md) 의 `2026-05-07 — p9-fb-27` 항목 참조 — live source of truth.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
- agent 가 kebab 의 wire schema 버전 / 기능 플래그 / 모델 정보 / 인덱스 통계 introspect 못 함.
|
||||
- error 는 stderr text — agent parse 어려움. timeout vs no-results vs config-missing vs not-indexed 구분 불가.
|
||||
|
||||
## Goal (skeleton)
|
||||
|
||||
- `kebab schema --json` — wire schema 버전 list, capability flags (mcp / daemon / streaming / 등), model versions (parser / chunker / embedding / prompt_template / index), index stats (doc count, chunk count, last ingest).
|
||||
- `kebab stats --json` 으로 분리 가능 — schema 는 정적, stats 는 동적. brainstorm 단계 결정.
|
||||
- 모든 명령의 error 출력을 structured JSON (stderr ndjson 또는 stdout 의 error.v1 wire).
|
||||
- exit code: 0 = OK, 1 = generic, 2 = config, 3 = not-indexed, 4 = timeout, 5 = no-results, …
|
||||
|
||||
## 후속 작업 — brainstorm 필요 항목
|
||||
|
||||
- error.v1 wire schema — fields (`code`, `message`, `details`, `hint`).
|
||||
- 기존 명령의 error path 전수 변환 — anyhow chain → error.v1.
|
||||
- schema vs stats 분리 여부.
|
||||
- fb-30 MCP `initialize` response 와 capability matrix 공유.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- error wire 변경 = breaking — 기존 stderr text 출력은 유지 (둘 다 출력 또는 `--json` 일 때만 wire).
|
||||
- exit code 안정성 — README 에 표 명시.
|
||||
- fb-30 / 29 의 prerequisite — agent 가 server 능력 먼저 introspect.
|
||||
42
tasks/p9/p9-fb-28-agent-invocation-flags.md
Normal file
42
tasks/p9/p9-fb-28-agent-invocation-flags.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-cli + kebab-app
|
||||
task_id: p9-fb-28
|
||||
title: "Agent invocation flags (--readonly + --quiet)"
|
||||
status: open
|
||||
target_version: 0.3.0
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§10 UX]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-06 — agent 가 KB 안전하게 사용 + progress 노이즈 끄기 명시 control 필요. shared / multi-agent host 에서 destructive 명령 차단 필수.
|
||||
---
|
||||
|
||||
# p9-fb-28 — Agent invocation flags
|
||||
|
||||
> ⏳ **백로그 only — 미구현.** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. read-only 의 강제력 (env vs flag vs sub-binary) / quiet 의 범위 (stderr 전체 vs progress 만) brainstorm 후 확정.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
- agent 가 실수로 `kebab nuke` / `kebab reset` 호출 위험 — read-only mode 강제 필요.
|
||||
- agent invoke 시 progress / spinner stderr 출력이 noise — 명시 quiet flag 필요.
|
||||
- 현재 TTY auto-detect 로 부분 해결되지만 TTY 가 emulate 된 환경 (예: agent host 의 pty wrapper) 에서 의도 안 한 spinner.
|
||||
|
||||
## Goal (skeleton)
|
||||
|
||||
- `KEBAB_READONLY=1` env 또는 `kebab --readonly <subcommand>` — destructive 명령 (`reset`, `nuke`, `ingest --overwrite` 등) 거부.
|
||||
- `kebab --quiet <subcommand>` — 모든 stderr progress / hint 끔. error 만 stderr.
|
||||
- agent host 권장 patterns README 에 명시 (예: skill 의 invocation env block).
|
||||
|
||||
## 후속 작업 — brainstorm 필요 항목
|
||||
|
||||
- read-only 의 enforcement layer — argparse vs runtime check.
|
||||
- quiet 와 `--json` 관계 — `--json` 이 자동 quiet 인지.
|
||||
- destructive 명령 enumerate — ingest 가 idempotent 인데 destructive 인지 분류.
|
||||
- daemon (fb-29) 위에서 read-only token / scope.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- read-only bypass 우회 (config 직접 수정 등) 는 막을 수 없음 — best-effort.
|
||||
- 사용자가 자기 invoke 에 readonly 걸지 않게 README 안내.
|
||||
- fb-30 MCP 의 tool 별 permission 과 통합 (read tool 만 노출 vs read+write).
|
||||
50
tasks/p9/p9-fb-29-http-daemon.md
Normal file
50
tasks/p9/p9-fb-29-http-daemon.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-cli + new crate (kebab-server)
|
||||
task_id: p9-fb-29
|
||||
title: "HTTP daemon (`kebab serve`) — subprocess overhead 제거"
|
||||
status: deferred
|
||||
target_version: P+
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§7 RAG, §10 UX]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-06 — agent loop 가 kebab CLI 를 반복 호출 시 subprocess fork + Lance/SQLite cold start 비용 누적. local HTTP daemon 이 latency 해결.
|
||||
---
|
||||
|
||||
# p9-fb-29 — HTTP daemon (`kebab serve`)
|
||||
|
||||
> 🚫 **Deferred (2026-05-07 brainstorm).** fb-30 stdio MCP 가 동일 사용자 가치 (agent integration + session 동안 hot cache) 를 daemon 복잡도 (PID file / port lock / single-instance / lifecycle UX / loopback security) 없이 제공. single-user local-first 환경에서 HTTP transport 가치 미미 (cold start 의 dominant cost = fastembed model load 는 stdio MCP subprocess 가 session 동안 보유 시 동일하게 회피, ask 는 Ollama 추론 latency 가 dominant 라 daemon 효과 제한적). 본 task 는 다음 trigger 시 재개:
|
||||
> - browser agent / remote multi-host 시나리오 등장 (현재 사용자 패턴 외).
|
||||
> - TUI ↔ CLI 다중 인스턴스 state sharing 요구.
|
||||
> - fb-30 MCP HTTP-SSE transport 옵션 도입 검토.
|
||||
>
|
||||
> fb-30 의 prerequisite 였던 본 task 는 fb-30 stdio-only 결정으로 의존 제거. 본 spec 은 brainstorm 결정의 기록 보존용.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
- 현재 `kebab search` / `kebab ask` 가 매 호출 process fork — Lance / SQLite / fastembed 모델 로드 cold start.
|
||||
- agent 가 10 회 search 도는 loop 면 cold start × 10. local-first 단일 사용자라도 latency 누적.
|
||||
- daemon 으로 띄우면 hot — sub-100ms search 가능.
|
||||
|
||||
## Goal (skeleton)
|
||||
|
||||
- `kebab serve --port <N> --bind 127.0.0.1` — local HTTP API.
|
||||
- endpoint: `/search`, `/ask`, `/fetch`, `/ingest`, `/stats`, `/schema`. wire schema v1 재사용.
|
||||
- auth: local bind 면 무인증 (외부 host 면 token).
|
||||
- streaming `/ask` (Server-Sent Events 또는 chunked).
|
||||
- lifecycle: 사용자 명시 실행 vs CLI 자동 spawn (XDG runtime path 의 socket).
|
||||
|
||||
## 후속 작업 — brainstorm 필요 항목
|
||||
|
||||
- web framework: axum / hyper / actix — workspace 통일성 + binary size.
|
||||
- 단일 인스턴스 보장 (PID file / socket lock).
|
||||
- daemon ↔ CLI shim — CLI 가 daemon 살아있으면 HTTP 사용, 없으면 fork.
|
||||
- TUI 와 daemon 공존 — TUI 도 daemon 있으면 HTTP 통해 (현재는 in-process).
|
||||
- fb-30 (MCP) 와 transport 공유 — MCP-over-HTTP-SSE.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- 단일 사용자 local 환경 — daemon 없는 단순함 trade-off.
|
||||
- fb-30 와 강결합 — 함께 brainstorm 하면 architecture 일관.
|
||||
- security: bind 127.0.0.1 default 강제 — 0.0.0.0 은 명시 opt-in.
|
||||
44
tasks/p9/p9-fb-30-mcp-server.md
Normal file
44
tasks/p9/p9-fb-30-mcp-server.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
phase: P9
|
||||
component: integrations + new crate (kebab-mcp)
|
||||
task_id: p9-fb-30
|
||||
title: "MCP server — agent host 무관 protocol surface"
|
||||
status: completed
|
||||
target_version: 0.3.0
|
||||
depends_on: [p9-fb-27]
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§7 RAG, §10 UX, externalAI 통합 절]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-06 — Claude Code 같은 AI agent 가 kebab CLI 를 사용하는 것이 궁극 목표. 현재 surface 는 Claude Code 전용 skill (subprocess wrapper) 만 — host 무관 표준 통신 없음.
|
||||
---
|
||||
|
||||
# p9-fb-30 — MCP server
|
||||
|
||||
> ✅ **구현 완료.** 본 spec 은 구현 시점의 frozen 상태. post-merge deviation (특히 `error.v1` 에 schema_version 필드 추가, ask/search spawn_blocking, manual dispatch 채택) 은 [HOTFIXES.md](../HOTFIXES.md) 의 `2026-05-07 — p9-fb-30` 항목 참조 — live source of truth.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
- 현재 외부 AI 통합은 `integrations/claude-code/kebab/` skill 한 종류 — Claude Code subprocess wrapper.
|
||||
- Cursor / OpenAI Agents / Copilot CLI 등 다른 host 는 별도 wrapper 작성 필요.
|
||||
- MCP (Model Context Protocol) 가 표준 — 한 번 server 구현하면 MCP-aware host 모두 지원.
|
||||
|
||||
## Goal (skeleton)
|
||||
|
||||
- `kebab mcp` subcommand 또는 별도 binary `kebab-mcp` — stdio MCP server.
|
||||
- Tool surface (최소): `search`, `ask`, `fetch`, `ingest_file`, `ingest_stdin`, `stats`, `schema`.
|
||||
- Resources: 옵션 — chunk / doc 을 MCP resource 로 노출 (host subscribe 가능).
|
||||
- Prompts: 옵션 — agent 가 재사용 가능한 prompt template (예: "summarize this KB section").
|
||||
- skill 과 병행 — skill 은 backward compat, 신규는 MCP 권장.
|
||||
|
||||
## 후속 작업 — brainstorm 필요 항목
|
||||
|
||||
- transport: stdio only (default + sole). fb-29 HTTP daemon 은 deferred — HTTP-SSE 옵션은 browser agent / remote 시나리오 demand 발생 시 fb-29 와 함께 재개.
|
||||
- tool 이름 / 인자 스키마 — wire schema v1 재사용 가능?
|
||||
- authentication — local-only 면 무인증, daemon 위면 token.
|
||||
- 새 crate `kebab-mcp` 위치 / 의존성 boundary (kebab-app facade 만 import).
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- MCP spec 진화 중 — 버전 lock 명시 필요.
|
||||
- skill 과 surface 중복 — 사용자 혼란 방지 README 안내.
|
||||
- fb-29 deferral 결과 — MCP transport 는 stdio 단일. HTTP 변형은 future task.
|
||||
42
tasks/p9/p9-fb-31-single-file-stdin-ingest.md
Normal file
42
tasks/p9/p9-fb-31-single-file-stdin-ingest.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-cli + kebab-app
|
||||
task_id: p9-fb-31
|
||||
title: "Single-file / stdin ingest — agent on-demand 저장"
|
||||
status: completed
|
||||
target_version: 0.3.0
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§3 ingest, §10 UX]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-06 — agent 가 읽은 article / fetch 한 page 를 즉시 KB 저장 needed. 현재 ingest 는 workspace 전체 scan 만.
|
||||
---
|
||||
|
||||
# p9-fb-31 — Single-file / stdin ingest
|
||||
|
||||
> ✅ **구현 완료.** 본 spec 은 구현 시점의 frozen 상태. post-merge deviation 은 [HOTFIXES.md](../HOTFIXES.md) 의 `2026-05-07 — p9-fb-31` 항목 참조 — live source of truth.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
- agent 가 web 에서 fetch 한 markdown / pdf 를 KB 에 저장하려 함 — 현재는 workspace 디렉토리에 file 쓰고 `kebab ingest` 전체 재실행.
|
||||
- agent 메모리상 string contents 도 stdin 으로 ingest 가능해야 — 임시 파일 거치는 비효율 제거.
|
||||
|
||||
## Goal (skeleton)
|
||||
|
||||
- `kebab ingest --file <path>` — 단일 파일만 ingest, workspace 외부도 가능 (workspace 안 copy 또는 absolute path 등록).
|
||||
- `kebab ingest --stdin --media md --title "X" [--source-uri "https://..."]` — stdin 에서 contents 읽고 KB 저장.
|
||||
- 결과는 기존 `ingest_report.v1` 와 동일 shape (단일 asset).
|
||||
- p9-fb-23 incremental ingest 와 호환 — 단일 파일도 mtime 기반 변경 감지.
|
||||
|
||||
## 후속 작업 — brainstorm 필요 항목
|
||||
|
||||
- workspace 외부 file 저장 정책 — copy in vs reference (path 만 저장).
|
||||
- stdin contents 의 doc_id 결정 — content hash + title.
|
||||
- source URI metadata 표현 — wire schema 추가 필드.
|
||||
- .kebabignore 우회 — 명시 ingest 면 강제? 아니면 거부?
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- workspace 정의 (§6.2) 와 충돌 가능 — workspace 안 copy 가 깔끔.
|
||||
- agent 가 무한 ingest 시 KB 비대 — quota / TTL 필요할 수도.
|
||||
- p9-fb-23, p9-fb-25 (workspace.include 제거) 와 정책 정합성 검토.
|
||||
42
tasks/p9/p9-fb-32-stale-doc-indicator.md
Normal file
42
tasks/p9/p9-fb-32-stale-doc-indicator.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-app + kebab-tui + kebab-cli
|
||||
task_id: p9-fb-32
|
||||
title: "Stale doc indicator (ingest 시점 대비 X 일 임계 알림)"
|
||||
status: open
|
||||
target_version: 0.4.0
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§3 ingest, §10 UX]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-06 — Claude Code 가 kebab CLI 사용 후 "최신성 약함" 지적. ingest 시점 snapshot 이라 이후 변경 사실 미반영. local-first 단일 사용자라 web fetch 안 함이 의도지만 사용자 / 외부 도구가 stale 여부 인지 못 함.
|
||||
---
|
||||
|
||||
# p9-fb-32 — Stale doc indicator
|
||||
|
||||
> ⏳ **백로그 only — 미구현.** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. stale threshold 정책 / "stale" 정의 (ingest 시점 vs file mtime) / wire schema 필드 위치 brainstorm 후 확정.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
- 답변에 사용된 chunk 가 N 일 전 ingest snapshot — 사용자 / 외부 도구는 fresh 여부 모름.
|
||||
- p9-fb-23 (incremental ingest) 가 mtime 변경 doc 만 재처리 — 사용자가 자주 ingest 하면 자연 해결, 단 사용자가 자주 안 돌리는 doc 도 있음.
|
||||
|
||||
## Goal (skeleton — brainstorm 단계에서 확정)
|
||||
|
||||
- 각 search hit / citation 에 `ingested_at` (또는 `age_days`) 필드 노출.
|
||||
- TUI inspect / search 결과에 stale 표시 (예: 30 일 이상 = 노란색 경고).
|
||||
- CLI `--json` 도 동일 필드 — 외부 도구가 stale 여부 판단.
|
||||
- 옵션: stale doc 자동 재 ingest 제안.
|
||||
|
||||
## 후속 작업 — brainstorm 필요 항목
|
||||
|
||||
- threshold 정책 — 사용자 config 가능 여부 (`stale_threshold_days`).
|
||||
- "stale" 의 정의 — ingest 시점 vs file mtime 시점 vs 둘 다.
|
||||
- wire schema search_hit.v1 / answer.v1 의 citation 에 필드 추가 — additive minor.
|
||||
- TUI 색상 / 표시 방식 — p9-fb-14 color theme 와 통합.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- p9-fb-23 incremental ingest 와 의존 — `ingested_at` 정확성 위해 incremental 의 timestamp 갱신 동작 확인.
|
||||
- additive wire 변경이라 외부 통합 영향 적음.
|
||||
- 사이즈 작음 (S) — 단순 필드 추가 + 표시 로직.
|
||||
46
tasks/p9/p9-fb-33-streaming-ask.md
Normal file
46
tasks/p9/p9-fb-33-streaming-ask.md
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-cli + kebab-app + wire-schema
|
||||
task_id: p9-fb-33
|
||||
title: "Streaming ask (ndjson delta) — agent token 즉시 소비"
|
||||
status: open
|
||||
target_version: 0.4.0
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§7 RAG, §10 UX, wire-schema answer.v1]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-06 — agent 가 token 도착 즉시 다음 행동 결정 가능해야 — final-only JSON 은 latency 손해.
|
||||
---
|
||||
|
||||
# p9-fb-33 — Streaming ask (ndjson delta)
|
||||
|
||||
> ⏳ **백로그 only — 미구현.** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. delta event 형식 / final-only fallback / TUI vs CLI 차이 brainstorm 후 확정.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
- 현재 `kebab ask --json` 추정 — final answer 한 번에 출력. agent 는 LLM token 도착마다 progressive UI / 조기 종료 / 후속 tool 호출 결정 가능해야 빠름.
|
||||
- TUI 는 이미 streaming 표시 — CLI / agent 가 동일 surface 못 받음.
|
||||
|
||||
## Goal (skeleton)
|
||||
|
||||
- `kebab ask --json --stream` — ndjson delta event.
|
||||
- event shape (제안):
|
||||
- `{"kind":"retrieval_done","hits":[...]}`
|
||||
- `{"kind":"token","delta":"...", "turn_index":0}`
|
||||
- `{"kind":"citation","ref":"[1]","chunk_id":"..."}`
|
||||
- `{"kind":"final","answer":"...","citations":[...]}`
|
||||
- `--stream` 미지정이면 현재 동작 유지 (final-only).
|
||||
- wire schema `answer_event.v1` 추가.
|
||||
|
||||
## 후속 작업 — brainstorm 필요 항목
|
||||
|
||||
- event 종류 / 순서 invariant.
|
||||
- token delta 의 partial markdown — fb-40 fact-grounded / fb-11 markdown render 와 정합성.
|
||||
- 중간 cancel — agent 가 SIGINT / connection close 하면 LLM 호출 중단.
|
||||
- daemon (fb-29) HTTP SSE 와 동일 event shape — 이중 구현 방지.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- wire schema additive minor — 기존 final-only path 보존.
|
||||
- TUI 의 streaming 코드 재사용 가능 — kebab-rag 의 generate stream API 가 이미 있을 것.
|
||||
- fb-30 MCP / fb-29 daemon 과 stream surface 통일 필요.
|
||||
43
tasks/p9/p9-fb-34-output-budget-controls.md
Normal file
43
tasks/p9/p9-fb-34-output-budget-controls.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-cli + kebab-app + wire-schema
|
||||
task_id: p9-fb-34
|
||||
title: "Output budget controls (--max-tokens / --snippet-chars / pagination)"
|
||||
status: open
|
||||
target_version: 0.4.0
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§4 search, §10 UX, wire-schema search_hit.v1]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-06 — agent context window 제한적. 검색 결과 양 / snippet 길이 / 페이지네이션 control 필요.
|
||||
---
|
||||
|
||||
# p9-fb-34 — Output budget controls
|
||||
|
||||
> ⏳ **백로그 only — 미구현.** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. budget 적용 layer (truncate vs k 조정) / cursor 형식 / 기본값 brainstorm 후 확정.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
- agent context window 한정 — 검색 결과 5KB 이하로 받고 싶을 때 control 없음.
|
||||
- snippet 길이 고정 → narrow context 에서 한 hit 만 받아도 차고 넘침.
|
||||
- top-5 본 후 추가 5 보고 싶을 때 페이지네이션 없음.
|
||||
|
||||
## Goal (skeleton)
|
||||
|
||||
- `kebab search --max-tokens N` — 결과 직렬화 size 가 N tokens 안에 들도록 truncate / k 자동 축소.
|
||||
- `kebab search --snippet-chars N` — 각 hit 의 snippet 최대 chars.
|
||||
- `kebab search --cursor <opaque>` — 이전 호출의 cursor 로 다음 페이지.
|
||||
- response 에 `next_cursor` 필드 (남은 hit 있을 때).
|
||||
|
||||
## 후속 작업 — brainstorm 필요 항목
|
||||
|
||||
- token 카운트 — tiktoken 류 dependency vs 단순 byte/4 근사.
|
||||
- truncate 우선순위 — snippet 단축 → k 축소 → metadata 제거.
|
||||
- cursor 의 안정성 — index 변경 후 cursor 유효성.
|
||||
- `kebab ask` 도 동일 인자 (`--max-tokens` 결과 답변 길이 제한)?
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- wire schema additive — `next_cursor` 필드 추가 minor.
|
||||
- agent UX — truncate 발생 시 명시적 hint (`truncated: true`) 필요.
|
||||
- 기본값 — agent 친화 (작은 budget) vs 사람 친화 (큰 budget) trade-off.
|
||||
43
tasks/p9/p9-fb-35-verbatim-fetch.md
Normal file
43
tasks/p9/p9-fb-35-verbatim-fetch.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-cli + kebab-app + wire-schema
|
||||
task_id: p9-fb-35
|
||||
title: "Verbatim fetch (`kebab fetch <chunk_id|doc_id>`) — citation deep-link"
|
||||
status: open
|
||||
target_version: 0.4.0
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§4 search, §5 storage, §10 UX]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-06 — agent 가 search hit / citation 보고 더 깊이 파볼 때 raw chunk text + 주변 context 필요.
|
||||
---
|
||||
|
||||
# p9-fb-35 — Verbatim fetch
|
||||
|
||||
> ⏳ **백로그 only — 미구현.** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. fetch unit (chunk vs doc vs span) / 주변 context (앞뒤 chunk N 개) / 옵션 정책 brainstorm 후 확정.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
- search 결과의 snippet 은 highlight 중심 — agent 가 "이 chunk 의 전체 raw text" 또는 "이 chunk 앞뒤 context" 원함.
|
||||
- 현재 inspect 는 TUI 전용 — CLI / `--json` 으로 chunk 가져오는 명시 surface 없음.
|
||||
- citation 의 doc_id 만 받고 doc 전체 다시 ingest / read 하는 비효율.
|
||||
|
||||
## Goal (skeleton)
|
||||
|
||||
- `kebab fetch chunk <chunk_id> [--context N]` — chunk verbatim + 앞뒤 N 개 chunk.
|
||||
- `kebab fetch doc <doc_id>` — doc 전체 raw text.
|
||||
- `kebab fetch span <doc_id> <line_start> <line_end>` — 특정 라인 범위.
|
||||
- response wire schema `fetch_result.v1` 추가.
|
||||
|
||||
## 후속 작업 — brainstorm 필요 항목
|
||||
|
||||
- chunk_id / doc_id 노출 — 현재 search_hit.v1 에 있는지 확인 + 안정성.
|
||||
- context window — N 개 chunk vs N tokens.
|
||||
- doc 전체 fetch 의 size 제한 (fb-34 budget 과 통합).
|
||||
- pdf / image 의 fetch — 텍스트 추출본 vs 원본 path.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- wire schema 신규 — `fetch_result.v1` JSON Schema 추가.
|
||||
- 큰 doc fetch 시 budget control 필수 — fb-34 와 통합.
|
||||
- chunk_id 안정성 — re-ingest 후 chunk_id 변경되면 agent 의 citation stale.
|
||||
43
tasks/p9/p9-fb-36-search-filters.md
Normal file
43
tasks/p9/p9-fb-36-search-filters.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-cli + kebab-search + wire-schema
|
||||
task_id: p9-fb-36
|
||||
title: "Search filter args (--media / --ingested-after / --doc-id / --tag)"
|
||||
status: open
|
||||
target_version: 0.4.0
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§4 search]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-06 — agent 가 검색 범위 좁힐 수단 필요. 현재 search 는 query string 만 받음.
|
||||
---
|
||||
|
||||
# p9-fb-36 — Search filter args
|
||||
|
||||
> ⏳ **백로그 only — 미구현.** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. filter 종류 / SQLite 쿼리 통합 / Lance vector 필터 적용 layer brainstorm 후 확정.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
- agent 가 "최근 1 주 내 doc 중에서만" / "pdf 만" / "tag=research 만" 검색 원함.
|
||||
- 현재 query 만 받고 후처리 filter 도 없음.
|
||||
|
||||
## Goal (skeleton)
|
||||
|
||||
- `kebab search Q --media md,pdf` — media type 필터.
|
||||
- `kebab search Q --ingested-after 2026-04-01` — ingest 시점 필터 (fb-32 stale 와 연계).
|
||||
- `kebab search Q --doc-id <id>` — 특정 doc 의 chunk 만.
|
||||
- `kebab search Q --tag <tag>` — tag 시스템 도입 시 (선행 brainstorm).
|
||||
- `--exclude-doc-id`, `--exclude-tag` 도 검토.
|
||||
|
||||
## 후속 작업 — brainstorm 필요 항목
|
||||
|
||||
- tag 시스템 도입 여부 — 새 SQLite 테이블 / migration.
|
||||
- filter 적용 layer — SQLite WHERE 절 + Lance vector pre-filter.
|
||||
- AND vs OR 의미 — 다중 filter 조합 default.
|
||||
- 기존 wire `SearchRequest` 에 추가 필드 (additive minor).
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- Lance vector pre-filter 가 효율적인지 (post-filter 면 k 부족 가능).
|
||||
- tag 시스템은 큰 추가 — 분리 spec 으로 갈 수도.
|
||||
- fb-32 (stale) 의 `ingested_at` 필드와 통합 — 같은 batch.
|
||||
46
tasks/p9/p9-fb-37-trace-and-stats.md
Normal file
46
tasks/p9/p9-fb-37-trace-and-stats.md
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-cli + kebab-search + kebab-rag
|
||||
task_id: p9-fb-37
|
||||
title: "Trace (--trace) + stats — pipeline 가시성"
|
||||
status: open
|
||||
target_version: 0.4.0
|
||||
depends_on: [p9-fb-27]
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§4 search, §7 RAG, §10 UX]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-06 — agent / 사용자가 "왜 이 결과가 나왔는지" debug 필요. retrieval pipeline 의 각 stage 결과 + KB 건강 점검 surface 부재.
|
||||
---
|
||||
|
||||
# p9-fb-37 — Trace + stats
|
||||
|
||||
> ⏳ **백로그 only — 미구현 (Nice-to-have).** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. trace 의 verbosity level / wire shape / stats 의 별도 명령 vs schema 통합 brainstorm 후 확정.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
- search 결과 의문 — lexical / vector / RRF / rerank 각 stage 가 무엇 반환했는지 모름.
|
||||
- KB 건강 — doc count / chunk count / last ingest / index size / model versions — 단일 surface 없음.
|
||||
- agent 가 stale 판단 / 사용자가 디버깅 시 둘 다 필요.
|
||||
|
||||
## Goal (skeleton)
|
||||
|
||||
- `kebab search Q --trace` 또는 `--explain` — 응답에 `trace` 필드:
|
||||
- `lexical_hits: [{doc_id, score, …}]`
|
||||
- `vector_hits: [...]`
|
||||
- `rrf_combined: [...]`
|
||||
- `reranked: [...]` (reranker 도입 시)
|
||||
- `timing: {lexical_ms, vector_ms, fusion_ms, total_ms}`
|
||||
- `kebab stats --json` — KB 통계 (fb-27 의 schema 와 별도 명령 또는 통합).
|
||||
- TUI inspect 에 trace view — 1 hit 클릭 시 stage breakdown.
|
||||
|
||||
## 후속 작업 — brainstorm 필요 항목
|
||||
|
||||
- trace 의 verbosity — 모든 stage default vs flag opt-in (응답 size 우려).
|
||||
- stats 명령의 위치 — `kebab stats` 또는 `kebab schema --include-stats`.
|
||||
- timing 정확도 — async stage 는 wall-clock 부정확.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- trace 응답 size 큼 — agent budget (fb-34) 와 충돌 가능, 기본 OFF 권장.
|
||||
- fb-27 introspection 의 stats 와 중복 — brainstorm 단계 통합 결정.
|
||||
- 우선순위 낮음 — 핵심 기능 (fb-26 ~ 36) 후순위.
|
||||
40
tasks/p9/p9-fb-38-score-semantics.md
Normal file
40
tasks/p9/p9-fb-38-score-semantics.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-search + kebab-app + wire-schema
|
||||
task_id: p9-fb-38
|
||||
title: "Score semantics 노출 + 문서화 (RRF score 천장 / 채널별 score 분리)"
|
||||
status: open
|
||||
target_version: 0.5.0
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§4 search, §10 UX, wire-schema search_hit.v1]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-06 — Claude Code 가 kebab CLI 사용 후 "top score ~0.5 천장" 지적. RRF 의 rank-only fusion 특성상 absolute relevance 가 아닌데 외부 도구가 score 를 confidence 로 오해.
|
||||
---
|
||||
|
||||
# p9-fb-38 — Score semantics 노출 + 문서화
|
||||
|
||||
> ⏳ **백로그 only — 미구현.** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. score field naming / wire schema 변경 범위 / 채널별 score 노출 정책 brainstorm 후 확정.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
- hybrid 검색의 RRF score 가 일정 ceiling 에 머무름. RRF 수식 (`2/(k+rank)`, post-merge hotfix) 상 max = `2/(k+1)`.
|
||||
- 외부 도구 (Claude Code skill, MCP) 가 `score` 를 0~1 confidence 로 해석 → "0.5 면 50% 확신" 오용.
|
||||
- 단일 channel score (raw BM25 / cosine sim) 가 wire 에 노출 안 됨 — 디버깅도 어려움.
|
||||
|
||||
## Goal (skeleton — brainstorm 단계에서 확정)
|
||||
|
||||
- score 의 의미를 wire 와 README 에 명시.
|
||||
- 채널별 raw score (lexical BM25, vector cosine) 를 search_hit 에 옵션 필드로 노출.
|
||||
- RRF score 와 channel score 의 관계 / scale 문서화.
|
||||
|
||||
## 후속 작업 — brainstorm 필요 항목
|
||||
|
||||
- score field 를 그대로 둘지 (legacy), `rrf_score` / `lexical_score` / `vector_score` 분리할지.
|
||||
- wire schema 변경이 additive (minor) 인지 breaking (major) 인지 결정.
|
||||
- README / docs/wire-schema 갱신 범위.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- wire schema breaking 시 외부 통합 (claude-code skill 등) 영향 — 버전 cascade 필요.
|
||||
- spec PR 우선 — design §4 search score scale 정의 추가.
|
||||
43
tasks/p9/p9-fb-39-retrieval-precision-tuning.md
Normal file
43
tasks/p9/p9-fb-39-retrieval-precision-tuning.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-search + kebab-rag + kebab-chunk
|
||||
task_id: p9-fb-39
|
||||
title: "Retrieval precision 튜닝 (rank 5+ 노이즈 완화)"
|
||||
status: open
|
||||
target_version: 0.5.0
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§3 chunking, §4 search, §7 RAG]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-06 — Claude Code 가 kebab CLI 사용 후 "rank 5+ 부터 노이즈 섞임" 지적. precision-at-k 가 k=5 이후 떨어짐.
|
||||
---
|
||||
|
||||
# p9-fb-39 — Retrieval precision 튜닝
|
||||
|
||||
> ⏳ **백로그 only — 미구현.** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. 어느 lever (chunk policy / RRF k / score gate / cross-encoder / embedding 업그레이드) 부터 손볼지, eval golden set 선행 여부 brainstorm 후 결정.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
- top-1~4 chunk 는 관련 있으나 5번째부터 무관 chunk 섞임. recall OK, precision-at-k 저하.
|
||||
- LLM 이 noise chunk 를 context 에 포함하면 답변 품질 저하 / hallucinate 위험.
|
||||
|
||||
## Goal (skeleton — brainstorm 단계에서 확정)
|
||||
|
||||
- top-k 결과의 precision 향상. 후보:
|
||||
1. chunk policy 재검토 (size / overlap / boundary).
|
||||
2. RRF k 파라미터 (현재 default 60) 재튜닝 또는 score gate threshold default ON.
|
||||
3. cross-encoder reranker PoC — top-N retrieve → rerank → top-k.
|
||||
4. embedding model 업그레이드 (fastembed default → 더 큰 / 한글 강한 모델).
|
||||
- 평가 지표: P@5, P@10, MRR, NDCG. P5 eval runner 활용.
|
||||
|
||||
## 후속 작업 — brainstorm 필요 항목
|
||||
|
||||
- 어느 lever 부터 손볼지 — 비용 / 효과 trade-off.
|
||||
- cross-encoder 도입 시 local-only 유지 가능한지 (fastembed cross-encoder 지원?).
|
||||
- embedding 변경이면 `embedding_version` cascade — 전체 재처리 필요.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- embedding_version bump = 전체 vector index 재구축. p9-fb-23 incremental ingest 와 충돌 가능.
|
||||
- cross-encoder 는 latency 증가 — 단일 사용자 local 환경에서 받아들일 수 있는지 확인.
|
||||
- eval golden set 부족하면 튜닝 불가 — golden set 확장 선행 필요할 수 있음.
|
||||
43
tasks/p9/p9-fb-40-fact-grounded-answer.md
Normal file
43
tasks/p9/p9-fb-40-fact-grounded-answer.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-rag + kebab-llm
|
||||
task_id: p9-fb-40
|
||||
title: "Fact-grounded answer 강화 (citation 강제 + 근거 없음 fallback)"
|
||||
status: open
|
||||
target_version: 0.5.0
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§7 RAG, prompt template]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-06 — Claude Code 가 kebab CLI 사용 후 "fact extraction 은 RAG 한계" 지적. fact 단위 질문에서 LLM 이 retrieved chunk 외 internal knowledge 로 답하거나 hallucinate.
|
||||
---
|
||||
|
||||
# p9-fb-40 — Fact-grounded answer 강화
|
||||
|
||||
> ⏳ **백로그 only — 미구현.** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. citation 강제 형식 / 검증 layer / "모름" fallback trigger / prompt_template_version cascade 영향 brainstorm 후 확정.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
- "X 의 정확한 값 / 날짜 / 숫자" 류 질문에서 LLM 이 retrieved chunk 의 fact 와 internal knowledge 충돌 시 internal 우세.
|
||||
- 근거 부족한 질문에도 LLM 이 그럴듯한 답 생성 — hallucinate.
|
||||
- RAG 본질적 한계지만 prompt / 검증 layer 로 완화 가능.
|
||||
|
||||
## Goal (skeleton — brainstorm 단계에서 확정)
|
||||
|
||||
- 답변의 모든 fact 가 retrieved chunk 안 span 으로 매핑되도록 강제.
|
||||
- 근거 부족 시 "모름" 답변 fallback.
|
||||
- citation 미포함 답변 거부 또는 경고.
|
||||
|
||||
## 후속 작업 — brainstorm 필요 항목
|
||||
|
||||
- prompt template 수정 — citation 강제 형식 (예: `[doc_id#L]` inline).
|
||||
- post-generation 검증 — 답변의 fact span 이 retrieved chunk 에 있는지 substring / fuzzy 매치.
|
||||
- "모름" fallback 의 trigger 조건 (top score gate, chunk count 등).
|
||||
- prompt_template_version cascade — bump 필요.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- 너무 strict 하면 정상 답변도 차단 — 경고만 / 거부의 trade-off.
|
||||
- post-generation 검증은 latency 증가.
|
||||
- prompt_template_version bump → eval re-run 필요.
|
||||
- p9-fb-15 (RAG multi-turn) 와 prompt 변경 영역 겹침 — 같은 batch 가능.
|
||||
41
tasks/p9/p9-fb-41-multi-hop-reasoning.md
Normal file
41
tasks/p9/p9-fb-41-multi-hop-reasoning.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-rag + kebab-search
|
||||
task_id: p9-fb-41
|
||||
title: "Multi-hop reasoning / query decomposition (P+, 큰 작업)"
|
||||
status: open
|
||||
target_version: 0.6.0+
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§7 RAG]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-06 — Claude Code 가 kebab CLI 사용 후 "추론 약함" 지적. RAG 가 chunk 독립 처리, multi-hop inference (A→B→C) 못 봄.
|
||||
---
|
||||
|
||||
# p9-fb-41 — Multi-hop reasoning / query decomposition
|
||||
|
||||
> ⏳ **백로그 only — 미구현 (P+, 큰 작업).** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. MVP 범위 / iteration 분할 / decomposition vs graph-retrieval 접근 선택 brainstorm 후 결정. 다른 fb 항목보다 우선순위 낮음.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
- 다단계 추론 질문 ("X 와 Y 의 공통 prerequisite 인 Z 는?") 에서 single-pass retrieval 로는 chunk 간 관계 못 읽음.
|
||||
- 사용자 질문을 sub-question 으로 분해 + 각각 retrieve + 결과 합성하면 답 가능.
|
||||
|
||||
## Goal (skeleton — brainstorm 단계에서 확정)
|
||||
|
||||
- query decomposition pipeline — LLM 이 사용자 질문을 sub-question N 개로 분해.
|
||||
- 각 sub-question 으로 separate retrieval → 결과 합성 → 최종 답변.
|
||||
- 또는 graph-based retrieval — chunk 간 link (citation, entity, doc 관계) 활용.
|
||||
|
||||
## 후속 작업 — brainstorm 필요 항목
|
||||
|
||||
- decomposition 의 trigger — 모든 질문에 적용 vs 사용자 명시 / heuristic 탐지.
|
||||
- LLM 호출 횟수 증가 → latency / cost. 단일 사용자 local 에서 acceptable 한지.
|
||||
- graph 구조면 SQLite 새 테이블 + parser 가 link 추출 — schema migration 필요.
|
||||
- evaluation — multi-hop golden set 추가 필요.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- 큰 작업 (XL). MVP 범위 / iteration 분할 brainstorm 단계 결정.
|
||||
- p9-fb-15 (multi-turn) 의 follow-up turn 으로 자연 분해되는 부분 있음 — overlap 검토.
|
||||
- 효과 측정 어려움 — eval golden set 없으면 체감 평가만 가능.
|
||||
41
tasks/p9/p9-fb-42-bulk-multi-query-rerank.md
Normal file
41
tasks/p9/p9-fb-42-bulk-multi-query-rerank.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-cli + kebab-search
|
||||
task_id: p9-fb-42
|
||||
title: "Bulk multi-query + re-rank hint — agent loop 효율"
|
||||
status: open
|
||||
target_version: 0.6.0+
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§4 search]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-06 — agent 가 N 개 query 동시 검색 / 결과 set 을 다른 관점으로 재정렬 원함. 현재는 N 회 subprocess 호출 + 단일 정렬 기준.
|
||||
---
|
||||
|
||||
# p9-fb-42 — Bulk multi-query + re-rank hint
|
||||
|
||||
> ⏳ **백로그 only — 미구현 (Nice-to-have).** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. multi-query input 형식 / 결과 합성 정책 / re-rank hint 의 LLM 호출 비용 brainstorm 후 확정.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
- agent 가 query decomposition (fb-41) 후 N 개 sub-query 검색 — N 회 subprocess fork.
|
||||
- 검색 결과 set 보고 "이 중 X 관점으로 다시 정렬" 요청 — 현재는 client 측에서 재호출.
|
||||
|
||||
## Goal (skeleton)
|
||||
|
||||
- `kebab search --queries '[{"q":"a","k":5},{"q":"b","k":5}]'` — bulk JSON input. response 는 query 별 결과 array.
|
||||
- `kebab search Q --rerank-hint "focus on X"` — top-N retrieve 후 LLM 재정렬 (cross-encoder 가능 시 selection).
|
||||
|
||||
## 후속 작업 — brainstorm 필요 항목
|
||||
|
||||
- bulk input 형식 — JSON array / ndjson stdin.
|
||||
- 결과 stream vs final — 큰 multi-query 면 stream 유리.
|
||||
- re-rank hint 의 LLM 모델 — kebab-llm 의 default 사용.
|
||||
- fb-39 (precision tuning) 의 cross-encoder 와 re-rank hint 통합 가능.
|
||||
- fb-29 daemon 위에서 더 의미 — subprocess overhead 이미 daemon 으로 해소되면 우선순위 낮음.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- Nice-to-have — fb-30 / 29 / 31 / 34 / 35 보다 우선순위 낮음.
|
||||
- re-rank hint 는 LLM 호출 추가 — latency / cost.
|
||||
- fb-39, fb-41 와 영역 겹침.
|
||||
Reference in New Issue
Block a user