功能规范:Knowledge Base(重新设计)
English: spec.md
Feature Branch: feature/kb-memory-redesignCreated: 2026-06-09 Status: Accepted (redesign — in development) Input: 对 knowledge_base 资源 kind 的彻底重新设计。一个 Knowledge Base 是共享知识基底(knowledge substrate)的一张「面」:用户上传任意格式的文件,Coffer 清洗并归一化为磁盘上的 Markdown(真相源),再以三种检索模式(grep、keyword、vector)提供回检索。SQLite 仅是可重建的索引。文档由人与 agent 共管(ADR-028):双方都通过 Coffer 的 MCP 网关读 和 写,每次写入都被审计(F01)。基底依据见 ADR-012,架构见 .specify/memory/constitution.md。
用户场景与测试
User Story 1 —— 用任意格式的文件建一个 KB(优先级 P1)
某开发者手里有设计笔记、ADR、内部 wiki、论文 PDF、一个表格、若干 HTML 页面。他不管格式,把它们一股脑丢进 Coffer。Coffer 把每个文件转换为干净的 Markdown,保留原件作为溯源,并对结果建索引,让 agent 可以从中检索。
为什么是这个优先级:这是本规范的核心。没有它就没有知识库。
独立可测:从全新安装开始,创建 KB design-notes,上传一个 .md、一个 .pdf、一个 .docx、一个 .csv;观察每个文件都在 ~/.coffer/knowledge/design-notes/docs/ 下变成一个 Markdown 文件,原件落在 raw/,并在统一的 documents 表里产生一行。
代表性场景:create a knowledge base;ingest converts any format to markdown;list documents in a knowledge base;delete a single document;delete a knowledge base cleans up files and index。
User Story 2 —— 三种模式检索(优先级 P1)
同一份语料在底层用多种方式检索:grep(对 Markdown 文件做精确/正则匹配,零索引)、keyword(SQLite FTS5 + BM25)、vector(sqlite-vec 配合已配置的 embedding provider),以及 hybrid(对 keyword + vector 做 reciprocal rank fusion,使精确/CJK/标识符命中与释义命中相互增强)。这些是引擎内部细节:外部调用方不选择模式——一次检索自动解析 KB 的默认策略(启用 vector 时为 hybrid,否则为 keyword)。grep 是一个独立的工具/端点(仅供 agent;已从人用 web 面板移除)。请求时若未配置 embedding provider,在内部回退到 keyword —— 绝不阻断,也无查询期标志(降级通过 documents_degraded 指标体现)。
为什么是这个优先级:检索就是产品。内部模式覆盖了从离线零配置到语义检索的全谱,而外部界面保持「一次查询 → 一个答案」。
独立可测:在已填充的 KB 上跑一次检索(无模式)和一次 grep 查询(两者都无需 embedding 配置即可工作);配置 embedding provider,在启用 vector 的 KB 上再检索一次;移除配置,再检索一次,观察结果仍返回且无报错(降级通过 documents_degraded 体现,而非逐查询标志)。
代表性场景:keyword search returns ranked passages;grep returns file/line matches;vector search returns ranked passages;vector falls back to keyword when embedding unconfigured;hybrid search fuses keyword and vector via RRF。
User Story 3 —— agent 通过 MCP 网关读和写(优先级 P1)
开发者的编码 agent 连到 Coffer 的 MCP 端点,拿到内置的 KB 工具:读工具(列出 KB、检索、grep、读取完整文档)与 写工具(新增文档、编辑文档、删除文档)。文档由人与 agent 共管:agent 可以与人类一起贡献与策展。每次 agent 写入都以 agent 为 actor 被审计(F01),走人类写入所用的同一套幂等 re-index 例程。
为什么是这个优先级:agent 侧检索正是让 KB 在编码过程中有用的关键;agent 侧策展让它成为活的、共管的知识库而非静态库(ADR-028)。
独立可测:在已填充的 KB 上,MCP 客户端能看到 coffer__list_knowledge_bases、coffer__search_knowledge、coffer__grep_knowledge、coffer__read_document、coffer__add_document、coffer__edit_document、coffer__delete_document;调用 coffer__add_document 创建一个可检索的文档。
代表性场景:built-in KB tools appear in client tool list;agent searches a knowledge base;agent greps a knowledge base;agent reads a document;agent adds a document via MCP;agent edits a document via MCP;agent deletes a document via MCP。
User Story 4 —— 策展语料:编辑、重建索引、重新 embedding(优先级 P2)
用户在自己的外部编辑器中打开文档的 Markdown(或通过编辑 API)修掉一处转换瑕疵,改动随后被拾取。他以同名重新上传某文件的更新版本,Coffer 就地更新同一文档(稳定 ULID id——不产生重复)。他改动 chunk 参数或 embedding 模型,Coffer 重新索引 / 重新 embedding 整个语料。全局唯一的 embedding 模型在「设置 → 向量模型」中通过添加 / 编辑弹窗配置(只能有一个模型,弹窗内含拉取模型与测试连接);由于更换模型会重新 embedding 所有库,UI 会先弹确认,而启用开关则放在分块默认值旁边。Coffer UI 以只读方式渲染 Markdown——它从不提供应用内文本编辑器——而是提供在外部编辑器中打开文档(或其所在文件夹)、在文件管理器 / 访达中显示等操作(Web 上经 daemon,桌面上为原生)。一旦某文档被编辑(source_mode = edited),就禁止从原始 raw 重新转换,以免覆盖编辑。
为什么是这个优先级:KB 是随时间策展的;一次性 ingest 不够。但它不是展示核心价值所必需。
独立可测:通过编辑 API(或在外部编辑器中编辑磁盘上的文件)编辑某文档的 Markdown,确认下一次读取 / 检索经由读取时惰性重建索引(lazy reindex-on-read)反映了编辑;以 replace=true 重新上传同一文件的变更版本,观察同一 doc id 被就地更新;改动 KB 的 chunk size,确认语料被重新切块并重新索引。
代表性场景:edit a document and reindex;external edit picked up by reindex-on-read;re-conversion blocked once edited;re-upload of an updated file updates the document in place;re-upload of an identical file is a no-op;changing chunk params re-indexes;changing embedding model re-embeds。
User Story 5 —— 在桌面与 CLI 管理,并观测(优先级 P2)
用户在桌面 UI 的 Resources 下、以及通过 coffer kb … 子命令管理 KB,并查看每个 KB 的指标(文档数、chunk 数、磁盘占用、已建索引的模式,以及因 embedding 服务暂不可用而向量嵌入待重试的文档数)。
为什么是这个优先级:非 CLI 用户和脚本化都需要它;但不阻塞核心流。
独立可测:在 UI 里创建一个 KB,拖入文件,检索;在终端 ingest 一个目录、grep、以 JSON 读取指标。
代表性场景:KB metrics report counts and disk usage;degraded embed surfaces documents_degraded and retries without re-chunking;(UI / CLI 流程延后到 e2e —— 见末尾说明)。
边界情况
- 不支持的格式:某文件类型没有对应转换器时,以
IngestRejected("unsupported_type")拒绝;不持久化任何东西。 - 转换库缺失:某格式的转换引擎未安装时,该格式的 ingest 返回
EngineUnavailable并指明缺失的依赖;daemon 不挂,其他格式照常 ingest。 - 转换为空:转换后得到空白 / 仅空白字符的 Markdown 时,以
IngestRejected("empty")拒绝。若是 PDF 转换为空,则以更具体的IngestRejected("scanned_pdf")(同为 415 状态)拒绝,以便 UI 呈现可操作的「看起来是扫描件 / 纯图片——请运行 OCR」消息,而非通用消息。 - 文件过大:超过
max_document_bytes(默认 25 MB)的文件在 API 边界、任何转换运行之前被拒绝。 - 重新上传,字节完全相同:字节未变(其
source_sha256与该文件名下已存文档相同)的重新上传是幂等 no-op——返回既有文档,不重写也不重新审计。 - 重新上传,内容变化,同名:以 KB 中已有的文件名重新上传更新后的文件,就地更新同一文档(复用 ULID id,覆盖
docs/+raw/,只保留最新一份原件,source_mode重置为converted)——但仅当调用方传replace=true;否则以duplicate拒绝,使覆盖始终显式。 - vector 不可用、未配置 embedding:检索在内部回退到 keyword 并正常返回结果、不报错;降级不作为查询期标志暴露(通过
documents_degraded指标体现)。 - 编辑后重新转换:对
source_mode == edited的文档请求重新转换会被拒绝;以replace=true重新上传变更后的源会就地更新它并重置为converted。 - 对未变内容重建索引:对 Markdown 的
content_sha256未变的文档重建索引是 no-op。 - 并发检索:对同一个 KB 的多次检索各自独立运行;没有 per-KB 锁拖慢读延迟。
- 被跟踪的源被移动 / 删除:当某文档被跟踪的外部
source_path被移动或删除时,check_sources把它报告为missing且绝不崩溃;source_path是机器本地的,因此(同步到)另一台机器上得到missing是预期且无害的。
Acceptance Scenarios
依据 agents/sdd.md 与 agents/testing.md,本节每个场景都被至少一个带 @pytest.mark.acceptance(spec="006-knowledge-base", scenario="…") 标记的测试引用。
Scenario: create a knowledge base
- Given the daemon is running and no knowledge bases are registered,
- When the user creates a KB with a unique name and a retrieval config,
- Then the KB is persisted,
~/.coffer/knowledge/<name>/docs/andraw/are created, and listing KBs shows it.
Scenario: ingest converts any format to markdown
- Given a knowledge base exists,
- When the user uploads a non-Markdown file (e.g.
.pdf,.docx,.csv,.html), - Then Coffer converts it to Markdown at
docs/<doc-id>.md(with YAML frontmatter), preserves the original atraw/<doc-id>.<ext>, inserts adocumentsrow (kind="knowledge_base",source_mode="converted"), chunks it into FTS5, and records auditKB_DOCUMENT_INGESTED.
Scenario: list documents in a knowledge base
- Given documents have been ingested,
- When the user lists documents,
- Then they see one row per document with stable doc ids, titles, original filenames, and timestamps, paginated.
Scenario: filter documents by title
- Given a KB with multiple documents,
- When the user lists documents with a title query
q, - Then only documents whose title contains
q(case-insensitive) are returned,totalreflects the filtered count, and results stay paginated bylimit/offset.
Scenario: keyword search returns ranked passages
- Given documents are indexed,
- When the user searches (no mode; Coffer uses the KB's resolved default strategy),
- Then they receive passages ranked by
bm25(), each carrying its source doc id, title, snippet, and score.
Scenario: keyword search matches CJK (Chinese) content
- Given a knowledge base with a document whose Markdown body is Chinese (no word-boundary spaces),
- When the user runs a keyword search with a CJK query,
- Then a multi-character query (e.g.
向量检索) matches via the FTS5 trigram index, and a short< 3-character query (e.g.向量) matches via the substring fallback — neither returns empty as the oldunicode61tokenizer did.
Scenario: grep returns file/line matches
- Given documents are on disk,
- When the user greps the KB with a pattern,
- Then Coffer runs ripgrep over
docs/(bounded by max-matches and a timeout) and returns{path, line_number, line}hits with no index involved.
Scenario: vector search returns ranked passages
- Given the KB has an embedding provider configured and documents embedded,
- When the engine runs a vector search (a vector-enabled KB's resolved default),
- Then Coffer embeds the query, runs a sqlite-vec KNN, and returns top-k passages with similarity scores.
Scenario: vector falls back to keyword when embedding unconfigured
- Given the KB has no embedding provider configured,
- When the engine resolves to a vector strategy but no embedder is available,
- Then Coffer runs a keyword search instead and returns results with no error; the degradation is NOT surfaced as a query-time flag (it is reported via
documents_degraded).
Scenario: hybrid search fuses keyword and vector via RRF
- Given a KB with vector enabled and documents embedded,
- When the engine fuses keyword+vector for a vector-enabled KB (its resolved default),
- Then Coffer runs BOTH keyword and vector searches and fuses them by reciprocal rank fusion (
K = 60, deduped by(document_id, position)), so a passage ranked by both lists outranks single-list hits, and returns the fused top-k.
Scenario: edit a document and reindex
- Given a converted document exists,
- When the user replaces its Markdown body through the edit API,
- Then
source_modebecomesedited, the single re-index routine deletes old chunks/FTS5/vec rows and re-chunks (re-embedding if vector is enabled), and subsequent search reflects the edit.
Scenario: external edit picked up by reindex-on-read
- Given a document whose Markdown file is edited out-of-band in the user's external editor (no API call),
- When the user next reads or searches that document,
- Then the lazy reindex-on-read scan detects the drifted
content_sha256, re-indexes through the single idempotent routine, and the read/search reflects the edit — with no filesystem watcher running.
Scenario: re-conversion blocked once edited
- Given a document whose
source_mode == edited, - When the user requests re-conversion from the raw original,
- Then Coffer rejects it with a clear error; re-uploading a new source file resets
source_modetoconverted.
Scenario: changing chunk params re-indexes
- Given a KB with indexed documents,
- When the user changes
chunk_sizeorchunk_overlap, - Then Coffer re-chunks and re-indexes the corpus (and re-embeds if vector is enabled) — chunk params are mutable, not locked.
Scenario: changing embedding model re-embeds
- Given a KB with vector indexing enabled and an embedding model set,
- When the user changes the embedding model,
- Then Coffer re-embeds the corpus into sqlite-vec — the embedding model is mutable, not locked.
Scenario: delete a single document
- Given a KB has documents,
- When the user deletes one document by id,
- Then the
docs/<doc-id>.mdandraw/<doc-id>.<ext>files are removed, its chunks/FTS5/vec rows are deleted, thedocumentsrow is removed, auditKB_DOCUMENT_DELETEDis recorded, and search no longer returns it.
Scenario: delete a knowledge base cleans up files and index
- Given a KB has documents and an index,
- When the user deletes the KB,
- Then all of its
documents/chunks/FTS5/vec rows are removed,~/.coffer/knowledge/<name>/is removed, and the Resource row is deleted.
Scenario: built-in KB tools appear in client tool list
- Given an MCP client connects to Coffer's gateway,
- When it lists tools,
- Then the read tools
coffer__list_knowledge_bases,coffer__search_knowledge,coffer__grep_knowledge,coffer__read_documentAND the write toolscoffer__add_document,coffer__edit_document,coffer__delete_documentare present (documents are co-managed — ADR-028).
Scenario: agent searches a knowledge base
- Given a KB with indexed documents,
- When the client calls
coffer__search_knowledge(kb, query, top_k?), - Then Coffer returns ranked passages structured for LLM consumption (passage + source doc id + score).
Scenario: agent greps a knowledge base
- Given a KB with documents on disk,
- When the client calls
coffer__grep_knowledge(kb, pattern), - Then Coffer returns file/line matches.
Scenario: agent reads a document
- Given a document exists in a KB,
- When the client calls
coffer__read_document(kb, doc_id), - Then Coffer returns the document's Markdown body and frontmatter, or a clear error if the id is unknown.
Scenario: agent adds a document via MCP
- Given a knowledge base exists,
- When the client calls
coffer__add_document(kb, filename, content)with Markdown content, - Then Coffer ingests it like a human upload (a new ULID-id document,
docs/+raw/written, indexed), records auditKB_DOCUMENT_INGESTEDwith the agent as actor, and the document is searchable.
Scenario: agent edits a document via MCP
- Given a converted document exists,
- When the client calls
coffer__edit_document(kb, doc_id, content), - Then the body is replaced,
source_modebecomesedited, the corpus is reindexed, and auditKB_DOCUMENT_UPDATEDis recorded with the agent as actor.
Scenario: agent deletes a document via MCP
- Given a document exists in a KB,
- When the client calls
coffer__delete_document(kb, doc_id), - Then the document's files and index rows are removed, audit
KB_DOCUMENT_DELETEDis recorded with the agent as actor, and search no longer returns it.
Scenario: re-upload of an updated file updates the document in place
- Given a document ingested from
report.md, - When the user re-uploads a changed
report.mdwithreplace=true, - Then the SAME document id is updated in place (raw + markdown overwritten, only the latest original kept,
source_modereset toconverted), no second document is created, and auditKB_DOCUMENT_UPDATEDis recorded.
Scenario: re-upload of an identical file is a no-op
- Given a document ingested from
report.md, - When the user re-uploads the byte-identical
report.md, - Then it is an idempotent no-op: the existing document is returned, no second document is created, and no
KB_DOCUMENT_UPDATEDaudit is recorded.
Scenario: KB metrics report counts and disk usage
- Given a KB has documents,
- When the user opens its detail view (UI or
coffer kb describe), - Then they see document count, chunk count, the indexed retrieval modes, the count of documents with a pending vector embed (
documents_degraded), and the on-disk byte size ofknowledge/<name>/.
Scenario: degraded embed surfaces documents_degraded and retries without re-chunking
- Given a vector-enabled KB whose embedding provider is unavailable when a document is ingested,
- When the document is indexed keyword-only and the user later reads the KB (list / search / metrics) with the provider still down, then again once it is restored,
- Then the document carries its real
content_sha256and a persistedembed_pendingflag,documents_degradedreports1on the degraded read, and the next reconcile retries only the embed (no re-chunk / FTS rewrite — the chunk rows are unchanged), clearingembed_pendingsodocuments_degradedreturns to0.
Scenario: check sources detects changed, unchanged, and missing originals
- Given documents ingested from external files (their absolute
source_pathrecorded in metadata), - When the user runs
check_sourcesafter one original is edited on disk, one is left untouched, and one is deleted, - Then the report classifies them as
changed,unchanged, andmissingrespectively (by re-hashing each external file against the storedsource_sha256), and nothing is re-indexed or audited by the detection itself.
Scenario: update from source refreshes a changed document in place
- Given a converted document whose external
source_pathoriginal has changed on disk, - When the user runs
update_from_sourcefor that document, - Then Coffer re-ingests it from the tracked file in place (same ULID id,
source_modestaysconverted), the new content is searchable and the old content is gone, andKB_DOCUMENT_UPDATEDis audited.
Scenario: update from source refuses an edited document
- Given a document whose
source_mode == edited, - When the user runs
update_from_sourcefor it, - Then Coffer refuses with the re-conversion-blocked error (hand edits are never clobbered), and
check_sourcesreports that document aseditedrather than overwriting it.
Scenario: auto_update_sources refreshes changed sources on check
- Given a KB with
auto_update_sourcesenabled and a document whose external original has changed, - When the user runs
check_sources, - Then the changed document is auto-refreshed in place (reported
updated) andKB_DOCUMENT_UPDATEDis audited, while a hand-edited changed document would be skipped (reportededited).
Scenario: test an embedding model
- Given an embedding provider, model id, and (where required) credential ref,
- When the user tests the embedding model,
- Then Coffer requests one embedding and reports success with the returned vector dimension, or a humanized failure message, without persisting anything.
延后到未来的测试工作(frontend Playwright + 全 CLI e2e):通过桌面 app 创建 / 上传 / 检索 / 删除 KB;CLI 覆盖每一个桌面操作;CLI 检索 / grep 返回机器可读 JSON。此处列出仅为完整性;
make verify-acceptance不对其门禁。
Requirements
Functional Requirements
Resource lifecycle
- FR-001: System MUST support the resource kind
knowledge_baseon the shared knowledge substrate; users MUST create, list, view, update (description + retrieval config), enable, disable, and delete KBs through the kind-agnostic Resource framework. - FR-002: System MUST validate each KB's config (enabled retrieval modes, chunk size/overlap, embedding provider/model/base_url/credential_ref) against a Pydantic schema by
kind, reject duplicate names, and persist nothing on failure. - FR-003: System MUST store each KB under
~/.coffer/knowledge/<name>/with normalized Markdown atdocs/<doc-id>.md(source of truth) and the original atraw/<doc-id>.<ext>(provenance). There are NO per-corpusindex//chroma/directories — all indexing lives incoffer.db.
Ingestion & conversion
- FR-004: Users MUST be able to upload a file of any supported format; the system MUST detect format, convert to Markdown via a pluggable
MarkdownConverterport, clean the output, prepend YAML frontmatter, writedocs/+raw/, and index it. - FR-005: Conversion MUST dispatch through a per-format converter registry confined to
infrastructure/: Markdown/text/source files pass through unchanged,csvhas a dedicated converter, and everything else (pdf / docx / pptx / xlsx / xls / html / epub / …) goes through the default MarkItDown engine. Formats MarkItDown has no converter for (legacy binary.doc/.ppt,.rtf,.odt) are rejected asunsupported_type, not advertised. A higher-fidelity engine for a format is a new converter in the registry, not a substrate change. - FR-006: System MUST reject files over
max_document_bytes(default 25 MB, configurable), files of unsupported type, and files whose conversion yields empty Markdown. - FR-007: Each document MUST be identified by a stable ULID minted at first ingest (not a content hash). The system MUST compute
source_sha256of the original (kept inmetadataas provenance) and match a re-upload to an existing document byoriginal_filenamewithin the store: a byte-identical re-upload is an idempotent no-op; a changed re-upload of a filename already present updates the same document in place (reuse the id) only whenreplace=true, otherwise it is rejected (duplicate); a new filename is a new document. Re-uploading the SAME file (same name) into two different KBs yields two independent documents (KB documents are not deduplicated across stores).
Storage as source of truth
- FR-008: Markdown files MUST be the sole source of truth; SQLite (
documents,chunks, FTS5, sqlite-vec) is a derived, rebuildable index. A reindex routine MUST be able to reconstruct all SQLite state from the files. - FR-008a: The KB MUST use lazy reindex-on-read: a read or search first detects on-disk drift by
content_sha256and reconciles the index through the single idempotent re-index routine (FR-016) before serving, so out-of-band edits — including edits made in the user's external editor — are visible immediately with no filesystem watcher running. - FR-009: System MUST use one unified
documentstable shared with thememorykind, discriminated bykindand a per-face JSONmetadatacolumn. There is nokb_documentstable.
Retrieval
- FR-010: Users MUST be able to search a KB and receive ranked passages (passage text + source doc id + title + score). Retrieval is automatic: the caller does NOT choose a mode; Coffer resolves the strategy internally (
hybridwhenvectoris enabled, elsekeyword). Defaulttop_kis 5; callers MAY settop_kin 1–20. - FR-010a: Listing documents MUST support an optional case-insensitive title filter
q, applied server-side BEFORE pagination;totalreflects the filtered count. - FR-011: System MUST support four retrieval modes:
grep(ripgrep overdocs/, bounded by max-matches + timeout, no index),keyword(FTS5MATCHordered bybm25()),vector(sqlite-vec KNN over embeddings), andhybrid(reciprocal rank fusion ofkeyword+vector, ADR-012). Default enabled modes arekeyword+grep;vectoris opt-in, and enablingvectoralso enableshybrid(which fuses both lists). Grep responses carry atruncatedflag that is true when matches beyondmax_matchesexist OR the server-side timeout cut the scan short (a timed-out grep returns no hits withtruncated=true, and thergprocess is killed). The keyword index uses an FTS5 trigram tokenizer so CJK and substring queries match —unicode61does not segment CJK text (no word-boundary spaces), so a query like向量检索returned nothing; a query with no token of ≥ 3 characters (e.g. a 2-character CJK term) falls back to a bounded substring (LIKE) scan rather than returning empty. These four modes are an INTERNAL engine detail — external surfaces (REST / MCP / CLI / UI) do NOT expose mode selection; a search always uses the KB's resolved default strategy, andgrepis a separate tool/endpoint (removed from the human web panel, still available to agents). - FR-011b:
hybridmode MUST run BOTHkeywordandvectorsearches and fuse them by reciprocal rank fusion (RRF): each passage's fused score isΣ_over_lists 1/(K + rank)withK = 60andrankthe 0-based position in that list; passages are deduped by chunk identity(document_id, position)so a passage in both lists sums both contributions and outranks single-list hits. The top-k by fused score are returned. This delivers the "optional hybrid via reciprocal rank fusion" promised by ADR-012. (Internal —hybridis the resolved default whenvectoris enabled, never an external mode parameter.) - FR-012: When
vector(orhybrid) is the resolved strategy but no embedding provider is configured, the system MUST fall back tokeywordinternally — it MUST NOT error or block. This degradation is NOT surfaced as a query-time response flag anymore (thefallbackfield is removed from the search response); vector-unavailable documents are reported via thedocuments_degradedmetric (FR-025).
Embedding configuration
- FR-013: The embedding provider MUST be user-configurable per KB via the nested
embeddingconfig object (DevPilot-style OpenAI-compatible:provider,model,base_url,credential_ref,dimensions), with an optional in-processlocalprovider (fastembed). Credentials MUST be referenced into the encrypted credential store, never stored in plaintext. - FR-014: Chunk parameters and the embedding model MUST be mutable; changing chunk params re-chunks+re-indexes and changing the embedding model re-embeds the corpus. There is NO immutability lock on these fields.
Curation & consistency
- FR-015: Each document MUST carry a
source_modeofconverted(Markdown derived from raw, re-convertible) oredited(re-conversion blocked). Document ids are stable ULIDs (FR-007), so re-uploading a changed source under the same filename withreplace=trueupdates the same document in place and resetssource_modetoconverted; uploading a different filename creates a new document and the edited one is untouched. Document edits arrive through the edit API, the agent MCPedit_documenttool, or by editing the on-disk Markdown in the user's external editor — the Coffer UI does NOT provide an in-app text editor. An edit through the edit API or MCP setssource_mode=edited; an external edit is picked up by lazy reindex-on-read (FR-008a). Users and agents MUST be able to edit a document's Markdown, re-upload/replace its source, delete it, and reindex. - FR-016: All write paths (re-upload, edit API, agent MCP write, external edit, reindex scan) MUST funnel through one idempotent re-index routine, invoked lazily on read when the on-disk
content_sha256has drifted: ifcontent_sha256is unchanged it is a no-op; if changed it deletes old chunks/FTS5/vec rows, re-chunks, re-embeds (if vector enabled), updates thedocumentsrow, and auditsKB_DOCUMENT_UPDATED. Documents are co-managed (ADR-028): both humans and agents may add, edit, and delete documents; every agent write is audited (FR-018) with the agent as actor.
Agent integration via MCP
- FR-017: Coffer's MCP gateway MUST expose built-in KB tools to every connected client, namespaced under the reserved
coffer__prefix: the read toolscoffer__list_knowledge_bases,coffer__search_knowledge(kb, query, top_k?)(nomode?parameter — retrieval mode is internal),coffer__grep_knowledge,coffer__read_document, AND the write toolscoffer__add_document(ingest Markdown content under a filename),coffer__edit_document(replace a document's body), andcoffer__delete_document. The write tools share the same service paths as the REST surface, so they honour the F01 audit (FR-018). - FR-018: Built-in KB tool invocations MUST be recorded in
mcp_invocationsexactly as upstream calls (tool name, who/when/duration/outcome — no arguments or returned content); the document-level effect of a write tool is additionally recorded in the F01 audit trail (KB_DOCUMENT_INGESTED/_UPDATED/_DELETED) with the agent as actor.
Surfaces
- FR-019: Users MUST be able to perform every KB operation through (a) a REST API under
/api/v1/knowledge_bases/, (b)coffer kb …subcommands, and (c) a desktop UI under the existingResourcesnavigation. - FR-020: The UI document viewer MUST render the Markdown read-only — it MUST NOT offer an in-app text editor for document content (humans edit via the external editor or the edit API; agents via MCP). Instead, at both file and containing-folder granularity, the viewer MUST offer affordances to open in external editor and reveal in file manager / Finder. Open/reveal perform the real OS action on both surfaces (honouring the global preferred-editor preference specced in
002-ui-shell): desktop (Tauri) via the OS opener, the web client via the loopback daemon's filesystem-action endpoints (spec 004 FR-039) — the daemon is always on the user's own machine, so it acts on the user's behalf (ADR-033). There is no copy-path fallback. To support these affordances, read API responses (FR/§Wire) MUST surface the document's absolute on-disk path and its containing folder's absolute path. The viewer MUST render the Markdown body at a comfortable reading max-width (centered) so long lines do not stretch edge-to-edge on wide screens. The detail-page document list MUST be a single scrollable list (the UI fetches one page at the maxlimit) with no in-UI page-based pager — the documents API stays paginated bylimit/offset(FR-010a) for programmatic/external callers.
Source-file tracking(源文件跟踪)
- FR-021: A path-based ingest (the
coffer kb ingestCLI, and a future desktop file picker) MUST record the external original's absolute path in the document's free-formmetadataassource_path— there is no schema/DB migration; it rides in the existing JSONmetadata. A web byte-upload and the agentadd_documentMCP tool MUST NOT set or infersource_path(an untrusted surface must never populate an arbitrary server path).source_pathis machine-local: it is meaningful only on the machine that ingested the file. - FR-022:
check_sourcesMUST classify each path-tracked document (those with asource_path) by re-hashing the external file with sha256 — streamed in chunks so a multi-GB original is never read fully into memory — and comparing to the storedsource_sha256:unchanged(digests match),changed(they differ), ormissing(the file is gone). Detection is on-demand only (no filesystem watcher), and detect-only changes nothing and audits nothing. - FR-023:
update_from_sourceMUST re-ingest a document from itssource_pathin place — reading the tracked file's bytes and replaying the existingreplace=truere-ingest path, so the document's stable ULID id is preserved and the corpus is re-chunked/re-indexed (audited via the existingKB_DOCUMENT_UPDATED). A document whosesource_mode == editedMUST be refused (the existingReconversionBlockederror) so hand edits are never clobbered; a vanished or untracked source is reported via the existingIngestRejected. - FR-024: A per-KB
auto_update_sourcesflag (default false) governscheck_sources: when false, detection only classifies; when true, eachchangeddocument whosesource_mode != editedis auto-refreshed in place viaupdate_from_source(reportedupdated), while achangedhand-edited document is skipped (reportededited). Togglingauto_update_sourcesMUST NOT re-chunk or re-embed the corpus (it is not a reindex-triggering field). - FR-025: When an embed degrades because the embedding provider is unavailable (
EngineUnavailable), the document MUST be indexed keyword-only and its retry state MUST be tracked on a dedicated persistedembed_pendingflag — decoupled fromcontent_sha256, which MUST always carry the real Markdown body hash (so a degraded document is no longer re-chunked + re-FTS'd on every scan, and the files-as-truth sha stays correct). The KB MUST surface the count of such documents asdocuments_degradedin its metrics, computed from the persistedembed_pendingflag so the count reflects a degrade observed during any read (list / get / search / grep), not only an explicitPOST /reindex. The next reconcile MUST retry only the embed for a still-pending document whose body is unchanged — re-chunking it in memory and upserting only the vectors (no FTS / chunk rewrite), clearingembed_pendingon success.
Key Entities
- Knowledge Base(kind 为
knowledge_base的 resource):config = 启用的检索模式、chunk size/overlap、embedding provider/model/base_url/credential_ref、max document bytes、description。 - Document(统一
documents行,kind="knowledge_base"):doc id(稳定 ULID)、KB resource 名、磁盘 path、title、description、content_sha256(始终是真实正文哈希)、embed_pending(索引派生的重试标志 —— embed 降级时为真;非文件真相)、source_mode、per-facemetadata(original_filename、original_format、source_sha256、converted_at、conversion_engine)、时间戳。 - Chunk(
chunks行):在文档内的 position。chunk 文本在常规 FTS5 索引(documents_fts)内部存一份,不再重复存进基础 SQLite 表;它始终可由 Markdown 文件重建,文件仍是真相源。 - Passage(检索结果,不持久化):passage 文本、源 doc id、title、score、position。
- Grep hit(检索结果,不持久化):path、行号、行内容。
Success Criteria
Measurable Outcomes
- SC-001: From a fresh install, a user creates a KB and ingests their first non-Markdown file (e.g. a PDF) within 60 seconds by following the quickstart alone.
- SC-002: With a 50-document KB (≤ 50 MB), keyword search latency for a typical query is ≤ 200 ms and grep ≤ 500 ms wall-clock at the REST surface on a developer laptop.
- SC-003: Deleting a KB removes 100% of its on-disk footprint and 100% of its SQLite rows; verified by a test that walks
~/.coffer/knowledge/and queriesdocuments/chunksbefore and after. - SC-004: An agent connected through the MCP gateway can list KBs, search, grep, read a document, AND add / edit / delete a document — all via built-in tools — in one MCP session, with no separate MCP server installed.
- SC-005:
coffer kb reindex <name>rebuilds all SQLite index state for the KB purely from the Markdown files (drop the rows, reindex, search returns identical results). - SC-006: Every Acceptance Scenario is covered by at least one
acceptance(spec="006-knowledge-base", scenario="…")test;make verify-acceptancereports zero uncovered scenarios. - SC-007: Engine isolation holds: no module under
coffer.application.*orcoffer.domain.*importsmarkitdown,docling,sqlite_vec, or an embedding-provider SDK (importlinter contract).
Assumptions
- 用户在自己的机器上运行 Coffer;没有多租户或远程访问需求。多机同步在范围之外(宪法层面)。
- keyword + grep 零配置且离线;vector 检索会访问一个已配置的 embedding provider,它可以是第三方 API(宪法允许 —— 只有用户数据留在本地)。
- 本分支未发布;没有数据迁移。一个迁移删除
kb_documents、删掉旧的 per-corpus 目录、创建统一 schema。 ripgrep在受支持平台(macOS arm64、Linux)可用;sqlite-vec 在这些平台上作为 SQLite 扩展加载。- KB 不是 memory 存储:它承载用户策展的文档。
memorykind(spec 007)是同一基底的可写面;两者共享documents表但靠kind区分。
Notes for reviewers
- 共享基底:
documents/chunks/FTS5/sqlite-vec 以及转换器端口与 spec 007(memory)共享。本规范拥有 KB 面(任意格式→Markdown、三模式读、人与 agent 共管写入 —— ADR-028);007 拥有 memory 面。两份规范里对基底的描述要保持同步;架构在宪法与重新设计 ADR 里,此处不复述。 - embedding 默认值:vector 是可选项;零配置默认是
keyword+grep(离线、语言无关)。双语语料推荐本地bge-m3或云 provider(英文小模型 embedding 中文效果差)。 - 共管(ADR-028):文档由人与 agent 共管。本切片交付全局 scope 下的共管核心——稳定 ULID 标识 + 重新上传就地更新(FR-007)、agent MCP 写工具(FR-017)。两块相关内容随后续统一知识 UI 切片一起落地:逐项目文档 scope(全局/项目 轴,与该 UI 相互依赖)与可恢复软删除(回收站/恢复,需要自己的 UI——目前删除是硬删除但留下 F01 审计痕迹)。
- 延后项:检索时的 reranking / HyDE / multi-query / LLM 综合;逐项目 KB 文档 scope 与可恢复软删除(见上,下一切片);应用内 Markdown 编辑器(查看器保持只读 + 外部编辑器入口);默认图像 OCR;默认开启的文件系统 watcher。