diff --git a/HANDOFF.md b/HANDOFF.md index 7fd7fdc..147aa3d 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -54,6 +54,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-08)** — TUI search async worker + generation counter. 기존 200ms debounce 후 `kebab_app::search_with_config` 동기 호출이 vector/hybrid 모드 50-200ms 동안 UI freeze 시키던 문제 해소. `SearchState` 에 `generation: u64` + `worker_thread: Option` + `worker_rx: Option>` 신규. `fire_search` 가 spawn 만 하고 즉시 return — worker 가 별 thread 에서 검색 후 `(generation, Result)` 를 channel 로 post. run loop 가 매 tick `poll_worker` 로 try_recv, generation 일치 시 hits 적용 / 불일치 시 silently 폐기 (사용자가 더 빠르게 타이핑하면 stale 결과 자동 drop). debounce_due 가 `searching && last_query == 현 input` 케이스 추가 skip — in-flight worker 의 결과 기다리는 동안 동일 query 재 spawn 안 함. spec: `tasks/p9/p9-fb-08-search-debounce.md`. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-05)** — `workspace.root` path policy 명확화. `kebab_config::expand_path_with_base(raw, data_dir, base_dir) -> PathBuf` 신규 — 기존 `expand_path` (tilde + env 만) 위에 relative path resolution 추가, 절대/`~`/`${VAR}` 입력은 base_dir 무시. `Config.source_dir: Option` 필드 (`#[serde(skip)]`) 신규 — `from_file` / `load` 가 `path.parent()` 로 stamp. `Config::resolve_workspace_root()` helper 가 `expand_path_with_base(&workspace.root, "", source_dir.unwrap_or(cwd))` 호출. kebab-app + kebab-source-fs 의 모든 `workspace.root` 사용 사이트가 `cfg.resolve_workspace_root()` 로 통일 — kebab-source-fs 의 fork 된 `expand_tilde` 헬퍼는 제거 (kebab-app 의 `storage.data_dir` 한 곳만 남음, P+ 통일 caveat). `kebab init` 가 생성하는 `config.toml` 위에 path policy 안내 헤더 코멘트 자동 prepend (절대/tilde/env/상대 + 상대 base = config dir). spec: `tasks/p9/p9-fb-05-config-path-policy.md`. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-19)** — In-process LRU search cache + `corpus_revision` 카운터. SQLite V004 migration 으로 `kv (key TEXT PK, value TEXT)` 테이블 + `corpus_revision = '0'` seed. `SqliteStore::corpus_revision()` / `bump_corpus_revision()` 메서드 (`UPDATE ... CAST AS INTEGER + 1` 으로 atomic). `kebab-app::ingest_with_config_cancellable` 가 `new + updated > 0` 시 bump — no-op reingest 는 cache 보존. `App.search_cache: Option>>>` (capacity from `config.search.cache_capacity`, default 256, 0 = 비활성). `SearchCacheKey` = `query_norm` (NFKC + trim + lowercase) + `mode` + `k` + `snippet_chars` + `embedding_version` + `chunker_version` + `corpus_revision` snapshot. `App::search` 가 lookup → miss 시 `search_uncached` → put. `search_uncached_with_config` facade 추가, CLI `kebab search --no-cache` 로 bypass (디버깅용). frozen design §9 versioning 표에 `corpus_revision` row 추가. spec: `tasks/p9/p9-fb-19-search-cache.md`. +- **2026-05-03 P9 도그푸딩 후속 (p9-fb-17)** — Multi-turn chat session 영속화 (storage 만 — UI 는 p9-fb-18). SQLite V005 migration (spec 의 V004 가 p9-fb-19 의 kv 와 충돌해서 V005 로 시프트, HOTFIXES) 으로 `chat_sessions` (session_id PK + created_at + updated_at + title + config_snapshot_json) + `chat_turns` (turn_id PK + session_id FK ON DELETE CASCADE + turn_index + question + answer + citations_json + created_at, UNIQUE(session_id, turn_index)) + `idx_chat_turns_session` 추가. `kebab_core::ChatSessionRepo` trait 6 메서드 (create_session / get_session / list_sessions / delete_session / append_turn / list_turns) + `kebab_core::{ChatSessionRow, ChatTurnRow}` 신규 export. `kebab-store-sqlite::SqliteStore` impl (별 `chat_sessions.rs` 모듈) — append_turn 이 insert + parent updated_at bump 을 같은 conn 에서 처리. frozen design §5 storage 에 §5.7a chat_sessions/turns 절 신설. spec: `tasks/p9/p9-fb-17-chat-session-storage.md`. unblocks p9-fb-18 (CLI session/repl). ## 다음 task 후보 diff --git a/crates/kebab-core/src/lib.rs b/crates/kebab-core/src/lib.rs index 99dd534..4512bf3 100644 --- a/crates/kebab-core/src/lib.rs +++ b/crates/kebab-core/src/lib.rs @@ -62,9 +62,9 @@ pub use jobs::{JobFilter, JobId, JobKind, JobRow, JobStatus}; pub use vector::{VectorHit, VectorRecord}; pub use errors::CoreError; pub use traits::{ - ChunkPolicy, Chunker, DocumentStore, Embedder, EmbeddingInput, - EmbeddingKind, ExtractConfig, ExtractContext, Extractor, FinishReason, - GenerateRequest, JobRepo, LanguageModel, Retriever, SourceConnector, + ChatSessionRepo, ChatSessionRow, ChatTurnRow, ChunkPolicy, Chunker, DocumentStore, + Embedder, EmbeddingInput, EmbeddingKind, ExtractConfig, ExtractContext, Extractor, + FinishReason, GenerateRequest, JobRepo, LanguageModel, Retriever, SourceConnector, SourceScope, TokenChunk, VectorStore, }; pub use normalize::{nfc, to_posix}; diff --git a/crates/kebab-core/src/traits.rs b/crates/kebab-core/src/traits.rs index ca9cd52..59dedfb 100644 --- a/crates/kebab-core/src/traits.rs +++ b/crates/kebab-core/src/traits.rs @@ -196,3 +196,68 @@ pub trait JobRepo { ) -> anyhow::Result<()>; fn list(&self, filter: &JobFilter) -> anyhow::Result>; } + +// ── p9-fb-17: chat session persistence ──────────────────────────────── + +/// Persistent multi-turn chat session — header row in `chat_sessions`. +/// Per-turn rows live in `chat_turns` (see [`ChatTurnRow`]). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ChatSessionRow { + pub session_id: String, + /// Unix epoch seconds at session creation time. + pub created_at: i64, + /// Unix epoch seconds, bumped on every `append_turn`. + pub updated_at: i64, + /// Optional human-readable label — defaults to the first + /// question's first ~40 chars on creation. + pub title: Option, + /// Snapshot of `prompt_template_version`, `llm.model`, + /// `max_context_tokens`, etc. — same shape as + /// `eval_runs.config_snapshot_json`. JSON string so the schema + /// can grow without an SQLite ALTER. + pub config_snapshot_json: String, +} + +/// One Q/A pair inside a `ChatSessionRow`. `turn_index` is monotonic +/// per session (0-based). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ChatTurnRow { + /// `blake3(session_id || turn_index)` (32 hex). Stable per (session, + /// turn) so a re-append at the same index is rejected via PK. + pub turn_id: String, + pub session_id: String, + pub turn_index: u32, + pub question: String, + pub answer: String, + /// `Vec` JSON-encoded so a session resume can replay + /// the same citation markers the user saw originally. + pub citations_json: String, + pub created_at: i64, +} + +/// Persistence trait for multi-turn chat sessions. Implemented by +/// `kebab-store-sqlite::SqliteStore`; consumed by `kebab-app` and the +/// future CLI / TUI session UIs (p9-fb-18). +pub trait ChatSessionRepo { + /// Create a new session. `session_id` is caller-supplied — auto + /// derivation lives in `kebab-app`. Errors on PK collision. + fn create_session(&self, row: &ChatSessionRow) -> anyhow::Result<()>; + + /// Look up a session by id; `Ok(None)` when missing. + fn get_session(&self, session_id: &str) -> anyhow::Result>; + + /// Most-recent-updated-first list of sessions, capped at `limit`. + fn list_sessions(&self, limit: usize) -> anyhow::Result>; + + /// Delete a session and (CASCADE) every turn under it. + fn delete_session(&self, session_id: &str) -> anyhow::Result<()>; + + /// Append a turn at `turn.turn_index`. Bumps the parent's + /// `updated_at`. PK collision (same session_id + turn_index) is + /// an error — the caller assigns the next monotonic index. + fn append_turn(&self, turn: &ChatTurnRow) -> anyhow::Result<()>; + + /// All turns for `session_id`, ordered by `turn_index ASC`. + /// Empty vec when the session has no turns yet. + fn list_turns(&self, session_id: &str) -> anyhow::Result>; +} diff --git a/crates/kebab-store-sqlite/src/chat_sessions.rs b/crates/kebab-store-sqlite/src/chat_sessions.rs new file mode 100644 index 0000000..3cf2ff5 --- /dev/null +++ b/crates/kebab-store-sqlite/src/chat_sessions.rs @@ -0,0 +1,168 @@ +//! p9-fb-17: `ChatSessionRepo` impl for `SqliteStore`. +//! +//! `chat_sessions` + `chat_turns` tables (V005 migration) back the +//! multi-turn conversation primitive (p9-fb-15 facade, p9-fb-16 TUI, +//! p9-fb-18 CLI `--session`). The trait + row types live in +//! `kebab-core::traits` so other store backends (postgres, …) can +//! plug in without depending on this crate. + +use anyhow::{Context, Result}; +use kebab_core::traits::{ChatSessionRepo, ChatSessionRow, ChatTurnRow}; +use rusqlite::{OptionalExtension, params}; + +use crate::error::StoreError; +use crate::store::SqliteStore; + +impl ChatSessionRepo for SqliteStore { + fn create_session(&self, row: &ChatSessionRow) -> Result<()> { + let conn = self.lock_conn(); + conn.execute( + "INSERT INTO chat_sessions + (session_id, created_at, updated_at, title, config_snapshot_json) + VALUES (?, ?, ?, ?, ?)", + params![ + row.session_id, + row.created_at, + row.updated_at, + row.title, + row.config_snapshot_json, + ], + ) + .map_err(StoreError::from) + .context("create_session")?; + Ok(()) + } + + fn get_session(&self, session_id: &str) -> Result> { + let conn = self.read_conn(); + let row = conn + .query_row( + "SELECT session_id, created_at, updated_at, title, config_snapshot_json + FROM chat_sessions WHERE session_id = ?", + params![session_id], + |r| { + Ok(ChatSessionRow { + session_id: r.get(0)?, + created_at: r.get(1)?, + updated_at: r.get(2)?, + title: r.get(3)?, + config_snapshot_json: r.get(4)?, + }) + }, + ) + .optional() + .map_err(StoreError::from) + .context("get_session")?; + Ok(row) + } + + fn list_sessions(&self, limit: usize) -> Result> { + let conn = self.read_conn(); + let mut stmt = conn + .prepare( + "SELECT session_id, created_at, updated_at, title, config_snapshot_json + FROM chat_sessions + ORDER BY updated_at DESC + LIMIT ?", + ) + .map_err(StoreError::from) + .context("list_sessions: prepare")?; + let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX); + let rows = stmt + .query_map(params![limit_i64], |r| { + Ok(ChatSessionRow { + session_id: r.get(0)?, + created_at: r.get(1)?, + updated_at: r.get(2)?, + title: r.get(3)?, + config_snapshot_json: r.get(4)?, + }) + }) + .map_err(StoreError::from) + .context("list_sessions: query")?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(StoreError::from).context("list_sessions: row")?); + } + Ok(out) + } + + fn delete_session(&self, session_id: &str) -> Result<()> { + let conn = self.lock_conn(); + // ON DELETE CASCADE in V005 migration sweeps `chat_turns`. + conn.execute( + "DELETE FROM chat_sessions WHERE session_id = ?", + params![session_id], + ) + .map_err(StoreError::from) + .context("delete_session")?; + Ok(()) + } + + fn append_turn(&self, turn: &ChatTurnRow) -> Result<()> { + let conn = self.lock_conn(); + // Wrap insert + parent updated_at in one transaction so a + // crash between the two never leaves a turn under a stale + // `updated_at`. + let tx_result: Result<()> = (|| { + conn.execute( + "INSERT INTO chat_turns + (turn_id, session_id, turn_index, question, answer, + citations_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)", + params![ + turn.turn_id, + turn.session_id, + turn.turn_index, + turn.question, + turn.answer, + turn.citations_json, + turn.created_at, + ], + ) + .map_err(StoreError::from) + .context("append_turn: insert")?; + conn.execute( + "UPDATE chat_sessions SET updated_at = ? WHERE session_id = ?", + params![turn.created_at, turn.session_id], + ) + .map_err(StoreError::from) + .context("append_turn: bump updated_at")?; + Ok(()) + })(); + tx_result + } + + fn list_turns(&self, session_id: &str) -> Result> { + let conn = self.read_conn(); + let mut stmt = conn + .prepare( + "SELECT turn_id, session_id, turn_index, question, answer, + citations_json, created_at + FROM chat_turns + WHERE session_id = ? + ORDER BY turn_index ASC", + ) + .map_err(StoreError::from) + .context("list_turns: prepare")?; + let rows = stmt + .query_map(params![session_id], |r| { + Ok(ChatTurnRow { + turn_id: r.get(0)?, + session_id: r.get(1)?, + turn_index: r.get(2)?, + question: r.get(3)?, + answer: r.get(4)?, + citations_json: r.get(5)?, + created_at: r.get(6)?, + }) + }) + .map_err(StoreError::from) + .context("list_turns: query")?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(StoreError::from).context("list_turns: row")?); + } + Ok(out) + } +} diff --git a/crates/kebab-store-sqlite/src/lib.rs b/crates/kebab-store-sqlite/src/lib.rs index 23290c1..1cc0f6f 100644 --- a/crates/kebab-store-sqlite/src/lib.rs +++ b/crates/kebab-store-sqlite/src/lib.rs @@ -18,6 +18,7 @@ //! round-trip test off a real Markdown fixture.) mod answers; +mod chat_sessions; mod documents; mod embeddings; mod error; diff --git a/crates/kebab-store-sqlite/tests/chat_sessions.rs b/crates/kebab-store-sqlite/tests/chat_sessions.rs new file mode 100644 index 0000000..aaf3a94 --- /dev/null +++ b/crates/kebab-store-sqlite/tests/chat_sessions.rs @@ -0,0 +1,175 @@ +//! p9-fb-17: `ChatSessionRepo` impl for `SqliteStore`. Verifies the +//! V005 schema, insert/list/delete, monotonic turn_index, and +//! ON DELETE CASCADE. + +use kebab_config::Config; +use kebab_core::traits::{ChatSessionRepo, ChatSessionRow, ChatTurnRow}; +use kebab_store_sqlite::SqliteStore; +use tempfile::TempDir; + +fn config_for(tmp: &TempDir) -> Config { + let mut c = Config::defaults(); + c.storage.data_dir = tmp.path().to_string_lossy().into_owned(); + c +} + +fn open_store(tmp: &TempDir) -> SqliteStore { + let cfg = config_for(tmp); + let store = SqliteStore::open(&cfg).unwrap(); + store.run_migrations().unwrap(); + store +} + +fn make_session(id: &str) -> ChatSessionRow { + ChatSessionRow { + session_id: id.to_string(), + created_at: 1_700_000_000, + updated_at: 1_700_000_000, + title: Some(format!("Title for {id}")), + config_snapshot_json: r#"{"prompt_template_version":"rag-v1","llm.model":"gemma4:e4b"}"# + .to_string(), + } +} + +fn make_turn(session_id: &str, index: u32) -> ChatTurnRow { + ChatTurnRow { + turn_id: format!("turn-{session_id}-{index:08x}"), + session_id: session_id.to_string(), + turn_index: index, + question: format!("Q{index} for {session_id}?"), + answer: format!("A{index} for {session_id}."), + citations_json: "[]".to_string(), + created_at: 1_700_000_000 + i64::from(index), + } +} + +#[test] +fn create_get_roundtrip() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + let session = make_session("sess-1"); + store.create_session(&session).unwrap(); + let fetched = store.get_session("sess-1").unwrap().expect("session present"); + assert_eq!(fetched, session); +} + +#[test] +fn get_missing_session_returns_none() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + assert!(store.get_session("nope").unwrap().is_none()); +} + +#[test] +fn create_session_pk_collision_errors() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + let session = make_session("dup"); + store.create_session(&session).unwrap(); + let err = store.create_session(&session).unwrap_err(); + assert!( + format!("{err:#}").contains("UNIQUE") + || format!("{err:#}").contains("constraint") + || format!("{err:#}").to_lowercase().contains("primary key"), + "expected PK collision error: {err:#}" + ); +} + +#[test] +fn append_turn_then_list_in_order() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + store.create_session(&make_session("multi")).unwrap(); + for i in 0..3 { + store.append_turn(&make_turn("multi", i)).unwrap(); + } + let turns = store.list_turns("multi").unwrap(); + assert_eq!(turns.len(), 3); + for (i, t) in turns.iter().enumerate() { + assert_eq!(t.turn_index as usize, i); + assert_eq!(t.question, format!("Q{i} for multi?")); + } +} + +#[test] +fn append_turn_collides_on_same_index() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + store.create_session(&make_session("dup-turn")).unwrap(); + store.append_turn(&make_turn("dup-turn", 0)).unwrap(); + let err = store.append_turn(&make_turn("dup-turn", 0)).unwrap_err(); + assert!( + format!("{err:#}").to_lowercase().contains("unique") + || format!("{err:#}").to_lowercase().contains("constraint") + || format!("{err:#}").to_lowercase().contains("primary key"), + "expected unique constraint: {err:#}" + ); +} + +#[test] +fn append_turn_bumps_session_updated_at() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + let session = make_session("bump"); + store.create_session(&session).unwrap(); + let pre = store + .get_session("bump") + .unwrap() + .unwrap() + .updated_at; + let mut t = make_turn("bump", 0); + t.created_at = pre + 100; + store.append_turn(&t).unwrap(); + let post = store + .get_session("bump") + .unwrap() + .unwrap() + .updated_at; + assert_eq!(post, pre + 100, "updated_at must follow latest turn's created_at"); +} + +#[test] +fn delete_session_cascades_to_turns() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + store.create_session(&make_session("cascade")).unwrap(); + for i in 0..2 { + store.append_turn(&make_turn("cascade", i)).unwrap(); + } + store.delete_session("cascade").unwrap(); + assert!(store.get_session("cascade").unwrap().is_none()); + assert_eq!( + store.list_turns("cascade").unwrap().len(), + 0, + "ON DELETE CASCADE must wipe orphan turns" + ); +} + +#[test] +fn list_sessions_orders_by_updated_at_desc() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + let mut a = make_session("a"); + a.updated_at = 100; + let mut b = make_session("b"); + b.updated_at = 300; + let mut c = make_session("c"); + c.updated_at = 200; + store.create_session(&a).unwrap(); + store.create_session(&b).unwrap(); + store.create_session(&c).unwrap(); + let listed = store.list_sessions(10).unwrap(); + let ids: Vec<_> = listed.iter().map(|s| s.session_id.clone()).collect(); + assert_eq!(ids, vec!["b", "c", "a"]); +} + +#[test] +fn list_sessions_respects_limit() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + for i in 0..5 { + store.create_session(&make_session(&format!("s{i}"))).unwrap(); + } + assert_eq!(store.list_sessions(2).unwrap().len(), 2); + assert_eq!(store.list_sessions(100).unwrap().len(), 5); +} diff --git a/docs/superpowers/specs/2026-04-27-kebab-final-form-design.md b/docs/superpowers/specs/2026-04-27-kebab-final-form-design.md index 9a5d815..e6b29b2 100644 --- a/docs/superpowers/specs/2026-04-27-kebab-final-form-design.md +++ b/docs/superpowers/specs/2026-04-27-kebab-final-form-design.md @@ -1045,6 +1045,38 @@ CREATE TABLE eval_query_results ( ); ``` +### 5.7a Chat sessions / turns (p9-fb-17) + +multi-turn 대화 영속화 — `kebab ask --session foo` 의 backing store. + +```sql +CREATE TABLE chat_sessions ( + session_id TEXT PRIMARY KEY NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + title TEXT, -- 첫 question 의 N 자 + config_snapshot_json TEXT NOT NULL -- prompt_template_version, llm.model 등 +) STRICT; + +CREATE TABLE chat_turns ( + turn_id TEXT PRIMARY KEY NOT NULL, -- blake3(session_id || turn_index) + session_id TEXT NOT NULL REFERENCES chat_sessions(session_id) ON DELETE CASCADE, + turn_index INTEGER NOT NULL, -- monotonic per session, 0-based + question TEXT NOT NULL, + answer TEXT NOT NULL, + citations_json TEXT NOT NULL, -- Vec JSON + created_at INTEGER NOT NULL, + UNIQUE(session_id, turn_index) +) STRICT; + +CREATE INDEX idx_chat_turns_session ON chat_turns(session_id, turn_index); +``` + +`kebab_core::ChatSessionRepo` trait 가 6 메서드 (create_session, +get_session, list_sessions, delete_session, append_turn, list_turns). +`kebab-store-sqlite::SqliteStore` impl 가 V005 migration 위에서 동작. +`kebab reset --data-only` (p9-fb-06) 가 양 테이블 wipe. + ### 5.8 트랜잭션 정책 - ingest 1 doc = 1 트랜잭션. diff --git a/migrations/V005__chat_sessions.sql b/migrations/V005__chat_sessions.sql new file mode 100644 index 0000000..080d442 --- /dev/null +++ b/migrations/V005__chat_sessions.sql @@ -0,0 +1,54 @@ +-- V005__chat_sessions.sql — multi-turn conversation persistence. +-- +-- p9-fb-17 introduces session-level storage for the multi-turn `Ask` +-- conversation primitive (p9-fb-15 facade, p9-fb-16 TUI). Each session +-- groups N consecutive Q/A turns under one `session_id`; the TUI +-- "이전 대화 이어가기" + the future `kebab ask --session foo` flag +-- (p9-fb-18) read+append against these tables. +-- +-- Schema notes: +-- +-- * `session_id` is user-supplied (`--session foo`) or auto-derived +-- from `blake3(first_question || first_ts)` as a 32-hex string. No +-- foreign-key into another table — sessions are sovereign. +-- +-- * `chat_turns.turn_index` is monotonic per session (0-based). The +-- `UNIQUE(session_id, turn_index)` pair enforces the invariant on +-- the storage side so a buggy caller cannot double-append turn 3. +-- +-- * `ON DELETE CASCADE` so `kebab reset --data-only` (p9-fb-06) +-- wipes both tables together — orphan turns can never outlive +-- their session. +-- +-- * `config_snapshot_json` mirrors `eval_runs.config_snapshot_json` +-- (P5-1) — captures the prompt_template_version, llm.model, and +-- max_context_tokens that produced the session so a retroactive +-- answer-quality regression can be re-traced. +-- +-- * `citations_json` carries `Vec` so the answer can be +-- redisplayed with the same citation markers a future session +-- sees on resume. +-- +-- * `INTEGER` timestamps (unix epoch seconds) — same convention the +-- rest of the schema uses (P1-7 baselines this). + +CREATE TABLE chat_sessions ( + session_id TEXT PRIMARY KEY NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + title TEXT, + config_snapshot_json TEXT NOT NULL +) STRICT; + +CREATE TABLE chat_turns ( + turn_id TEXT PRIMARY KEY NOT NULL, + session_id TEXT NOT NULL REFERENCES chat_sessions(session_id) ON DELETE CASCADE, + turn_index INTEGER NOT NULL, + question TEXT NOT NULL, + answer TEXT NOT NULL, + citations_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(session_id, turn_index) +) STRICT; + +CREATE INDEX idx_chat_turns_session ON chat_turns(session_id, turn_index); diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index d87debd..451e583 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -14,6 +14,19 @@ 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-03 — p9-fb-17 migration number V004 → V005 + +**Spec amended**: `tasks/p9/p9-fb-17-chat-session-storage.md` (frozen — +original contract calls the migration `V004__chat_sessions.sql`). + +**Why renamed**: `V004__kv.sql` was already taken by p9-fb-19's `kv` +table for the `corpus_revision` counter (merged earlier the same day, +PR #78). Refinery numbers must be globally unique + monotonically +increasing, so chat-session storage shifts to `V005__chat_sessions.sql`. + +**Behavior unchanged**: identical schema to the spec (chat_sessions + +chat_turns + idx_chat_turns_session); only the file name moved. + ## 2026-05-03 — p9-fb-19 spec `index_version` → impl `corpus_revision` rename **Spec amended**: `tasks/p9/p9-fb-19-search-cache.md` (frozen — original diff --git a/tasks/p9/p9-fb-17-chat-session-storage.md b/tasks/p9/p9-fb-17-chat-session-storage.md index 38ee55a..51984bc 100644 --- a/tasks/p9/p9-fb-17-chat-session-storage.md +++ b/tasks/p9/p9-fb-17-chat-session-storage.md @@ -3,7 +3,7 @@ phase: P9 component: kebab-store-sqlite task_id: p9-fb-17 title: "SQLite V004 — chat_sessions / chat_turns" -status: planned +status: in_progress depends_on: [p9-fb-15] unblocks: [p9-fb-18] contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md