Add the online reclaim command to the safety matrix and a
command-by-command section (dry-run default, content gate, --compact
to reclaim bytes), the operator guide AGENTS.md requires for new
destructive lifecycle ops. Follow-up to #927 (#914).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDbhmszrjG9s5MrPrTuNtm
A named profile (`--profile`, `OMP_PROFILE`, or the legacy `PI_PROFILE`) owns
`~/.omp/profiles/<name>/agent` and ignores `PI_CODING_AGENT_DIR`, as OMP does;
`install-hooks` wrote the extension into `PI_CODING_AGENT_DIR` instead. Profile
names are normalized and refused like OMP does, `PI_CONFIG_DIR` renames the
`~/.omp` root, and on Linux and macOS sessions move to `$XDG_DATA_HOME/omp`
when that directory exists. `install-mcp`, auto-wire, session import,
`backfill`, `doctor` and the `uninstall` sweep follow the same agent dir.
Refs #820
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
#660 stopped the indexer from superseding an OKF-conformed ledger on every
hook append, but the rows it already wrote stay and nothing removed them:
`compact` deletes nothing, `forget-sweep` only hard-deletes decay
tombstones (only `decay` writes `superseded_at`), and `reindex` loses the
DB-only state. One reported store holds 6,539 versions of 101 live pages,
95% of its rows.
Adds `ai-memory reclaim-ledger-versions` (`POST
/admin/reclaim-ledger-versions`), an online command that deletes only the
superseded versions of paths whose *content* opens with a hook log entry.
Dry run unless `--confirm`; `--drop-latest` also removes each ledger's live
row; `--compact` rebuilds the FTS index and VACUUMs to return the bytes.
The content gate is the load-bearing part and is now one definition:
`ai_memory_core::log_ledger` owns the predicates, `ai_memory_wiki::ledger`
keeps the file-reading half and delegates, and the store decides from a
`pages.body` with no filesystem access. A real page a human named
`log-2026-09.md` keeps its whole version chain. Only `is_latest=0 AND
superseded_at IS NULL` rows are eligible, so decay-owned rows stay with the
sweep that reasons about them.
Derived FTS/entity/vector/link rows go with the page through the existing
ON DELETE CASCADE. The FTS delete trigger is stood down for the bulk delete —
its DDL read back from sqlite_master and re-executed, so it cannot drift
from the schema — and pages_fts is rebuilt wholesale, which is what keeps
this from re-tokenizing tens of gigabytes of ledger body row by row.
Reading the gate needs a bounded prefix, not the whole body: GATE_PREFIX_BYTES
lives with the predicate, shared by the file reader and the store.
Refs #914
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.
Sessions imported by `backfill` before it carried per-event timestamps all
have started_at/ended_at on the import day, which flattens every
time-ordered view of that history. This adds an admin repair for sessions
already imported; it is independent of the companion fix to `backfill`
itself (#919).
The repair runs online, through the server's single writer, because it does
not need direct disk access (invariant 9). POST /admin/repair-session-times
validates a caller-supplied batch of (session_id, started_at, ended_at)
candidates in one transaction, scoped to (workspace, project) like
purge-session. It only rewrites a row whose current started_at postdates
the candidate's own transcript end, the bug's signature, so a correctly
hook-captured session or a re-run is a no-op (`not_flattened` /
`unchanged`). Without confirm it is a real dry run: the write runs and
rolls back. Out-of-scope sessions are reported not-found and left
untouched; negative, inverted or future times are refused; an open session
never gets an end time; and a confirmed batch writes one audit_log row with
every repaired session's before/after times.
`ai-memory repair-backfill-timestamps` is the thin client. It reuses
backfill's session discovery and the workstream transcript reader, takes
each session's earliest and latest event time, resolves native ids with the
new `SessionId::from_native` (now shared with the hook router instead of a
private copy), chunks the batch at the server's cap, and prints each
repaired session's old and new times.
ai-memory user grant|revoke|grants, ai-memory project access|grants,
[auth] new_projects_restricted, and the native hook client drops an
event the server refused with 403 instead of retrying it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014aqFKAuGVkuBoewmpA3cx9
Every database user is stamped as an AuthorizedViewer (never root) and
every surface attaches ScopeResolver::with_project_authz for them: MCP
tools, /api/v1 and the web pages, hook routes and captures. Read-shaped
tools that mutate need write; managed-run, workstream and session-id
entry points authorize the project the id resolves to; message queues
need write to deliver, pop and cancel. Captures into a project the
author may not write are dropped and counted (dropped_unauthorized).
Root-only /admin routes manage access modes and grants.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014aqFKAuGVkuBoewmpA3cx9
Slice 3 on top of slice 2's choke point:
- V69 projects.created_by; resolve_project_authz derives is_creator
from it, so one principal per request is right for every project.
- ScopeResolver::resolve_existing_args[_traced] take a ProjectAccess,
so read-shaped mutations can demand write; resolve_read_args* stay
Read. resolve_many_existing authorizes every scope, and
resolve_write_args records the creator.
- authorize_scope_for and *_guarded free functions for routes that
name a project directly. A refusal names the project and both levels.
- Unscoped reads filtered in SQL before LIMIT with the choke point's
rule (not restricted, creator, or any grant; global scope shared).
- Grant storage: grant/revoke/list, audit_log rows, access-mode
setter, [auth] new_projects_restricted, grants follow a project move.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014aqFKAuGVkuBoewmpA3cx9
`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.
Fixes-only patch on the 2.4.x line; additive items (input_token_safety_margin,
HTTP-exposure status field, status --workspace/--project scoping, jev adapter
docs) are reverted here and live on the release/2.5 feature line to keep the
2.5.0 version reserved for it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDbhmszrjG9s5MrPrTuNtm
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>
Add a server-rendered page under /web that lists the pending
auto-improvement proposals of all projects. The page has a project
filter and a sort. It shows the rationale, the proposed body, and a
warning for proposals that write under _rules/ or replace a page.
The approve and reject buttons post from the browser to the existing
/admin/pending-writes/{id}/approve|reject handlers with the session
cookie and the CSRF header. Admission, audit, attribution, and the
single writer stay the same. ai-memory-web adds no write route.
The page uses the same Capability::Admin decision as /admin, and
serve passes the trusted-proxy setting to it. A non-root session gets
a 403 page. The HTML auth redirect does not send that session to the
change-password form.
Two store reads feed the page. One counts pending proposals per
project, so the total and the project filter include every project.
The other lists proposals, optionally for one project, with a cap of
500 rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwU4vj4tZLYVLR7vEKeh2W