GET /api/v1/.../pages/<path> sent an ETag over the markdown body and the
author's name and email only, and answered 304 to a matching
If-None-Match. The JSON it returns also carries pinned, tier, title,
kind, frontmatter, updated_at, and the page's links and backlinks, and
all of those change while the body stays byte-identical: pinning a page
or retitling it in frontmatter writes a new version with the same body,
and another page linking to it changes its backlinks without touching
it at all. A client revalidating after the 300 s max-age was told 304
and kept showing the stale page.
The handler now builds the response first and hashes the serialized
JSON, so the tag changes exactly when the representation does. The
links query moves ahead of the 304 check, which is the cost of that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
parse_markdown found the frontmatter fence only as "---\n" at byte zero
and "\n---\n" to close it. A wiki checked out on Windows with
core.autocrlf=true has CRLF throughout, and one saved as "UTF-8 with
BOM" opens with U+FEFF, so either missed the fence: kind, tier, tags and
pinned were dropped, the title came from the body's H1 instead of the
frontmatter, and the whole YAML block was imported as the top of the
page body.
The fence is now found the way ai_memory_wiki::markdown::parse finds it
since #663 and #908: past a leading BOM, with LF or CRLF fence lines,
and the YAML's trailing CR trimmed. The body keeps its own line endings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
inject_into_head scans the page for the first <head> one byte at a time
and slices html[cursor..] at every step. A byte index inside a
multi-byte UTF-8 character is not a char boundary, so the slice panics.
serve --web-ui-dir injects into the custom SPA's index.html while it
mounts, so an index.html saved with a UTF-8 BOM, or with any non-ASCII
text ahead of <head> outside a comment, stopped the server at startup
with "byte index 1 is not a char boundary".
The scan now advances by the length of the character under the cursor.
The built-in pages start with ASCII up to <head>, so their output is
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
extract_links skipped fenced code blocks but read every other line
whole, so a wikilink or markdown link inside an inline code span became
a stored link. The /web renderer shows that span as code, as its own
preprocessor comment says the engine does too, so the page offered no
link while the target listed it as a backlink. A page explaining
the syntax with `[[other-project:notes/example]]` got a broken
cross-project link finding from lint for a dependency it does not have.
Each line now has its inline code spans (backticks included) replaced
by spaces before the two extractors run. Blanking instead of cutting
keeps a link whose label is code, like [`foo`](foo.md). A backtick run
opens a span only a run of the same length closes (CommonMark 6.1), and
one with no closer on the line is literal text, so a stray backtick
hides nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The /web page view dropped the body's leading H1 whatever it said, on
the premise that the first H1 is the page title the header already
shows. The title comes from derive_title instead: a frontmatter
`title:` outranks the H1, and only an ATX `# ` line can name a page, so
a setext H1 never does. A decision page written with
`title: Auth decisions` over `# Token refresh after sleep`, or a page
opening with a setext heading, lost that heading from the rendered
page, the only place the page said it.
strip_leading_h1 now takes the page title and drops the H1 only when
its text is that title, which is the duplicate it exists to remove. An
H1 that repeats the title, the common case (auto-improve and the session
synthesizer both open with `# {title}`), is still dropped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /admin/delete-workspace now accepts an optional "dry_run": true,
which always wins over "force" -- {"force": true, "dry_run": true}
still only previews, the same way purge-project treats its own
dry_run field. The preview runs the same lookups and counts a
confirmed delete uses to decide what to remove (same 404/409),
through ops::delete_workspace's new PurgeMode parameter, which now
also reports sessions_deleted, observations_deleted, handoffs_deleted
and embeddings_deleted on the confirmed response -- previously only
projects_deleted/pages_deleted/workstreams_deleted/managed_runs_deleted
were reported despite the cascade removing all of it. Two new fields,
present on both the preview and the confirmed response, cover what the
delete reaches into a *different* workspace through this workspace's
sessions: collateral_observations_deleted and
collateral_handoffs_denulled, the same shape purge-project already
reports one level down (observations.session_id is ON DELETE CASCADE
and handoffs.from_session_id/accepted_by_session are ON DELETE SET
NULL, neither scoped to the deleted workspace_id). A preview never
issues the DELETE, the purged_scopes tombstone, the admission call,
wiki directory removal, or mirror dispatch; a missing workspace is now
NotFound under Preview too (previously only detected via the DELETE's
own row count, which Preview never runs), mapped to the same 404 a
confirmed delete gives. merge-workspace's own use of delete_workspace
(draining a workspace then removing the empty shell) is unaffected --
it always runs Commit, never dry_run.
Cross-workspace agent_messages (V64) are affected by this same cascade
shape and are not yet counted or previewed, a known follow-up.
Store-level tests cover: preview reports the same whole
DeleteWorkspaceSummary struct (now PartialEq) a confirmed delete then
produces, seeding a managed run and a page embedding so those counts
are non-trivial; preview changes nothing (every table's row count,
including workspaces itself and the purged_scopes tombstone); and the
collateral shape (deleting a workspace collaterally deletes an
observation, and orphans a handoff's session reference, in a different
workspace). Admin-level tests add the HTTP equivalents plus: every
numeric field and workstream_ids compared between preview and
confirmed, not a subset; the raw workstream segment directory surviving
a preview and gone after confirm; {"force": true, "dry_run": true}
deletes nothing; 404 parity for both an unknown workspace and a race
where the workspace vanishes between lookup and delete; no webhook
dispatch on preview; an adversarial workspace-A/workspace-B control
where B holds its own full row set (including a session that stamps an
observation directly into A, correctly counted as A's own and never as
collateral); and root/DB-user/anonymous auth on the preview payload.
Docs: lifecycle-ops.md gains a "Preview with dry_run" section next to
the existing purge-project one and an updated matrix row;
security-boundaries.md gets a new 10d row describing the guard as
report-not-prevent for the cross-workspace cascade; CHANGELOG entry
under Unreleased.
The merged #952 recipe used `ai-memory status --format=json`, but StatusArgs
only has --json (no --format), so the snippet failed with unknown-argument.
Also widened the example script's SECRET_EXCLUDES default (and its doc
references) to include *.pem/*.key/*.crt so private CA bundles and keys under
the config dir are excluded from the mirror by default, matching the shipped
.gitignore.
Follow-up to #952 (#950).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDbhmszrjG9s5MrPrTuNtm
The merged #951 stated OpenRouter exposes an OpenAI-compatible /v1/embeddings
endpoint as fact; that is unverified (historically OpenRouter has not) and
would silently fail for a user who configured it. Reworded to a conditional
'verify it exists first' pointer and made the multilingual-embedder
recommendation provider-agnostic.
Follow-up to #951 (#949).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDbhmszrjG9s5MrPrTuNtm
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
Review of #961 found the ported TypeScript matcher claimed more than it
enforced. Both matchers now:
- recognize OpenClaw's and Devin's `exec` shell tool, and the TS list
gains the native aliases (`shell_command`, `terminal`, `execute_bash`,
`execute_cmd`);
- resolve relative arguments from a shell tool's `workdir` (OpenCode
`bash`, OpenClaw `exec`, Codex `shell`) instead of the event cwd;
- treat `dir/**` as covering `dir` itself when `dir` holds a glob, as the
TS already did; Rust charges that match to its budget.
TS `captureStartsWith` walks only the prefix instead of spreading both
strings per pattern and argument, and `captureGlob` reuses
`captureCharEq`.
The shell drop/keep tables move into a `shell` section of the shared
capture-policy fixture that both `shell_fixture_vectors` and the Node
runtime evidence execute, with tool-alias, workdir, glob-directory and
Windows vectors. The Linux CI test job installs Node 24 and runs the
Node evidence with `--ignored`, which now fails instead of skipping when
Node cannot strip types.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The native `ai-memory hook` drops a shell-tool event whose command line
names an ignored path (#946), but the generated OpenCode, OMP, Pi and
OpenClaw integrations still kept every `bash` call, so `cat
docs/adr/0001.md` captured the ignored file there.
`ts_capture_policy_v1` now ports the same lexical matcher: POSIX-style
word splitting without expansion, flag and `NAME=value` arguments,
`~/` expansion, cwd-relative resolution, a literal-prefix filter per
pattern, glob-reaches-directory matching, and fail-closed on an
exhausted match budget. Protocol fields for non-file tools stay
unchanged, so server re-inspection keeps agreeing.
The shared capture-policy fixture gains TS-adapter `bash` drop/keep
vectors that both the Rust fixture test and the Node runtime evidence
execute, and the Node evidence mirrors the native shell drop/keep
tables. marker-file.md drops the native-only caveat.
Closes#948
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W26vaSsKiAmpTizV9xfeFm
Adds a hermes skill-root family to install-skills (project .hermes/skills,
global ~/.hermes/skills) and the matching hermes entry in
memory_install_self_routing's target_hints. Hermes needs no special-casing:
both roots fall out of base.join(agent_dir).join(SKILLS_DIR), the same as the
other agent families. Completes Hermes routing support alongside its lifecycle
hooks (#933).
Closes#942
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 native hook, hooks/_lib.sh, hooks/lib/ai-memory-hook.ps1 and the
generated TypeScript plugins resolve the identity host-side, skip it
when the marker declares project or identity, and are each checked
against the core's shared fixture. New marker key: identity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014aqFKAuGVkuBoewmpA3cx9
/hook, /hook/batch items and /handoff accept identity / identity_src;
only the explicit and git_remote rungs route by identity, anything else
routes by name as before. The path-keyed project cache includes the
identity, so one path holding two repositories on two machines does
not answer for both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014aqFKAuGVkuBoewmpA3cx9
ai-memory-core gains the repository-identity resolution chain (marker
identity, marker project, upstream/origin remote normalised with
credentials stripped, folder name) and a shared fixture of remote-URL
cases. V70 adds projects.identity / identity_source with a partial
unique index per workspace and no backfill (case-only name clashes
would fail the upgrade). resolve_project_by_identity matches, claims in
place when the caller may write (slice 2's resolve_project_authz on
the same transaction), leaves it unclaimed otherwise, or splits a
different repository into owner-named projects; created projects
record created_by.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014aqFKAuGVkuBoewmpA3cx9
Without --confirm, purge-session only refused with a generic
"destructive and irreversible" message; the counts of what would be
deleted appeared only after a confirmed run actually deleted them.
Applies the same PR #945 pattern from purge-project to purge-session,
reusing its ops::PurgeMode::{Commit, Preview} and PurgeSummary's
collateral-count naming, plus a new shared CLI preview helper.
POST /admin/purge-session now accepts an optional "dry_run": true,
which always wins over "confirm" -- {"confirm": true, "dry_run": true}
still only previews. The preview runs the same lookups and counts a
confirmed purge uses to decide what to delete (same 404 for a session
outside the named scope -- verified adversarially: naming a different
real project in the same workspace, or a different real workspace
whose project shares the name, both 404 with no counts in the body),
including two new counts for rows a purge collaterally deletes or
orphans in a DIFFERENT project via this session's own id cascading
(collateral_observations_deleted, collateral_handoffs_denulled -- the
same shape purge-project's preview reports one level up, at project
rather than session granularity). It never issues the DELETE,
tombstone insert, or audit row: ops::purge_session takes a new
PurgeMode parameter and returns right after counting under Preview,
rolling back the (read-only) transaction. The HTTP handler's preview
branch goes straight through WriterHandle::purge_session, bypassing
Wiki::purge_session's file-removal loop entirely, since under Preview
the reported paths are predictions, not rows actually gone.
`removed_paths` is restructured, not behavior-changed, to make this
possible: it used to be computed by re-querying `is_latest` AFTER the
`DELETE FROM pages` loop had already run, which only answers the
question when a delete actually happened. It is now computed before
anything is cut, by excluding this purge's own `page_ids` from the
live rows found at each path instead. The two computations agree on
every existing test; the change is what lets a Preview run predict the
same set a Commit run would actually remove, without ever issuing a
DELETE to find out.
The CLI asks for this preview (bounded to a few seconds, including auth
refresh) before refusing, and prints "Would purge session from <ws>/<proj>:
N observations, ..." (the session id itself is never printed). A
404/403 (or anything else unexpected) prints the server's own error
before the refusal; a plain 400 (older server), a timeout, or an
unreachable server fall back silently to the existing refusal. The
preview machinery (timeout, outcome classification, refusal-message
formatting) is extracted into a shared commands::purge_preview module
so purge-project and purge-session cannot drift apart, and
purge-project's own command is refactored to reuse it.
Store-level tests cover: preview reports the same whole PurgeSessionSummary
struct a confirmed purge then produces (seeding an authored handoff, a
handoff only accepted -- which must survive and not count -- and an
auto-improve run with its own rejection row); the manual-page removed_paths
cases (later manual rewrite keeps it empty, prior manual page still gets
its path removed) agree between Preview and Commit; preview changes
nothing (every table a session purge touches, the purged_sessions
tombstone, and the audit_log row); and the mirror shape (purging a
session collaterally deletes an observation, and orphans a handoff's
session reference, in a different project). Admin-level tests add the
HTTP equivalents, confirm {"confirm": true, "dry_run": true} deletes
nothing (including that the session row and its observation survive,
not just its page file), 404 parity, the adversarial cross-scope check
above, no webhook dispatch on preview, and root-vs-DB-user-vs-anonymous
auth on the preview payload. CLI tests cover the summary-line wording
(session id never included), the pure outcome-to-message mapping, and
run_preview's HTTP-status classification against a real local server.
Docs: lifecycle-ops.md gains a "Preview without --confirm" section for
purge-session (plus a note that accepted-only handoffs keep their row
and lose only accepted_by_session, and that agent_messages/other-project
auto_improve_runs session references are nulled but not counted today --
a follow-up) and an updated matrix row; security-boundaries.md's 10b
row cites the new tests; CHANGELOG gets a matching entry.
Option A from #953 (proposed by @rntjr): [search.fts] stopwords config
key. Absent keeps the built-in English list byte-identical; [] disables
the filter; a non-empty list replaces the default outright. Case folds
with full Unicode case-folding (matching the unicode61 remove_diacritics
2 content tokenizer) but never strips diacritics, so an accented capital
still matches a configured lowercase entry while "e"/"é" stay distinct.
Threaded from Config::load through a new ReaderPool::set_fts_stopwords /
FtsStopwords, mirroring how [retrieval] already reaches ReaderPool.
Env override AI_MEMORY_SEARCH_FTS_STOPWORDS (CSV, matching
allowed_hosts/cors_allow_origins/trusted_proxy_cidrs) is applied as data
in Config::load rather than through the usual __-split figment Env
layer, since that layer merges raw values before any deserializer could
tell a present-but-blank var (must mean "unset") apart from a real
empty-list override.
Closes#953.
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>
Before recommending push-to-remote, the doc should name what actually
gets pushed. The wiki captures user prompts, tool I/O, and consolidated
page bodies, so pushing to any remote - private included - carries a
spillage risk that the reader needs to weigh before configuring an
origin.
Add a section 'Security: what ends up in your wiki' between the
data-dir description and the first push recipe, covering:
- The exposure: what the sanitizer catches, what it does not,
employer-managed cloud repos and account-shared visibility.
- A suggested mirror-repo .gitignore extending the derived-state and
secret-file exclusions already covered elsewhere in the doc.
- Encrypted alternatives when a private mirror repo is not enough:
restic/borg to object storage, git-crypt for selective encryption
inside the mirror, age-encrypted tarball to a second location,
local-only backup with off-site rotation.
The CHANGELOG line is updated to name the new section explicitly.
Refs #950.
Co-Authored-By: Claude <noreply@anthropic.com>
The initial CHANGELOG line was written for the smallest early draft
of the subsection and no longer describes what ships on merge. Rewrite
it to name the four blocks that actually landed: working env-file,
openai-compat embedder path against OpenRouter's supported embedding
models, pointer to the maintainer's model comparison, and the security
/ gotchas block.
Refs #949.
Co-Authored-By: Claude <noreply@anthropic.com>
Five operational notes that a new OpenRouter user hits but that were
not in the doc:
- Bind-address-vs-port-publish trap for Docker: the process bind does
not decide reachability; the container publish spec does. If the
port is ever non-loopback, set AI_MEMORY_AUTH_TOKEN and
AI_MEMORY_ALLOWED_HOSTS. Cross-reference docs/https-via-proxy.md
for TLS.
- Secret hygiene for the env file that holds LLM_API_KEY, and the
interaction with docs/backup.md (the sample script excludes *.env
by default).
- Cheap-model drift: consolidation writes durable pages, so a
fabricated kind:decision page becomes first-class evidence on next
read. Spot-check decisions/ during the first week on a new model,
or raise the tier for consolidation only.
- :free-suffix models on OpenRouter draw from a shared quota across
all free-tier users; use for evaluation, not production. Prefer a
model listed with multiple upstream providers.
- Reasoning-mode models are ineligible for the strict-JSON
consolidation prompt, matching the standing note in
install.md#llm-provider-tiers.
All items are descriptive and environment-agnostic; no specific model
version chronology, no fabricated-page examples, and no TLS
interception cross-reference (that content is filed as its own issue
and does not belong in the provider doc).
Refs #949.
Co-Authored-By: Claude <noreply@anthropic.com>
The subsection now defers to docs/llm-provider-comparison.md for LLM
model selection rather than duplicating rankings. The project already
maintains its own A/B testing there with a methodology (English
consolidation fixtures) that produces the authoritative recommendation
(anthropic/claude-haiku-4.5 by default, openai/gpt-5.4-mini as the
cheaper alternative, reasoning models ineligible). One paragraph
pointer keeps the OpenRouter subsection focused on OpenRouter-specific
configuration.
Also correct a stale backreference in the embedding paragraph
('model selection guide below' -> the guide is now the short pointer
above), and reword the English-vs-non-English embedding note to name
the two multilingual models directly rather than route back through
a section that no longer covers them.
Refs #949.
Co-Authored-By: Claude <noreply@anthropic.com>
The previous example was a three-line export block that omitted every
provider knob a real install ends up setting. Users following it needed to
cross-reference docs/install.md and rediscover AI_MEMORY_LLM_COMPAT_STRICT
on their own.
Rewrite the example as a minimal .env file (the shape the docker
--env-file and the systemd EnvironmentFile= flag both consume). Add
AI_MEMORY_LLM_COMPAT_STRICT=true with a one-paragraph rationale: some
model backends (DeepSeek notably) wrap JSON in markdown fences without
schema-constrained response_format, and the tolerant parser has to strip
them; the strict path is more reliable for consolidation. Change the
default model in the example to deepseek/deepseek-v4-flash, which better
matches the recommendation coming in the next commit (benchmark table).
Preserve the follow-up note that llm-test verifies the wiring and the
note that AI_MEMORY_LLM_MODEL is required.
Refs #949.
Co-Authored-By: Claude <noreply@anthropic.com>
The previous version of the OpenRouter subsection stated 'OpenRouter is a
chat gateway; it does not serve first-class embeddings' and directed users
to AI_MEMORY_EMBEDDING_PROVIDER=openai + AI_MEMORY_EMBEDDING_BASE_URL. Both
parts were wrong.
OpenRouter does serve OpenAI-compatible embedding models (baai/bge-m3,
intfloat/multilingual-e5-large, nvidia/nemotron-3-embed-1b:free among
others). The default /api/v1/models listing hides them; the ?category=embedding
filter (or the web UI's Embeddings filter) surfaces them. They answer the
plain OpenAI /v1/embeddings shape.
The correct provider name for hosted OpenRouter embeddings is
openai-compat, not openai. Config::embedder_config uses the OpenAiCompat
branch (crates/ai-memory-cli/src/config.rs:1856-1861), which reuses
LLM_API_KEY automatically when EMBEDDING_API_KEY is unset — so a single
OpenRouter key powers both LLM and embeddings without a separate credential.
Routing through AI_MEMORY_EMBEDDING_PROVIDER=openai would trigger the
OpenAI-specific key precedence chain documented in install.md#llm-provider-tiers,
which is more complex than necessary and rejects an EMBEDDING_API_KEY-less
setup unless AI_MEMORY_EMBEDDING_BASE_URL is set.
Replace the closing embedding paragraph with the openai-compat setup (env
vars, the automatic key reuse, the required AI_MEMORY_EMBEDDING_DIM), keep
the AI_MEMORY_LLM_BASE_URL-does-not-redirect-embeddings sentence, and add
a forward pointer to the model selection guide.
Refs #949.
Co-Authored-By: Claude <noreply@anthropic.com>
The docs already tell users that pushing the wiki to a remote git repository
is a supported backup pattern: docs/deploy.md#backups says 'markdown - back
up with rsync or git push to a remote', docs/design-decisions.md says 'No
remote/cloud sync (use git remote on the wiki dir)', and both
docs/airgapped-install.md and SECURITY.md defer the channel security to the
operator ('Remote sync security'). But the recipe itself is nowhere. A
homelab or laptop install has no built-in remote-push cadence, and building
one from scratch takes 100+ lines of bash plus systemd units plus a
gitignore that catches derived state and secrets.
Fill the gap with a docs-only addition:
- New docs/backup.md walks through what to include (wiki/, raw/, config.toml
minus secrets) vs exclude (db/ derived from wiki via reindex, models/
redownloadable, logs/, .serve.lock), the rsync + git push + tarball flow,
the systemd --user timer schedule, restore, and the SECURITY.md-aligned
posture (private repo, least-privilege push credential, secret exclusion,
encryption at rest is out of scope for ai-memory).
- A worked example under docs/examples/backup/ following the precedent set
by docs/examples/jev-reranker-adapter/ and docs/examples/auto-improve-eval/:
the snapshot script (configurable through six env vars), a systemd --user
.service oneshot, a daily .timer, a .gitignore for the mirror repo, and
a README with the install-and-enable steps plus non-systemd equivalents
for macOS launchd, Windows Task Scheduler via WSL, and Docker sidecar.
- One-line pointers from docs/deploy.md#backups (right after the tarball
routine) and docs/airgapped-install.md (extending the existing 'git
remote sync' bullet).
- A row in the README docs table between lifecycle-ops.md and
llm-providers.md, positioned as a companion to lifecycle-ops.md.
The on-box "ai-memory backup --to TARBALL" command is unchanged. This is
docs-only; no core CLI subcommand for remote push is proposed here (the
issue leaves that decision to the maintainer).
Refs #950.
Co-Authored-By: Claude <noreply@anthropic.com>
OpenRouter is already a first-class openai-compat target: the docker
env-example ships an OpenRouter default, llm-provider-comparison.md
benchmarks five OpenRouter models and recommends anthropic/claude-haiku-4.5
as the default for most users, and the OpenAI client layers OpenRouter's
HTTP-Referer / X-Title app-attribution headers automatically whenever the
base URL points at openrouter.ai. But docs/llm-providers.md mentions
OpenRouter only inside the generic openai-compat recommended-defaults row
and a see-also pointer at the bottom, so a new user reading the
provider-facing doc cannot answer the exact env vars, which model, or where
embeddings fit.
Add a recommended-defaults row for openai-compat + OpenRouter and a
subsection covering: the exact env vars ai-memory reads, a minimal working
example, the explicit non-acceptance of AI_MEMORY_LLM_PROVIDER=openrouter,
the model-selection pointer to llm-provider-comparison.md, one line about
the app-attribution headers, and one line about embeddings (OpenRouter is
chat-only; use local sentence-transformers or reuse openai +
AI_MEMORY_EMBEDDING_BASE_URL).
Refs #949.
Co-Authored-By: Claude <noreply@anthropic.com>
The example used to ask which document was most relevant, which pulled
index pages to the top. The replacement asks for the page that contains
the answer and keeps the choice-versus-rubric numbers labeled separately.
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>
Without --confirm, purge-project only refused with a generic
"destructive and irreversible" message; the counts of what would be
deleted appeared only after a confirmed run actually deleted them. An
operator purged a project believing it held 0 sessions / 0 pages, and
it actually held 1,063 observations reachable only through a different
project's session (a pre-#871 Windows path-casing split) -- recovered
only from a backup taken a minute earlier.
POST /admin/purge-project now accepts an optional "dry_run": true,
which always wins over "confirm" -- {"confirm": true, "dry_run": true}
still only previews, the same way reclaim-ledger-versions treats its
own dry_run field, so a preview request can never become destructive.
The preview runs the same lookups and counts a confirmed purge uses to
decide what to delete (same 404/409), including two new counts for
rows a purge collaterally deletes or orphans in a DIFFERENT project via
its sessions cascading (collateral_observations_deleted,
collateral_handoffs_denulled -- observations.session_id is ON DELETE
CASCADE and handoffs.from_session_id/accepted_by_session are ON DELETE
SET NULL, neither scoped to the purged project_id). It never issues the
DELETE: PurgeMode::{Commit, Preview} replaces the earlier commit: bool,
and Preview returns right after counting instead of running the delete
and rolling it back, so a preview of a large project doesn't hold the
single-writer actor for as long as a real purge would and starve every
hook capture queued behind it. Preview also skips wiki file removal,
admission webhook dispatch, and both checkpoints; a 200 preview is
therefore not a guarantee the confirmed purge will succeed, since
admission only runs on the confirmed path.
The CLI asks for this preview (bounded to a few seconds, including auth
refresh) before refusing, and prints "Would purge <ws>/<proj>: N pages,
..." plus any collateral-damage counts. A 404/409/403 (or anything else
unexpected) prints the server's own error before the refusal; a plain
400 (older server), a timeout, or an unreachable server fall back
silently to the existing refusal, so no existing script's behavior
changes except gaining information.
Store-level tests cover: preview reports the same counts a confirmed
purge then produces; preview changes nothing (every project-scoped
table's row count, including purged_scopes and audit_log); the incident
shape (an observation stamped into the purged project from a session
that lives elsewhere); and the mirror shape (purging a project
collaterally deletes an observation, and orphans a handoff's session
reference, in a different project). Admin-level tests add the HTTP
equivalents, confirm {"confirm": true, "dry_run": true} deletes
nothing, no webhook dispatch on preview, 404 parity, a 409 on a live
managed-run lease without --force, and root-vs-DB-user-vs-anonymous
auth on the preview payload. CLI tests cover the summary-line wording,
the pure outcome-to-message mapping, and run_preview's HTTP-status
classification against a real local server (400/404/200/connection
failure).
One choice question over the whole candidate list instead of one rubric
score per candidate; choice probability maps to relevance, which is sound
because the reranker leg is sort-only. +14/+21 hit@1 over the rubric shape
on the same 35B/4B backends (live golden set), 3x lower rerank latency,
and the only shape where replay-trained small judges hold their quality.
Scopes the absolute-relevance caveat to consumers that read absolute
values.
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
Hermes runs a configured hook command through shlex.split with the event JSON on stdin and no shell, so its integration is exec-form (like Zero and ZCode) rather than a .sh bundle: AgentChoice::Hermes (alias hermes-agent) makes install-hooks, setup-agent and finalize-session accept it, and no script subdir is staged.
build_hermes_hooks_yaml emits the ready-to-paste hooks: block for ~/.hermes/config.yaml with the two events ai-memory can act on - pre_tool_call -> pre-tool-use and post_tool_call -> post-tool-use. Their tool_name / tool_input payload is the envelope the router already maps for agent=hermes, which is what gives Hermes sessions tool observations, tool-family titles and capture exclusions.
install-hooks --agent hermes never writes the config file: it is YAML the operator also edits and Hermes gates user hooks behind its own acceptance prompt (hooks_auto_accept), so the block is printed for pasting - the same choice made for Pool. Session lifecycle is deliberately left to the memory-provider plugin, so no hook-driven session-end is installed and a session cannot be closed twice.
Verified against Hermes v0.21.4: the block parses through Hermes own hermes_yaml, _parse_hooks_block registers both events, split_command_line yields an argv that runs the native hook subcommand, and that command spools the event with rc=0. Docs updated in docs/install.md, docs/support-matrix.md and docs/mcp-install.md. Follow-up to #623.
(cherry picked from commit 18155089cd)