dikw-core v0.6.0
0.6.0 — config-driven provider API-key env vars (BREAKING); DeepSeek V4 Pro + Gitee bge-m3; horizontal model comparison
Changed
- BREAKING — provider API-key env var is now config-driven, and
DIKW_EMBEDDING_API_KEY
is removed.ProviderConfiggains two required fields,llm_api_key_envand
embedding_api_key_env, naming the environment variable that holds each leg's key.
The engine no longer hardcodes any key var name:anthropic_compat/openai_compat
read exactly the var named indikw.yml, with no fallback. The dikw-invented
DIKW_EMBEDDING_API_KEYmagic name is gone — embedding keys now use vendor-canonical
names (OPENAI_API_KEY,GITEE_API_KEY, …) chosen viaembedding_api_key_env. The
LLM/embedding "two separate keys" separation is now achieved by naming distinct vars
(point both legs at one var to share a key, or at different vars to split vendors)
rather than by a special name + no-fallback rule. Migration: add the two fields to
everydikw.ymlprovider:block (a freshdikw initscaffold writes them), and in
.envrenameDIKW_EMBEDDING_API_KEY→ the vendor var your config names; a same-vendor
Anthropic+MiniMax.envthat reusedANTHROPIC_API_KEYfor a MiniMax key should move
the MiniMax key toMINIMAX_API_KEYand setllm_api_key_env: MINIMAX_API_KEY. Wipe
the localevals/.cache/snapshots/after upgrading (its snapshotdikw.ymls predate
the fields)./v1/health'sapi_key_presentand thedikw client checkprobe now key
off the configured var; thetools/e2e_verify.pyreal-leg gate derives its required
keys from the active profile'sprovider.{llm,embedding}_api_key_env.
Added
- DeepSeek V4 Pro (LLM) + Gitee AI bge-m3 (embeddings) support — config-only. DeepSeek
runs via the existinganthropic_compatprotocol against its Anthropic-compatible
endpoint (llm_base_url: https://api.deepseek.com/anthropic,llm_model: deepseek-v4-pro,
key inDEEPSEEK_API_KEY); DeepSeek ignores thecache_controlfield the provider
sends (no error — only the Anthropic prompt-cache discount is absent, same cost note as
openai_compat). bge-m3 runs viaopenai_compatembeddings against Gitee
(embedding_base_url: https://ai.gitee.com/v1,embedding_model: bge-m3,
embedding_dim: 1024,embedding_batch_size: 16, key inGITEE_API_KEY). No engine
code; a committed reference config ships attests/fixtures/live-deepseek-gitee-bgem3.dikw.yml.
Seedocs/providers.md. - Horizontal model-comparison harness (
evals/tools/compare_models.py). A dev tool
(not shipped in the wheel) that runs the same eval dataset against N model arms and emits
an arm-by-metric comparison matrix + per-arm JSON.comparecompares embedding models
via retrieval eval (deterministic, 1 run/arm: hit@k / mrr / nDCG@10 / recall@100);
compare-synthcompares LLM models via synth eval (N runs/arm + a Welch t-test of each
arm vs the baseline arm: grounding / atomicity / duplicate / wikilink / language, plus judge
dims with--judge). Each arm carries a fullprovider:block, so two same-protocol
vendors (DeepSeek + MiniMax) resolve distinct keys via their*_api_key_env. Reuses the
tested statistics fromab_experiment.pyand the direction rule fromclient/baseline.py.
Seeevals/README.mdanddocs/providers.md. - Real-environment end-to-end verification harness (
tools/e2e_verify.py). A dev
tool (not shipped in the wheel) that drives everydikw clientverb against a
live server in one of two throwaway environments, then destroys it:--mode local
(temp-dir base + long-liveddikw serveon SQLite) and--mode docker(server +
pgvectorPostgres via a generated compose project, image built from the local
working tree — not the released PyPIexamples/docker/Dockerfile). CLI coverage is
asserted against the live Typer tree, so adding a verb without a sequence step fails
the run. Provider posture is tiered + skip-loud: structural legs (ingest --no-embed,
pages/graph/lint/delete/tasks) run with no keys; real legs
(check/embed/synth/vector-retrieve/eval) run when the keys named by the
active profile'sprovider.{llm,embedding}_api_key_envare present (from.env)
and SKIP loudly otherwise. Both modes
use a free host port (never a fixed8765) so concurrent runs don't collide; docker
teardown is guaranteed (down -v --rmi localremoves containers, volumes and the
built image;--prunesweeps crashed-run leftovers by label/name).--observewires the
docs/observabilityOTel stack and surfaces a Jaeger trace link on failure. Registered
as acli/server/clientleg in thedikw-core-verifyskill; wrapped by
tests/test_e2e_verify_{local,docker}.py(-m slow). Default provider profile is the
committed MiniMax + Qwen3-Embedding-0.6B template; swap vendor/model via
--provider-profile <dikw.yml>. dangling_provenancedrift lint kind — flag a K/W page citing a deleted source
(read-only). A new deterministiclintkind that flags aknowledge/(K) or
wisdom/(W) page whosesources:provenance edge points at a source file that
no longer exists on disk. It is read-only — surfaced, never auto-repaired: there
is no fixer (likeduplicate_title,lint proposereports it for human triage and
lands every issue inskipped), because thesources:frontmatter is the user's to
edit (ADR-0001's non-cascade design — delete never rewrites another page's content).
Disk is the source of truth (ADR-0005), so detection stats the file, not the
documentsprojection: a source present on disk but not yetingest-ed (no active D
row) is not dangling — there the fix isingest, not editing frontmatter. A
provenance path that escapes the base is dangling and its external target is never
stat-ed. Runs in the defaultlintscan, sharing the per-page provenance read with
missing_provenance(zero extra storage round-trips); suppressible per page via
lint: {skip: [dangling_provenance]}. Final slice of ADR-0005
(filesystem-as-source-of-truth) — the arc (thedeleteverb +missing_file/
untracked_file/stale_index/dangling_provenancedrift kinds) is now complete,
anddocs/design.mdgains a "Disk is the source of truth" invariant section.stale_index+untracked_filedrift lint kinds — re-project hand-edited /
hand-written K/W pages (and unlock hand-authored knowledge pages as first-class).
Two new deterministiclintkinds, both fixed by oneReindexPageFixer:
stale_indexflags an activeknowledge/(K) orwisdom/(W) row whose on-disk
body hash no longer matches the indexedhash(a hand-edit outside dikw);
untracked_fileflags a.md/.markdownfile underknowledge/orwisdom/
with no active row (hand-written, or restored outside dikw). Both propose a single
reindex_pageop that re-projects the current on-disk bytes through
persist_knowledge/persist_wisdom— re-chunk, re-link, re-provenance,
inline-or-deferred re-embed — without rewriting the file (disk is the source of
truth, ADR-0005) and without re-runningsynth(so a hand-edit is preserved, not
regenerated from the D-source). Run in the defaultlintscan; fix with
dikw client lint propose --rule stale_index(oruntracked_file) →
dikw client lint apply <task_id>.untracked_filecloses the "hand-write a K page,
the engine never indexes it" gap and makes hand-authored pages first-class;
stale_indexcloses the "edit a K/W file on disk, the storage projection silently
drifts" gap. Detection is near-free:stale_indexreuses the per-page read the
other lexical checks already do (no separate mtime-prefiltered hashing pass), and
untracked_fileis a cheap disk walk (stat + membership, no read) rooted at
knowledge/+wisdom/so the siblingtrash//.dikw//assets/trees are
naturally excluded and.gitkeep/ non-markdown files never trip. Both are K/W-only
(D-layer adds/edits stayingest's job); a page failing its re-projection is
deactivated and surfaced viaApplyReport.persist_errors, successes under
ApplyReport.reindexed_documents. Third slice of ADR-0005 (dangling_provenance
is the fourth, above). This supersedes the never-builtdikw client reindex <path>— the
reindex story is nowdikw client lint propose --rule stale_index(or
--rule untracked_file) followed bydikw client lint apply <task_id>.missing_filedrift lint kind — purge orphaned document rows (D/K/W). A new
deterministiclintkind (withMissingFileFixer) that detects an active
documentsrow whose backing file is gone from disk — asources/(D),
knowledge/(K), orwisdom/(W) file deleted outside dikw — and proposes a
singlepurge_documentop that drops the orphaned row + its outgoing edges via
Storage.delete_document. Runs in the defaultlintscan; fix it with
dikw client lint propose --rule missing_file→dikw client lint apply <task_id>.
Closes the original gap where deleting a source file left its row stuck at
active=Trueforever (run_lintnever scanned D rows). Inbound[[wikilink]]s
from live pages are left to surface asbroken_wikilink(delete_document clears
only outgoing edges; the kind never rewrites a user's page); a truly dangling edge
(both ends purged) clears itself. The op carries the resolvedlayer, re-checks
at apply time that the file is still absent and the row still exists (propose→apply
race / restored-file safety), and reports purged paths under
ApplyReport.purged_documents. Second slice of ADR-0005
(filesystem-as-source-of-truth);untracked_file/stale_index/
dangling_provenanceland in follow-ups.dikw client delete <path>— first-class document deletion (D/K/W). A new
immediate verb (api.delete_page/POST /v1/base/delete) that deletes any
registered document — asources/file, aknowledge/page, or awisdom/
page — by path: it purges the storage row + its outgoing links/provenance
(Storage.delete_document) and soft-deletes the on-disk file to
<base>/trash/<layer>/<rel>with an audittrashed:block (recover with a plain
mvback into place). It is symmetric withwisdom write: explicitly-targeted,
immediate (no propose/apply —trash/is the safety net),--waitby default,
--reasonfor an audit note. Closes the gap where deletion existed only as a side
effect of thelintorphan_page/non_atomic_pagefixers (K-layer stubs only) —
arbitrary K pages and all D/W documents were previously undeletable.
Inbound[[wikilink]]s from live pages are left dangling and surface as
broken_wikilinkon the nextdikw client lint— delete never rewrites another
page. First slice of ADR-0005 (filesystem-as-source-of-truth); the driftlint
kinds (missing_file/untracked_file/stale_index/dangling_provenance)
land in follow-ups. Internally, the soft-delete primitivemove_to_trashwas
promoted out ofdomains/knowledge/lint_fix.pyinto the shared, layer-agnostic
domains/trash.pyso D/W deletes reuse it.
Fixed
-
OTel validation stack now runs on arm64 (Apple Silicon). The
docs/observability/docker-compose.ymlcollector was pinned to
otel/opentelemetry-collector-contrib:0.116.0, whose arm64 binary is
dynamically linked (interpreter /lib/ld-linux-aarch64.so.1) while the image
isFROM scratch— so on Apple Silicon the container exited immediately with
exec /otelcol-contrib: no such file or directoryand the stack came up with
jaeger/prometheus/grafana healthy but zero traces. Bumped to0.117.0,
the nearest release that restored the static arm64 build (verified: boots
clean against the existingotel-collector-config.yaml); amd64 was
unaffected. This also fixestools/e2e_verify.py --observeon arm64, which
drives this same compose file. -
Synth front-matter is whitelisted to
tags;write_pageguards reserved
keys. Enforces in code the forbidden-key policy 0.5.3 added to the synth prompt
(the "Synth forbidssources/lintin emitted front-matter" entry below):
that change only reworded the prompt — the parser still routed every non-tags
key intoextrasandwrite_pagemerged it over the engine's authoritative
fields, so a disobedient LLM (or a hand-edited file flowing through lint-apply's
update_page) could still overridesources/category/id, inject alint:
block that suppressed lint on a fresh page, or — via ahandler/contentkey
colliding withfrontmatter.Post(**meta)— silently collapse the whole file to a
literal string. Now: the synth parser (_parse_one_page_block) drops every
non-tagsfront-matter key the LLM emits (titlecomes from the body# H1,
category/slugfrom the<page>attributes, the rest engine-managed), covering
every LLM-sourced page (synth fan-out + the lint grounded/split/merge fixers that
share the parser) at one point; and the sharedwrite_pagesink filters caller
extrasagainst_RESERVED_FRONTMATTER_KEYSand assigns metadata via
post.metadata.update, mirroring the W-layerwrite_wisdom_fileguard. User
extras(e.g. an Obsidianaliases:list) still pass through, and thelint:
block written byorphan_page.mark_as_leafis deliberately not reserved.
Behaviour-preserving for conformant synth output (which emits onlytags).
Security
- Raise the
python-multipartfloor to>=0.0.31(security floor) — clears the
open Dependabot form-parsing advisories. The declared floor was>=0.0.26, which
let the published wheel resolve apython-multipartvulnerable to the
multipart/form-dataresource-exhaustion / DoS chain (GHSA-5rvq-cxj2-64vf and the
<0.0.31follow-ups GHSA-v9pg-7xvm-68hf / GHSA-6jv3-5f52-599m / GHSA-vffw-93wf-4j4q).
The lock was already bumped to0.0.31by Dependabot (#209), but the manifest floor
still permitted a downstream install below the fix; raising it hardens the
published-wheel contract and, by re-touchinguv.lock, lets GitHub's dependency
graph re-ingest the already-patched resolution (python-multipart 0.0.31,
starlette 1.3.1) so the eight stale alerts auto-resolve. Starlette's matching
request.form()limit-bypass / DoS fixes (≥1.3.1, GHSA-82w8-qh3p-5jfq and the
<1.1.0advisories) already ship transitively viafastapi(locked) — it is not a
direct dependency, so no direct pin is added. Metadata-only: no resolved-version or
code change (uv.lockdiff is the recorded root specifier alone).