311 Commits

Author SHA1 Message Date
Arnav aca850937f feat(query): retrieval evals and event-time page validity (#220)
Two additions, both optional and additive.

`obsidian-wiki eval` scores the query index against a JSONL gold set —
recall@1/@3/@5, MRR, intent accuracy, and should_read precision — with
--min-recall/--min-mrr/--min-intent as CI gates. Ships a 15-page fixture
vault and 26-case gold set; baseline recall@5 0.950, MRR 0.900. The gold
set includes paraphrase cases sharing no vocabulary with their target
page, so the lexical index's ceiling is measured rather than assumed.

Event time adds three optional frontmatter fields: valid_from,
valid_until (inclusive), and superseded_by. A page whose window has
passed is historical, not wrong — it stays in the vault and in the graph
and only drops out of default retrieval, so `--as-of DATE` answers the
same question as of any date and `--include-historical` shows the whole
history. Structural intents (impact/bridges/hubs/clusters) deliberately
ignore the filter: deleting a page still breaks the historical pages
linking to it. lint reports temporal_errors (fail) and
superseded_dangling (warn). Pages that don't opt in are unaffected and
their JSON is unchanged.

Also fixes a classify_query bug the eval found on its first run: "what
do I know about X", the phrasing wiki-query documents, matched the gap
pattern because one alternation collapsed the affirmative and negated
forms. Only the negated forms are gap questions now.
v2026.09.1
2026-09-12 08:54:13 -07:00
JaySon 58aba1808c Make manifest source keys machine-portable (#217)
* docs(contract): define the portable source key contract

A vault can be checked out on more than one machine and synced over git, so a
stored source key must be machine-independent. Replace the "keys MUST be absolute
paths with ~ expanded" rule with three canonical forms:

- vault-relative for sources inside the vault (`Raw/x.pdf`),
- home-relative for sources under `$HOME` (`~/.claude/...`),
- a pseudo-key (any `scheme:` / `://` identifier) for anything that is not a file.

Never store a bare absolute path; normalize before comparing; identity must
survive a path change. The pseudo-key namespaces are recommended names, not a
closed list — what matters is the shape, so a custom scheme still works. Project
identity follows the same rule: `source_repo` (`host/owner/name`), with an
optional `~`-relative `source_cwd_hint` for a machine-local checkout.

Legacy manifests full of absolute keys stay readable, and `scripts/manifest.py
migrate` converts them (merging collisions, keeping the newest `ingested_at`);
after a cross-machine move the old vault root is supplied with `--from-root`.
This is the single canonical definition — the other skills reference it.

* feat(cache): resolve portable keys and classify cross-machine sources

Read and write manifest source keys in the portable forms instead of treating
the raw string as the identity.

Write side: `stored_key` maps a source to vault-relative (`Raw/x.pdf`) or
home-relative (`~/.claude/...`), and `update_source` records that form. A source
with no portable representation keeps its path only as a fallback and now warns
on stderr; an explicit `key=` from the caller is authoritative and re-keys an
entry the manifest previously tracked by path.

Read side: `resolve_key` normalizes every stored form to an absolute path (or
None for a pseudo-key), and `_same_source` / `_missing_on_disk` compare through
it, so an absolute query matches a vault-relative or `~`-relative key and legacy
absolute keys keep working unchanged.

Classification: `check_sources` gains an `unavailable` bucket. A key that is
machine-specific and points outside this vault may simply not exist here, so it
is reported as `unavailable` rather than `missing` — `missing` narrows to
vault-local sources genuinely gone, which is what `wiki-ingest` reads as
"optionally clean up". A relative key counts as vault-local only when its first
segment names a real top-level vault entry; a bare vault-root filename is
vault-local by construction, while a legacy ingest-root key, an out-of-vault
namespace, and a key written for another OS (drive-letter or backslash form) are
not. When the vault cannot be listed the lexical answer stands, so an I/O hiccup
never hides a real loss.

CLI: `cache-update --key` records an explicit pseudo-key
(`repo:` / `url:` / `agent:`) for a source outside the vault and `$HOME`, and
the JSON receipt reports the normalized key.

* feat(manifest): mirror the key helper, resolve delta keys, add migrate

`scripts/manifest.py` is a standalone stdlib helper that cannot import the
package, so it carries its own copies of `resolve_key` / `stored_key` (kept
honest by the parity test in `tests/test_manifest_portable_keys.py`).

`delta` now resolves every stored key to an absolute path before matching, so a
vault-relative or home-relative key is directly hit by a scanned absolute path
instead of showing up as new.

`migrate` (with `normalize` kept as an alias for existing instructions) rewrites
legacy absolute keys to the portable form: in-vault keys become vault-relative,
`$HOME` keys become `~`-relative, and pseudo-keys and legacy ingest-root-relative
keys are preserved untouched. A path with no portable form is kept as-is with a
warning rather than dropped, so provenance is never lost, and collisions merge by
keeping the newest `ingested_at` while unioning `pages_created` / `pages_updated`
/ `pages_produced`.

After a vault moves between machines its old keys are rooted at the *old* vault
path, which matches neither the new root nor `$HOME`; `--from-root
<old-vault-root>` (repeatable) strips it explicitly. Without it the summary says
`nothing portable to write — N key(s) kept non-portable` rather than claiming
success. The old root is never guessed — a wrong guess would write
plausible-looking but incorrect relative keys — so the hint names the flag to
pass instead.

* feat(lint): report machine paths in page sources frontmatter

A page `sources:` entry holding a machine absolute path cannot resolve on another
machine, so a synced vault silently loses the trail. Add a read-only finding,
`machine_path_sources`, that names the page and the offending entries.

It is reported at warn level and rewrites nothing — cleaning existing pages is a
one-time, owner-reviewed pass, not something a lint run should do. All three
frontmatter shapes are handled (flow list, scalar value, block list), and both
POSIX and Windows-drive absolute paths are recognized. `~`-relative and
vault-relative entries are fine and are not reported.

* docs: converge skills on the key contract and document the new surface

Point the dependent skills at the contract instead of restating (and diverging
from) it:

- `wiki-status`: describe portable keys, document the `Unavailable`
  classification, and identify a project by `source_repo` + `source_cwd_hint` in
  the manifest example.
- `wiki-ingest`: document the `unavailable` bucket and state that the manifest
  key and page `sources:` must be portable; note `cache-update --key` for a
  source with no portable path form.
- `wiki-update`: record project identity as `source_repo` and an optional
  `~`-relative `source_cwd_hint`.
- `wiki-query`: resolve a project's source from `source_repo`, falling back
  through `source_cwd_hint` -> legacy `source_cwd` -> page `source_path`, and
  report the repository rather than a machine path.
- `claude-history-ingest`: replace the "keys are absolute paths" instruction.
- `wiki-rebuild`: use an `$OBSIDIAN_VAULT_PATH` placeholder instead of a user path.

User docs get the same treatment: `docs/cli.md` documents `cache-update --key`,
`manifest.py migrate` / `--from-root`, the `unavailable` bucket and the lint
finding, and `docs/architecture.md` states the key rule. Structural doc tests
pin the contract section, the documented CLI surface, the portable manifest
example, and that all four skills agree on the project identity fields.

* fix(manifest): read the ingest timestamp under either field name

cache.py writes `last_ingested`; `_newest` and `cmd_delta` read only
`ingested_at`. On a real manifest both comparisons saw "", so a migrate
collision kept the first-seen entry rather than the newest, and every
already-ingested file came back MOD.

---------

Co-authored-by: ar9av <ar9avg@gmail.com>
2026-09-11 00:32:53 -07:00
Arnav cfc9e38549 fix(cli): read and write text with an explicit UTF-8 encoding (#218)
`read_text()`/`write_text()`/`open()` without `encoding=` use the locale
default, which is GBK on a Chinese Windows install. A global config holding
a non-ASCII comment then crashed every command that resolves the vault path
(#213).

Fixed at all 13 call sites rather than the four in the report: the same
fallback breaks setup's vault scaffold (hot.md carries an em dash) and
scripts/manifest.py on a manifest with non-ASCII source keys.

The regression test is a static AST scan, since the failure only reproduces
under a non-UTF-8 locale that CI does not have.
2026-09-11 00:31:32 -07:00
JaySon 0a9232f82d fix(deps): make uv lock/sync/run work, and declare pytest for tests (#214)
Every `uv lock`, `uv sync`, and `uv run` in this repo failed outright:

    No solution found when resolving dependencies for split
    (markers: python_full_version == '3.9.*')
    ... mcp>=2.0.0 depends on Python>=3.10 ...

uv resolves every Python version covered by `requires-python` (>=3.9), not
just the active interpreter, and the `server` extra pulled in `mcp>=2.0`,
which requires >=3.10. The 3.9 split therefore had no solution and the whole
lock was unusable.

Two changes:

- Mark mcp as 3.10+ inside the `server` extra:
  `"mcp>=2.0; python_version >= '3.10'"`. This states what is already true
  rather than adding a new constraint: the memory server is a 3.10+ surface
  (server.py imports mcp at module scope, and the Dockerfile ships
  python:3.12-slim). The pure-stdlib CLI keeps its 3.9 support, so the CI
  matrix is unchanged.

- Declare pytest in a `[dependency-groups] dev` group so the suite runs
  through uv with no extra flags (`uv sync && uv run pytest tests/ -q`).
  CI previously pip-installed pytest ad hoc.

uv.lock is regenerated. It predated the `server` extra entirely — no
fastapi/uvicorn/mcp entries — so uv re-resolved from scratch on every run.

Verified: `uv sync && uv run pytest tests/ -q` -> 701 passed, 5 skipped.
`uv sync --extra server` pulls mcp 2.2.0 / fastapi 0.141.1 / uvicorn 0.52.4,
and `import obsidian_wiki.server` yields `app = FastAPI`.
2026-09-11 00:01:01 -07:00
Arnav f8e286ab4d feat(staging): port staged-write promotion into Python (#215)
`_staging/` promotion existed only as agent instructions in
.skills/wiki-stage-commit/SKILL.md — `grep -rn "_staging" obsidian_wiki/`
returned nothing. That was fine while an agent was the only actor, but it
meant a UI or a CLI could not promote a staged page without writing a second
copy of the rules, which is exactly what #208 was trying to avoid.

obsidian_wiki/staging.py now owns the parts with one correct answer:

  list_staged(vault)  inventory, classified new / update / patch, with both
                      sides' content revisions pinned
  promote(...)        atomic os.replace to the live path
  discard(...)        move back to _raw/rejected-…, never clobbering an
                      earlier rejection of the same page

Promotion is a rename, so the page that lands is byte-for-byte the one that
was reviewed: arbitrary frontmatter, prose and wikilinks survive untouched.

Judgment stays with the skill. `.patch.md` files are listed but refused by
promote() — merging a human-readable diff into a page whose surrounding text
may have moved is not mechanical.

The revision check is the reason this exists. An agent can write to _staging/
or to the live page while a human is mid-review, so callers pass the hashes
they reviewed and get StagingConflict instead of a silent overwrite.
pin_live_absent covers the case a bare None cannot express: "there was no
live page when I looked."

Also:
- `obsidian-wiki staging list|promote|discard`, exit 9 on conflict so a
  caller can tell "re-read and ask again" from "bad input"
- server._resolve and the /v1/staging listing now use the shared module
  instead of their own copies
- SKILL.md calls the commands rather than restating the steps

Closes #212.
2026-09-10 23:33:54 -07:00
Arnav 3e9e6abeae fix(server): bind loopback by default, WIKI_HOST to override (#211)
main() bound 0.0.0.0 unconditionally. That was defensible while the server
was an API for remote agents; now that /ui serves a browser console over the
whole vault, a bare `python -m obsidian_wiki.server` on a box with a public
address exposes it to the internet.

Default to 127.0.0.1 and read WIKI_HOST to override. A container has to
listen on every interface, so the Dockerfile now sets WIKI_HOST=0.0.0.0
explicitly — `docker run -p 8080:8080` behaves exactly as before.

Closes the loopback-by-default point raised in #208.
2026-09-09 02:35:38 -07:00
Arnav c8f29b3e84 feat(server): read-only operations console at /ui (#210)
* feat(server): read-only operations console at /ui

Addresses the Phase 1 scope of #208 — a browser view of vault operations —
without the parts that turned out not to be needed.

Three read-only routes, all derived from the vault at request time (no
database, no cached state):

  GET /v1/status   page/category counts, _raw and _staging depth, manifest
                   source freshness (flagging pages_produced entries that no
                   longer exist), recent changes, git branch/dirty/log
  GET /v1/health   run_doctor() + lint_vault(), called directly rather than
                   shelling out to the CLI and parsing its output
  GET /v1/staging  staged pages with a unified diff against the live page

plus GET /ui, a single static file with no build step and no dependencies.

The console shell carries no vault data: it fetches with the same bearer key
API clients use, held in sessionStorage. No cookie, so no CSRF surface.
Diffs come from difflib as plain text and are inserted with textContent, so
agent-authored markdown cannot become HTML — covered by a test that stages
a page containing a script tag and an onerror handler.

Staged promotion and discard are deliberately absent: that behavior lives in
.skills/wiki-stage-commit/SKILL.md as agent instructions, not in this package,
so a UI button would mean a second promotion workflow. The view links out to
the skill instead.

* fix(server): read every manifest container shape, not just "sources"

Running the console against a real vault surfaced an empty sources panel:
that vault's .manifest.json has no "sources" key at all. The manifest is
written by several skills and is genuinely polymorphic — wiki-ingest keys
entries under "sources", wiki-update under "projects", wiki-research under
"research_sessions" — and each names its page list differently
(pages_produced / pages_created / pages_in_vault).

Read all three containers, reusing cache._iter_entries for the dict-or-list
handling it already does.
2026-09-09 02:20:53 -07:00
Amir Fathi fcb97dc7e4 fix(wiki-lint): correct SKILL.md's broken-wikilink and relationship-target recipes (#206)
* fix(wiki-lint): correct SKILL.md's broken-wikilink and relationship-target recipes

Check 2's "How to check" and Check 13's "Broken target" step described a
naive strip of the raw [[bracket]] contents, which flags a pipe-aliased
link ([[page|Display]]), a heading/block anchor ([[page#Heading]],
[[page#^blockid]]), a table-escaped pipe ([[page\|A]]), and a non-.md
embed (images, PDFs, .canvas, .base) as broken even when they resolve
fine. lint.py's own _wikilink_page_target/_normalise_node_id already
handle all four cases; the prose never caught up. Consolidate mode's
Action 1 reads Check 2's flagged list and rewrites or strips what it
finds, so the false positives were not just noisy output, they drove a
destructive fix on valid links.

Bring both checks' documented recipe in line with what the code already
does.

* docs(wiki-lint): skip attachment extensions, not every dotted target

A dot in a target is not always an extension: [[Node.js]] and
[[v1.2 release notes]] are page links. Match the prose to lint.py's
attachment allowlist so the skill and the CLI agree.

---------

Co-authored-by: ar9av <ar9avg@gmail.com>
2026-09-05 22:51:56 -07:00
bacca87 29e384cda2 fix(skills): parser-safe title/summary in canonical page template and server write_page (#207)
* fix(skills): parser-safe title/summary in canonical page template and server write_page

The canonical Page Template in llm-wiki/SKILL.md showed `title:` and
`summary:` as bare YAML scalars, and server.py write_page interpolated
them the same way. Free-text values containing ": " (colon + space),
"#", or quotes break YAML parsing - Obsidian then reports "Invalid
properties" and hides the frontmatter. Any ingest following the
canonical template can corrupt pages this way: one real vault ended up
with 25 unparseable pages from a single ingest of colon-heavy technical
summaries.

PR #3 already fixed wiki-update's own template with folded scalars; the
canonical template every other write skill follows (wiki-ingest: "Use
the page template from the llm-wiki skill") was left unguarded, and PR
#2 (quoted variant) was closed unmerged. PR #103's OKF export now
auto-repairs unquoted colons at read time - this fixes the source.

- llm-wiki/SKILL.md: Page Template (and the project-overview example)
  now emit title/summary as folded scalars (>-), with explicit
  parser-safety guidance matching wiki-update's merged wording.
- server.py write_page: emit title/summary via json.dumps - YAML 1.2 is
  a superset of JSON, so the emitted double-quoted scalar always parses,
  for any caller-supplied value.
- tests: regression test writes a page whose title/summary contain
  ": ", "#", and quotes and asserts both round-trip through a JSON
  parse of the emitted scalar; the existing round-trip assertion is
  updated to the quoted form.

* fix(server): emit folded scalars, not JSON, so the vault's own readers agree

json.dumps satisfies a YAML 1.2 parser, but _parse_frontmatter_values only
strips outer quotes — it never unescapes. A quoted title came back with
literal backslashes, and ensure_ascii=True turned every non-ASCII character
into a \uXXXX escape both on disk and in every downstream read.

A folded block needs no escaping, round-trips through pyyaml and the vault's
own parser alike, stays readable in the file, and matches the >- convention
this PR documents in llm-wiki/SKILL.md.

---------

Co-authored-by: Marco Baccarani <marco.baccarani@committa.it>
Co-authored-by: ar9av <ar9avg@gmail.com>
v2026.09
2026-09-05 14:05:40 -07:00
Amir Fathi f268d43744 fix(lint): stop flagging embeds and normalise .md/escaped-pipe wikilink targets (#204)
* fix(lint): stop flagging embeds and normalise .md/escaped-pipe wikilink targets

broken_links harvests every [[wikilink]] target, including embeds
(![[diagram.png]], [[board.canvas]], [[dashboard.base]]) that by_slug can
never hold since _iter_pages globs only *.md. An explicit .md suffix
([[alpha.md]]) and a table-escaped pipe ([[beta\|B]]) were also compared
raw against the slugged index instead of being normalised the way
_normalise_node_id already normalises a page's own id, so both never
resolved either.

_wikilink_page_target() returns None for a non-.md-extension target
(skipping attachment embeds) and otherwise strips an explicit .md suffix
and a trailing backslash before _parse_page slugs it. On the issue's own
repro vault this drops broken_links from 7 flagged (6 false positives) to
the one genuinely broken link.

Fixes #176

* fix(lint): only skip attachment extensions, not every dotted page name

Path("Node.js").suffix is ".js", so the previous `if suffix: return None`
dropped links to any page whose name contains a dot. Those links feed orphan
detection and link_count as well as broken_links, so "Node.js", "Next.js",
"v1.2 release notes" and friends were reported as orphans.

Skip only Obsidian's attachment extensions instead.

---------

Co-authored-by: ar9av <ar9avg@gmail.com>
2026-09-03 04:09:35 -07:00
Arnav 3f29e56d0b fix(cli): report the install mode that actually happened (#205)
On Windows without symlink privilege, install_skills falls back to copying
per agent directory. The fallback was tracked per call, so the warning
printed 12 times and the setup summary still claimed 'mode: symlink'.

Track the fallback module-wide: warn once, and report 'mode: copy'.

Verified on a Windows Server 2022 box (ACP 936, non-admin user).
2026-08-31 15:54:37 -07:00
Arnav c4b71c5f83 Merge pull request #171 from mike840609/fix/code-understanding-config-and-doctor
fix(cli): complete code-understanding config resolution and doctor hardening
v2026.08.7
2026-08-31 13:56:59 -07:00
Arnav fabcd7624d Merge pull request #198 from zorionarrillaga/lint-duplicate-stems
feat(lint): report pages that share a slugged stem
2026-08-31 13:45:46 -07:00
Arnav 100c8c693c Merge pull request #200 from AmirF194/fix/199-preserve-reserved-category-underscore
fix: preserve reserved leading-underscore categories in write_page's slug
2026-08-31 13:45:04 -07:00
Arnav 52cc28ef95 Merge pull request #203 from Ar9av/docs/uv-pipx-install
docs: document uv/pipx install paths (and the uvx footgun)
2026-08-31 11:05:07 -07:00
ar9av f13de99c8c docs: document uv and pipx install paths, warn about uvx
Closes #201
2026-08-31 10:55:49 -07:00
Arnav b6cd9639c3 Merge pull request #202 from Yiji2656/fix/windows-manifest-relative-path
fix: normalize relative paths in manifest delta matching
2026-08-31 10:53:53 -07:00
Roy b795ff17b3 fix: normalize relative paths in manifest delta matching 2026-08-31 17:01:54 +08:00
Amir Fathi 78ac04bddd fix: preserve reserved leading-underscore categories in write_page's slug
_slug() strips every leading non-alphanumeric character, so write_page's
rel-path build (`_slug(category)/_slug(title).md`) drops the underscore
from a reserved category like `_raw`. The four skip lists that exclude
unreviewed captures from graph analysis, lint, context packing and trust
scoring all key on the exact string `_raw` (and its siblings), so a page
written with category `_raw` lands in `raw/` instead and is silently
pulled into compiled-knowledge processing.

Add _category_slug(), which slugifies the category and prepends the
leading underscore when the caller's category started with one, and use
it wherever write_page builds the page path or its frontmatter.

Fixes #199
2026-08-30 14:22:49 +00:00
Zorion Arrillaga f085d14f04 feat(lint): report pages that share a slugged stem
`graph_analysis` identifies a page by its slugged filename stem, so two pages
whose stems slug alike are one node — and one entry in the graphrag index.
On a vault holding concepts/vector-search.md and entities/vector-search.md,
build_index keeps only the entities page; the concepts page is absent from
the index that memory_search and graph-query read.

duplicate_titles does not cover this: it keys on the title: value, and stems
can collide while titles differ.

Add duplicate_stems, built over graph_analysis.iter_pages with
graph_analysis._page_slug so the report keys as the merge does. It reads
pages rather than links, so a collision nothing references is still reported.
Severity warn, beside duplicate_titles.

Borrowing the graph's page selection means a collision inside _bootstrap/ is
now visible to this check, where lint's own SKIP_DIRS excludes that
directory. The existing bootstrap exclusion test still passes; the tradeoff
is called out in the pull request rather than settled here.
2026-08-28 01:05:20 +02:00
Arnav 37596cffee Add Discord badge to README
Added a Discord badge link to the README.
2026-08-26 13:08:35 -07:00
ar9av 427a9016b6 fix: graph-query false positive for non-English gap questions
_score() matched terms as bare substrings, so short function words
(German "ich", "den", French/Spanish equivalents) hit inside unrelated
longer words in a title/summary and scored a page unrelated to the
actual query term. Combined with an absolute index_only threshold,
this let an unrelated page get flagged index_only:true — telling the
calling agent it can answer without reading any page.

- _score() now requires a word-boundary prefix match (\bterm) instead
  of a bare substring, so short terms can't hit mid-word. Prefix
  (not \bterm\b) keeps inflected/plural matches.
- index_only now also requires the top candidate to lead the runner-up
  by 2x, not just clear an absolute score — a real hit does; noise
  scores cluster together.
- extended the stop-word list with common German/French/Spanish
  function words to cut the residual noise these fixes don't remove.

Fixes #191
2026-08-25 18:32:58 -07:00
Arnav 140a374a4b Merge pull request #197 from mike840609/codex/remove-superpowers-files
docs: remove superpowers planning artifacts
2026-08-25 18:24:59 -07:00
ar9av 4e4fad5a39 Skip wiki-capture Stop hook nudge while in plan mode
Plan mode restricts Claude to read-only actions, so nudging it to run
/wiki-capture --quick (which writes to the vault) just deadlocks the
session. Skip without claiming the sentinel so the real nudge still
fires once plan mode ends.

Fixes #194
2026-08-25 18:10:32 -07:00
Mike Tsai da0079e497 docs: remove superpowers planning artifacts 2026-08-26 09:04:43 +08:00
Arnav 7d4c7aed26 Merge pull request #196 from mike840609/feat/global-writing-profile
feat: add global wiki writing profile
2026-08-25 17:42:34 -07:00
Arnav 8b5859d0f8 Vault state management: manifest write safety, lifecycle transitions, and equilibrium mode (#195)
* fix(cache): lock and atomically write .manifest.json

update_source was a plain read-modify-write with no mutual exclusion, so
any two overlapping writers lost an entry silently: the second reader's
snapshot predated the first writer's write. This fires in three real
configurations — batch-plan fan-out dispatching parallel ingest subagents,
the Dockerized server writing a vault a local skill is also using, and a
scheduled daily-update overlapping an interactive session.

Reproduced with 12 concurrent update_source processes against one vault:
6 of 12 entries recorded before, 12 of 12 after.

Writes now take an advisory lockfile and replace the manifest atomically
via a temp file plus os.replace. O_CREAT|O_EXCL rather than fcntl because
this ships on Windows. Hashing stays outside the lock — it can take
seconds on a large source tree and nothing in the manifest depends on it.

The contention test spawns real subprocesses; a single-process test
cannot observe this race.

* feat(trust): flag illegal lifecycle transitions in lint

The lifecycle field is a state machine, but nothing enforced its edges.
Ledger entries now record the lifecycle at review time, giving lint a
baseline to compare the current frontmatter against.

Only edges that are illegal for every possible sequence of unobserved
intermediate states are flagged: falling back to draft (only ingest sets
draft) and exiting archived (terminal). draft -> verified is deliberately
not flagged — ledger snapshots are sparse, so a legitimate intermediate
reviewed may simply never have been recorded, and flagging that pair
would fire on valid history.

Additive: entries predating the lifecycle field carry no baseline and are
skipped, so existing vaults behave identically until their next
trust-record. Owner-extended lifecycle states never match the illegal
pairs, so the check degrades to a no-op rather than firing on states it
does not understand. Warns by default, fails under --strict-trust,
matching how the surrounding trust checks were already staged.

* feat(wiki-status): add vault equilibrium mode

The maintenance skills each optimize one shared vault for a different
objective, so any of them can undo another's work and no single skill
reporting clean means maintenance is done.

Equilibrium mode runs every maintenance audit in report-only form and
reports whether any skill still has a pending change. The vault is
converged only when all of them propose nothing.

It also detects oscillation via a snapshot in _insights.md, alongside the
existing GRAPH_SNAPSHOT: a skill whose pending count keeps returning
after being driven to zero is being reversed by another skill, and the
vault can never settle. Reported, never auto-fixed — resolving it is a
decision about whose objective wins.

Pending moves are attributed back to their manifest source, so a source
that repeatedly produces pages needing repair surfaces as a suggestion to
review its source_quality. Report-only; the owner still decides through
the existing trust flow.

Skill-only, no Python: lint already emits JSON counts and the other three
audits are LLM passes.

* docs: document manifest write safety, lifecycle checks, and equilibrium

Covers the advisory lock and atomic manifest write (including the
parallel-run contract), the illegal_lifecycle_transitions check and why
draft -> verified is excluded, and equilibrium mode in the architecture
feature list.
v2026.08.6
2026-08-25 17:19:03 -07:00
Mike Tsai 6234c0041c fix: close global writing profile review findings 2026-08-26 00:35:19 +08:00
Mike Tsai 248c2f80a0 docs: explain global wiki writing profile 2026-08-26 00:11:26 +08:00
Mike Tsai e74ee2393f docs: apply global writing profile contract 2026-08-26 00:08:16 +08:00
Mike Tsai e9af0968bf feat: keep writing profile setup paths aligned 2026-08-26 00:01:13 +08:00
Mike Tsai 6dfe1c639b feat: provision global wiki writing profile 2026-08-25 23:57:35 +08:00
Mike Tsai 60c39a757b feat: add wiki writing profile template 2026-08-25 23:53:18 +08:00
Mike Tsai 6d058de579 chore: ignore local worktrees 2026-08-25 23:48:14 +08:00
Mike Tsai e1c8ec60fc docs: plan global wiki writing profile 2026-08-25 23:29:36 +08:00
Mike Tsai cba910a7da docs(skills): list codegraph-gitignore check; document CODE_UNDERSTANDING resolution
code-understand skill's doctor capability list gains the new
code-understanding.codegraph-gitignore check; llm-wiki skill documents that
CODE_UNDERSTANDING_BACKEND / CODE_UNDERSTANDING_CODEGRAPH_BIN resolve like
OBSIDIAN_VAULT_PATH (env > project .env > global config > default).
2026-08-25 23:26:05 +08:00
Mike Tsai 6831c5f6e9 docs(configuration): document code-understanding settings resolution order
CODE_UNDERSTANDING_BACKEND / CODE_UNDERSTANDING_CODEGRAPH_BIN resolve like
OBSIDIAN_VAULT_PATH: env (empty = unset) > nearest project .env > global
config > default. Also note the --backend flag precedence in the CLI
reference.
2026-08-25 23:26:05 +08:00
Mike Tsai 562b2c6dd1 refactor(cli): point code-understanding doctor hints at the obsidian-wiki command
The codegraph-index / codegraph-fresh doctor hints told users to run
'codegraph index' directly, leaking backend implementation details (the
provider actually picks init vs sync itself). Both now say to re-run
`obsidian-wiki code-understand --project <project>`.
2026-08-25 23:26:05 +08:00
Mike Tsai 2259724f96 fix(cli): resolve code-understanding settings via project .env chain
code-understand and the doctor code-understanding checks now resolve
CODE_UNDERSTANDING_BACKEND / CODE_UNDERSTANDING_CODEGRAPH_BIN through the
same chain as OBSIDIAN_VAULT_PATH: os.environ (empty = unset) > nearest
walk-up project .env > ~/.obsidian-wiki/config > defaults (auto, None).
Previously only os.environ was consulted, so a config-file backend or bin
path was silently ignored; the codegraph bin path in particular had no
config fallback at all. code_understanding.py itself is unchanged — the
resolution layer lives in the CLI.
2026-08-25 23:26:05 +08:00
Mike Tsai bf8eff71fa feat(cli): doctor warns when .codegraph/ is not gitignored
New code-understanding.codegraph-gitignore check: when CodeGraph is in use
and the project .gitignore does not ignore the .codegraph/ sidecar, doctor
warns with 'add .codegraph/ to .gitignore' (comments do not count). The
sidecar stays a local disposable cache, not wiki knowledge.
2026-08-25 23:21:10 +08:00
Mike Tsai 4e3b7c9912 fix(cli): doctor --strict no longer fails on optional missing codegraph
Optional backend: when CODE_UNDERSTANDING_BACKEND is auto/builtin and the
codegraph binary is absent, the code-understanding.codegraph check now
reports info instead of warn, so doctor --strict exits 0. An explicitly
requested codegraph backend with a missing binary still fails. Add the info
icon to the doctor report.
2026-08-25 23:21:10 +08:00
Mike Tsai 55e5e3cb70 test(cli): RED anchors for code-understanding follow-ups
Pin the follow-up contract before implementation: optional missing CodeGraph
must report info (not warn) so doctor --strict stays green; the new
code-understanding.codegraph-gitignore check; CODE_UNDERSTANDING_* resolution
from project .env and ~/.obsidian-wiki/config; and doctor hints pointing at
`obsidian-wiki code-understand` instead of leaking codegraph internals.
2026-08-25 23:21:10 +08:00
Mike Tsai 6dc1f7c55e docs: design global wiki writing profile 2026-08-25 23:18:52 +08:00
Arnav 8a7f6af810 fix(code-understand): gate codegraph index_state on codegraph.db alone (#193)
codegraph 1.5.0 never writes .codegraph/source.json, so initialized was
permanently False, doctor's warning was cosmetic-looking but real, and
ensure_index() could never reach its sync branch — the index silently
never updated. source.json is now optional: used as a mismatch guard
when present, no longer required for initialized.

Fixes #192
v2026.08.5
2026-08-23 18:40:40 -07:00
georgelichen 7620af79ff Keep Windows skill setup working without link privileges (#189)
Windows agent installs can contain directory junctions, and standard accounts
often cannot create new symbolic links. Detect link-like entries safely, remove
junctions without traversing their targets, fall back to copied skills only for
WinError 1314, and make status output tolerant of GBK consoles.

Constraint: Windows users may lack Developer Mode or Create symbolic links privilege
Rejected: Require elevation or Developer Mode | blocks standard Windows accounts
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep --copy as the explicit deterministic mode; only WinError 1314 may trigger automatic fallback
Tested: pytest -q tests/test_cli_install.py tests/test_ast_extractor.py tests/test_cache.py tests/test_graph_analysis.py tests/test_graphrag.py (196 passed); py_compile; actual obsidian-wiki setup with the target vault (exit 0)
Not-tested: Full suite still has unrelated Windows doctor/environment failures
Related: #186 #187 #188

Co-authored-by: Chen Li <george_lichne@163.com>
2026-08-23 06:49:57 -07:00
stemForge 81b18b649e fix(graphrag): strip trailing punctuation on the gap and list query paths (#190)
classify_query() strips punctuation on the direct path but not on the gap and
list paths, where both end in .strip().split() -- and .strip() without arguments
removes only whitespace. "What do I know about mise?" therefore yields the term
"mise?", which matches no title, tag or summary, so the query returns nothing
even when the page exists. Dropping the question mark makes the same query work,
which is how the asymmetry surfaced.

Both paths now go through a shared _split_terms() helper that splits on
whitespace and strips the same punctuation set the direct path already used, now
named _TERM_PUNCT. The direct path keeps its behaviour unchanged: its len(w) > 2
guard still applies to the unstripped token, so only the inline literal is
replaced by the constant.

Gap queries nearly always end in a question mark, so this path was broken for
its ordinary input. Three regression tests cover the gap path, the list path,
and agreement between the gap and direct paths on the same subject.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:46:45 -07:00
Arnav 52c9f2bae2 feat(server): run a vault as a Dockerized HTTP + MCP memory service (#185)
Everything else here is local: skills on your machine, a vault on your disk.
This puts a vault behind a URL so an agent elsewhere can search and write it.

One container, one vault, one API key. The vault stays plain markdown on a
mounted volume, so Obsidian still opens it. The container does no LLM work --
search and packing delegate to the existing graphrag/context_pack modules, and
the caller's agent does the thinking.

- obsidian_wiki/server.py: four vault functions, five REST routes and four MCP
  tools as transport over them. Auth is one bearer key; the process refuses to
  start without WIKI_API_KEY unless WIKI_ALLOW_ANONYMOUS=1. Every path is
  resolved inside the vault or refused.
- Dockerfile, docker-compose.yml, .dockerignore, and a ghcr push on v* tags.
- New [server] extra; the core wheel stays dependency-free.
- docs/deployment.md, plus the WIKI_* vars in configuration.md and .env.example.

Requires mcp>=2.0: 2.0 renamed FastMCP to MCPServer, and session_manager only
exists once streamable_http_app() has been called -- a mounted sub-app's own
lifespan is ignored, so it is wired into the FastAPI lifespan by hand.
2026-08-21 14:37:37 -07:00
Arnav 9ae541c849 fix(cache): normalize path separators in sha256_dir for cross-platform stability (#184)
Fixes #178. sha256_dir() used str(fp.relative_to(path)), which yields
backslash-separated paths on Windows and forward-slash on POSIX, and sorted
entries by that same OS-native string. Both the hash input and the iteration
order now use as_posix(), so a directory source hashes identically across
platforms and unchanged vaults no longer re-trigger incremental ingest after
a cross-platform sync.
2026-08-20 18:22:05 -07:00
ar9av d7b030d9f1 docs(readme): swap hero image for local asset
Replace the GitHub-attachment hero image URL in README.md and
README_TW.md with a locally hosted assets/hero.png.
2026-08-19 14:11:18 -07:00
Arnav 05f8fd8527 fix(daily-update, wiki-ingest): wire up LINT_SCHEDULE and OBSIDIAN_MAX_PAGES_PER_INGEST (#183)
Both were documented in .env.example and docs/configuration.md but never
read anywhere in the project (#179).

- wiki-ingest: Step 4's page-planning cap now reads
  OBSIDIAN_MAX_PAGES_PER_INGEST (default 15) instead of a hard-coded
  "10-15 pages" literal.
- daily-update: new Step 4a runs wiki-lint on the LINT_SCHEDULE cadence
  (daily/weekly/manual, default weekly), tracked via a
  $STATE_DIR/.last_lint timestamp, and folds the result into the log
  line and user-facing report.
2026-08-19 12:39:04 -07:00