feat(knowledge): add documents automatically, and dedup per document - #1380
Conversation
Design Review (Fable 5) — 🟡 CONCERNSAdvisory design-level review of Design-Verdict: CONCERNS Sound dedup redesign and write path, but three unattended behaviors flip on by default at upgrade — spend, persistence, and deletion — with only log-level visibility. Watch
Suggestions
[DESIGN-REVIEWED] 7d18791 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/dashboard/handlers/knowledge.py:588 -- counting False positive or not applicable? A repository writer can comment: |
UX Review (Fable 5) — 🟡 CONCERNSAdvisory UX-level review of UX-Verdict: CONCERNS Solid copy overall, but toggle clicks give no visible response on slow/failed saves, and two strings quietly invert what users will expect. Watch
Suggestions
[UX-REVIEWED] 7d18791 |
Opus 5 Review — ✅ human override acceptedReviewed Human judgment by @NicholasRBowers overrides the Opus 5 finding for Verdict recorded from an authorized human decision for commit False positive or not applicable? A repository writer can comment: |
0b6c0f5 to
e5fa134
Compare
|
Dispositions for findings raised on
Also addressed from the advisory reviews, since each was a real defect rather than a preference:
Separately, a Windows CI failure exposed a real bug in this change: |
e5fa134 to
ceacce3
Compare
|
Second round, now at
On the reviewer's note about mixed provenance in |
ceacce3 to
9468b3e
Compare
|
Round 3, now at
Two adjacent defects that the same read path carried, fixed in the same change:
Four tests added ( Gates on this head: 26,908 backend passed with zero new failures against this host's pre-existing baseline, mypy / isort / flake8 clean. Screenshot URLs re-pinned to the new SHA. |
9468b3e to
c7684f3
Compare
|
Round 4, now at
Tests: Gates on this head: 26,897 backend passed; the only failures are this host's pre-existing sandbox set, each confirmed failing on clean |
2b447e2 to
580b554
Compare
|
Now at CodeQL Two prior attempts did not clear it. First, The warrant is deliberately this function's boundary, not caller provenance: Two sinks beyond the reported one were fixed earlier in the same pass and carry no suppression: the sensitive-path event and its raised message previously embedded the raw name.
Also cleared this round: the Gates: pytest with no new failures, isort, flake8, mypy 713 files, docs-lint, brand gate, scrub-lint, |
580b554 to
99e5d3b
Compare
|
Now at CodeQL Three suppression attempts failed and I stopped trying to silence the query. For the record, so nobody repeats them: removing the dead The query was right about the shape of the code even though So the name no longer goes to the log at all. The test now asserts the invariant rather than the message. Suite: 47 failures, every one inside the pre-existing environmental families and zero in the knowledge area. Clean main at the same base fails 48 in the same families, 29 of them in Gates: isort, flake8, mypy 713 files, docs-lint, brand gate, scrub-lint, 203 knowledge tests. |
|
Now at 1.
2. The revive branch wrote The fix. One module-level The revive branch also gained a distinction it was missing. When the content is genuinely gone, a folder row goes to Tests. Two added, each proven non-vacuous by reverting its own fix in isolation: Suite: 48 failures, exactly matching clean main at the same base, all in the pre-existing environmental families, zero in the knowledge area. Gates: isort, flake8, mypy 713 files, docs-lint, brand gate, scrub-lint, 205 knowledge tests. Separately — the two shard failures on the prior head were flakes, not findings. |
|
Now at 1.
2. The mechanism as described holds: a losing file's row is Storing So the claim is released by content hash — the only handle such a row legitimately has — via a new Two tests, each proven non-vacuous by reverting its own fix: Adding the Suite: 47 failures, all in the pre-existing environmental families, zero in the knowledge area; clean main at the same base fails 48. Gates: isort, flake8, mypy 713 files, docs-lint, brand gate, scrub-lint. On |
|
Now at 1. The handler already computed 2. The gate refused the write, deleted the caller's superseded items and recorded a terminal duplicate job — but never recorded that the refusing source has a copy. Under "one document, many locations" that is exactly the thing that must be written down: the copy was invisible to the reference count, so deleting the holder destroyed the only items while the second folder's file was still on disk, and nothing would bring the content back. I fixed it by attaching the location rather than by removing the shortcut. Removing it would work, but it would spend a full extraction pass on every duplicate file, which is the specific cost the gate exists to avoid — and the auto-registration paths in this PR make duplicates common (a repo and its worktrees, the same design doc in two project folders). Attaching costs one INSERT and makes the refusal safe, so the outcome the finding asked for is reached without giving up the saving: the refusing source is now a holder, and deleting the first source moves the document to it instead of destroying it. The regression test asserts exactly that sequence. This also composes correctly with the two mechanisms added last round: the refused source's row is the Tests: Suite: 48 failures, matching clean main exactly at the same base, all in the pre-existing environmental families, zero in the knowledge area. Gates: isort, flake8, mypy 713 files, docs-lint, brand gate, scrub-lint. |
|
Now at 1. I added 2. Same shape as the folder-file case fixed earlier, in the artifact path: with an empty group there was nothing to detach, so the artifact source stayed a location of the winner's items after the artifact was gone, and a later winner deletion would hand it a document with no artifact behind it. Together with the previous rounds this closes the pattern at all four sites where ownership moves or a copy goes away: item-level delete, source cascade, folder-file delete, artifact delete. A pre-existing test of mine failed, and its premise — not the fix — was wrong. Tests: Suite: 48 failures, matching clean main exactly at the same base, all in the pre-existing environmental families, zero in the knowledge area. Gates: isort, flake8, mypy 713 files, docs-lint, brand gate, scrub-lint, 212 knowledge tests. Unrelated infrastructure note: |
|
Now at
A claim is specific to the content it was made for. A source that lost a dedup owns no items but IS a location of the winner's; when its own copy is then edited, that claim points at the wrong document. Deleting the holder afterwards hands this source the superseded text, which stays searchable with nothing behind it. Exactly as described. Fixed as one rule in the store, not three checks at three ingest paths. Sibling instance fixed at the same time: Three tests, each proven non-vacuous by reverting its own fix: the edit case (claim released, and deleting the holder then takes the superseded text with it); a negative test pinning that the rule does NOT fire for a live group, an unchanged hash, or a missing prior hash — the three ways an over-eager version of this would destroy real co-ownership; and Suite: 47 failures against clean main's 48 at the same base, all in the pre-existing environmental families, zero in the knowledge area. Gates: isort, flake8, mypy 713 files, docs-lint, brand gate, scrub-lint, 194 knowledge tests. |
|
Now at 1. The comment above that query already claimed "one DocRef per DOCUMENT", but the grouping was Aggregate documents are now enumerated from 2.
Fixed by making the unattended pass certainty-gated rather than preview-only. Flagging the tradeoff explicitly since it is a behaviour decision on the repo owner's feature: unattended fuzzy collapse is now off, unattended exact collapse stays on. If the intent is that nothing at all is deleted without a human in the loop, the one-line change is Tests, each proven non-vacuous by reverting its own fix: Suite: 46 failures against clean main's 48 at the same base, all in the pre-existing environmental families, zero in the knowledge area. Gates: isort, flake8, mypy 713 files, docs-lint, brand gate, scrub-lint, 217 knowledge tests. |
|
Now at
Reproduced before changing anything, with two identical files in one watched folder (a The reason it is unrecoverable is structural, and it is worth naming because it explains why a guard is the right answer rather than better bookkeeping. Every protection this PR adds is a location in another source. Within one source there is no second holder, so
Nothing legitimate is given up. Two identical files in one folder are two files, and indexing both is honest — the space a collapse saves is not worth losing the document. Duplicates within an aggregate are already refused earlier and more cheaply by the pre-ingest gate's Test: Rebase: this push also absorbed 22 commits of main, with one import-block conflict in Suite: 47 failures, all in the pre-existing environmental families, zero in the knowledge area. One extra flake appeared and was ruled out: |
|
Now at 1.
Adoption now acts only when the hash picks out ONE row; an ambiguous hash adopts nothing. The asymmetry is deliberate: an un-adopted row leaves a stale claim, which is visible and recoverable, whereas a cross-wired group destroys content on the next delete. The same ambiguity existed in the mirror-image helper, which you did not name, and is fixed too. 2. Verified the substance first.
So the exposure is closed at the point that actually leaks: a source marked auto-added has its document text scrubbed before hashing, chunking, extraction and storage — the same scrub, in the same position, the artifact path already uses. The credential never reaches the worker or the index, and the zero-friction registration the feature exists for is preserved. Scoped by If the owner would rather have the confirmation gate, it is one line: seed Tests, each proven non-vacuous: Suite: 45 failures, all in the pre-existing environmental families, zero in the knowledge area. Gates: isort, flake8, mypy 713 files, docs-lint, brand gate, scrub-lint.
|
|
Now at
Collapses chain. If source C lost an earlier round to B, C is a location of B's items while owning nothing. When B then loses to A, the old code attached only B's source to A's items and called Fixed as suggested. Every source that can reach the loser's copy — the loser plus any source that previously deferred to it — is now attached to the winner's items first, and only then are the loser's items hard-deleted with no Test:
All three shards fail one repo-wide guard: That file is not in this diff ( Local suite otherwise: 47 failures = the pre-existing environmental families plus that one guard, zero in the knowledge area. Gates: isort, flake8, mypy 719 files, docs-lint, brand gate, scrub-lint.
|
|
Now at Recovered finding — The GPT job's steps all succeeded but it never emitted a Verified directly rather than taken on faith:
For Scope, checked per call site rather than assumed:
So the location bookkeeping added in the earlier rounds is inert for non-plaintext folder documents. In user terms: the same PDF in two synced folders is collapsed to one copy; later removing the winning folder cannot hand the survivor to the other folder, because the two rows are keyed in different domains — the document either stays searchable with no file behind it, or vanishes from a folder that still has it on disk. Not fixed in this push, and not going to be guessed at. The two candidate designs differ in blast radius, and picking one is a scope call for the repository owner:
Escalated to the owner rather than started unattended. The three shard-2 failures remain the separately-reported |
The Knowledge Library only grew when the user added a source by hand. Documents the agent read while working, and the design docs of the project they were working in, stayed invisible to search. Three write paths, all on by default and all disableable: a knowledge_add_document MCP tool landing in one aggregate "Auto-added" source; automatic registration of each worked-in project's documents; and a document filter plus per-sweep chunk budget that make registering a code repository bounded enough to need no confirmation step. De-duplication is reworked to operate on documents rather than sources. It previously treated a source holding many documents as one unit whose hash was only its first item's, so a match cascade-deleted the whole library; the guard against that carved aggregate sources out of dedup entirely, which meant their documents were never deduped at all. Removing the source-level unit removes the need for the guard. Also: a pre-ingest exact-hash gate so no duplicate is written on any path, a scheduled dedup sweep (nothing invoked one automatically), and a backfill giving legacy items a document identity key so they are visible to exact matching at all.
|
Now at
Two different quantities were both called a content hash. Folder rows now carry the text hash separately in The Population needs no new plumbing. Deliberately no backfill. The column is nullable and legacy rows are left NULL. A Migration verified on a copy of the real 667-item database, not just on fixtures: items, sources, folder state and Tests, proven non-vacuous: Known narrower limitation, not addressed here. The pre-ingest duplicate gate compares a folder file's bytes hash against the aggregate tables' text hash, so a transformed folder document is not recognised as a duplicate of an artifact or agent document. That costs a redundant indexed copy rather than losing data, and closing it means hashing extracted text before the gate — which reintroduces the extraction pass the gate exists to avoid. Flagged rather than folded into this change. Suite: 46 failures, all pre-existing environmental families, zero in the knowledge area. Gates: isort, flake8, mypy 719 files, docs-lint, brand gate, scrub-lint. Shard 2's |
Human judgment recorded@NicholasRBowers marked the fable AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
Problem
The Knowledge Library only grew when the user added a source by hand. Two kinds of document therefore stayed invisible to search:
local_knowledge_search,knowledge_dedup).FileReader.SUPPORTEDaccepts — source code, logs, CSVs, extensionless files — because per-source filtering is denylist-only with no include allowlist. Pointing it at a repository ingests the repository, not its docs.A config key named
knowledge.auto_ingest_doc_linkshas existed for this and does nothing: its only consumer is a platform seam that resolves toreturn None.Separately, de-duplication had a hole this feature would have walked straight into.
enumerate_docsenumerated folder sources per file but fell back to one unit per SOURCE for everything else. For a source holding N documents that unit's hash was only its first item's, so a match made the whole source the loser anddelete_source_cascaderemoved all N. The guard against that (_AGGREGATE_SOURCE_TYPES) excluded aggregate sources from dedup entirely — so artifact documents were never de-duplicated at all.Why it matters
Every document the user has to remember to file is a document that will not be there when they search for it later — and the value of a knowledge library is exactly the things you did not think to save. Meanwhile the un-filtered folder path made the one available workaround worse than useless: ingesting a repository's source code costs one LLM extraction call per chunk and pollutes retrieval for every future query.
The dedup hole mattered immediately: a new aggregate source for agent-added documents would have been un-deduped by construction, and the un-guarded path deletes user data.
Fix (symptoms → root cause → change)
Root cause of the missing documents: there was no agent write path, and no way to say "documents only" to a folder scan.
Root cause of the dedup hole: the dedup unit was sometimes a source. A source is not a document, so treating it as one is wrong for every source holding more than one — and the carve-out that hid the symptom also disabled the feature.
1. A document filter, expressed as source properties (
doc_filter.py)One rule decides every case: auto-add prose written for humans about intent, decisions, and how things work; exclude prose written for agents, generated files, and machine-readable lists. It is expressed as the
propertiesa folder source already understands —include_extensions,ignore_patterns,extra_skip_dirs,min_file_bytes— so the ordinary scan path applies it with no special casing..txtis excluded: inside a repository it is nearly always a list, not prose (verified againstSOURCES.txt,scrub-allowlist.txt,windows-expected-failures.txt).Root-anchoring is the load-bearing detail. Repository boilerplate (
AGENTS.md,SECURITY.md,LICENSE*, …) is matched against the path relative to the project root with no separator in the pattern, so it can only match a top-level path. Matching those as bare basenames at any depth destroys real documents — measured deletingdocs/kiro-cli/mcp/security.mdanddocs/system-specs/modules/security.md.Measured on the real trees: KiroCrew 2706 walked → 270 with a document extension → 167 after the filter (3.4 MB); opportunity-planner 150 → 105. This matches the plan's predicted ~165/~105.
2.
include_extensions, a size floor, and a per-sweep chunk budget (folder_watcher.py)_walkgains an extension allowlist that can only narrow (never widen past reader support) and a size floor that reuses thestatalready taken for the mtime.Nonemeans "no allowlist" — today's behaviour for every existing source — and an empty set means "nothing", a distinction that would silently change every source if inverted.File filters bound pollution; only a chunk budget bounds cost — dropping 277 files to 209 removed only ~140 of ~1740 extraction calls, because a handful of large documents dominates. So discovered files are now ordered newest-first unconditionally, and a sweep stops once it has ingested
knowledge.auto_ingest_chunk_budgetchunks (default 150 ≈ one repository per hour at the 300s interval). Files not reached keep or lack theirfolder_file_staterow, so the next sweep resumes from them — the existingstatuscolumn already carries the resume point. Never applied to a folder the user added by hand: they asked for the whole folder._walkalso now normalizes the relative path beforefnmatch. Patterns are written with/, so on Windows every pattern containing a separator silently never matched — that broke the new*.egg-info/*rule and, latently, the existingsub/*style.3. Project documents, registered without a confirmation step (
project_docs.py)Each live chat slot's project dir resolves to its nearest
.gitancestor and is registered as anactivelocal_foldersource carrying the document filter.The manual path uses
pending_confirmationbecause an unfiltered walk is unbounded. The filter plus the budget makes it bounded, so the gate is unnecessary rather than skipped — and dismissal happens after the fact instead: deleting the source writes a tombstone that survives the delete.Two guards worth naming:
$HOMEis refused..gitin a home directory is a common dotfiles setup; without this, any project dir under such a home would register the entire home directory.deniedaudit event. Instead the recorded path must still resolve to itself, which catches the directory being swapped for a link elsewhere.4. The agent write path (
agent_source.py,knowledge_add_document)Documents land in one aggregate
agent://source ("Auto-added"), with per-document groups in a newagent_item_statetable keyed by a slug derived from the path or title — not the content, so an edit replaces the group instead of accumulating copies. It routes throughIngestionPipeline.ingest_file(one ingestion path), redacts content and title before they cross into the store, refuses sensitive paths, and serialises adds so the before/after item-id attribution stays correct.Deleting this source deliberately does not tombstone it, unlike the per-path auto-sources. Those are keyed to one folder, so re-registering a folder the user removed would override an explicit choice. This row is not a place — it is the container for a feature that already has a discoverable off switch (
knowledge.auto_add_documents). Making the delete a second, hidden, permanent off switch gives one intent two controls, and the one with no UI wins. So deleting it means "clear what is in here"; the toggle means "stop adding". This matches the sibling Artifacts source.This replaces the never-built server-side link scanner rather than reviving it.
knowledge.doc_ingest_hostsis deliberately not applied: it is SSRF protection for KiroCrew fetching a URL unattended, its default is[]= deny-all, and wiring it here would make the feature ingest nothing while its toggle read on. The agent already fetched the content with its own tools under its own approval; KiroCrew fetches nothing.5. Dedup on documents, and no duplicate written at all
enumerate_docsreplaces the source-level fallback with oneDocRefper(source_id, content_hash)group, so aggregate sources dedup per document like any other._AGGREGATE_SOURCE_TYPESis gone — with no source-level unit there is nothing to guard against._delete_docdeletes a document's items and marks its owning state rowdedupedso nothing re-ingests it. It removes the source row only once the source is provably empty (no items, no state rows, not a folder/vault) — which keeps a collapsed one-shot upload from lingering as an empty row without ever taking a source that still holds documents.DocRef.keynow falls back to the content hash, not"". Every document in one source previously shared a key, so removing one marked them all removed and protected them all from deletion.dedup_documenttakes the just-written document'scontent_hash. A source id alone is ambiguous once a source holds more than one document; it was picking whichever item came back first.IngestionPipelinerefuses a byte-identical write on every path, recording a terminalskipped_duplicatejob so callers can tell "already present" from "nothing to do". Refusing is not the same as doing nothing: the items the call was going to replace are deleted first, because the document's content changed to something already stored elsewhere and its old items are now superseded. A folder file refused this way is markeddeduped, notdone—donewith an empty group looks like a successful ingest that produced nothing, and the scan would never revisit it.dedup_sweepautomatically; it was reachable only from the CLI and an MCP tool. The watcher now runs one everyknowledge.dedup_every_n_sweeps(default 12, ~hourly). Because running it on a schedule is a change in kind — deletes that used to need a human command now happen unattended — the first sweep in a process is a dry run that only logs what it would collapse. The gate and the sweep are complements: only the sweep catches a near-duplicate or a pre-existing one, and it needs embeddings so it can never run inline.6. Config, UI, docs
auto_ingest_doc_linksis renamed toauto_add_documentsrather than joined by a new key: it already meant "documents encountered while working get added automatically", which is this feature. The loader accepts the legacy spelling (same idiom asagent.yolo→agent.dangerously_skip_permissions) so an existing config's value carries over instead of silently reverting.New:
auto_register_project_docs(true),auto_ingest_chunk_budget(150),dedup_every_n_sweeps(12). All five knowledge keys added to_EDITABLE_CONFIG— a key absent from that allowlist renders a toggle that then fails to save.auto_ingest_artifactsalso gets its first frontend surface (it was config-file only), so all three auto-ingest toggles sit together in one Knowledge Library section.Deviations from the implementation plan
enumerate_docskeeps the folder branch. The plan specified oneGROUP BY source_id, content_hashfor everything. That merges two identical files in the same folder into a singleDocRefspanning both — losing the per-file identity_delete_docneeds to mark each row. Only the non-folder fallback is replaced; the folder branch was already per-document and correct._delete_docmay remove a provably-empty source. The plan asked for a hard assertion thatdelete_source_cascadeis unreachable from dedup. Taken literally, a collapsed one-shot upload leaves an empty source row in the UI until the next boot reaps it. The real invariant is never delete a source that still holds documents, so that is what is enforced (and tested).items.content_hashbackfill (plan stage 5b.1 / A4b) is NOT in this PR. I implemented it, and it was a data-loss bug: it grouped bysource_id, so a folder source with N legacy files got one identical hash on all of them, and the newly-automatic sweep then reads them as exact duplicates and deletes N−1. That is the same source-as-document confusion this PR exists to fix, reproduced in the migration. A correct version has to derive per document group, and it is a migration that rewrites every legacy row at process start whose failure mode is silently shredding a knowledge base — so it belongs in its own change, with a dry-run and a count report, not bolted onto this one. Dropping it leaves behaviour at the status quo: legacy null-hash rows reach dedup through the filename+embedding tier exactly as they did before. What IS kept is the durable half — a test asserting every ingest path stamps the column, so the gap cannot grow.Tests
New:
test_knowledge_doc_filter.py(18),test_knowledge_project_docs.py(37),test_knowledge_agent_source.py(43), plusTestKnowledgeAutoIngestintest_config_loader.py(16).What they lock in, beyond the happy paths:
docs/**/security.mdsurvives while rootSECURITY.mdis dropped — the exact paths an unanchored filter was measured destroying.test_walk_with_project_properties_matches_the_predicatebuilds a tree, walks it with the real source properties, filters it withshould_ingest_doc, and asserts the two sets are equal./still matches when the OS separator is\— the Windows break above.include_extensions=Nonepreserves today's behaviour,set()takes nothing, and an allowlist cannot widen past reader support.$HOMEis refused.apply=Falseand only later passes delete.items.content_hash.Updated to the new contracts:
test_knowledge_dedup.py(three tests whose premise this PR deliberately inverts — aggregate sources are no longer carved out),test_folder_watcher.pyandtest_perf_boot_path.py(_ingest_filenow returns(item_ids, outcome)),test_knowledge_artifact_ingest.pyandtest_knowledge_ingest_guard.py(_maybe_deduptakes the document's hash).Manual verification
Full suites locally: 26 896 backend passed, 8 262 frontend passed, mypy clean (635 files, CI-parity venv with no faiss), isort / flake8 /
tsc -bclean, all 11 i18n gate checks pass, and a fresh-interpreter import of every touched entry point (pytest's discovery order hides ordering cycles).32 backend tests fail on this host — all 32 also fail on clean
main(sandbox user-namespaceEPERM, hardlink and root-owned-binary assumptions), verified by running the same files in the main clone.The document filter was run over both real trees to confirm the counts still land where they were measured (167 / 105) — a filter regression shows up as a count change long before it shows up as bad search results.
Screenshots
Captured against an isolated pod serving this branch's built bundle. The spec asserted no modal overlay immediately before each shot (Playwright's
to_be_visible()passes for an element underneath an overlay), verified all five expected literals, read the numeric input's value, and asserted the pre-rename labelChunk Budget Per Scanis absent so a stale bundle would fail loudly rather than be quietly photographed.Full Settings → Chat page, in context