v4.3.0
Removed
- Token-saver
permissions.denyrules retired; installer now sweeps them away (ADR-0043
amendment). Daily-use evidence:Read(**/*.lock)hard-blocked readingpubspec.lockin a
Flutter session (small file, needed to resolve a dependency constraint) and the auto-deny was
indistinguishable from a manual denial. A prompt-less hard block trades a bounded token cost for
an unbounded workflow obstruction across heterogeneous projects.configureTokenSaverno longer
takes adenyRulesoption; the four old rules live on only asLEGACY_TOKEN_SAVER_DENY_RULES,
a frozen list that every reconcile (install,--no-token-saver,--uninstall) strips from
~/.claude/settings.jsonwhile leaving user-authored deny rules untouched (new regression test
covers the sweep, both with pieces ON and with everything OFF). Token savings on build
artifacts remain the job of the compaction hooks, which degrade gracefully instead of refusing.
Security
obscura_research's crawl no longer hands an LLM curator content hidden from every human
viewer, unfiltered. Found while reviewing Scrapling (a
Python scraping library) for capabilities worth matching: its own MCP server strips CSS/ARIA-
hidden elements before conversion, which this package had no equivalent of. Verified live, not
hypothesized — a synthetic page hiding text behinddisplay:none/aria-hiddenhad that text
pass straight through the realobscurabinary's--dump markdown(and--dump text, tried as
a cheaper fix-hypothesis and found to fail identically — neither is visibility-aware) as if it
were visible, including a prompt-injection-shaped payload that this package's own
scanInjectionheuristic — a visible-text scanner — also failed to flag. Every page
obscura_researchfetches is now parsed withcheerio(packages/obscura-web/src/sanitize.mjs
— the package's first runtime dependency beyond@modelcontextprotocol/sdk/execa/zod, added
deliberately for this) and stripped of hidden elements,<template>content, and HTML comments
before either the heuristic extractor or the local curator sees it. Re-verified live,
end-to-end, through the real pipeline against the same synthetic payload: the curated result
contained only real, visible content. Scoped to the automated crawl —obscura_fetch's
general, single-URL default is unaffected (matching its existing markdown fidelity from
sanitized HTML would need a second, unapproved dependency); a class-based hiding rule (versus an
element's own inlinestyle/aria-hidden) is a named, tested, accepted gap, since catching it
needs a real browser's computed styles. See ADR-0057's third addendum.
Fixed
- CI red since
b59e4fb(4 pushes): two independent causes, both reproduced before fixing.
(1)lint:slop-check.mjswas committed unformatted for the pinnedprettier@3.8.4—
reformatted (installed copy re-synced). (2)test-python: the ADR-0038 regression test
(test_usage_boost_decays_stale_credit_so_new_note_not_buried) was green on Windows and red on
ubuntu — retrieval order was platform-dependent:os.walkreturns names sorted on NTFS but
hash-ordered on ext4, insertion order decides FTS rowids, and rowids are the hidden tie-breaker
when BM25/cosine scores tie exactly; with a tie at the top, the ranks fed into RRF differ per
platform and the stale note's residual usage credit (linear taper leaves ~0.022 at 89.5/90
days) crossed the ~w/(k+r)²one-rank margin, burying the brand-new note the test protects.
Root fixes, all five ordering sites + the decay: sorted walk in_iter_markdown_files;
ORDER BY score, path(BM25 FTS query);ORDER BY dist, path, ordinal(sqlite-vec path);
heapq.nsmallestover(-score, path, ordinal)(brute-force cosine — same O(n·log k));
(-score, path)tie-breaks inreciprocal_rank_fusionand the post-lever sort; and
usage_counts_decayedtaper linear→quadratic so window-edge credit lands two orders below
any rank margin instead of inside it. Verified: an adverse-order simulation (reversed
insertion, the ext4 condition) reproduced the CI failure locally with the ordering fixes alone
and passes with the full set; 197/197 pytest; all four retrieval/token/assemble bench gates
exit 0 locally at CI thresholds. - vkm-design
modes/visual-loop.md: capability-check note for preview panes that only render
file://inside the project folder. Two real sessions hit the blank "static snapshot" +
"no site open" wall on an outside-the-project file and misread it as a page bug; the mode now
prescribes the copy-into-project pattern (dot-named temp, re-copy after each edit, delete at
loop end) instead of fighting the pane or skipping the loop. - vkm-design audit scripts: four defects exposed by a real user session log (a session
"audited" a roadmap HTML with these scripts and half the tool output was wrong — each fix
below re-verified against the exact input that failed).
(1)scale.mjsno longer infers the 1.067 ratio: its steps sit 6.7% apart, so the default
±3% tolerance bands cover ~91% of the value space and almost any incoherent set "fits" — 22
real ad-hoc font sizes scored 1 offender under inferred 1.067, a false PASS that buried
exactly the incoherence the check exists to expose (now honestly 11+ offenders under the
best non-degenerate ratio, with a hint that--ratio 1.067remains available explicitly).
(2)scale.mjs --genaccepts the natural named-flag form (--gen --base 16 --ratio 1.25 --steps 6): the packed-only parser swallowed a following--baseas its value and died
with a misleading "not a comma-separated number list".
(3)checkSpacingattaches ahalfGriddiagnosis when it explains part of the mess: a file
interleaving 4k and 4k+2 paddings (a real 2px half-step rhythm) was reported flat as "8
values off-rhythm", sending the session on a manual detour to conclude "false alarm" — the
CLI now says "only 3 stay off — the rest is a half-step rhythm; judge intent" itself, while
the exit code still fails against the declared base.
(4)audit-css.mjsgiven.html/.htmaudits only<style>blocks + inlinestyle=""
attributes: the whole file used to go through the rule regex, which read a<script>mermaid
theme object ({ background: "#0e2846", … }) as CSS — one barecolor:key away from
fabricated contrast pairs — while real inline styles stayed invisible. Regression tests for
all four; installed copies under~/.claude/skills/re-synced. - vkm-doctor skills-drift check: compares EVERY installed skill file, not just
SKILL.md.
The motivating real case (2026-07-18):vkm-discipline/domains/design-ui.mdstale under a
byte-identicalSKILL.md— the check reported "ok" for exactly the drift class it was built
to catch, and the routing fix that file carried (design tasks → vkm-design gate) silently
never reached sessions. A skill is now STALE if any of its template files differs installed-
side or is absent (new regression test reproduces the design-ui.md case). - vkm-design
modes/visual-loop.md: new trap — republishing a document to a NEW artifact URL
while the user still holds the old link. Real session: the fixes landed on a fresh artifact,
the user's open copy never changed, and the session read back "no veo ningún cambio" as a
design complaint instead of a delivery failure. The loop now instructs: list existing
artifacts, redeploy to the SAME url, verify at the link the user actually holds. obsidian-memory-rag: concurrentensure_freshwriters no longer die withdatabase is locked. Two simultaneousvault_hybrid_searchcalls with a stale index (reproduced live while
verifying ADR-0056) racedindex_vectors'BEGIN IMMEDIATE; the loser only got Python's
implicit ~5ssqlite3.connecttimeout — undocumented and demonstrably too short for a
batch_commit_every=64batch under a real embedder. Root-cause fix in the shared
store.connect(): explicitPRAGMA busy_timeout=30000on every connection, so a second writer
blocks and then reads the winner's committed state (the existing mtime/size incremental logic
already skips re-indexing) — no manual retry loop needed, SQLite's busy handler transparently
retriesBEGIN IMMEDIATE. Verified red→green with a new barrier-synced two-thread test
(tests/test_concurrent_ensure_fresh.py, slow-embedder holds the lock ~6.4s); full suite 197
passed, none modified.
Added
-
vkm-downloads: background downloads, sets of files, resume, and fastest-mirror selection
(ADR-0059). Follow-up to the synchronous tool: the user needed to download large files or SETS
of files "whether the download is fast or slow" and to "find the site with the fastest download" —
both blocked by the MCP transport's ~60s per-call wall (a 3 GB ISO can't be one synchronous call)
and by the conservative 500MB cap. Additive (the syncdownload_fileand every ADR-0058 guardrail
are byte-for-byte unchanged): four new tools + a background job runner.download_start({ files })
registers a job and returns ajob_idimmediately, running the transfer as an async task inside
the long-lived stdio server — no child process, so the "never execute downloaded bytes" property
holds;download_status/download_cancelpoll and abort. Each file may list several mirror
URLs;probe_mirrors(andprefer_fastest) measures each with a ~512 KB ranged GET and ranks by
real throughput (latency tiebreak; asize_mismatchflag catches a "mirror" that isn't the same
file), all through the same guarded, IP-pinned path so a private/loopback mirror is refused, not
probed. Large files are gated by a free-disk-space check (fs.statfs) rather than a hard byte
wall (background ceilingVKM_DOWNLOAD_MAX_BYTES_BG, 100GB); interrupted downloads resume from
their.partvia HTTP Range. Verified live end-to-end against the real Hiren's BootCD PE servers:
probe_mirrorsranked its two real mirrors by measured speed (edgeuno 9.1 Mbps vs origin 8.8 Mbps,
same 3,291,686,912-byte size), and the 3 GB ISO the sync path refused streamed in the background at
~130 Mbps and cancelled cleanly (.partremoved). 18 new tests (mirror ranking with an injected
clock, the job runner over injected request/DNS incl. batch/mirror-pick/cancel/resume, the disk
guard, and the four MCP handlers). -
vkm-downloads: a guarded file-download manager MCP, opt-in via--downloads(ADR-0058).
The user wanted Claude to download files on request (find a URL, save it to disk) — a capability
no existing tool had, and one that writes bytes to disk rather than reading the web as DATA. New
self-contained package@vkmikc/vkm-downloadsexposingdownload_resolve(metadata only — HEAD/
GET-and-abort, writes nothing, safe to call before asking the user) anddownload_file(streams
to~/Downloads/vkm-kit/, returns path/size/SHA-256). Its own installer flag, deliberately NOT
in--full(even though--fullincludes--obscura): opting into stealth web reads must
never silently grant disk writes — consent stays granular. Guardrails, all tested against an
injected fake request/response + fake DNS (no sockets, no real DNS): http(s) only; the host is
resolved once and refused if any record is loopback/private/link-local/CGNAT, with the socket
pinned to the validated IP (node:http(s)+ a customlookup, chosen overfetchprecisely
to close the DNS-rebinding TOCTOU that a resolve-then-fetch leaves in that guard — the guard that
stops an injected URL from targeting the kit's own loopback services like Ollama:11434);
redirects followed and re-validated per hop (a 302 to127.0.0.1is refused); a 500MB cap
(VKM_DOWNLOAD_MAX_BYTES) enforced byContent-Lengthand mid-stream, deleting the partial
file on abort; filenames sanitized (../, separators,%2f, NUL, Windows-illegal + reserved
device names) with apath.resolveroot-check before writing and no-overwrite de-dup;
extension from the URL/Content-Type, never an unvalidatedContent-Disposition. Downloaded files
are never opened or executed (no code path spawns a process — grep-verifiable), and every
download is appended to a JSONL audit log (~/.vkm/downloads/downloads.log). The tool descriptions
steer the agent to resolve-then-confirm-then-download, reinforcing (not replacing) Claude Code's
own per-file download-permission rule. Version-locked to the kit (ADR-0042/0051). 38 new tests. -
obscura_research: the answer was being lost at gather, not at rank (ADR-0057). User report:
"search too little, nothing removes the junk, Claude gets the leftovers." A bench built first and
gated on (evals/research/, metrics ported fromobsidian-memory-rag'sbench_recall.py) falsified
three assumptions in the approved plan before any pipeline code changed — most importantly, that the
BM25 rerank ADR-0054/0055 shipped was actively worse than doing nothing: SearXNG already fuses rank
across every engine that returned a URL (results.py#calculate_score), and re-ranking survivors with
BM25 over ~30 tokens of SEO snippet threw that away for -0.115 nDCG@10. The rerank is deleted, not
tuned. Separately, thevocab-mismatchcase ADR-0055 was built for turned out to be a GATHER failure,
not a ranking one — the answer was never in the pool to begin with (a colloquial Spanish query
returned Instagram reels and Facebook posts; the same question in canonical English technical wording
returned the actual OWASP page at rank 4) — so query expansion, not reranking, is what fixes it.- Pipeline:
serp.mjspreserves SearXNG's own order/score instead of re-ranking (rankCandidates
stays exported for the no-SearXNG fallback only); fetch runs at bounded concurrency (semaphore,
default 4) instead of sequentially;top_kdefault 8→20 (calibrated for the old sequential loop,
now the knob deciding whether an expanded, fused pool's answer is read at all); a wall-clock deadline
(OBSCURA_RESEARCH_DEADLINE_MS, default 50s) returns the best-ranked finished prefix with
partial/remainingrather than losing everything to the MCP SDK's non-negotiable 60s
tools/calltimeout (resetTimeoutOnProgressdefaults false — the server cannot extend it). - Query expansion (
expandQuery,ollama-client.mjs) turns one question into several
canonically-worded, translated sub-queries, run against SearXNG'sgeneral,it,sciencecategories
(it/scienceare site APIs — github, mdn, arxiv, semantic scholar — that answer while
Google/Brave-backedgeneralscrapers are suspended, and don't themselves get suspended; costs zero
extra upstream requests, one/searchcall per sub-query). Runs on a separate, more capable model
(OBSCURA_OLLAMA_EXPAND_MODEL, defaultqwen3.5:4b-q4_K_M) from curation's — expansion is a recall
task (does the model already know "hidden instructions on a page" is called prompt injection?), not
reading comprehension, and measured phi4-mini at +0 answers found while doubling social-media junk
in the pool. Absent that model, the tool correctly does NOT expand rather than expand badly. - The curator (
curatePage) now returns a bandedrelevance: 0-10+reasoninstead of one bit, and
drops results below a conservative threshold (droppedreported,include_rejected: true
escapes it) — reversing ADR-0055 §6, which returnedrelevant: falseresults anyway. - Ban-avoidance, found the hard way (fixture capture + probes suspended every SearXNG-backed engine
for 180s-3600s, and a suspended instance answers HTTP 200 +results: []— indistinguishable from
"no hits" unlessunresponsive_enginesis read): sub-query fan-out throttled
(OBSCURA_SUBQUERY_CONCURRENCY, default 2); a SearXNG response cache keyed by
(query, page, categories) that distinguishes a stable "exhausted" empty from a transient "banned"
one (only the former is cached);enginesUnavailablesurfaced in the response so "no such page
exists" reads differently from "come back in 3 minutes";bench-capture.mjsrefuses to overwrite
good fixtures with empty ones captured against a rate-limited instance. - Resilience hardening adapted from Scrapling (verified
against its actual source, not its README): its "adaptive scraping" isSequenceMatcher.ratio()
scored across DOM element facets (tag/text/attributes/tree path/siblings) — this package has no DOM
to fingerprint that way (SERP records are flat, pages are markdown), so only the primitive itself
ported (text-similarity.mjs#similarityRatio, a from-scratch port pinned against difflib's own
canonical example). Used by a new generic, similarity-ranked SERP fallback
(serp.mjs#genericExtractLinks) engaged when a specific per-engine HTML parser breaks on markup
drift — the exact risk ADR-0051 already flagged and had no mitigation for beyond fixtures. Also
added: pragmatic robots.txt compliance (robots.mjs, fail-open, scoped to the automated crawl only,
neverobscura_fetch) and a page-fetch cache mirroring the search cache one stage later in the
pipeline. TLS fingerprint impersonation and proxy rotation were both explicitly declined (the first
is redundant with obscura's own real-browser rendering; the second is out of scope for a local,
personal-use tool with no measured need). - Bugs fixed in passing:
extraction: "ollama"no longer lies when the excerpt is empty or the
verdict isfalse; a failed fetch reportsrelevant: nullinstead of hardcodingtrue(it used to
outrank a page the curator had actually rejected);truncatedpropagates instead of being silently
dropped;capResultsbounds the response (the prior worst case, ~30k tokens, exceeded
MAX_MCP_OUTPUT_TOKENS's 25k default); one URL identity (url-identity.mjs, with a frozen,
tripwire-testedhash8soRESEARCH/'s existing notes are never orphaned) replaces three
independently-drifted ones. - Mass research now accumulates across calls instead of re-covering the same ground:
persist:true+topicreadsRESEARCH/<topic>/sources/(already a durable, one-file-per-URL
record) before crawling and excludes already-banked candidates, sotop_k's budget goes to
genuinely new pages on a second call —alreadyCoveredreports how many were skipped. - Fixed a real deadline bug, found only by running the redesigned pipeline live against a
real SearXNG + Ollama + obscura stack: a call configured with a 50s deadline took 58.3s
wall-clock, within 1.7s of the MCP transport's hard 60s cutoff. The concurrency pool's deadline
check only gated picking up new work; an item already in flight ran to its own independent
timeout (obscura's fetch ~45s worst case, curation's 30s) instead of the deadline meant to
bound the whole call. Each item is now raced against the time actually left, marked
timedOut: trueif it doesn't finish — re-run live with identical parameters: 58.3s → 50.012s.
No mock-based test caught this; it took a real run against real services. - Mass research now genuinely accumulates across sessions, not just calls:
persist:true+
topicreadsRESEARCH/<topic>/sources/(a durable, one-file-per-URL record) before
crawling and excludes already-banked candidates viaexcludeHashes— a second call on a
well-covered topic, even from a fresh MCP process days later, reaches further into the pool
instead of returning the same top hits (alreadyCoveredreports how many were skipped). - A second real deadline gap, found by tracing every caller after the first fix: the
SearXNG gather phase's sub-query fan-out never received the deadline at all (an oversight,
not a design gap —mapWithConcurrencyalready supported it), so a degraded SearXNG could
burn the whole budget gathering candidates before a single page was ever fetched. Fixed the
same way — the deadline now gates both the outer fan-out and the inner per-sub-query page
walk — with two new regression tests. packages/obscura-web239/239 tests. New ADR-0057 (supersedes ADR-0055 §5/§6, reverses
ADR-0054's sequential-fetch choice). Verified live end-to-end: a real query against the
real stack expanded into 5 genuine sub-queries, curatedsqlite.org/lockingv3.htmland
sqlite.org/wal.htmlinto its top results (relevance 9 and 8), and persisted 8 curated notes
to disk as real files.obscura_fetchgains an opt-incss_selectorparam and a new sibling
tool,obscura_fetch_many(bounded-concurrency batch fetch, one URL's failure doesn't sink
the rest) — both inspired by reviewing Scrapling's own MCP server tool surface. Full monorepo
green running the actual CI command (npm run test --workspaces --if-present) from the repo
root, not just this package in isolation.- Quality-based early stop:
target_relevant. Left at its default, a call always spent its
wholetop_k/deadline_msbudget even after the curator had already confirmed plenty of good
pages — the tool had no notion of "enough", only "out of time" or "out of candidates". Set
target_relevant, and the call stops dispatching further candidates as soon as that many pages
have been genuinely curated relevant (a real curator verdict at/abovedrop_below; a heuristic
fallback or an unfetched page never counts), returningtargetReached: trueso a short response
reads as a deliberate success rather than a cutoff (partial/remainingcan still report
alongside it — the two are not exclusive). - Fixed a real, if narrow,
relevant/drop_belowinconsistency found building the above:
curatePagehas nodropBelowparameter and derived its ownrelevantboolean from the
module's fixed default, so a call using a non-defaultdrop_belowcould get a per-result
relevant:truethat quietly disagreed with that very call'skept/droppedfilter (relevance
5 withdrop_below:7reportedrelevant:trueagainst curatePage's baked-in 3, even though the
result was correctly dropped).relevantis now derived once, inresearch.mjs, from THIS
call's owndropBelow— byte-identical for callers using the default. One stale test asserted
the pre-ADR-0057 "not-relevant still returns a result" behavior that was deliberately reversed
above; rewritten to exercise the sameextractioncorrectness throughinclude_rejected
instead.packages/obscura-web246/246 tests. - The curator bake-off fase 3 was approved on (measure, don't assume the model), run — with an
honest, inconclusive result. Built the scorer that didn't exist (bench-curate.mjs+
a real, labelled golden set anchored on the exact live MDN-false-positive failure above) and
ranphi4-mini:3.8b-q4_K_M(current default) vsqwen3.5:4b-q4_K_Mvsqwen3.5:9b-q4_K_M
as curator.qwen3.5:9bis the only one that stopped scoring the MDN pages relevant (0 false
positives) but at 40.6s/page with half its calls timing out at 60s it is disqualified by
latency alone (removed viaollama rm, per this session's own pre-declared bake-off rule).
Between the two practical options,qwen3.5:4bdoes not beatphi4-mini(lower accuracy,
identical false-positive rate on the hard case, slightly slower) — default stays
phi4-mini, and the false-positive failure itself remains open at this model size class on
this hardware (a rubric fix — the prompt's own "official documentation" band plausibly
rewarding MDN's general authority over its topical relevance — is the next unexplored lever,
not attempted this round). n=8 declared small, matching this ADR's existing honesty about the
expansion bake-off's own sample size.DEFAULT_MODEL(ollama-client.mjs) now carries this
reasoning inline, matching the doc-commentDEFAULT_EXPAND_MODELalready had for its own
bake-off — the decision used to live only in the ADR, not at the line of code it governs. - Fase 4, partially shipped: embeddings wired for near-dup removal + MMR diversity; per-page
chunk selection built but NOT wired.ollama-client.mjsgainsembedPassages
(OBSCURA_OLLAMA_EMBED_MODEL, defaultqwen3-embedding:0.6b, batched/api/embed) and
rank.mjsgainsmmr. Both wired intodeepResearchas two new opt-in params sharing ONE
batched embed call over the final kept set (never per-page):dedupe_similar(drops a later
page whose embedding is near-identical, cosine ≥0.92, to one already kept — an objective
redundancy signal, never a relevance judgment; count reported asdedupedSimilar) and
diversify(MMR reorder,mmr_lambdadefault 0.5, off by default per ADR-0028's own
precedent). Both skipped when too littledeadline_msremains or the embed model isn't
pulled, degrading silently to the plain curated order.chunk.mjs(sliding-window,
paragraph-respecting, overlapping chunking) is also built and tested, but deliberately left
UNWIRED into the per-page fetch+curate path — that would need per-page embedding calls inside
the deadline-critical hot loop, and there is no bench measuring whether chunk-based passage
selection actually beats today's blind 12k-char truncation; wiring something unmeasured into a
hard-deadline path is the wrong trade, matching this session's own repeated "a phase that
doesn't move the metric doesn't merge" discipline.packages/obscura-web283/283 tests. - Both remaining gaps attempted directly — both honestly negative, neither shipped. A
domain-check revision toCURATE_SYSTEM_PROMPT(name the query's and page's technology; cap
the score if they differ) was measured over 3 trials vs 2 on the baseline: false-positive rate
improved inconsistently while false-negative rate got WORSE — the same instruction that
correctly rejected an MDN page in one trial also made the model rejectsqlite.org/wal.html
itself as off-topic in two of three, the very vocab-mismatch failure ADR-0055 exists to
prevent. Reverted; the finding lives in the prompt constant's own doc-comment.
selectChunksByRelevance(chunk.mjs) — rank chunks by similarity to the query, keep the
best up to the input budget — was tested live against two real long pages with a verified
fact buried past the 12k-char truncation point: one case improved (relevance 5→8, on-topic
reason emerged), one regressed (relevance 8→5 — greedy similarity selection discarded a
page's own orienting introduction that blind truncation keeps for free by being first).
Not wired. Both negative results are recorded in ADR-0057's seventh addendum in full, not
softened — a negative result this session already paid to learn is worth more than a
positive one it didn't earn.packages/obscura-web288/288 tests.
- Pipeline:
-
vkm-doctor: skills-drift check. Real gap found on the maintainer's own machine:vkm-design
andvkm-researchexisted in the kit's templates but were missing from~/.claude/skills/
(install predated their addition) and nothing noticed. The doctor now compares what the kit
defines against what's installed and reports MISSING (noSKILL.mdinstalled) and STALE
(installedSKILL.mdcontent differs from the template), with an actionable reinstall hint. The
canonical list comes fromcreate-vkm-kit's ownskillAssetFilesresolver via a dynamic import
isolated insrc/skills-drift.mjs— it can never drift from what the installer actually installs,
and when that import isn't resolvable (standalone npm install) the check degrades to an honest
skip, never a silent false-negative; no~/.claude/skills/directory at all is likewise an
informative skip.packages/vkm-doctor11/11 tests green. -
RESEARCH/: a persistent web-research knowledge bank in the same vault (ADR-0056). Research
passages fromobscura_research(ADR-0054/0055) used to die with the tool's response; this makes
them accumulate. Spans three packages plus the installer/docs:packages/obscura-web:obscura_researchgains opt-inpersist: boolean(defaultfalse) +
topic(slug^[\w][\w-]*$); withpersistit returnspersisted: {topic, written, updated, dir}and writes one verbatim-extract note per curated result to
RESEARCH/<topic>/sources/<hash8-url>-<slug>.md(frontmatter: url/title/retrieved/query/
extraction/relevant/origin: web/status: raw), plus a per-topic hub (_index.md: query log,
open questions) and a regenerated global index (RESEARCH/_index.md). Requires
OBSCURA_RESEARCH_DIR(typed error if unset); every write is root-checked against it first.
Dedup by URL hash8 updates in place, never duplicates;fetchFailedresults are never
persisted,relevant:falseones are (flag intact).persist:false(default) is byte-identical
to the pre-ADR-0056 tool. New toolobscura_consolidate(topic, force): the local-Ollama half of
dual consolidation — map-reducessources/(≤12k-char batches) intosummary.md,
status: draft-local; aconsolidatedsummary is never overwritten (forceor not); no Ollama
→ typedOllamaUnavailableError, no silent fallback.src/research-persist.mjs(new),
src/ollama-client.mjsgainschatJSON/summarizeNotes.packages/obscura-web87/87 tests
green.packages/obsidian-memory-rag(196 pytest) +packages/obsidian-memory-mcp(164 node): retrieval-level
isolation so folder convention alone doesn't leak research noise into memory recall.
paths.pyaddsRESEARCH_PREFIX/validate_section/in_section/section_sql_filter;
search_vault/hybrid_searchgainsection: "research" | "memory" | Nonethat cuts at
candidate collection (BM25 + vector), filters graph neighbors, and re-applies the invariant on
the final fused list (query.py:699-703) —Nonestays byte-identical. CLI gets--section
withchoiceson all four search subcommands.vault_fts_search/vault_hybrid_searchmirror
section(zod enum);assemble_contextgainsinclude_research(defaultfalse→ passes
--section memory). MCP schema-budget gate (10,800 chars total / 450 per string) stays green,
landing at 10,748.packages/create-vkm-kit: the generatedobscura-webMCP config gets
OBSCURA_RESEARCH_DIR = <vault>/RESEARCHby default (override:--obscura-research-dir).
New skill/vkm-research(fourth vkm skill): consolidates a topic's sources into a quality
summary.md(wikilinks,supersedeson contradiction), marks the hub, and doubles as the
import path for hand-authored reports dropped intosources/. Vault scaffolding seeds
RESEARCH/_index.mdand links it fromSTART_HERE.md's vault map (no orphan-at-birth). Managed
CLAUDE.md/AGENTS.md block gains a short es/en "Investigación/Research" rule (research recall via
section:"research", memory recall stays uncontaminated, the memory-close ritual never writes
underRESEARCH/,origin: webnotes are untrusted data) — budget raised as a reviewed
decision (ADR-0036 precedent): es 8,660→9,375 chars (budget 9,100→9,850), en 8,421→9,093 chars
(budget 8,850→9,550), ~5% headroom kept.packages/create-vkm-kit242/242 tests green (1
unrelated skip).- Docs: ADR-0056,
docs/{es,en}/glosario.md/glossary.md(RESEARCH/,obscura_consolidate,
theraw → draft-local → consolidatedlifecycle),packages/obscura-web/README.md.
-
obscura_research: deep web research as local CPU/RAM work, not tokens (ADR-0054). Third tool
inpackages/obscura-web, alongsideobscura_fetch/obscura_search. Origin: a user pushback that
obscura_search's 20-result ceiling was too shallow for real research, and that the fix must not be
"dispatch a subagent" — a subagent still spends real LLM tokens/quota, it only relocates the cost out
of the main conversation. The actual lever:obscura/SearXNG already run as local, per-request/
on-demand OS processes (ADR-0051/ADR-0052) that cost zero tokens to execute; the gap was that the
"search deep" loop lived in the agent calling the MCP N times.obscura_research(query, max_candidates=50≤300, top_k=8≤30, passage_chars=800)closes that gap: (1) walks SearXNG'spageno
server-side (searxngSearchextended with apageparam, verified live against
docs.searxng.org/dev/search_api.html) to gather up tomax_candidates— one loopback HTTP
round-trip per page, zero LLM tokens; (2) ranks every candidate's title+snippet locally with BM25
(rankCandidates, k1=1.5/b=0.75, +1-smoothed IDF so a small pool never scores negative) — the
candidate pool is its own corpus, no external index or embedding call; (3) fetches only the top
top_kvia the existingobscuraFetch; (4) excerpts each to the paragraph(s) that actually match the
query (extractPassage) instead of returning the whole page. Response carriesscanned(candidates
gathered) andfetched(pages read) alongsideresults, so the agent sees how deep it searched
without paying tokens for the difference. Honest degrade: no local SearXNG → skips the pageno loop
and falls through tosearchWeb's existing single-page scrape chain (same ceiling as
obscura_search) rather than hand-rolling fragile per-engine offset pagination against
DuckDuckGo/Bing/Brave HTML —serp.mjsalready documents why that path doesn't scale. Zero new npm
dependencies. 47/47@vkmikc/obscura-webtests green (11 new), 49/49 elsewhere in the monorepo,
unchanged. -
obscura_researchcurates pages with a local Ollama model instead of keyword overlap
(ADR-0055). Follow-up user pushback on the above: BM25/keyword-window extraction alone
systematically discards exactly what research is supposed to find — information that's related
to the query but doesn't share its vocabulary. Fix, per the user's own spec: a local LLM must
understand and relate each page, one page at a time, nothing accumulated in context, only the
already-curated info handed back.packages/obscura-web/src/ollama-client.mjs— a
self-contained adaptation ofvkm-spec/src/ollama-client.mjs's already-proven pattern (typed
OllamaUnavailableError, structured-outputformat, deterministic-fallback hard invariant;
read in full before reuse, not assumed), same default model (phi4-mini:3.8b-q4_K_M) and host
(127.0.0.1:11434) asvkm-specso both share one daemon.deepResearchnow runs one
checkOllamaper call (not per page); when available, each of thetop_kfetched pages is
curated individually via the newcuratePage(markdown, query)(input capped at 12,000 chars,
truncatedreported, never silent) before its full text goes out of scope — never combined
with another page's text, never returned to the agent, only the curated excerpt is. A single
page's curation failure degrades only that page to the ADR-0054 heuristic (now itself improved:
extractPassagekeeps the paragraph after a keyword match too, since elaboration rarely
repeats the matched term) — one flaky page never sinks the whole call, and the tool keeps
working with Ollama absent exactly like it keeps working with obscura/SearXNG absent. Every
result now reportsextraction: "ollama" | "heuristic"andrelevant: boolean. New env vars,
all optional:OBSCURA_RESEARCH_OLLAMA=0,OBSCURA_OLLAMA_HOST,OBSCURA_OLLAMA_MODEL,
OBSCURA_OLLAMA_KEEP_ALIVE. Zero new npm dependencies (reuseszod). 66/66
@vkmikc/obscura-webtests green (19 new). -
obscura_researchfinal result order now trusts Ollama's verdict over BM25. Found via a
real (non-mocked) end-to-end run against "bees":chosen's fetch order comes from BM25, which
is blind to meaning, but the response kept that same order even after a page was actually
curated — a page Ollama explicitly markedrelevant: falsecould still outrank one it
confirmedtrue, purely because BM25 scored the rejected page's title/snippet text higher.
deepResearchnow re-sortsresultsbyrelevant(stable — ties keep their original BM25
order) after curation. 67/67@vkmikc/obscura-webtests green (1 new, reproduces the ordering
bug with a keyword-stuffed-but-off-topic page BM25 would otherwise rank first). -
Write-time hygiene lint (
vault-lint.mjs): drift now dies at the write path. A manual
hygiene pass on a real vault (2026-07-12) found three classes of silent drift no tool prevented
at the source: 17 observation lines with non-canonical categories ([DECISIÓN],[LECCIÓN GENERAL],[regla]— invisible tovault_observations(category:'decision')), 6 broken
[[wikilinks]], and 18 orphan notes the graph couldn't reach. Everyvault_write_file/
vault_edit_file/vault_append_filenow returnswarningswhen the text being written has
(a) a non-canonical observation category (suggests the canonical one; per-vault extras via
memory-schema.json→observationCategories), (b) a[[wikilink]]that resolves to no note
(template placeholders<…>exempt), or (c) a NEW knowledge note (PROJECTS/STACKS/PRACTICES/
RULES) with zero links — orphan at birth. Strictly scoped to the text the call introduces
(never nags about pre-existing lines), warning-only (never blocks), fail-open (a lint crash
cannot break a write), and zero schema cost (no new tools, no description growth). -
Scaffolded vaults are born connected.
create-vkm-kit'sSTART_HERE.mdnow ships a
"Vault map / Mapa del vault" section wikilinking every note the scaffold creates (MEMORY,
SESSION_LOG, PRACTICES/*, RULES/TEMPLATE,_meta/agent-profiles) — the hub whose absence let
the 18-orphan drift accumulate. -
vault_auditno longer flags template placeholders.[[PROJECTS/<proyecto>]]in
RULES/TEMPLATE is deliberate scaffolding; the broken-link scan now skips<…>targets — the
same exemption the write-time lint applies. -
/vkm-designskill: professional, anti-generic design for any medium (ADR-0053). Third vkm
skill installed bycreate-vkm-kit. A designer-cognition core (references/designer-mind.md:
Gestalt, hierarchy-first, process order, crit vocabulary), a mandatory design-direction protocol
with a banned-by-default "slop fingerprint" (references/direction.md), quantified foundations
(type scales, OKLCH palettes, motion numbers, inline WCAG formula), a live-verified library map
across web/mobile/desktop/TUI/dataviz (references/libraries.md— candidates unversioned, hard
verify-online rule), four modes (generate,critique,visual-loop,handoffwith DTCG
tokens), executable lineage capsules (references/lineages.md— real free typefaces + OKLCH
seeds + shape/motion numbers per design language), a fully worked brief→deliver example with
real validator output (examples/worked-example.md), ceremony scaling (full protocol for new
surfaces, inherited direction for edits, numbers-only for micro-tasks), and four zero-dep
validators with CLI exit codes and unit tests:scripts/contrast.mjs(WCAG; accepts hex AND
oklch()per CSS Color 4 with gamut-clip detection),scripts/scale.mjs(check +--gen),
scripts/palette.mjs(gamut-aware OKLCH neutral/accent ramps + semantic ok/warn/danger tokens
searched to a guaranteed >= 4.5:1 on both surface extremes;--jsonemits DTCG tokens) and
scripts/audit-css.mjs(static stylesheet audit for critique mode: declared color pairs with
var()resolution, font sizes, spacing rhythm — honest static-only scope), plus
scripts/slop-check.mjs(the anti-generic fingerprint mechanized: scans HTML/CSS for
Inter/Poppins defaults, the indigo/violet family, gradient text, glassmorphism, emoji
iconography, uniform radii and stock shadows — exit 1 until each hit is justified or
replaced; measured end-to-end inevals/design-bench/). Critique mode also
gains an "infer the incumbent direction" workflow for edits inside existing systems, and
references/marks.mdcovers logo/wordmark/favicon craft (construction, optical corrections,
16px→512px test matrix, SVG hygiene). After user feedback that a gate-passing page can still
be forgettable,references/contemporary.mdadds the ceiling: time-stamped award-level
currents (2026, verified online) as executable recipes with their slop-versions, a "boldness
budget" (1–2 full-intensity moves per surface, in the first viewport) and the lineup test
(logo hidden, distinguishable from ten templates?) wired as a Major finding in critique, a
per-iteration check in the visual loop and a hard requirement of the committed direction —
correct is the floor, memorable is the target.references/illustration.mdhandles any bespoke
figurative drawing of a real thing (species, product, landmark, mascot): because hand-plotting
bézier coordinates cannot depict a complex real subject faithfully — a model emits the category
average, so a hand-drawn "guapote" cichlid came out a tuna with every gate green — it teaches a
technique-matching decision (trace a reference / treat a photo / icon library / hand-draw only if
abstract), a real tracing pipelinescripts/trace-svg.mjs(potrace + jimp: reference → Otsu/
alpha mask → vector → restyle), and an IoU fidelity gate (measured overlap of trace vs
reference, ship ≥ ~0.7). Proven onevals/design-bench"The Cabinet": 7 subjects across 3
categories traced from CC/CC0 references, IoU 0.66–0.92.examples/illustration-gallery.mdruns
the technique choice across 8 subjects; wired as a Major finding in critique/visual-loop and a
build step in generate.illustration.mdStep 4 +foundations.mdadd the mixed-ratio
alignment rule: logos/avatars/icons/illustrations of different native aspect ratios in one
grid render at different sizes and off-centre (a "desfase") — normalize each to a shared box by
its longest side (object-fit: containfor raster; shared squareviewBox+
preserveAspectRatiofor SVG), withtrace-svg.mjs --squareproducing grid-ready output at
trace time.trace-svg.mjsalso cleans the mask before tracing (largest connected
component + fill-holes + morphological smoothing) so the trace follows the subject's shape, not
the photo's background grain and interior texture — a raw threshold traces the noise and ships
a speckled artifact; a cleaned one traces to a single smooth path. Documented limits (both
caught by the IoU gate): a busy background can't be thresholded, and fill-holes closes
meaningful holes — so the rule is trace CLEAN references, verify, and drop what won't come out
faithful rather than ship artifacts.scripts/treat-photo.mjs(jimp-only) covers the table's
other faithful branch — treat a real photo with duotone / halftone / cutout when a subject
has no clean line to trace (fine texture, a portrait): the output IS the photo so it stays
faithful with full detail, the treatment makes it one system, no trace artifacts. The
illustration toolkit now matches the decision table end to end: trace a clean reference OR treat
a photo — both faithful, neither hand-plotted (evals/design-benchrun 6f). The lineage
catalog quadrupled (8 → 16 executable capsules: naturalist/field-guide, heritage/workwear, Y2K
chrome, groovy 70s, sci-fi HUD, Scandinavian folk, collage/zine, soft-dimensional/clay) and the
contemporary currents grew 10 → 14 (image-treatment-as-identity, sticker/badge layer, giant
cropped wordmark, custom cursor/selection). Final polish from live A/B feedback: the skill
declares no house style (a model's second-order comfort look — one favourite type pairing/
colour world reused across briefs — is named in the slop fingerprint), the variety sampler
forces rotation on five axes (hue, lineage ≠ last build, type pairing, current, layout
topology — the reused header→hero→grid→pricing→giant-wordmark skeleton is called out as
structural convergence), andfoundations.mdadds "when the image IS the content, show it
WHOLE" (natural ratio /contain, never a cover-crop gallery).vkm-discipline's
domains/design-ui.mdstays the acceptance gate and now points to the skill. -
vault_delete_file+vault_move_file: note lifecycle lands in the hybrid MCP (17 tools).
Until now the vault surface could create and edit notes but never retire or reorganize them —
deletes/renames meant dropping to the shell (outside the vault lock, no path-escape checks, no
etag precondition). Both tools run under the advisory write lock withsafeVaultPathvalidation
and opt-inifMatch. Safety rails, chosen so an agent mistake is recoverable and a broken link is
visible instead of silent:- Delete is soft by default — the file moves to
.trash/<same relative path>(Obsidian's
vault-trash folder, uniquified on collision);permanent: trueunlinks for real and is the only
way to remove something already in.trash/. Restore is justvault_move_fileback out. - Move refuses an existing destination without
overwrite: true, creates destination parent dirs,
and runs the destination-sidememory-schema.jsoncheck like a fresh write there would. - Both refuse directories and the core protocol notes (
START_HERE/MEMORY/SESSION_LOG/
KNOWN_FAILURES), and neither rewrites[[wikilinks]]— the result reports which notes still
reference the old name (linkRefs/staleLinkRefs, boundary-checked so[[typescript-advanced]]
never counts fortypescript) so the agent fixes links deliberately. - Schema budget (ADR-0035) raised 8,000 → 9,200 chars in the same change: two destructive-capable
tools whose rails must be visible in the schema, not discovered by error. Also fixes the stale
"fourteen tools" count in the stack rule (it was 15 —assemble_contextwas never enumerated).
- Delete is soft by default — the file moves to
-
Note-lifecycle tool set completes the hybrid MCP surface (22 tools):
vault_append_file,
vault_frontmatter_set,vault_backlinks,vault_git_history,vault_rotate_log.vault_append_file— CRLF-aware append (creates if missing, normalizes newlines to the file's
EOL, guarantees separation): the close ritual'sSESSION_LOGone-liner no longer needs a read +
single-line-anchor edit round-trip.vault_frontmatter_set— set/remove top-level scalar YAML frontmatter keys without
text-matching (status: hypothesis→confirmed,last_verified). Creates the block when absent,
preserves everything else byte-for-byte, refuses nested/multi-line values instead of corrupting
them, JSON-quotes values YAML could misread.vault_backlinks— read-only "who[[links]]here" (boundary-checked, self-references
excluded); the impact check before a delete/move, and it works for already-deleted targets.vault_git_history— read-only bridge to the sync repo: a note's commits, or its content at a
rev(strict hash/HEAD~Nallowlist, never parsed as a git option) — recovery even after
permanent: true. Old content returns inside the untrusted-data envelope like any vault read.vault_rotate_log— MCP face of the existingrotate-logengine via a new Python
json-rotate-logsubcommand (same options, one JSON object out): archive oldSESSION_LOG.md
sections keeping the newest N; moves, never deletes;dryRunpreviews. Closes the loop with
vault_audit's bloat warning in-session.- The rotate engine (
rotate.py) now also rotates flat bullet logs: whenSESSION_LOG.md
has no##sections, top-level-bullets (the close ritual's "one line at the end" format,
indented continuations attached) are the rotation unit; sections still win on mixed files so a
bullet list inside a section is never rotated on its own.RotateResult/json-rotate-loggain
amode: "sections" | "bullets"field. Found live: the shipped vault convention writes bullet
lines, so section-only rotation was a silent no-op (sections_total: 0) on real logs.
vault_memory_report's recent-activity digest (reflect.py) shares the splitter and inherits
the fallback — a flat bullet log now counts its newest entries instead of reporting zero activity. - Schema budget: 9,200 → 10,800 chars (measured 10,622) — five tools at ~320 chars each, already
trimmed to the load-bearing contract.
-
npm run preview:assets— local preview server for theme-aware SVG assets. Tiny
dependency-free static server (scripts/preview-assets.mjs,127.0.0.1:4180) for visually
verifyingdocs/assets/*.svgin a live browser, where GitHub'sprefers-color-schemetheming
actually applies. Exists because agent browser panes commonly blockfile://navigation (hit
live while QA-ing the hero update). Read-only, GET/HEAD only, localhost-only, traversal-safe,
never serves dotfiles. Documented inCONTRIBUTING.md.
Changed
- Docs refresh + restyle across the repo (docs +
--helptext only, no runtime change).
RootREADME.md/README.en.md: npm / node / platform badges + an at-a-glance suite strip.
docs/{es,en}glossaries: the ten missing 4.x terms added (vkm-kit, token-saver,
vkm-doctor,vkm-spec, skills,assemble_context, knowledge graph, memory report,
obscura-web, SearXNG).docs/observability.md: thevkm-doctorsurface (local OTLP sink on
127.0.0.1:4319→~/.vkm/telemetry/, labelled--include-transcriptsfallback) was entirely
undocumented — now surface #2, summary table included.packages/create-vkm-kit/README.md:
staleVahlame/obsidian-memory-kitrepo URLs fixed, suite paragraph added, Options table
completed with the twelve missing flags (--minimal,--pin-failures/--usage-boost,
--memory-enforcement,--effort-gate,--token-saver/--terse-style,--telemetry,
--skills/--agents,--ollama,--uninstall). Newpackages/vkm-doctor/README.mdand
packages/vkm-spec/README.md(neither had one).docs/README.mdhub: usage / migration /
glossary links.--helpdrift fixed: the skills paragraph now lists/vkm-design(ADR-0053)
and says "the three skills" (was "the two").docs/assets/hero.svg: new bottom band "Suite de
eficiencia 4.x" (token-saver, vkm-doctor, vkm-spec, skills, obscura-web) — the hero only told
the 3.x memory story; same light/dark theming, new accent contrast-checked (5.45:1 light /
6.95:1 dark), README alt texts updated to match.