memory_query gated the reserved _global preferences union on 'no named
workspace/project'. The routing doctrine tells static MCP clients to pass
workspace+project on every call, which set that gate false, so static clients
never received global_scope_hits despite the documented contract (#930). The
union now keys on single-project resolution (scopes empty); an explicit
multi-scopes set is the only opt-out, and global=true/as_of are unaffected. The
double-search guard now resolves the queried project (named or active) rather
than the active-project default. The union remains keyed strictly to the
reserved _global scope and never leaks another project's pages (adversarial
test + security-boundaries row 1b).
Closes#930
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDbhmszrjG9s5MrPrTuNtm
Page files rewritten directly under wiki/<ws>/<project>/ (the OKF import
path) got their new version indexed but never embedded: reindex_page_locked
upserted straight into the store and never touched the embedder, so the
only place a page ever got embedded was the write_page API path the
watcher itself must never use. Hybrid search silently degraded to
FTS-only ranking for every watcher-driven rewrite until someone ran
`ai-memory embed` by hand.
reindex_page_locked now resolves the pre-upsert latest page id (when both
an embedder and a store reader are attached) and returns the embed inputs
for the caller to use when upsert_page mints a genuinely new version, so a
no-op reconcile pass never re-embeds a stable tree. Fails closed without a
reader, rather than embedding unconditionally, so a missing reader can
never turn into re-embedding the whole tree every RECONCILE_INTERVAL.
The embed call itself moved outside the mutation lock, mirroring
write_page: reindex_page drops its read guard before embedding, and
hard_delete_decay_tombstone (which holds the exclusive write lock for its
whole body) deliberately discards the pending embed rather than ever
calling out to the provider while that lock is held.
Deletion of pages whose file disappeared is not addressed here; the
watcher still only reconciles create/modify events (tracked in #929).
Refs #929.
The web renderer's wikilink preprocessor skipped every line indented four
spaces or a tab, as an indented code block. CommonMark only makes such a
line code where a code block can start, so a nested list item written with
four spaces (`- Decisions:` then ` - see [[decisions/auth]]`) or a
paragraph's continuation line rendered the wikilink as literal `[[…]]`
text inside an ordinary list item, while the engine's link extractor
indexed it as a link and listed the page in the target's backlinks.
The preprocessor now takes its code ranges from the renderer's own parser
(`into_offset_iter`, same options), skipping exactly the fenced and
indented code blocks and inline-code spans the page shows as code, and
rewrites wikilinks in the text between them line by line as before.
Indented code inside a list item, fences of either glyph, and inline code
stay literal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shell tools (Bash, shell, execute_bash, terminal, ...) were classified as
non-file and always kept under an active capture policy, so a command
such as `cat docs/adr/*.md` stored an ignored file's full text in the
post-tool-use observation body.
The native hook now tokenizes the command line lexically, resolves each
path-like argument against the event cwd, and drops the event when one
matches an ignore pattern or is a glob that can reach one. Protocol
fields stay unchanged for non-file tools, so server re-inspection keeps
agreeing and no server change is needed.
The marker-file reference documents the lexical limits and how to
exclude large tool results that Claude Code saves and re-reads from
another path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Grok Build CLI posts Claude Code's snake_case tool fields (tool_name /
tool_input / tool_use_id), but AgentKind::Grok was absent from both
closed_tool_agent (payload.rs) and the tool_observation_metadata agent
match (capture_policy.rs), so tool body extraction returned None and
every Grok PostToolUse observation was stored with an empty body while
still being captured. Grok now shares the Claude Code tool mapping.
Closes#931
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDbhmszrjG9s5MrPrTuNtm
BootstrapBatch.rationale is serde-defaulted, and Anthropic's tool_use
schema does not enforce required fields, so a chunk can return an
empty rationale. The manifest joined every chunk's rationale with
"---" separators, leaving bare separators for the empty ones.
Drop rationales that are empty after trimming before the join, and
say so when no chunk returned one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hbGY9oRywtM3Nz2NcXxUF
Bootstrap estimates tokens as bytes / 4 and filled --max-input-tokens
and --chunk-input-tokens to the last estimated token. That estimate
undercounts non-English text and source code (about 40% on Portuguese
mixed with code, as #884 measured for consolidation), so a chunk sized
to fit a model's context window could overflow it on input alone.
Fill 80% of each budget by the estimate, the default consolidation
uses for the same undercount, in both the prune and the chunk packing.
No flag or config key is added; a run may plan more chunks than before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hbGY9oRywtM3Nz2NcXxUF
A multi-page batch writes each update at a path the model chose. When
that path named an existing pinned page, apply_batch replaced its body
and wrote the new version with pinned = 0, because neither the batch
loop nor the wiki write path looks at the page being replaced. Pinned
pages are documented as immutable to automation.
Skip such updates with a warning, next to the existing invariant-slot
skip. _slots/ pages are pinned automatically and keep their own
state/invariant regime, so they are not skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hbGY9oRywtM3Nz2NcXxUF
build_chunk_request picked max_tokens from the chunk count: 16K when a
run had several chunks, 64K when it had one. A repository small enough
for a single chunk under default chunking therefore got the 64K cap
meant for --chunk-input-tokens 0, and a 64K-context model rejects that
request before reading any input.
Key the cap on the chunking mode instead: every call under chunking is
a chunk that fits the chunk budget and gets 16K; only a disabled
chunk budget (one call with the whole pruned bundle) keeps 64K. The
--max-input-tokens help also stops claiming that its 150K default
leaves room for 64K of output in a 200K window.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hbGY9oRywtM3Nz2NcXxUF
Identical harness runs were landing the same generic session-page title
and tripping the M8 duplicate-title lint. Prompt the model to name THIS
session and not reuse listed titles, suffix a colliding title with a
deterministic `(session <8-char-id>)` on both write paths, and keep the
uniqueness wording compact so the advertised 6000-token input floor
still projects observation bodies.
`ai-memory restore --force` deleted the live `wiki/` and `db/` before the
tarball had been opened, validated or extracted, and before the restored
store had been opened. A truncated or corrupt archive, an entry outside
the allowed layout, or a snapshot the current binary could not open (a
backup taken by a newer release, a torn file) therefore left an empty or
half-extracted data dir with nothing to fall back to — at the one moment
the operator has no other copy.
The tarball is now extracted and validated into a staging directory
beside the live data, the staged store is opened there so pending
migrations run and the snapshot is verified, and only then are the live
`wiki/`, `db/` and (when the archive carries one) `config.toml` renamed
aside, the staged copies renamed into place, and the previous data
deleted. Every move is a same-filesystem rename; a failed move reverses
the moves already made, and a failed reversal reports the directory that
still holds the pre-restore data. Scratch directories are removed in
every outcome.
A successful restore behaves as before: `wiki/` and `db/` become what
the archive holds, `config.toml` is replaced only when the archive has
one, `logs/`, `models/` and `raw/` are never touched, `--force` is still
required for a populated data dir, and the sibling-process guard runs
first.
Regression tests drive the new `restore_data_dir` directly: the four
"keeps live data" cases fail under the previous order of operations and
pass here; success-path cases cover the populated, empty and
config-less archives and reopen the swapped-in store.
Every session/observation `ai-memory backfill` imported was stamped with
started_at/ended_at/created_at at import time, discarding each transcript
event's own timestamp even though the workstream adapters already captured
it (`NewWorkstreamEvent::occurred_at`) — a session imported today from a
transcript recorded weeks ago showed up as having happened today.
`NewSession` and `NewObservation` gain an optional `occurred_at`
(microseconds); the store falls back to `now()` when it is `None`, so live
hook capture is unaffected. This includes `admit_hook_session_event`'s own
session-row INSERT (the real path a live `/hook` session-start event takes,
separate from `begin_session_row`) — missing that one meant a backfilled
session's `ended_at` (original, past) could sit before its `started_at`
(import time).
The `/hook` body accepts an RFC 3339 `occurred_at`, read from the top level
of the body only (not the nested `payload`/`event`/`properties`/`info`/`path`
search other hook fields use, so a harness payload that happens to carry an
`occurred_at` key elsewhere in its own structure is never mistaken for this
field). It is client-controlled input arriving over the hook endpoint, so
`HookEnvelope::occurred_at_micros` bounds it (must be > 0 and no more than
five minutes ahead of server time) before it is trusted; anything else —
missing, unparsable, or out of bounds — resolves to `None` rather than
erroring, keeping hooks fire-and-forget. It is numeric metadata, not text, so
it never goes through the sanitizer.
Backfill validates each transcript event's own timestamp (an unparsable one
is treated as missing) and threads the resolved time through `map_event` and
into the session-start/session-end items: a missing timestamp inherits the
nearest preceding valid one, an event before the first valid timestamp
inherits that first one, and the session's start/end times are the earliest/
latest valid event time in the transcript rather than assuming it is already
time-sorted.
Because a backfilled session's `ended_at` can land well in the past, it can
sit below the auto-improve review watermark and the cross-session
experience-pass anchor (both keyed on `ended_at`), so a freshly imported
session may not get an automatic review pass until a newer session moves
those forward; an opt-in retention window measured from an observation's own
time can also make an old backfilled observation immediately prunable rather
than only after it ages in place; and the "most recently active project"
restart fallback, which looks at how recent observations are, may not pick a
project that was just backfilled. These are documented consequences, not
regressions introduced here — they follow directly from timestamps now being
honest.
Pages written before the previous commit keep the bare date that
conform_frontmatter copied from expires_at, because conformance only
fills absent keys. serve now repairs them before the watcher starts:
the latest index row in place through the writer (same version row,
updated_at and generated.at untouched), then the page file with its
body untouched, in one wiki commit. A stale_after is repaired only when
it equals its date-only expires_at, the exact signature of the old
derivation; anything else was not written by ai-memory and is left
alone. conform_frontmatter applies the same rule, so a restore, a hand
edit or a reindex heals an affected page as well.
This is an idempotent startup pass rather than a registered
WikiMigration: a new migration name makes every older binary refuse
the wiki (NewerWikiFormat), which a patch fix should not force. The
repair adds nothing to wiki_migrations and is a no-op on a clean store.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKb3cisaT5r2kiW9mYiWvt
conform_frontmatter copied expires_at into the OKF stale_after key
verbatim. expires_at also accepts a bare YYYY-MM-DD (documented in
docs/usage.md as end of day, UTC), so such a page was written, and
exported by export-okf, with stale_after: 2026-10-01. OKF v0.2 now
requires every timestamp to carry an explicit UTC offset
(knowledge-catalog #323), and its earlier text read a bare date as the
start of that day, a day before ai-memory's TTL hides the page.
The end-of-day rule moves into ai_memory_core::parse_expires_at_instant,
which the wiki's TTL validation and the OKF derivation now share. A
date-only value becomes the instant it names
(2026-10-01T23:59:59.999999Z); an RFC 3339 value is still carried
verbatim, so existing pages keep byte-identical frontmatter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKb3cisaT5r2kiW9mYiWvt
A link target whose final path component is empty (`sessions/`, as a
`relations:` value or as `[notes/](notes/)`) is a directory, not a page. The
relation route appended the extension anyway — `sessions/` became the literal
`sessions/.md` — while the body/wikilink route kept it extension-less. Either
way the row was stored with `to_page_id = NULL`, and no page write could ever
repoint it: `latest_page_id_for_link` matches a target path exactly, and every
page path carries `.md`. One live store held three such rows across two
projects, visible only as `links ... (unresolved: 3)` in `ai-memory status`.
Both routes now share one predicate (`last_segment_names_a_page`) and skip a
directory target, or a stem-less `.md`, with the existing warning.
Nine ai-memory-cli tests failed on a machine whose shell exports a harness
store relocation: CLAUDE_CONFIG_DIR fails five (doctor/backfill, unit and
e2e), CODEX_HOME two in run.rs, PI_CODING_AGENT_DIR two in removal.rs.
Each plants its fixture under a temporary $HOME, and the variable, which
the code honors correctly, sends the lookup elsewhere.
- e2e_support::hermetic() drops the eight relocations
environment_session_dir_with reads, as it already drops AI_MEMORY_*;
the one-off CODEX_HOME removal in backfill_failures.rs goes away.
- doctor::scan_local and backfill::collect_local_sessions delegate to
_with variants that take the relocation lookup; production passes
relocated_session_dir (the build_launch_plan expression both had
inline), tests pass |_| None.
- The run.rs tests clear the Codex plan's session_dir, which also keeps
the passthrough is_none() check from passing for the wrong reason.
- removal.rs removes PI_CODING_AGENT_DIR next to its existing removals.
No behavior change outside tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7RSKQrakhnAfKjGaNaVR2
OMP was left out of the closed-tool agents introduced with the safe
tool-context capture (#190), so every OMP pre-tool-use/post-tool-use
observation was stored with the event name as its title and an empty
body: session pages, handoffs and the auto-improve reviewer only saw
the prompts.
The generated Pi extension is the OMP extension with its AGENT
constant renamed (build_pi_extension), so OMP posts the exact
tool/callID/args/output/isError payload Pi does. Route it through the
same metadata schema, closed-summary path and isError outcome; unknown
tool families still keep no output.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A link target whose final path component is empty (`sessions/`, as a
`relations:` value or as `[notes/](notes/)`) is a directory, not a page. The
relation route appended the extension anyway — `sessions/` became the literal
`sessions/.md` — while the body/wikilink route kept it extension-less. Either
way the row was stored with `to_page_id = NULL`, and no page write could ever
repoint it: `latest_page_id_for_link` matches a target path exactly, and every
page path carries `.md`. One live store held three such rows across two
projects, visible only as `links ... (unresolved: 3)` in `ai-memory status`.
Both routes now share one predicate (`last_segment_names_a_page`) and skip a
directory target, or a stem-less `.md`, with the existing warning.
Two gaps the same investigation surfaced:
- `memory_lint` reported unresolved links only through
`DanglingCrossLink`, so a broken same-project link stayed invisible outside
the status counter. `ReaderPool::dangling_internal_links` now feeds the same
`broken_link` findings.
- `ai-memory status --workspace/--project` scopes the `links` line to one
project. `GET /admin/status` accepts the optional pair and carries a
`links_scope` object (`ScopeLinkStatus`), so a multi-project store's
aggregate is not read as one project's. Without the pair, the response and
the human output are unchanged.
Follow-up to #886. The word-boundary cut searched `out[..60]` for a
hyphen, so a slug whose first 60 characters ended exactly on a word
(hyphen at index 60) dropped that word. And any hyphen counted, however
early: `a` followed by one 70-letter token collapsed the slug to `a`,
losing the rest of the title.
The window now includes index 60, and a hyphen in the first half is
ignored, so the cut falls back to the hard 60. Two regression tests;
reverting either change turns exactly its own test red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7RSKQrakhnAfKjGaNaVR2
`markdown::parse` matched the fence lines only as `---\n` … `\n---\n`,
so a page a Windows editor saved, or one `core.autocrlf=true` checked
out, had no frontmatter at all: reindex dropped its tier, pin and TTL and
titled it from the filename, and the one-shot OKF file pass wrote a
second frontmatter block above the authored one. Accept `---\r\n` fences
per file and leave the body's line endings untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
native_session_in_checkout (#880) compared the stored OpenCode directory
with cwd exactly. On Windows OpenCode stores C:/Users/me/repo while cwd is
C:\Users\me\repo (#891), so the check never matched there. Use the same two
spellings the other OpenCode lookups got in #882, and extend that change's
Windows test to cover it.
The router titles an untitled lifecycle event with its kind name. Once #895
made the title fallback skip tool-family labels, a real labels-only session
reached its Stop ("stop"), or "session-end" when no Stop was captured, and
took that as its page title. Skip those literals the same way SessionStart
is skipped, so the page falls back to its own identity.
The default filter prepends rmcp=warn so an operator can restore the SDK's
info lines through log_level (#894). A target directive beats the global
level whichever is louder, though, so under log_level "error" or "off" the
cap re-enabled rmcp warnings the operator had silenced. Leave it out when
the last bare level in log_level is already warn or quieter; an explicit
rmcp directive still wins as before.
Closes#902.
The startup banner only helps someone watching the startup. The case #902
describes - a .env.production where AI_MEMORY_AUTH_TOKEN was never filled in -
is found weeks later, by something polling, not by rereading boot output.
Adds HttpExposureReport to /admin/status and renders it in `ai-memory status`
(text and --json).
The verdict needs the *bound* address, which is not known until after AdminState
is built, so it travels in an Arc<OnceLock<_>> that serve fills once the listener
exists. Unset reads as Unknown.
An older server sends no field. The CLI defaults it to Unknown rather than Safe:
assuming safety from absence would be exactly the false reassurance this is meant
to prevent.
A test pins that the polled verdict and the startup banner cannot disagree - an
operator seeing "safe" in status while the banner said otherwise would trust the
wrong one.
Part of #902.
The warning for an unauthenticated non-loopback bind was emitted only through
tracing::warn!. The stderr layer in logging.rs sits behind EnvFilter, built from
config.log_level (default "info") or RUST_LOG, so log_level = "error" or
RUST_LOG=error silences the one line telling the operator the server is reachable
from the network without a credential. A security notice a log level can switch
off is not a notice.
Adds exposure_banner(), printed with eprintln! independently of the filter, for
both unauthenticated states: --allow-insecure-no-auth and the in-container bind
that #407 deliberately allows. Returned as text rather than printed so it can be
asserted. The structured tracing::warn! stays for log collectors.
No behaviour change to what is refused: #407's Quick Start carve-out still starts,
it is just no longer possible to miss.