100 Commits
Author SHA1 Message Date
15178b885e feat(admin): add password sessions and separated API credentials (#533)
* feat(auth): add browser sessions and API credentials

* fix(auth): preserve 1.x compatibility paths

* fix(changelog): preserve released sections

* docs(auth): tie mirror triggers to 2.0 cutover

* docs(store): correct mirror migration version

* docs(changelog): link admin console PR

* fix(migrations): renumber human_auth/api_credentials to V54/V55

Main gained V52__purged_sessions_tombstones and V53__page_embed_failures
after this branch was opened, so the merge produced four migration files
across two version numbers. Git saw no conflict - the filenames differ -
and the collision only surfaces at runtime:

    UNIQUE constraint failed: refinery_schema_history.version

on a fresh database, so the merged tree could not open a store at all.

Renumbered V52__human_auth -> V54 and V53__api_credentials -> V55, and
shifted the version numbers the tests pin: `run_to`/`open_to` targets, the
`schema_version` assertions, the rollback assertion, and the test names.
The pre-migration fixture point moves 51 -> 53 because main's V52/V53
create unrelated tables (purged_sessions, page_embed_failures) and touch
neither `users` nor any table these migrations rewrite.

Migration content is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDbhmszrjG9s5MrPrTuNtm

---------

Co-authored-by: AkitaOnRails <fabioakita@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:26:41 -03:00
Djalma JúniorandAkitaOnRails c22777e6ba feat(admin): add read-only GET /admin/audit-log (#531)
The `audit_log` table has existed since V01 (plus `author_id` in V16) but had no
HTTP reader: operators could ask "who expired alice's token?" only by querying
SQLite directly.

- `Reader::list_audit_events` with a keyset cursor (`before_id`), so paging
  cannot skip or duplicate rows while new events land. `limit` clamps to 1..=200
  (default 50).
- LEFT JOINs resolve workspace/project/page/author names. The table has no FKs on
  those columns by design (V05: append-only event log, orphan rows expected), so
  every resolved name is an `Option`.
- Route sits in the same group and capability as its read-only neighbours
  (`audit-contamination`, `open-sessions`, `checkpoints`).
- `detail` is exposed for schema fidelity but is NOT documented as a payload:
  the single writer (`ops::audit`) inserts the literal `'{}'` on every call.

Not an MCP tool, so MEMORY_INSTRUCTIONS/SNIPPET_BODY and the documented tool
count are untouched.

Co-authored-by: AkitaOnRails <fabioakita@gmail.com>
2026-08-30 12:47:05 -03:00
Djalma JúniorandAkitaOnRails 4756e153b9 feat(sweep): opt-in bounded retention for raw observations (#508)
* feat(sweep): opt-in bounded retention for raw observations

The M8 forget sweep acts on pages only, so `observations` grew without
limit: nothing in the tree could delete a row for age. On a three-month-old
install the table plus its FTS shadow and four indexes were ~5.4 GB of a
5.53 GB store (5,236,232 rows), against 0.03 GB of `pages` — the 2,365
compiled pages that hold the durable value.

Adds a fourth sweep pass, disabled unless `decay.observation_retention_days`
is positive. It deletes an observation only when its session was already
consolidated into a summary page that is still live, and the row is older
than the configured age — three gates on columns the schema already
maintains, so raw capture is never removed while it is the last copy of a
session's work. The pass runs LAST so this run's own evictions and
hard-deletes are already visible to its predicate.

Batches are one transaction and one writer-actor message each, so a
multi-million row prune never holds the write lock across the run. The
session-end observation watermark is repaired downward in the same
transaction, and one `prune_observations` audit row is written per batch
that actually deleted. Zero migrations.

* docs+audit(sweep): state prune irreversibility; record deleted count in audit detail

Review follow-up for #508:

1. The irreversibility is now stated in the reviewer's terms — a pruned
   session can never be re-consolidated, and its summary page becomes the
   only surviving account — in docs/ARCHITECTURE.md (sweep pass prose),
   crates/ai-memory-cli/templates/config.default.toml (above the knobs,
   with the 0-default named as a contract), and the CHANGELOG entry.

2. Each 'prune_observations' audit row now records what it did:
   audit() keeps its shape and delegates to a new audit_with_detail(),
   and the prune writes {"deleted": N} into audit_log.detail per batch
   that actually deleted. Asserted in
   only_a_consolidated_session_with_a_live_page_loses_its_observations.

Also merged upstream/main (v1.34.0) and kept the entry under [Unreleased].

---------

Co-authored-by: AkitaOnRails <fabioakita@gmail.com>
2026-08-29 01:03:11 -03:00
2a9f6ead93 feat(config): add EMBEDDING_API_KEY for embedding-only credentials (#514)
Embeddings are already independently configurable — provider, model,
dimension and base URL each have their own setting — but there was no key
to go with them. `openai_embedding_api_key` returned `OPENAI_API_KEY`
before the base-URL check ran, so pointing `AI_MEMORY_EMBEDDING_BASE_URL`
at a second provider sent it the chat model's credential. The existing
`LLM_API_KEY` fallback only fires when `OPENAI_API_KEY` is absent, which
also takes the `openai` chat provider down, since it reads that same
variable.

`voyage` and `google`/`gemini` already name their own key and are
untouched. Scoped to the two embedders that borrowed another role's:
`openai` resolves EMBEDDING_API_KEY -> OPENAI_API_KEY -> LLM_API_KEY (the
last still only with a custom base URL), and `openai-compat` resolves
EMBEDDING_API_KEY -> LLM_API_KEY, staying keyless when neither is set.

With the variable absent, resolution is byte-identical to before.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: AkitaOnRails <fabioakita@gmail.com>
2026-08-28 15:48:08 -03:00
Djalma JúniorandClaude Opus 5 197eaeaec6 feat(auto-improve,decay): scope proposal staging and reinforcement to the operator
Two small opt-ins that complete the per-operator story on a shared server,
both inert at default config.

Pending auto-improve proposals now record who staged them. The old
one-pending-per-target index was right for one operator and wrong for
several: one person's pending suggestion for a page blocked everybody
else's. V42 folds the author into the unique key through
COALESCE(staged_by_actor_user, '') — a plain composite index would NOT
work, because SQLite treats NULLs as distinct in a UNIQUE index, so every
single-operator database would silently start accepting unlimited pending
proposals per page. The bucket is the qualified identity storage key from
owner_stamp(actor.identity_key(), distinguishes_operators): the interactive
doors (memory_auto_improve, /admin/auto-improve) both derive it through the
same two accessors, because a bucket computed two ways eventually disagrees
with itself. The scheduler, the telemetry report and the curator stage
UNATTRIBUTED by design — bucketing an interactive call by root_username on
a single-operator server would leave two proposals pending for one page,
the exact collision V42 promises cannot happen.

Riding with it, the collision fix: a proposal colliding with one already
pending used to abort its whole staging run via `?`, losing the run row,
its sibling proposals and the paid LLM call. Now only the UNIQUE extended
code is swallowed (the primary constraint code also covers CHECK/FK/RAISE
failures, which must keep surfacing), the colliding proposal alone is
skipped, and every surface names it: the MCP/admin `skipped` lists, the CLI
output, and the scheduler's log — the unattended path has no response for
anyone to read, so a drop that does not reach the log reaches nobody.

Page reinforcement is now also recorded per operator (V43 page_access,
keyed on IdentityKey::storage_key(), written in the same transaction as the
shared scalar so the two cannot drift), and the retention formula gains an
optional breadth term: [decay] breadth_weight, default 0.0, provably
identity at the default and at 0/1 distinct readers under any weight — so
no eviction decision moves until an operator deliberately turns it on, and
there is no cliff to migrate around. The sweep and the curator read the
same grouped distinct-actor query (none at all while the weight is 0), and
the access-bump throttle is keyed (page, operator) ON PURPOSE: keyed on the
page alone, whoever read it first would swallow everybody else's
reinforcement inside the cooldown window, under-counting breadth exactly on
the busy pages it matters for.

MERGE ORDER: V42/V43 are numbered after the handoff slice's V39–V41 and
must land AFTER it, or refinery's out-of-order rule bites any already
migrated database.

Review-matrix cells covered: proxy-user (sub-only bucket, per-operator
buckets), anonymous/no-auth (unattributed bucket, single-operator
compat), legacy-row (pre-V42 NULL buckets, pre-V43 pages score
unchanged), disable/re-enable (owner_stamp gate at read/write of the
bucket; breadth weight off/on/off).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 03:35:21 -03:00
Djalma Júnior 259d8fa96e docs: point the changelog entries at this PR 2026-07-31 12:31:10 -03:00
Djalma Júnior 59d737629c docs: point the changelog entries at this PR 2026-07-31 12:31:07 -03:00
Djalma Júnior 01bda0d3dd docs: point the changelog entries at this PR 2026-07-31 12:31:05 -03:00
Djalma JúniorandClaude Fable 5 2d7b325eaf feat(multiuser): scope handoffs and sessions to their owner
On a shared server the open-handoff lookup was scoped by (workspace,
project, state) alone, so the next session to start — whoever it belonged
to — consumed the pending baton, and delivery is destructive: the author
simply lost it. cwd did not help, because memory_handoff_begin rows are
manual (from_session_id = NULL), and manual handoffs bypass the cwd rule
and outrank automatic ones — the deliberate artefact was exactly the one
that crossed operators. Sessions had the sibling hazard: finalize-session
picks "the newest open session in the scope" and acts destructively on it
(ends it, synthesises a page from its observations, mints a handoff from
its raw prompts), across everyone.

Design: handoffs and sessions record their operator (migrations V39/V40)
as the qualified IdentityKey::storage_key() TEXT the identity contract
defines — never a raw name, so a username can never alias an equal OIDC
subject. The read side is OwnerFilter (own rows + shared), the write side
is owner_stamp, which stamps only where the deployment actually
distinguishes operators: a single-operator server that names its operator
via [auth].root_username keeps writing the pre-ownership NULL, or its
HTTP writes would become invisible to the same person's stdio transport.
Ownership is checked BEFORE the manual/cwd delivery rules; the claim
predicate rides inside the accept UPDATE's WHERE so an unadmitted caller
changes 0 rows and is told so (no double delivery on a lost race); and
both supersession sweeps bind the triggering row's owner NULL-safely
(owner_user IS ?), so one operator starting or ending a session can no
longer expire another operator's pending baton.

Invariants throughout: absent owner = shared = pre-feature behaviour, so
every stored row, every unauthenticated server, and every caller without
an actor behaves exactly as before; cross-operator escape hatches
(any_owner on accept/cancel, --all-owners on finalize-session) require
admin authority; the SessionEnd path attributes the page, the checkpoint
and the baton to the operator recorded on the session, not to whoever
delivered the event; briefing counts and the read-only overviews apply
the same filter as the fetch so a count never advertises a baton its
caller cannot retrieve. A new owner-scoped listing endpoint (V41 index)
makes a mis-delivered baton inspectable, redacting prompt-derived fields
from callers an authenticating server can neither name nor place as
root. Handoff lifecycle events raise admission ops (handoff_begin /
handoff_accept / handoff_cancel) in one fixed order — deciders before
the operation, observers only after it happened — and a refusal on the
automatic paths degrades the event (baton skipped, claim left open)
rather than failing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 12:12:28 -03:00
Djalma JúniorandClaude Opus 5 a180c62aa8 feat(slots): scope slot injection to the operator whose session it is
A memory slot is a page the engine injects verbatim into every session
start for its project. On a server shared by several operators that is a
boundary problem in both directions: one operator's "what I am working
on" becomes everybody's agent context, and anything that can steer a
write path — a hand-written memory_write_page, or observation text the
consolidation model reads — can plant chosen text into somebody else's
next brief.

With `[slots] per_user = true`, slots the engine writes are namespaced
under the writing operator, session briefs and consolidation prompts
carry the shared slots plus the requesting operator's own (the recent-
pages pointer list included — a path and a title are already somebody's
working context), and writing into another operator's namespace is
refused at every door that chooses a slot path: memory_write_page
re-homes the shared slot into the caller's own prefix and refuses
foreign namespaces (admins may curate), and the consolidator applies the
identical placement rule to the model-chosen paths it writes, surfacing
each refusal as a `skipped_reason` instead of silently dropping it. What
the flag scopes is INJECTION, not access: an exact-path read still
returns anyone's slot.

Namespaces are `IdentityKey::path_segment()` values (`_slots/u-alice/…`,
`_slots/s-<sub>/…`), never raw names, and the read filter and the write
placement both key on `ActorContext::identity_key()` — one accessor, so
a sub-only operator (the regression that shipped twice) owns the same
namespace on both sides instead of getting a write-only page or, worse,
the shared slot. The qualified-segment contract also bought two
simplifications over the earlier raw-name design, taken rather than
ported: the `Unnamespaceable` placement case is gone (path_segment is
total — hostile values hex-encode, so every identified writer owns a
working namespace and nothing can fall back onto the shared slot), and
the GLOB-metacharacter username validation plus its Windows test skip
are gone (segments are `[A-Za-z0-9._-]` by construction, so no
metacharacter can reach the SQL patterns and no hostile byte ever
becomes a filename).

Invariants: everything defaults off — with `[slots] per_user` absent a
nested slot path carries no ownership meaning and every slot reaches
every brief byte-identically to before; un-namespaced slots are shared
under either setting, so toggling the flag never orphans or reinterprets
stored pages. The store binds the visibility GLOB as the only OPTIONAL
parameter in the four briefing statements, always LAST and after the
expiry cutoff: a fixed parameter placed after it would shift by one
exactly when the rule needs no pattern — an InvalidParameterCount at
runtime the compiler cannot see.

The Wiki carries the same switch (`with_per_user_slots`) because its
auto-improve approval path is a slot writer of its own that reaches
neither write_page nor apply_batch; the enforcement at that door rides
with the auto-improve staging slice, which owns the proposal attribution
(`staged_by_actor_user`) the decision has to key on — never the
approver, since the unattended scheduler approves with no user at all.

A `docker/multiuser-test/` harness drives the boundary end to end
through an SSO-terminating nginx as three operators (two usernames, one
OIDC subject alone), asserting against `/handoff?briefing=1` — the
surface that carries slot BODIES — plus the unproxied port for the
header-forgery negatives. Its handoff-ownership section is gated behind
AI_MEMORY_TEST_HANDOFF_OWNERSHIP=1 until that slice lands, since the
tools it drives exist either way and cannot gate themselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:51:16 -03:00
Djalma JúniorandClaude Opus 5 5044af9702 feat(auto-improve,decay): scope proposal staging and reinforcement to the operator
Two small opt-ins that complete the per-operator story on a shared server,
both inert at default config.

Pending auto-improve proposals now record who staged them. The old
one-pending-per-target index was right for one operator and wrong for
several: one person's pending suggestion for a page blocked everybody
else's. V42 folds the author into the unique key through
COALESCE(staged_by_actor_user, '') — a plain composite index would NOT
work, because SQLite treats NULLs as distinct in a UNIQUE index, so every
single-operator database would silently start accepting unlimited pending
proposals per page. The bucket is the qualified identity storage key from
owner_stamp(actor.identity_key(), distinguishes_operators): the interactive
doors (memory_auto_improve, /admin/auto-improve) both derive it through the
same two accessors, because a bucket computed two ways eventually disagrees
with itself. The scheduler, the telemetry report and the curator stage
UNATTRIBUTED by design — bucketing an interactive call by root_username on
a single-operator server would leave two proposals pending for one page,
the exact collision V42 promises cannot happen.

Riding with it, the collision fix: a proposal colliding with one already
pending used to abort its whole staging run via `?`, losing the run row,
its sibling proposals and the paid LLM call. Now only the UNIQUE extended
code is swallowed (the primary constraint code also covers CHECK/FK/RAISE
failures, which must keep surfacing), the colliding proposal alone is
skipped, and every surface names it: the MCP/admin `skipped` lists, the CLI
output, and the scheduler's log — the unattended path has no response for
anyone to read, so a drop that does not reach the log reaches nobody.

Page reinforcement is now also recorded per operator (V43 page_access,
keyed on IdentityKey::storage_key(), written in the same transaction as the
shared scalar so the two cannot drift), and the retention formula gains an
optional breadth term: [decay] breadth_weight, default 0.0, provably
identity at the default and at 0/1 distinct readers under any weight — so
no eviction decision moves until an operator deliberately turns it on, and
there is no cliff to migrate around. The sweep and the curator read the
same grouped distinct-actor query (none at all while the weight is 0), and
the access-bump throttle is keyed (page, operator) ON PURPOSE: keyed on the
page alone, whoever read it first would swallow everybody else's
reinforcement inside the cooldown window, under-counting breadth exactly on
the busy pages it matters for.

MERGE ORDER: V42/V43 are numbered after the handoff slice's V39–V41 and
must land AFTER it, or refinery's out-of-order rule bites any already
migrated database.

Review-matrix cells covered: proxy-user (sub-only bucket, per-operator
buckets), anonymous/no-auth (unattributed bucket, single-operator
compat), legacy-row (pre-V42 NULL buckets, pre-V43 pages score
unchanged), disable/re-enable (owner_stamp gate at read/write of the
bucket; breadth weight off/on/off).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:42:27 -03:00
Djalma Júnior 30c3967e29 docs: point the changelog entries at this PR 2026-07-31 10:58:39 -03:00
Djalma Júnior 79fb16667c feat(auth): qualified identity keys and trusted-proxy identity assertion
A proxy that terminates SSO cannot forward the user's own credential
upstream — it authenticates with the root bearer and describes the human in
headers. This gives those headers a trust rule and gives the identities they
assert a stable contract.

The trust rule: `X-Memory-Actor-*` headers are pure client input, ignored
unless `[auth].actor_proxy_secret` is configured AND echoed in
`X-Memory-Actor-Proxy-Secret`. An asserted caller drops to the user tier —
only the configured `root_username` keeps root — and a duplicated actor
header is refused with 400 rather than resolved to either value. The server
refuses to start with a proxy secret but no `root_username`: every proxied
caller would be non-root, so nothing could ever reach a root-only capability
again, including creating the first DB user.

The identity contract: every ownership decision resolves a request through
one accessor, `ActorContext::identity_key()`, to a QUALIFIED key —
`sub:<subject>` or `user:<name>`, never a bare string. Raw values in one
TEXT namespace would let a username equal to somebody else's OIDC subject
silently alias their identity; qualification makes the collision
unrepresentable. `sub` outranks `user` because OIDC defines `sub` as the
stable, non-reassignable identifier and explicitly forbids relying on
`preferred_username` — which is also the direction that keeps a sub-only
ingress stable when it later starts forwarding usernames. Where an identity
becomes a wiki path segment, `IdentityKey::path_segment()` derives a
filesystem-safe, injective encoding (subjects are often URLs, and pages are
real files on every platform).

`OwnerFilter` / `owner_stamp` carry the read and write sides of the same
contract, with absent = shared as the compatibility rule: a NULL owner is
visible to everyone, which is the shape of everything already stored.
`owner_stamp` additionally requires that the deployment actually
distinguishes operators, because a single-operator server that stamped its
one name would split its own transports (HTTP names the operator; stdio
carries no actor).

The admin gates ask that same question — `distinguishes_operators` — instead
of `users_exist()`, at both doors: the `/admin/*` route layer and the MCP
`memory_forget_sweep` tool. A trusted-proxy deployment never writes a
`users` row, so keyed on rows alone it would wave every proxied caller
through the single-operator escape hatch on every operational route.

Nothing changes at default config: no proxy secret means no overlay, no
identity means no stamp, and every gate keeps its historical single-operator
behaviour.

First slice of the multi-operator work reviewed in #310; the ownership
consumers (handoffs/sessions, slots, proposals) follow separately on top of
this contract.
2026-07-31 10:54:32 -03:00
Djalma Júnior 18e6ec07f8 feat(admin): merge-workspace — fold one workspace's projects into another
Add `POST /admin/merge-workspace {from, to, confirm}`, completing the workspace
CRUD surface next to rename/delete. It folds every project of the source
workspace into the destination, then deletes the emptied source.

The merge is sugar over move-project: extract a `move_project_core` (and a
`delete_workspace_core`) that return the report/summary as data instead of an
HTTP response, so the same validated move path is reusable. Each source project
therefore runs true-move when the destination has no twin (lossless re-stamp)
or copy-purge when it does (content merge under `on_conflict`). Destructive, so
`confirm=true` is required; `force` overrides the live-session guard per
project. The merge stops at the first failing project — moves already committed
stand, and the failing project plus the source workspace are left intact for
the operator to resolve and re-run (moves are idempotent).

`handle_move_project` / `handle_delete_workspace` become thin wrappers over
their cores, preserving their existing HTTP behavior (every prior move/delete
test stays green).

Tests: merge folds multiple fresh projects and deletes the empty source;
a colliding project merges by copy-purge while a solo one true-moves, both
pages surviving; missing confirm is refused with nothing moved.
2026-07-14 21:34:04 -03:00
Djalma Júnior 95ca148d10 fix(consolidate): resolve target from observations + preflight admission before the LLM
Resolve `memory_consolidate`'s target `(workspace, project)` from where the
session's observations actually landed instead of the `sessions` row. Because
`begin_session` uses `ON CONFLICT DO NOTHING`, a session that adopts its scope
marker mid-run keeps a row frozen on the pre-marker scope, while its
observations carry the correct per-cwd scope — so a hybrid session consolidated
into the wrong project. Add `ReaderPool::session_scope_from_observations` and
prefer the majority observation scope, then the session row, then startup IDs.

Run the blocking admission chain before the LLM via a new
`Wiki::preflight_admission`, so a rejected scope/actor fails fast and
identically in single-page, multi-page, and dry-run modes instead of only
surfacing at write time (after the LLM was already spent). A `dry_run` is now a
cheap plan: it runs the preflight and reports the resolved page path without
calling the LLM; a real run still produces the page bodies.

Tests: session_scope_from_observations resolution + empty fallback;
preflight_admission rejects (no write) / no-op without a chain; single- and
multi-page dry runs return the plan without invoking a panicking LLM.
2026-07-14 21:17:43 -03:00
Djalma Júnior c0a8555085 fix(consolidate): attribute memory_consolidate writes to the request actor
memory_consolidate wrote its consolidated page as a hard-coded anonymous
actor: the MCP handler never extracted the request identity, so the write
carried no user. Two consequences on any deploy: the on-disk page's
last_modified_by was always empty, and an actor-gated admission webhook
rejected the write (empty actor) even though memory_write_page on the same
connection — which does carry the actor — was accepted.

- memory_consolidate now takes the request Parts and derives the actor +
  author_id (the same pattern as memory_write_page).
- consolidate_session / consolidate_session_multi / build_update thread the
  actor + author_id into the WritePageRequest (and its admission context),
  replacing the hard-coded anonymous.
- The automatic session-end consolidation in the hook router is
  system-initiated (no request actor), so it stays anonymous — unchanged.

Test: build_update stamps the passed actor + author_id onto the request and
its admission context.
2026-07-13 13:57:26 -03:00
Djalma Júnior d34da5e99b fix(audit): drop the observation-vs-session drift check (false positives)
audit-contamination's CHECK B flagged every observation whose project_id
differs from its owning session's project_id as high-confidence contamination,
claiming "on a healthy DB this is always empty."

That premise is wrong for the per-event cwd resolution the engine uses: an
observation's project is set from the cwd OF THAT EVENT, so an agent that
legitimately `cd`s across repos in one session produces observations whose
project differs from the session's home project. That is correct attribution,
not contamination — and observations carry no cwd of their own to disambiguate
it, so the check could not tell the two apart. On a busy multi-repo instance it
drowned the report in false positives.

Remove CHECK B (query, findings, the observations_drifted count, and the now-
unused session_id finding field + its CLI rendering). CHECK A — a session whose
own cwd prefix-resolves to a different project than it landed in — is anchored
on real cwd evidence and remains the precise, high-precision bug signature.

Test updated: the cross-project observation is asserted NOT flagged; only
CHECK A fires.
2026-07-13 13:47:01 -03:00
Djalma Júnior d380a67d19 feat(recall): honor [recall] default_global in memory_recent too
#177 made memory_query broaden an unscoped read to global when a repo opts
into `[recall] default_global`; memory_recent stayed project-scoped. Complete
the pair so "most recent" also spans every project for a meta-repo.

- reader: recent_pages_global(limit) — the cross-project analog of
  recent_pages_for_project, returning the most-recently-updated latest pages
  across every project, each annotated with its workspace + project name.
- memory_recent: when the caller passes no explicit workspace/project AND the
  actor opted into default_global, return those cross-project hits in a
  `global_hits` field. Strict precedence: an explicit workspace/project always
  scopes (same rule as memory_query).
- The response gains an optional `global_hits` (omitted when empty), so a plain
  project-scoped `{ "hits": [...] }` stays byte-identical to before.

Test: an unscoped memory_recent under default_global surfaces pages from two
workspaces; an explicit workspace+project still scopes to one.
2026-07-13 13:36:01 -03:00
Djalma Júnior 70f97c728f feat(admin): GET /admin/projects — authoritative project inventory
Expose the full `(workspace, project)` list (with page counts + last-updated,
reusing `list_projects_with_stats`) as a read-only admin endpoint.

Useful to any external consumer that needs the live project inventory — a
dashboard, an export, or a backup/mirror that lays the wiki out as
`wiki/<workspace>/<project>/` and wants to reconcile against it: any directory
whose project no longer appears here is an orphan (e.g. from a delete that
bypassed the admission chain, such as an offline `--data-dir` purge or a
direct DB edit) and can be pruned so the copy reflects the live server.

Test: seeds two (workspace, project) scopes and asserts both surface in the
`projects` array.
2026-07-13 12:42:33 -03:00
Djalma Júnior a6c7837997 feat(store): audit-log attribution for delete-page + handoff lifecycle
Completes the audit_log follow-up after #175 (which covered purge/rename).
The remaining destructive/state ops now write audit rows too:

- delete_page takes &mut Connection + an author_id, captures the page id
  before the DELETE, and writes an attributed "delete_page" audit row —
  but ONLY on a real deletion (a no-op delete of a missing page writes
  nothing, so the trail isn't polluted). Threaded from memory_delete_page
  and the admin delete-page handler (Extension<UserId>, NULL when
  single-user / unauthenticated) through the writer actor.
- insert/accept/cancel_handoff wrap their write in a transaction and audit
  the lifecycle (op + workspace/project), scoped via a small handoff_scope
  lookup. Author is NULL by design — handoffs are agent/session-keyed, not
  owned by a DB user.

Tests: delete_page writes one attributed row pointing at the deleted page
id; a no-op delete writes none; the handoff lifecycle writes scoped rows
with a NULL author.
2026-07-13 09:33:50 -03:00
Djalma Júnior d02943895e feat(recall): opt-in [recall] default_global to broaden default-scoped queries
A meta-repo (e.g. ai-memory itself, which must see ai-memory-ops / infra
constantly) had to pass `global=true` by hand on every memory_query. This
adds a per-repo opt-in: `[recall] default_global = true` in a project's
.ai-memory.toml makes a default-scoped memory_query behave as global.

Threaded end-to-end, reusing the drop_subagent pipeline:
- CLI marker parse (hook_capture): parse `default_global` (quoted or bare,
  section-agnostic) and forward it as the `default_global` query flag.
- Hook envelope (payload): HookQuery.default_global ->
  HookEnvelope.recall_default_global_requested (truthy interpretation).
- ActiveProject (core): the per-actor entry + single fallback slot now carry
  a default_global bool, published alongside the project pointer by the hook
  router. get_for / the tuple resolution path stay byte-for-byte unchanged;
  a new default_global_for(actor) reads the flag with the same slot dispatch.
- memory_query (mcp): when the caller passes NO explicit scoping
  (global/scopes/workspace/project) and the actor opted in, route to the
  existing global search. Strict precedence: an explicit arg always wins.

Opt-in and default-false: a repo without the marker is unaffected.
memory_recent stays project-scoped for now (a global "recent" needs a new
cross-project reader) — documented follow-up.

Tests: marker parse (append/omit), envelope mapping, ActiveProject
round-trip + isolation + false-default, and an end-to-end memory_query gate
(unscoped query broadens; explicit workspace+project still wins).
2026-07-12 14:25:45 -03:00
Djalma Júnior 803c95f72a feat(store): audit-log attribution for purge-project and rename-project
The append-only audit_log only recorded page upserts and decay
soft-deletes; the destructive/administrative project ops wrote nothing,
so "who wiped project X?" — V16's motivating question — had no answer.

- ops::purge_project and ops::rename_project now take an author_id and
  write an attributed audit_log row inside their transaction (rename is
  now transactional, so the row commits atomically with the UPDATE; a
  name-collision rollback leaves no row).
- Thread the authenticated operator (Extension<UserId>, NULL when
  single-user / unauthenticated) from the admin handlers
  (/admin/purge-project, /admin/rename-project) through the writer.
- Internal sub-purges (move-project's copy-purge step, hook self-heal)
  pass author_id=None — they are distinct ops, not standalone purges.
- Tests: purge/rename each write one attributed row; a rolled-back
  rename writes none.

delete_page and handoff transitions stay unaudited for now (documented
follow-up): delete_page needs a wider &Connection->&mut signature change
and single-page deletes are recoverable via the git mirror, so they ship
separately to keep this change focused on the irreversible project ops.
2026-07-12 09:08:39 -03:00
Djalma Júnior c7a7dc290d docs(instructions): resync AGENTS.md with the slim routing snippet + guard drift
The committed root AGENTS.md still carried the pre-slim managed block
(inline tool-routing table, "Use Retrieved Memory As Operating Guidance",
"Learning Review") while SNIPPET_BODY had been slimmed to defer detail to
the installed Agent Skills. Nothing forced the committed copy to track the
generator, so agents and forks reading AGENTS.md inherited stale routing.

- Regenerate AGENTS.md from the current SNIPPET_BODY
  (install-instructions --target AGENTS.md --no-skills), preserving all
  content outside the ai-memory markers.
- Add a routing_snippet unit test that extracts the managed block from the
  committed AGENTS.md and asserts it equals full_block(); it fails whenever
  the two drift and names the regeneration command.
2026-07-12 08:51:02 -03:00
Djalma Júnior 90fbedd519 chore: neutralize the serpro realm in OIDC examples + drop a dead error variant
- Replace the `serpro` realm in example/doc issuer URLs with `ai-memory`
  (ai-memory-llm oidc.rs, ai-memory-cli cli.rs) so the shipped examples
  aren't tied to a specific org name.
- Remove `LlmError::RetriesExhausted`, which is never constructed or matched.
2026-07-10 22:20:15 -03:00
Djalma Júnior 92ed027361 feat(admin): rename-workspace endpoint (column-only)
Mirrors the existing rename-project: `rename_workspace` (store fn + writer
command + `POST /admin/rename-workspace`) is a `workspaces.name` UPDATE only
— the on-disk dir is UUID-keyed, so nothing moves. 404 on unknown, 422 on a
UNIQUE(name) collision (`WorkspaceNameTaken`) or an empty/`/` name
(`InvalidWorkspaceName`). Store + admin integration tests.
2026-07-10 22:17:42 -03:00
Djalma Júnior 822a7c4c1e feat(hooks): per-source ingest rate limiter to isolate a flooding source
The only ingest backstop is a single global semaphore, so one runaway
source (the 2026-06-29 subagent flood) saturates all of DEFAULT_HOOK_INGEST_
MAX_IN_FLIGHT permits and every other source starts getting 429 — then the
instance OOMs. Adds a per-source token-bucket rate limiter checked BEFORE the
global semaphore, keyed by session id, so one flooder is throttled without
denying everyone else. Bounded LRU of buckets (same shape as
SubagentSessionSet). Disabled/pass-through unless AI_MEMORY_HOOK_RATE_PER_SEC
is set, so there is zero behaviour change by default. The batch (/hook/batch)
path stops at the committed prefix when a source is over budget — a
legitimate large spool drain is smoothed (client retries the rest as tokens
refill) rather than dropped.
2026-07-10 21:34:43 -03:00
Djalma Júnior c053caf151 feat(admin): delete-workspace endpoint to remove orphan workspaces
A cross-workspace move can leave behind empty workspace rows (e.g. a stray
`_perftmp`) that nothing could remove. Adds the missing primitive:

- store `delete_workspace(force)` — refuses a workspace that still holds
  projects unless `force`, then a single `DELETE FROM workspaces` whose
  `workspace_id` FKs cascade projects/pages/sessions/observations/handoffs;
  returns the removed counts. New `StoreError::WorkspaceNotEmpty`.
- writer command + async wrapper (single-writer actor).
- best-effort `Wiki::remove_workspace_dir` (missing dir is not an error).
- `POST /admin/delete-workspace {workspace, force?}`: 409 when non-empty
  without force, 404 when unknown, 200 with the cascade counts + a mirror
  checkpoint.

Store unit tests (guard / force-cascade / empty / not-found) and admin
integration tests (409 / 200 / 404) cover it.
2026-07-10 21:22:50 -03:00
Djalma Júnior 47ea0123de fix(mcp): make tool calls work over the stdio transport
Every tools/call over `serve --transport stdio` failed with
`-32602 Invalid params: missing extension http::request::Parts`. The 14 tool
handlers used rmcp's hard-required `Extension<Parts>` extractor; only the
streamable-HTTP transport injects that extension, so the stdio path aborted
before entering any handler body (initialize and tools/list, which don't use
it, worked — matching the symptom).

Replace the extractor with a small `OptionalParts` (implementing rmcp's
FromContextPart) that yields the real Parts over HTTP — preserving the auth
middleware's injected identity — and a synthetic anonymous Parts over stdio,
the correct identity for a local unauthenticated serve. Handler bodies are
byte-identical. Adds a unit test for the anonymous default; the e2e stdio
path now returns results instead of -32602.
2026-07-10 21:20:51 -03:00
Djalma Júnior fc1991f3fb perf(admin): skip the contributors enrich webhook on move-project copies
A project move copies each page's frontmatter — including the contributors
list — verbatim, so re-running the `contributors` enrich webhook on every
copy adds nothing but blocking per-page latency to a bulk move (the
dominant embedding cost is already avoided by carrying the source vector
over). The copy leg now passes an AdmissionContext with
skip_webhooks=["contributors"] instead of None; write_page still resolves
the destination names and runs the other hooks (git-mirror), so the copy
lands correctly and mirrors as before — only the redundant enrich is
skipped. Adds a test asserting the contributors webhook is skipped on the
merge copy while a non-skipped webhook still fires.
2026-07-10 21:20:50 -03:00
Djalma Júnior 4fecbb8cc5 feat(mcp): name the resolved scope in memory_read_page not-found errors
A by-path read with no explicit workspace/project auto-resolves to the
active project. In a parallel multi-project session that scope can differ
from the one a concurrent write landed in, so the page exists but the
by-path read looks in the wrong bucket and 404s (the write itself
persisted). The error surfaced was the raw disk "file not found", which
also risked leaking an absolute path. It now names the resolved
workspace/project and, when the scope was auto-resolved, hints to pass an
explicit workspace+project — making the transient scope-bleed diagnosable
from the error alone. Genuine disk/parse errors are unchanged.
2026-07-10 21:20:50 -03:00
Djalma Júnior e233662f20 fix(instructions): anchor marker matching to full lines so refresh is idempotent
install-instructions and uninstall located the managed block with a naive
`str::find` of the marker strings. The canonical snippet's own prose quoted
the end marker inline, so the matcher stopped at that inline mention,
truncated the block, and re-injected the tail on every re-run; uninstall
left the same orphan and never round-tripped to the original file.

Two defenses: (1) a `find_marker_line` helper that only matches a marker
sitting alone on its own line — the form `full_block()` always writes —
used by both the merge and strip paths, which also repairs blocks written
by older versions and any user content that mentions the markers; (2)
reword the snippet so it no longer embeds the literal markers, keeping the
agent-driven `memory_install_self_routing` path (which never runs the CLI
matcher) safe too.

Adds regression tests covering the inline-mention case in core, install,
and uninstall.
2026-07-10 21:20:50 -03:00
Djalma JúniorandAkitaOnRails 7feb2c21f1 fix(mcp): run the raw-observation fallback on the explicit scopes path (#159)
* fix(mcp): run the raw-observation fallback on the explicit scopes path

memory_query's raw_hits fallback (bounded raw observations when the
compiled-page search misses) was gated on `args.scopes.is_empty()`, so a
query using an explicit `scopes: [{workspace, project}]` returned no
`raw_hits` even when a scope had matching observations and zero compiled
pages. Resolve each requested scope and rank-merge its observations,
mirroring the page-search path, so the fallback fires on the scoped path
too. Adds a regression test.

* fix(mcp): complete scoped raw fallback coverage

---------

Co-authored-by: AkitaOnRails <fabioakita@gmail.com>
2026-07-10 21:00:34 -03:00
Djalma Júnior 8651080502 feat(store): warn when a new project's name already exists in another workspace
Homonymous projects across workspaces are legal — rows are id-namespaced —
but creating one by accident is almost always a mis-scoped write, and the
creation path was silent (no warning, no signal in the return). It now
checks, inside the same transaction, whether the name already exists in any
other workspace and emits a `tracing::warn` naming those workspaces when it
does. Creation is not blocked (the homonym still gets its own id). Adds a
unit test covering the helper and the non-blocking creation.
2026-07-10 19:33:00 -03:00
Djalma Júnior 0e96d1edf9 build(deps): bump anyhow 1.0.102 -> 1.0.103 (RUSTSEC-2026-0190)
cargo-deny flags 1.0.102 for RUSTSEC-2026-0190 (unsoundness in
anyhow's Error::downcast_mut). 1.0.103 is the patched release.
Lockfile-only; no source changes.
2026-06-29 14:26:32 -03:00
Djalma Júnior b8cf2cf7f2 feat(hooks): per-project drop_subagent_captures opt-in with tail tracking
A project sets `drop_subagent_captures = "true"` in its .ai-memory.toml;
the host-side hook forwards it as the `drop_subagent` query flag (alongside
the existing workspace/project/project_strategy marker fields) so the ingest
router accepts but does not persist that project's subagent-session captures,
keeping only top-level sessions. Scoping the opt-in per project avoids a
server-global switch that would shed subagent captures for every project on a
shared instance.

Captures are accepted (HTTP 202 / counted in the /hook/batch ack) so clients
do not retry or spool them. Detection combines a per-event marker (subagentType
for grok; agent_type/agent_id for Claude Code) with a bounded LRU set of
subagent session ids, seeded by any marked event and by the newly registered
SubagentStart/SubagentStop lifecycle hooks (claude-code + grok) and cleared on
SubagentStop, so the unmarked tail of a subagent session (user_prompt_submit/
stop/session_end) is dropped too. The marker file only forwards the project's
opt-in; the server makes every drop decision. Only the ingest/hook layer is
touched; the observation store is not.
2026-06-29 13:56:06 -03:00
Djalma Júnior 9912e48b9c Fix CLI OIDC bearer fallback 2026-06-26 19:27:27 -03:00
Djalma JúniorandClaude Opus 4.8 aa61bf1eb2 style: cargo fmt (hook_spool batched drain + hook_batch handler)
Run rustfmt over the new batch code so `cargo fmt --check` (CI) passes.
Whitespace-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:17:21 -03:00
Djalma JúniorandClaude Opus 4.8 c010c57a25 test(robustness): stop cargo test --workspace flaking on the process guard + autoscope settle
The full workspace run flaked non-deterministically — a different test each run
(reset / reindex / restore unit tests, the uninstall purge test, or the
autoscope stress read) — while every one passed in isolation. Two unrelated
causes:

1. process_guard::sibling_processes() scans the real process table for other
   `ai-memory` processes and the destructive commands refuse if any exist. A dev
   box (or a parallel run that spawns its own ai-memory) almost always has one,
   so reset/reindex/restore/uninstall refused at random. Skip the scan under
   cfg!(test) (the crate's in-process unit tests) or when a spawned binary is
   told via AI_MEMORY_TEST_NO_PROCESS_GUARD (the two uninstall purge integration
   tests set it). The dedicated, #[ignore]d guard test sets neither, so the guard
   itself stays covered.

2. autoscope_stress fire_hook_and_settle waited a fixed 15ms for the spawned
   process_envelope write to land, which races under load (a session's read saw
   {hits: []}). Poll a per-session read until the write is visible instead.

Confirmed: `cargo test --workspace` now passes 3/3 consecutive runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:10:25 -03:00
Djalma JúniorandClaude Opus 4.8 6351090d74 fix(store): prefix-match repo_path/cwd with _/% literally instead of dropping them
find_project_by_cwd_prefix silently failed for any cwd or repo_path containing a
LIKE wildcard (`_`, `%`): is_safe_cwd_for_prefix_match rejected the whole cwd, and
the SQL rejected the whole repo_path. So an agent in /home/u/my_project/src never
prefix-matched its parent project — it created a fresh `src` project and scattered
observations there. `_` in a path is extremely common; macOS's /var/folders/<hash>
temp always trips it (which is why the repo-root test passed on Linux CI but failed
on macOS).

The original property — a stored repo_path must never act as a LIKE wildcard — is
now met by ESCAPING the repo_path in the descendant LIKE arm (replace(...) over
`\`, `%`, `_` under ESCAPE '\') so it matches LITERALLY, rather than refusing such
paths outright. The cwd is the bound value (?2), literal in LIKE, so its `_`/`%`
need no handling. The contamination-audit query matches candidate prefixes in Rust
(`==` / `starts_with`, literal), so its `_`/`%` filter is dropped too.

Adds resolve_matches_literal_underscore_repo_path (a `_` repo_path matches its
literal child, but `/repo/myXapp` does NOT match `/repo/my_app`). The macOS-only
repo-root prefix-match test now passes via this fix (canonicalize kept for the
/var -> /private/var symlink).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:10:25 -03:00
Djalma JúniorandClaude Opus 4.8 58960791ca test(hooks): make repo-root prefix-match test robust to macOS temp paths
host_resolved_repo_root_override_records_repo_path_when_visible failed only on
macOS. The default TempDir resolves under /var/folders/<hash>, and that hash
contains a `_`; is_safe_cwd_for_prefix_match rejects any cwd carrying a `_`
LIKE-wildcard, so the sibling cwd never prefix-matched the repo project and the
assertion failed. (/var is also a symlink to /private/var, which git2's repo
discovery resolves, so an unresolved sibling cwd wouldn't match either.) Linux
CI hits neither, so the test passed there.

Build the test git repo under an underscore-free, canonicalized /tmp base so the
assertion exercises the intended prefix-match on every platform.

NOTE: this exposes a real product limitation worth a separate fix — any agent
whose cwd contains `_` (e.g. /home/u/my_project/src) silently fails prefix-match
via is_safe_cwd_for_prefix_match's `_`/`%` rejection (the code comment even
concedes the bound value is literal, "not a safety issue per se"). Out of scope
for this branch; tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:09:33 -03:00
Djalma JúniorandClaude Opus 4.8 3c83a5cabd feat(hook): batch spool delivery via POST /hook/batch to amortize per-event RTT
Under heavy parallel load (many agent sessions sharing one data-dir spool)
against a remote, gated server, the sequential per-event drain falls behind:
each POST pays TLS + network RTT + the edge auth hop, and the 250ms mid-session
drain clears only a handful per pass. The spool grows to MAX_SPOOL_FILES and
prune_spool_file_count evicts the OLDEST entries at enqueue time WITHOUT
delivering them — silent capture loss (the attempts:0 / last_error:null symptom).

Server: add POST /hook/batch — accepts a JSON array of {url, body}, reuses
HookEnvelope::from_query_and_body + process() per event, processed INLINE and
FAIL-FAST (stops on the first error, returns {accepted: K}). One ingest permit
and one auth pass per batch instead of per event; per-event query parsed from
each item's url via serde_urlencoded (same crate axum's Query uses).

Client: drain now delivers in batches (grouped by endpoint + bearer, bounded by
count and 8MiB), deleting only the acked prefix and charging the fail-fast
blocker a retry; falls back to per-event POST /hook automatically on 404/405 so
it stays compatible with a pre-upgrade server during rollout. Keep-alive already
came from reusing one client per drain.

Observability: prune_spool_file_count now warns on stderr when it evicts
undelivered events at the cap, so sustained loss is visible, not silent.

The drain signature is unchanged, so the session-start/end/incremental call
sites are untouched. Existing spool tests still pass; adds batch round-trip,
per-event fallback, batch_endpoint, and server-handler (ack + 429 + query parse)
tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:09:33 -03:00
Djalma Júnior 6df0612f7d fix(hook): give 429 a free retry + add mid-session spool drain
Two hook-spool delivery-reliability fixes:

1. A 429 no longer burns a spooled event's retry budget. `post_hook`
   returned a bare bool, so the drain treated a 429 (`hook queue full` —
   the router's saturation signal; the event was never processed) the same
   as a genuine 401/5xx/transport failure: it bumped `attempts` and dropped
   the event after MAX_ATTEMPTS=8. A saturation burst therefore silently
   discarded real observations. `post_hook` now returns a
   `PostOutcome { Delivered, Saturated, Failed }`; the drain leaves a
   `Saturated` (429) entry queued WITHOUT bumping attempts (`MAX_AGE_MS`
   still bounds it), and only `Failed` counts toward MAX_ATTEMPTS.

2. Mid-session catch-up drain. Per-event hooks only enqueue, so the spool
   was drained solely at session boundaries. Under a heavy session, enqueue
   outpaced the boundary drain and the backlog grew until the next boundary.
   `post-tool-use` now runs a size-triggered, tightly time-boxed (~250 ms)
   drain once the spool crosses a threshold (default 32, override
   `AI_MEMORY_HOOK_INCREMENTAL_THRESHOLD`), so a hot session keeps the
   backlog flat without ever stalling a tool call; a light session pays only
   a `read_dir`. A detached background drain was rejected — the hook process
   exits immediately, tearing down the runtime.

Tests: post_hook outcome mapping (429->Saturated, 2xx->Delivered,
error->Failed); drain leaves a 429 entry queued at attempts=0 across
>MAX_ATTEMPTS passes; spool_len; should_incremental_drain predicate;
threshold env parse.
2026-06-15 23:24:29 -03:00
Djalma Júnior 9e35a41465 feat(admin): add read-only cross-project contamination audit
`GET /admin/audit-contamination` + `ai-memory audit-contamination` — a
cheap, SQL-only structural audit that flags likely cross-project
mislandings without an LLM or multi-agent sweep. Two HIGH-precision
heuristics:

- session_wrong_bucket (CHECK A): a session whose `cwd`
  longest-prefix-resolves to a different project than the one it landed in
  — the auto-scope-bleed signature. Resolved with the SAME
  `find_project_by_cwd_prefix` the runtime uses, so the audit never claims
  a session a live resolve would not.
- observation_session_drift (CHECK B): an observation whose project
  disagrees with its owning session. Empty on a healthy DB, so a non-empty
  result is a regression tripwire.

The endpoint only reports (never mutates), takes an optional
`?workspace=&project=` scope, and is safe to run on any cadence (e.g. a
cron probe alerting on non-zero counts). Purely semantic mislandings (no
cwd/session anomaly) are out of scope by design.

Tests: store integration (both checks + scope + clean-DB negatives, seeded
fixture); the admin route-enumeration/authz test covers the new GET route.
2026-06-15 23:16:38 -03:00
Djalma Júnior ad690dee2d fix(admin): propagate authenticated actor into purge/move admission
`/admin/purge-project` and `/admin/move-project` built their
`AdmissionContext` with `..Default::default()`, leaving the actor empty.
With a `scope-guard` admission webhook enabled (per-user ACL), every purge
and move was rejected with `403 user '' not allowed to purge_project` — the
destructive ops were effectively unusable. Extract the auth-middleware
`Extension<ActorContext>` (the same path `handle_write_page` already uses)
and set it on the purge/move admission contexts so scope-guard authorizes
by user.

- handle_purge_project, handle_move_project: extract `actor_ext`.
- true_move_project (move_ctx) + copy_purge_merge (source purge_ctx):
  thread the actor through.
- rename-project is unaffected (it runs no admission chain).
- Regression tests: `purge_project_admission_carries_the_actor` and
  `scope_guard_blocks_purge_without_actor_allows_with_actor` (Reject-policy
  webhook authorizing by `ctx.actor.user`: empty actor -> 500, actor=alice -> 200).
2026-06-14 20:08:35 -03:00
Djalma JúniorandClaude Opus 4.8 d7917906bc fix(mcp): accept workspace arg in memory_handoff_begin/accept
memory_handoff_begin and memory_handoff_accept took `project` only and
resolved through the project-only effective_ids path, silently dropping any
caller-supplied workspace. With the per-actor active-project fallback, a
cross-workspace handoff was written to — or read from — whatever project the
contaminable active-project slot pointed at, not the named (workspace,
project). memory_handoff_cancel already carried `workspace`; begin/accept now
match it.

- begin resolves through write_target_ids_with_actor (create-if-missing,
  honours explicit workspace) — same path as memory_write_page.
- accept resolves through effective_ids_for_read_args_with_actor (find-only,
  lookup_ids when workspace+project are given) — same path as cancel.
- Regression test covers a cross-workspace begin/accept round-trip and that
  the handoff does NOT bleed into the current project.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:49:33 -03:00
Djalma JúniorandClaude Opus 4.8 aa46f5ef5b test(store): pin v19 repair test to run_to(19) so the V20 rebuild can't lock
v19_repairs_orphan_observation_attribution_and_purges_empty_projects seeds
session + observation rows (leaving cached prepared statements on `sessions`)
and THEN called the open-ended `migrations::run`. Once V20 joined the chain,
that call also ran V20's `DROP TABLE sessions`, which fails with
SQLITE_LOCKED ("database table is locked") because the cached statements
still reference the table. Target V19 explicitly — the version the test
exercises. Production is unaffected: migrations run on a fresh connection
before any query, so the V20 table rebuild never races a cached statement
(the begin_session_accepts_all_supported_agent_kinds test, which runs the
full chain via fresh_db before seeding, already covers V20 end-to-end).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:23:00 -03:00
Djalma JúniorandClaude Opus 4.8 13591b7679 fix(store): add V20 migration so grok sessions persist on upgraded DBs
The grok feature added AgentKind::Grok (as_str -> 'grok') but no migration
extended the sessions.agent_kind CHECK, which only allows up to
antigravity-cli/omp/other (V11). On a migrated database begin_session with
agent_kind='grok' violates the CHECK, so the hook router fails to persist
grok sessions and capture silently breaks server-side. (Capture appeared to
work only against the still-old prod server, which maps unknown 'grok' ->
Other -> 'other'.)

V20 mirrors the V11 antigravity precedent: rebuild sessions with 'grok' in
the CHECK, and — because this rebuild now runs AFTER V18 — reinstate the
V18 sessions_ws_proj_pairing_ai trigger that the table swap drops (a missing
trigger silently disabled the (workspace_id, project_id) pairing invariant
and broke the hook router's split-brain self-heal). Add AgentKind::Grok to
begin_session_accepts_all_supported_agent_kinds so the gap can't recur.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:09:32 -03:00
Djalma JúniorandClaude Opus 4.8 9f3db3f543 fix(hook): skip session-start handoff fetch for agents that ignore stdout (grok)
Grok ignores hook stdout on SessionStart (per Grok's hooks docs). The
native session-start hook's GET /handoff is destructive — it marks the
handoff accepted server-side before returning — so fetching it for Grok
consumed the pending handoff and then discarded the result, silently
losing it. Gate the fetch behind AgentKind::session_start_injects_handoff
(false only for Grok); those agents recover the handoff on demand via the
MCP memory_handoff_accept tool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 02:42:36 -03:00
Djalma JúniorandClaude Opus 4.8 27e8d844cd feat(install-hooks): add Grok Build CLI as a supported agent
Grok's ~/.grok/hooks/*.json shares Claude Code's JSON shape and
seven-event vocabulary, so the `grok` agent reuses the claude-code hook
scripts and emits the native `ai-memory hook --event … --agent grok`
command. ai-memory entries merge into a dedicated ai-memory.json so any
third-party ~/.grok/hooks/*.json file is left untouched.

- AgentKind::Grok (kebab wire tag "grok") + as_str/from_wire + test
- AgentChoice::Grok in the CLI value-enum + parse test
- build_grok_payload_with_data_dir (reuses CLAUDE_CODE_EVENTS, --agent grok)
- grok_hooks_path + apply_to_grok_settings + render_grok
- resolve_hooks_dir / setup_agent map Grok -> claude-code scripts
- uninstall strips ai-memory entries from ~/.grok/hooks/ai-memory.json
- README + CHANGELOG

NOTE: Grok ignores hook stdout on SessionStart, so capture works but
handoff injection does not -- recover via the MCP memory_handoff_accept
tool. Documented in --help, render header, README, and CHANGELOG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:58:42 -03:00
Djalma Júnior 73ce3c8ccb Merge remote-tracking branch 'upstream/main' 2026-06-13 13:18:55 -03:00
Djalma Júnior d81b2ecd53 Merge remote-tracking branch 'upstream/main' 2026-06-12 20:01:29 -03:00
Djalma Júnior 7e326051d7 Add macOS release artifacts 2026-06-12 19:27:11 -03:00
Djalma Júnior 395552294e Merge remote-tracking branch 'upstream/main' 2026-06-11 19:56:33 -03:00
Djalma Júnior 3c97a5ea04 feat(hooks): make spool drain/handoff timings env-configurable
The per-request timeouts (drain POST, handoff GET) and the session-boundary
budgets (session-start cleanup, session-end flush) read ms overrides from
AI_MEMORY_HOOK_DRAIN_TIMEOUT_MS / _HANDOFF_TIMEOUT_MS / _START_BUDGET_MS /
_END_BUDGET_MS, defaulting to today's values (3s / 3s / 3s / 10s). Read at
hook runtime (no re-install-hooks); non-numeric or zero falls back to default.
2026-06-11 19:50:50 -03:00
Djalma Júnior 87300b96b1 feat(hooks): default native macOS/Linux Claude Code to the binary hook command
`for_bash_runner` now returns `PosixNative` on native macOS/Linux (mirroring
Windows), so Claude Code hooks invoke `ai-memory hook` — getting the spool +
OIDC fallback — instead of the `.sh` script that POSTs via curl. The Docker
wrapper (`bin/ai-memory`) forces `AI_MEMORY_HOOK_PLATFORM=posix`, so its
host-rendered config keeps the `.sh` scripts (the host has no local binary).

Validated in a Linux container (runtime-source image): install-hooks renders
the binary command by default and `.sh` when `posix` is forced; the rendered
hook spools to `<data_dir>/hook-spool` (0600), drains on session-end, and the
server writes the session page.
2026-06-11 18:44:56 -03:00
Djalma Júnior 6b7759cab5 feat(hooks): per-developer OIDC auth + async spool-based capture
Two related capabilities for headless lifecycle hooks behind an IdP and
against remote/slow servers.

OIDC device-flow auth (crates/ai-memory-llm/src/oidc.rs, auth.rs, cli.rs):
`ai-memory auth login oidc-device --issuer <kc> --client-id <id>` runs the
OIDC device-authorization grant against any issuer (e.g. Keycloak), storing
access+refresh in the shared auth.json. A headless hook then authenticates as
the individual developer (per-user JWT, attributed via preferred_username)
instead of a shared static token. The server is unchanged — its auth layer
already validates the realm JWT on the hook routes (mcp:read).

Spool-based capture (hook_spool.rs, hook.rs):
Per-tool-call hooks append the event to a local spool (instant, never blocks
the agent) instead of POSTing synchronously. The spool drains at session
boundaries (cleanup on session-start, main flush on session-end), so capture
is reliable against a remote/slow server with no dropped events and no
per-tool-call network latency. Each event carries its own auth (static token
inline; OIDC resolved+refreshed at drain). Dead events are pruned by attempts
+ age. The session-start handoff GET stays synchronous with a larger timeout.

Also: opt-in AI_MEMORY_HOOK_PLATFORM=posix-native so native Linux/macOS
installs get the same spool + OIDC path (the default stays .sh, Docker-safe).

Tested: fmt/clippy clean; unit tests for oidc/spool/render; dockerized e2e
(hot path never blocks, backlog recovery, handoff read, OIDC JWT accepted on
/hook with per-user attribution).
2026-06-11 18:44:56 -03:00
Djalma Júnior f1d4bf4b4d Merge upstream/main into fork main (post-#87 windows release binary) 2026-06-11 16:11:29 -03:00
Djalma Júnior 56e7b7f447 ci(release): add Windows x86_64 binary to tagged releases
Add a `windows` job to release.yml that builds ai-memory-cli on
windows-latest and publishes ai-memory-windows-x86_64.zip alongside the
Linux tarballs. The zip mirrors the Linux release layout minus the
Linux-only service assets (packaging/systemd|sysusers|tmpfiles|env): the
.exe, the full hooks bundle (.ps1 + .sh), the default config template,
README/LICENSE and docs/{install,windows}.md. Checksum is emitted in
sha256sum format so the release body checksums block stays uniform.

Wire the job into github-release `needs` so the release waits for the
Windows artifact, add a Windows line to the release body, and document a
no-toolchain install path as a new scenario in docs/windows.md. This
gives native-Windows users the fast windows-native hook path without a
Rust toolchain or Docker.
2026-06-11 01:04:21 -03:00
Djalma Júnior 11a4566181 Merge upstream/main into fork main (post-#86 reindex-from-wiki)
Brings the fork's main current with upstream — now includes reindex-from-wiki
(#86) + the audit hardening on top. Keeps the fork-only CLAUDE.md routing doc.
2026-06-08 20:42:01 -03:00
Djalma Júnior 1a2edf2533 feat(wiki): self-describing _meta.md manifests (backfill on startup)
Completes reindex-from-wiki: the engine now writes a per-scope _meta.md
manifest (workspace/project name + repo_path) so the wiki tree alone is
enough to rebuild the index — no external input needed by `reindex`.

- ReaderPool::list_all_scopes returns (ws_id, ws_name, proj_id, proj_name,
  repo_path) for every scope.
- Wiki::backfill_scope_manifests writes any missing _meta.md (idempotent —
  unchanged content is left untouched, so it never churns the wiki git).
- serve calls it on startup (non-fatal), and the reader is now attached to
  the wiki unconditionally so the backfill can enumerate scopes.
- _meta.md is already excluded from page indexing (is_reserved_page_file).

Round-trip test: write pages (incl. one at log.md) -> backfill manifests ->
reindex_all on a FRESH store rebuilds the named scopes + all pages from the
wiki tree alone.
2026-06-08 13:17:02 -03:00
Djalma Júnior e622f33140 feat(wiki): reindex-from-wiki — rebuild the index from self-describing markdown
The store is a derived index; the wiki markdown is the source of truth
(design-decisions §3: 'DB is rebuildable from files'). This makes that
guarantee operable, so a data dir can be moved onto a clean migration
lineage by rebuilding rather than carrying a divergent
refinery_schema_history.

- `ai-memory reindex` (lifecycle command, server stopped like reset/restore):
  walks <ws-uuid>/<proj-uuid>/, recreates workspaces/projects from each
  scope's `_meta.md` manifest via the new ensure_{workspace,project}_with_id
  store ops (preserving the ids the tree is keyed by), and reindexes every
  page. Returns a {workspaces, projects, pages} summary.
- Page detection by CONTENT, not filename: a frontmatter file named `log.md`
  is a real page and is indexed (previously dropped); the raw `## [..]`
  event ledger, `_meta.md` and `bootstrap.md` are skipped. Fixes a class of
  silently-dropped pages on reindex.

Validated against a real 659-page backup: reindex reconstructs 659/659
pages (incl. the log.md-named page), preserves workspace/project names +
repo_path from _meta.md, excludes _meta.md, FTS works, and the rebuilt DB
has a clean V1-V19 lineage (no operator migration, no tolerate flag).

Still TODO for the cohesive PR: engine writes _meta.md on scope create so
the wiki is self-describing in production (this commit reads it).
2026-06-08 12:56:27 -03:00
Djalma Júnior 9d69968ec8 docs(claude): refresh ai-memory routing block (scopes/global/read-page)
Annotate memory_query with scopes/global=true, add the memory_read_page
routing row, and a 'broaden the search' section. Rebased onto upstream
and corrected the now-stale 'no global mode' wording (global=true shipped).
2026-06-07 10:56:20 -03:00
Djalma Júnior 5b45b687d3 fix(fts): match hyphenated terms against both content and path index
Follow-up to the dotted-filename fix: searching `ui-refresh` returned
nothing even though `follow-ups/ui-refresh-scroll-restoration.md` exists.

Root cause is a tokeniser/index mismatch the first fix didn't cover. The
FTS5 tokeniser is `unicode61 remove_diacritics 2 tokenchars '/_-'`, so
`/ _ -` stay INSIDE tokens — a body mention of `ai-memory` indexes as the
single token `ai-memory`. But `ops::path_search_text` pre-expands `/ . - _`
to spaces, so a path indexes the sub-tokens (`ui`, `refresh`, …). `.` is a
separator either way. Result: `"ai-memory"` matches content but not the
split path index; `"ai refresh"` matches the path but not the content
token. The single-form quoting could only ever satisfy one surface.

quote_fts5_token now emits BOTH forms OR'd — `("ui-refresh" OR "ui refresh")`
— so a search hits whichever surface indexed the term. No-punctuation
tokens still collapse to a single phrase; literal-quote fragments keep the
simple escaped form.

Validated via Docker live exploration (real binary, real path_search
index): `ui-refresh`/`ui-refresh-scroll` hit the path page, `ai-memory`
still hits the body page (regression guard), dotted/slug cases unchanged.
Graduated into a real-FTS5 ops.rs test that pins the exact quirk
(`"ui-refresh"` matches 0 rows, `"ui refresh"` matches 1) — the two
opposing content-vs-path tests are each other's mutation guard.
2026-06-06 00:16:18 -03:00
Djalma Júnior 92d1e5a31d feat(cli): base-path awareness for thin-client commands
The server nests every route under --base-path (AI_MEMORY_BASE_PATH),
so under e.g. /wiki the admin routes live at /wiki/admin/*. But the
thin-client commands (status, write-page, embed, search, read-page, …)
baked root-absolute paths (/admin/status) into the request URL, so they
404 under a base path — breaking in-pod ops and the Dockerfile
HEALTHCHECK (ai-memory status), which is why the chart had to fall back
to a tcpSocket liveness probe.

ServerEndpoint now carries a normalised base_path and a build_url() that
joins origin + base + path in one place (all three request sites use it).
The prefix is resolved from, in precedence order:
  1. the path component of AI_MEMORY_SERVER_URL (remote client), then
  2. AI_MEMORY_BASE_PATH (in-pod client; same var serve reads to nest).
Both are run through the server's own normalize_prefix, so client and
server agree on the prefix byte-for-byte. With no base configured the
joined URL is byte-identical to before — OFF by default.

Validated end-to-end via Docker live exploration (15 hypotheses, all
green): bug-repro 404, both fix paths 200, write->search persistence
under base, negative control (wrong base -> 404), URL-path-wins-over-env
precedence, trailing-slash normalisation, traversal neutralisation
(/wiki/../etc -> root), multi-segment base, and the HEALTHCHECK scenario.
Findings graduated into unit tests; mutation-checked (ignoring base_path
fails them).
2026-06-05 23:32:42 -03:00
Djalma Júnior f753ec71df test(store): real-FTS5 regression for dotted-filename search
Add an end-to-end test that drives the actual populated pages_fts index
(via upsert_page -> path_search triggers) instead of only asserting the
string output of prepare_fts5_query. It seeds a page at
reference/architecture-current.md, runs the reader's exact MATCH for
current.md, and asserts the prepared query both does not raise an FTS5
syntax error and matches the page. A guard asserts the same token bare
still errors, so the quoting sanitizer stays load-bearing.

Mutation-checked: reverting should_quote_fts5_token to its pre-fix form
makes this fail with the original fts5: syntax error near ".".
2026-06-05 22:26:58 -03:00
Djalma Júnior fa6f1bfcd0 fix(fts): quote punctuated tokens so filename searches don't error
A term with a dot (e.g. a filename `current.md`) passed through
prepare_fts5_query bare and FTS5 errored: syntax error near ".".
should_quote_fts5_token only quoted tokens with `-`. Now any token with
ASCII punctuation (except a trailing `*` prefix op and the `:` column
separator) is quoted as a phrase — avoids the error AND matches:
current.md -> "current.md" matches architecture-current.md. Accented
letters/digits stay bare. Tests: dotted_filename_token_is_quoted,
prefix_star_token_stays_bare.
2026-06-05 22:02:34 -03:00
Djalma Júnior a0f63ed03c style(auto-scope): satisfy rustfmt + clippy doc_lazy_continuation
CI on PR caught two style issues that didn't surface in local
non-strict runs:

- rustfmt wanted multi-line argument lists on three helpers in
  autoscope_stress.rs whose single-line form exceeded the 100-column
  limit. Mechanical reflow only — semantics unchanged.

- clippy::doc_lazy_continuation was unhappy with a doc comment that
  wrapped onto a line starting with "+ stderr." — the leading "+"
  reads as a list-item marker. Rephrased the sentence to start the
  continuation with a regular word.

No code or test behavior changes.
2026-06-04 19:26:32 -03:00
Djalma Júnior 5825a48e48 test(auto-scope): comprehensive concurrent + multi-user coverage
40 new tests covering the autoscope feature surface across four shapes:

* Randomised invariants on the `ActiveProject` map (4 tests, ~40k checks
  across 5 seeds × 2000 ops each). Uses an inline xorshift RNG to avoid
  adding `proptest` to the workspace deps. Asserts: cap is never
  exceeded; fresh write round-trips until overwrite/eviction; `clear()`
  wipes both slots; TTL expiry is monotonic via `keyed_only_get`.

* In-process concurrent stress (11 tests in `autoscope_stress.rs`). Each
  scenario assembles a real Router (StreamableHttpService nested at /mcp
  + hook_router at /hook) with a fake actor-injecting middleware that
  mirrors the production rung-1/rung-2 ActorContext shape. Scenarios:
  - per_session isolates 8x20 concurrent reads after one hook each
  - per_session isolates concurrent writes from N sessions
  - per_actor isolates users sharing a session_id
  - burst above max_entries does not corrupt (no 5xx, no panic)
  - TTL eviction under concurrent insertion remains graceful
  - x-memory-actor-session-id is a cache key, not a credential
  - sustained 5s of mixed hook+read traffic at ~250 ops/sec/session
  - real Claude Code / OpenCode / Codex payload shapes route correctly

* Real bearer-token multi-user (3 tests in `autoscope_multiuser.rs`).
  Stands up multi-user mode with token_pepper + 3 seeded users, drives
  the production Bearer→hash→users-table lookup path (replicated from
  ai_memory_cli::auth::require_bearer's Bearer-only branch since
  ai-memory-cli is binary-only and the auth module isn't reachable from
  another crate). Pins: distinct DB users with the same session_id stay
  isolated; anonymous requests don't inherit a DB user's per-actor slot;
  forged Bearer doesn't authenticate.

* Subprocess env-var smoke (9 tests in `autoscope_env.rs`). Spawns the
  `ai-memory serve` binary and parses startup logs to confirm figment
  round-trips `AI_MEMORY_AUTO_SCOPE__MODE`, `__SESSION_TTL_SECS`, and
  `__MAX_ENTRIES`. Pins boundary clamping (max_entries=0, ttl=0 →
  start with defaults) and serde-strict rejection (empty/invalid mode
  → fail fast at startup, never silently fall through to `single`).

Two `#[cfg(test)]` helpers added to `ActiveProject` / `PerActorMap`
(`keyed_entry_count_for_test`, `raw_len`) to let the invariant tests
peek at the keyed map size without exposing it publicly.

`ai-memory-hooks` added as a dev-dependency of `ai-memory-mcp` so the
stress tests can assemble the hook router alongside the MCP service.
2026-06-04 13:54:41 -03:00
Djalma Júnior fee4c854f4 fix(auto-scope): write tools must honor per-actor map, not single slot
The new `[auto_scope]` modes (`per_session`, `per_actor`) thread
`ActorKey` through every read tool, but the write side still went
through `write_target_ids` which read `self.active_project.get()` —
the legacy single-slot accessor. In `per_session` / `per_actor` mode
this collapses N concurrent users' writes into whatever single-slot
project was published last, because the per-actor map is bypassed.

The bug was caught by the new `per_session_isolates_concurrent_writes`
stress scenario: 8 sessions each hooked into a distinct project then
fired `memory_write_page` concurrently. With the old code, the
round-trip read in each session's own scope failed to find its own
write — every write had silently landed in the same single-slot
project. After the fix, every session's write lands in its own
project_id.

The legacy `write_target_ids` is preserved as a `#[cfg(test)]` thin
wrapper over the new `write_target_ids_with_actor` so existing test
fixtures keep compiling unchanged.
2026-06-04 13:54:41 -03:00
Djalma Júnior d237fac720 fix(auto-scope): key per-actor map by raw session_id, not the resolved UUID
The hook router was writing the per-actor map with the resolved
`SessionId` UUID's string form, but agents forwarding a session id over
the rung-4 `X-Memory-Actor-Session-Id` header on `/mcp` pass the
original opaque string. The two never matched, so the MCP read tools
fell through to the single slot and `per_actor` looked like Single mode
in practice.

Use `env.session_id` directly when building the actor key — same raw
string both endpoints exchange. The MCP server's
`actor_key_from_parts` already reads the raw header value, so set and
get now land on the same map slot.

Validation: spun up a local engine with `[auto_scope] mode = "per_actor"`,
seeded `default/alpha` and `default/beta`, fired two hook events
`sess-A → alpha`, `sess-B → beta`, then called `memory_recent` over
`/mcp` with `X-Memory-Actor-Session-Id: sess-A` (alpha title returned)
and `: sess-B` (beta title returned). Two parallel "sessions" on the
same engine no longer see each other's last-write.
2026-06-04 13:54:41 -03:00
Djalma Júnior 9a85f9b67d feat(auto-scope): thread ActorContext into every MCP read tool
Closes the read-side of the per-session / per-actor isolation seam: the
hook router was already publishing the in-process "current project"
pointer keyed by `(user, session_id)` under the opt-in `[auto_scope]`
modes, but the MCP read tools all consulted a backward-compatible
wrapper that bypassed the actor key and answered from the single slot.
Migrate every read tool so opt-in isolation now takes effect on the
full surface.

- `memory_query`, `memory_recent`, `memory_read_page`, `memory_status`,
  `memory_briefing`, `memory_explore`, `memory_lint`, `memory_forget_sweep`,
  `memory_handoff_begin`, `memory_handoff_accept`, and `memory_delete_page`
  now accept the request's `Extension<axum::http::request::Parts>` and
  derive the per-call `ActorKey` via `Self::actor_key_from_parts`.
  The actor flows into `effective_ids_with_actor` /
  `effective_ids_for_read_args_with_actor`, which read the per-key slot
  in `[auto_scope] mode = per_session | per_actor` and fall back to the
  single slot when the actor is empty.
- The historical `effective_ids` / `effective_ids_for_read_args`
  wrappers are removed now that every call site has been migrated;
  callers that have no request context (test harnesses, ad-hoc probes)
  pass `&ActorKey::default()` explicitly to the new methods, which is
  the same "single slot" behaviour as the old wrappers.
- New `docs/auto-scope.md` documents the three modes, the
  configuration knobs (`mode`, `session_ttl_secs`, `max_entries`), the
  per-rung source of `user` / `session_id`, and the memory-footprint
  characteristics. `CHANGELOG.md` lists the full read-tool surface
  under `[Unreleased]`.
- Test fixtures gain a `test_parts_default()` helper that produces an
  empty `axum::http::request::Parts` for tool calls in unit tests; all
  28 existing tool-test sites pass the helper through, so the
  workspace test suite remains green without rewriting any test's
  semantics.

Validation
- `cargo fmt --all -- --check`, `TAILWIND_SKIP=1 cargo clippy --workspace
  --all-targets -- -D warnings`, `TAILWIND_SKIP=1 cargo test --workspace`
  all clean (36 `test result: ok` lines, zero failures).
2026-06-04 13:54:41 -03:00
Djalma Júnior 060182ca38 feat(auto-scope): per-session and per-actor ActiveProject isolation modes
The hook-published ActiveProject pointer has been a single process-wide slot
since issue #2 — right for one operator on one project at a time, but
collapses on shared installs where concurrent sessions or operators publish
to the same slot from different cwds. Add opt-in isolation modes alongside
the legacy default.

Mode is chosen via a new `[auto_scope]` config block. `single` (default)
preserves the historical behaviour. `per_session` keys the pointer by
`session_id`, isolating concurrent agent runs of the same operator.
`per_actor` keys by `(user, session_id)`, isolating across operators as
well — pairs with multi-user mode where `user` comes from the `users` row
that owns the bearer token. Both opt-in modes still publish to the single
slot in parallel, so any caller without actor context (anonymous probe,
legacy code path) gets the most recent project rather than an empty
pointer — graceful degradation, never a silent error.

Per-key entries carry an `Instant` insertion timestamp and are TTL-evicted
(default 1 hour) on every read + write; a hard cap (default 4096) drops the
oldest insertions first when exceeded. Both knobs are configurable.

Wiring
- `core/src/active_project.rs`: `ActiveProjectMode` enum, `ActorKey`,
  `PerActorMap`, `set_for` / `get_for` API. Legacy `set` / `get` / `clear`
  still touch the single slot only and remain the right primitive for
  admin operations (e.g. `move-project` invalidates the pointer for every
  caller, regardless of mode).
- `cli/src/config.rs`: `[auto_scope]` block (`mode`, `session_ttl_secs`,
  `max_entries`); defaults are `single` / 3600s / 4096.
- `cli/src/commands/serve.rs`: constructs `ActiveProject` with the
  configured mode and logs it once at startup.
- `hooks/src/router.rs`: `handle_hook` / `handle_handoff` extract
  `ActorContext.user` from the request `Extension` BEFORE the spawn drops
  the request, pass it through `process_envelope` / `process`, and the
  resolver writes via `set_for(actor)`. `/handoff` has no `session_id` in
  the request, so per-session falls back to the single slot there and
  per-actor keys by `user` alone — both intentional.
- `mcp/src/server.rs`: `effective_ids` / `effective_ids_for_read_args` gain
  `_with_actor` variants; the historical signatures stay as wrappers that
  pass an empty actor (single-slot fallback), so every existing tool call
  site compiles unchanged. `actor_key_from_parts` is the shared extractor
  for follow-up tool migrations; tools that already accept
  `Extension<Parts>` (e.g. write/delete page) can opt in immediately by
  switching to the `_with_actor` variant.

Tests
- 13 unit tests in `core::active_project::tests` cover Single legacy API,
  PerSession key isolation, PerSession ignoring `user`, PerActor isolating
  by `(user, session_id)`, empty-actor graceful degradation, the single
  slot tracking the latest write across modes, `clear` wiping both stores,
  TTL eviction (with a test-only `keyed_only_get` so the single-slot
  fallback does not mask the eviction), and LRU-by-Instant cap eviction.
- Existing hooks test suite continues to pass after threading actor
  through `resolve_project_ids` (56 tests).

Validation
- `cargo fmt --all -- --check`, `TAILWIND_SKIP=1 cargo clippy --workspace
  --all-targets -- -D warnings`, `TAILWIND_SKIP=1 cargo test --workspace`
  all clean.

This is a checkpoint: the set path (hook router) is fully threaded; read
tools fall back to the single slot for now (graceful degradation). A
follow-up commit will migrate each read tool to call the `_with_actor`
variant so `per_session` / `per_actor` reads also isolate per caller.
2026-06-04 13:54:10 -03:00
Djalma Júnior 591869e3d8 fix(admin): rename-project after purge returns 404 instead of false 200
When `POST /admin/rename-project` raced a concurrent `POST /admin/purge-project`
against the same project, both endpoints returned `200 OK`:
- purge legitimately deleted the row + content
- rename's `UPDATE projects SET name=? WHERE id=?` affected zero rows but
  `conn.execute` returned `Ok(0)`, which the writer treated as success

The admin handler then responded `200 OK` with `pages: 0`, indistinguishable
from a happy rename of an empty project. Operators saw a misleading double
success when in reality the rename was undone (or never applied) by the
concurrent purge.

The fix detects `Ok(0)` in `ops::rename_project` and returns
`StoreError::NotFound`; the admin handler maps that to `404 Not Found` so the
caller sees an honest failure.

Live reproduction over 20 concurrent purge+rename trials moved the
false-success rate from 20/20 to 8/20 — the remaining cases are runs where
the rename actually committed first (UPDATE affected 1 row) and the purge
then deleted, which is structurally correct "last writer wins" and not
addressed here.

Coverage:
- ops::tests::rename_project_after_purge_returns_not_found (unit, semantic)
- ops::tests::rename_project_of_live_project_succeeds (regression guard)
- admin_rename::rename_project_after_purge_returns_404_not_silent_200 (integration)
2026-06-04 13:51:08 -03:00
Djalma Júnior 64c2cc786d feat(delete-page): admin endpoint + MCP workspace arg + CLI subcommand
Closes the structural gap where `delete-page` was the only basic CRUD
operation without a complete surface and the MCP variant silently routed
deletes to the wrong slot on multi-workspace servers.

- **`POST /admin/delete-page`** (new) — accepts explicit `{workspace,
  project, path}` and uses `lookup_ws_proj_no_create` (the same no-create
  resolver `purge-project`/`rename-project` use), so a typo'd or unknown
  workspace/project returns `404 'X' not found` instead of silently
  auto-creating empty containers and returning misleading `deleted: true`.
  Forwards admission context with `op=Delete` and respects the
  `X-Memory-Skip-Webhooks` loop-prevention header just like write-page.

- **`memory_delete_page` MCP** — `DeletePageArgs` gains an optional
  `workspace`; scope now resolves through `effective_ids_for_read_args`,
  the same path the read tools use. Previously the tool called
  `effective_ids(project)` which, for a project name that lived in
  multiple workspaces, could land the delete in the wrong slot and
  return `deleted: true` for a page that was never touched. Tool
  docstring updated to instruct passing `workspace + project` together
  on shared servers.

- **`ai-memory delete-page` CLI** — new subcommand, thin client of
  `/admin/delete-page`. Mirrors the `write-page`/`read-page` CLI shape
  (`--path`, `--workspace`, `--project`; project auto-derives from cwd
  via the shared `resolve_project_name`). Terminal users now have a
  complete delete-single-page path for the first time.

Tests:
- `admin::tests::delete_page_removes_existing_page` — happy path.
- `admin::tests::delete_page_unknown_workspace_does_not_fake_success` —
  Bug 5 regression: typo'd scope returns 404, not `deleted: true`.
- `admin::tests::delete_page_idempotent_for_missing_file_in_existing_scope`
  — matches MCP semantics.
- `admin::tests::delete_page_traversal_rejected_with_422`.
- `server::tests::memory_delete_page_with_explicit_workspace_targets_right_scope`
  — Bug 5 cross-workspace regression: seed identical `notes/twin.md` in
  `alpha/shared` and `beta/shared`, delete beta with explicit workspace,
  confirm alpha survives and beta is gone.

Full gate: workspace tests 257/0, fmt clean, clippy -D warnings clean.
End-to-end smoke validated against running engine: write + delete + read-
back round-trip on multi-workspace data and CLI surface exit codes for
both success and typo cases.

Refs: CHANGELOG entries under [Unreleased].
2026-06-03 15:57:35 -03:00
Djalma Júnior 6e252987b7 fix(serve): serve custom SPA shell at trailing-slash root {slug}/
nest(slug, spa) routes {slug} (inner /) and {slug}/<path> (inner
/{*path}) but leaves the bare trailing-slash root {slug}/ unrouted, so
it 404s. The SPA normalises its home to exactly {slug}/, so refreshing
the app root returned a hard 404 (memory.djalmajr.dev/web/ and
serpro.cithyper.click/wiki/web/). Serve the injected shell at {slug}/
too — unlike the builtin browser (which redirects {slug}/ -> {slug}),
a SPA stays put on a 200 rather than bouncing on every root refresh.

Covered by custom_spa_trailing_slash_root_serves_shell and an added
/wiki/web/ case in custom_spa_index_routes_are_injected_under_base_path.
2026-06-02 22:48:16 -03:00
Djalma Júnior d1a59ef7f9 feat(web): render [[wikilinks]] as clickable internal links
The server-rendered browser left `[[wiki links]]` as literal text — only
standard `[label](url)` markdown became clickable. Render the wikilink
forms the engine's link extractor already understands as internal links:

- `[[notes/foo]]`            → current workspace/project
- `[[notes/foo|label]]`      → explicit display label
- `[[project:path]]`         → sibling project, current workspace
- `[[workspace/project:path]]` → fully-qualified

Targets are resolved to the page route via `page_href`, so they inherit
whatever prefix the mount uses. Bare targets get a `.md` suffix; anchors
and queries are stripped. External schemes, path traversal, and empty
targets are left as literal `[[…]]`. Wikilinks inside fenced code blocks
and inline code are not rewritten.

Implemented by rewriting `[[…]]` into markdown links before parsing
(pulldown-cmark consumes `[...]` as reference syntax, so source-level
preprocessing is the robust hook), skipping code spans. 5 new tests;
ai-memory-web suite + clippy green.
2026-06-02 14:05:16 -03:00
Djalma Júnior f7ab853c20 test(serve): cover base-path nesting, base-href injection, and redirect
Add router-level integration tests for the base-path feature that the
unit tests for the string helpers didn't exercise:

- base_path_nests_all_surfaces_and_root_404s: the web UI + /api/v1 are
  reachable under `{base_path}` and 404 at the host root (nothing leaks
  outside the prefix).
- inject_web_base_href_targets_html_only: HTML responses get the injected
  `<base href>`; non-HTML assets pass through untouched.
- trailing_slash_redirect_carries_the_prefix: `/wiki/web/` → `/wiki/web`
  (the surrounding base nest does not rewrite Location headers).
- no_base_path_is_byte_equivalent_at_root: default mount injects
  `<base href="/web/">` and serves at root.

Shared `based_web_router` helper assembles the surface exactly like the
serve handler (mount + nest) and returns the TempDir guard. Full
workspace suite + clippy green.
2026-06-02 13:47:06 -03:00
Djalma Júnior a123f9040d feat(web): make the server-rendered browser subpath-aware via <base href>
The built-in read-only wiki browser (askama templates) emitted root-
absolute URLs (`/web/…` for assets, search form, home, and project/page
links). Under `AI_MEMORY_BASE_PATH` those escaped the prefix and 404'd
behind a reverse proxy — only the custom SPA was subpath-aware.

Make the templates emit RELATIVE URLs (`static/…`, `w/…`, `search`, `.`)
and inject `<base href="{base_path}{web_slug}/">` into every text/html
response from the server-rendered router, so links resolve under the
configured prefix exactly like the SPA's injected index. Also fix the
strip-trailing-slash redirect to carry the full external prefix (the
surrounding base-path nest does not rewrite Location headers).

Default (`/web/`) is byte-equivalent to the previous absolute links.
Verified live: GET /wiki/web emits `<base href="/wiki/web/">` + relative
links; GET /web emits `<base href="/web/">`; redirects include the prefix.
ai-memory-web + ai-memory-cli suites green.
2026-06-02 13:37:23 -03:00
Djalma Júnior cf617a4c06 feat(web): inject ai-memory-base-path meta so the SPA builds API URLs under the prefix
The <base href> folds in the web slug (e.g. /web), so it can't tell the SPA
where /api/v1 lives — that route hangs off the base path, not the web mount.
Inject a separate <meta name="ai-memory-base-path"> (escaped) the SPA reads to
form `${base}/api/v1`. Empty base => empty content => SPA falls back to /api/v1
(unchanged default). Unit-tested.
2026-06-02 13:37:23 -03:00
Djalma Júnior 31ea1f0d6d feat(web): serve the HTTP surface under a configurable base path
Adds --base-path / AI_MEMORY_BASE_PATH (default empty = host root,
byte-identical to today) so ai-memory can be hosted under a URL subpath
behind a reverse proxy that preserves the prefix — completing the
reverse-proxy deployment story in docs/https-via-proxy.md, whose recipes
currently all assume the host root. When set (e.g. /wiki) the whole
surface nests under it: /wiki/mcp, /wiki/api/v1, /wiki/hook and the web UI.

--web-slug / AI_MEMORY_WEB_SLUG (default /web) chooses where the UI mounts
within the base; "/" serves it at the base root. The server injects a
normalized <base href> into the served index so a custom --web-ui-dir SPA
resolves its relative asset/router URLs under the prefix without a rebuild.

Safety: empty base path => identical routing (tested). The prefix is pure
routing — require_bearer/CORS/cookie semantics unchanged. Path normalization
(normalize_prefix/web_base_href) collapses edge/duplicate slashes, restricts
to a safe charset, HTML-escapes the injected href, and never emits a
protocol-relative "//" — covered by unit tests (""/"/"/"//"/"wiki"/"/wiki/"
/unsafe-chars). The /api/v1, /mcp, /hook contract paths are unchanged; they
only inherit the base prefix.

Follow-up: the built-in server-rendered browser emits root-absolute /web
links; making those prefix-relative is a separate change. The documented
reverse-proxy + custom-UI path is fully supported here.
2026-06-02 13:37:23 -03:00
Djalma Júnior 806113f573 fix(admin): move-project duplicate must not clobber a claimed dedup slot
Code-review finding: in on_conflict=duplicate, a source page whose natural path
equals an earlier page's de-duplicated target took the no-conflict branch and
wrote to that path WITHOUT consulting used_dest_paths, silently superseding the
page that already claimed the slot — defeating the 'keep both' guarantee.

The no-conflict branch now de-duplicates too when its natural path was already
claimed. Test: a source with X.md (conflicting) + X-from-<srcws>.md (natural) —
all three distinct contents survive.
2026-06-01 18:39:57 -03:00
Djalma Júnior 494deb9907 fix(admin): tighten move-project per review (crash-safety, on_conflict, cache, tests)
Follow-up hardening on top of the first rework:

- True-move ordering is now rename-FIRST, SQL-commit-LAST, so the DB is never
  ahead of disk: a rename failure touches nothing; a SQL failure renames the
  dir back; a crash between leaves at most an orphan dir with the DB still at
  the source (recoverable), never a row pointing at a missing file. The V18
  pairing trigger rejects any watcher reindex during the window.

- copy-purge same-path conflicts are now an explicit `on_conflict` policy:
  `block` (default — abort with 409 listing the conflicts, source intact),
  `overwrite` (source supersedes the destination page), or `duplicate` (keep
  both under a de-duplicated path). CLI `--on-conflict`, reported in `conflicts`.

- The move now PROACTIVELY evicts the hook router's per-cwd cache entries for
  the moved project (a fire-and-forget hook wired from serve into AdminState),
  so the next hook re-resolves cleanly rather than relying only on the trigger.

- New integration test (ai-memory-hooks) proving the actual recovery loop: a
  cached project moved to another workspace makes the next hook's stale write
  trip the pairing trigger; the router evicts + re-resolves into a consistent
  pair instead of writing a split-brain row. Plus block/overwrite copy-purge
  tests.

Validated end-to-end against a real server in a container (true-move,
block/overwrite/duplicate, partial-skip, and the live-session guard exercised
through an actual /hook event): 22/22.
2026-06-01 17:15:30 -03:00
Djalma Júnior 46d5a524f3 fix(admin): harden move-project (safe failure model, live-session guard, conflicts)
Addresses the PR #60 review. The destructive cross-workspace move now:

- True-move failure model: the dir rename is all-or-nothing. The destination
  dir is pre-checked absent; on rename failure the SQL re-stamp is rolled back
  (the op is symmetric) so the move never leaves the DB ahead of disk. A double
  fault (rollback also fails) surfaces a precise manual-repair message.

- Live-session guard: refuses (409) to move the project the hook router has
  published as the active project; `force: true` / `--force` overrides. On any
  successful move the active pointer is republished (true-move → destination
  workspace) or cleared (copy-purge → new project_id), so the next hook resolves
  cleanly. Backed by the V18 (workspace_id, project_id) insert trigger: a stale
  cached write now fails and the router re-resolves instead of corrupting.

- Copy-purge same-path conflicts → duplicate (keep both): a source page whose
  path already exists in the destination with different content lands under a
  de-duplicated path (<stem>-from-<src_workspace>...), reported in the response
  `conflicts` array. Identical content stays a no-op supersession.

- CLI/docs/test accuracy: the pre-confirm warning and the admin_move module
  header now describe true-move vs copy-purge correctly; lifecycle-ops documents
  the failure model, the guard, and conflict duplication.

Tests: rollback-on-rename-failure, active-project refuse/force+republish,
conflict duplication, copy-purge embedding carry-over (existing destination),
partial-copy reporting, and idempotent re-run.
2026-06-01 15:03:05 -03:00
Djalma Júnior bf1fd43203 feat(store): enforce (workspace_id, project_id) pairing on insert
The schema denormalises workspace_id onto every domain row but only enforced
project_id and workspace_id independently — never that the project actually
lives in that workspace. A stale writer (e.g. a hook router cache holding the
old workspace for a just-moved project) could silently insert a split-brain
row.

V18 adds BEFORE INSERT triggers on pages/sessions/observations/handoffs that
ABORT when workspace_id disagrees with the project's workspace. INSERT only,
so the move-project re-stamp (UPDATE) is unaffected; the hook router already
re-resolves on a write error, turning silent corruption into self-healing.
2026-06-01 14:40:56 -03:00
Djalma Júnior 4475803205 feat(admin): fire the admission chain on move-project
move-project copies pages into the destination and purges the source. With
admission webhooks configured, both halves must notify the chain or a backup
mirror / index webhook goes stale after a move:

- each per-page copy passes admission_ctx: None to write_page, so the chain
  resolves the destination workspace/project NAMES from the destination IDs
  and a mirror lands the copy under the destination path;
- the source removal routes through Wiki::purge_project with an explicit
  AdmissionContext (op=purge_project, source names), mirroring
  handle_purge_project, so a mirror drops the source project.

No-op when no admission chain is configured.
2026-06-01 14:00:11 -03:00
Djalma Júnior c8a3c14af5 feat(admin): move-project does a lossless true-move for fresh destinations
A cross-workspace move into a workspace that has no same-named project is
now a true move instead of copy+purge: re-stamp the project's workspace_id
across every domain table (projects, pages, sessions, observations,
handoffs, audit_log) in one writer transaction, keeping the same
project_id, then fs::rename the on-disk dir. page_embeddings and links are
keyed by page_id, so they follow with no re-stamp.

This preserves what copy+purge dropped — sessions, observations, handoffs
and the full supersession history — and is O(1) (one transaction + one
rename) instead of O(pages) with a re-embed and admission webhook per page.

The copy+purge path is kept for the merge case (destination already holds
a same-named project), where two project_ids can't be re-stamped into one
without colliding on UNIQUE(workspace_id, name). The response reports which
ran via a new `moved_via` field ("true-move" | "copy-purge").

SQL commits before the dir rename so the watcher re-derives the same
(ws, proj) the DB already holds and the reindex is a sha256 no-op.
2026-06-01 12:16:09 -03:00
Djalma Júnior ff490de2a7 perf(admin): move-project carries source embeddings instead of re-embedding
Per-page embedding was the dominant cost of a bulk move-project (~7s/page).
Each copied page went through write_page, which recomputed its embedding even
though the source page already had one.

Carry the source vector over verbatim: copy via an embedder-less Wiki
(Wiki::without_embedder) so write_page never re-embeds, then store_embedding
the source vector against the new page id. Only embeddings computed with the
currently configured embedder are reused (load_embeddings filters on
provider/model/dim), so the one-model-per-index invariant holds; a page
lacking a current-model embedding is copied without one (backfill via
`ai-memory embed`).

Adds Wiki::without_embedder and a test proving the carried vector (a marker)
survives the move rather than being recomputed.
2026-06-01 12:10:40 -03:00
Djalma Júnior 4a9fd1ff96 feat(admin): move-project — copy a project into another workspace
Add `move-project` (CLI + `POST /admin/move-project`) to move a project
across workspaces. Crossing the workspace boundary is implemented as a
copy-then-purge through the normal write path rather than a low-level
workspace_id re-stamp:

- copy every latest page into the destination via `Wiki::write_page`
  (so sanitization, link re-resolution, FTS, and admission/git-mirror
  webhooks all fire naturally),
- only after every page copies successfully, purge the source project
  (cascade rows + remove the on-disk dir).

Copy-before-purge is the safety property: a copy failure aborts before
the purge, leaving the source intact; an unreadable source page is
skipped and blocks the purge so a fixed re-run is safe. Get-or-create on
the destination merges into an existing same-named project. Same-workspace
moves are rejected (422 — use rename-project). Sessions/observations/
handoffs do not migrate (only durable pages).

Integration tests in tests/admin_move.rs cover copy+purge, confirm-gate,
404, merge-into-existing, and same-workspace rejection. Docs updated in
README, ARCHITECTURE, and lifecycle-ops.
2026-06-01 12:10:40 -03:00
Djalma Júnior b3bd998576 fix(mcp): make read_page reliable — cross-workspace scope + DB fallback
memory_read_page (and /admin/read-page) could fail to return a page that
search can see — the two symptoms from gotchas/read-page-by-query-misses:

1. Scope: memory_read_page resolved via effective_ids(project) only, so a
   page in a DIFFERENT workspace couldn't be targeted (the workspace arg
   didn't exist). It now takes an optional `workspace` and resolves through
   effective_ids_for_read_args — the same chain memory_query uses — so
   explicit workspace+project reaches a sibling project on a shared server.

2. os-error-2: the on-disk markdown read could fail when the index is
   momentarily ahead of disk (recently-written page, watcher/disk skew),
   returning "No such file or directory" for a page that demonstrably
   exists. Both handlers now fall back to the store's faithful copy of the
   body (new ReaderPool::page_body_by_ids) before erroring, and only 404
   when there's genuinely no is_latest row. The MCP response tags a
   fallback read with "served_from":"db-fallback".

Tests: admin_read_page covers the DB fallback for a DB-only page, the
on-disk happy path, and a true 404. Markdown stays the source of truth;
the DB copy is written in the same transaction, so serving it is safe.
2026-06-01 00:45:22 -03:00
Djalma Júnior 5ce34537fc feat(store): index page path/slug in FTS so filename searches hit
Search (memory_query, /admin/search, read_page query-mode) previously
FTS5-matched only a page's title + body, so a distinctive slug like
`followup-bulk-rename-runbook-titles` was missed unless the words also
appeared in the prose.

V17 adds a `pages.path_search` column holding the path normalised for
search and includes it in the (still content-backed, no body duplication)
pages_fts index. The path is indexed in both forms so either query style
hits: `/` and `.` become spaces while `-`/`_` are kept (the whole
hyphenated slug stays one token), and the path is also fully split into
words. `notes/foo-bar.md` -> `notes foo-bar md notes foo bar md`.

The writer keeps path_search in sync via ops::path_search_text, which is
byte-identical to the migration backfill so live writes and `rebuild`
index the same text. No reader SQL change: FTS MATCH already searches all
columns and snippet() still targets body (column 1).
2026-06-01 00:45:22 -03:00
Djalma Júnior 4b7a122020 fix(mcp): write_target_ids defaults workspace to the active project's
A memory_write_page with an explicit `project` but no `workspace` resolved
the workspace to the server's baked `--workspace` (e.g. "default"), ignoring
the hook-published ActiveProject. From a cwd routed to another workspace,
`{project: "foo"}` would silently land in (and recreate) `default/foo`
instead of `<cwd-workspace>/foo` — the opposite of the active-project-wins
behaviour reads already follow.

Default the workspace to the ActiveProject's workspace when none is given,
falling back to the baked default only when no ActiveProject is published
yet. Pass `workspace` explicitly to target the shared/baked workspace.
Reads (effective_ids) already consult the ActiveProject; this makes writes
consistent.
2026-05-31 14:30:20 -03:00
Djalma Júnior ca114e6724 feat(admission): non-blocking webhooks (blocking=false, fire-and-forget)
The chain ran every subscribed webhook synchronously inside the write path,
so a pure backup/mirror (git-mirror) added its full round-trip latency to
every write even though it never mutates the page.

Add `blocking: bool` (default true) to WebhookConfig:
- blocking=true keeps today's behaviour — runs in `run`, may mutate, a Reject
  aborts the write.
- blocking=false is dispatched fire-and-forget via `dispatch_async` AFTER the
  page lands on disk: the engine doesn't await it and ignores its response (so
  it can't mutate/reject), honouring the same event subscription + skip list.

write_page/delete_page/purge_project fire the non-blocking set after the
blocking chain + the on-disk write. Worst-case write latency is now Σ timeout_ms
over blocking webhooks only. Test covers run-skips + async-dispatches; docs
updated.
2026-05-30 18:42:20 -03:00
Djalma Júnior c333d48a71 refactor(admission): adopt ai_memory_core::ActorContext (single identity source)
The v0.8 multi-user work landed a canonical ai_memory_core::ActorContext
(injected as Extension<ActorContext> by the four-rung auth middleware) and
threaded it through WritePageRequest.actor for on-disk attribution. The
admission chain carried its OWN duplicate ai_memory_wiki::ActorContext, fed by
a separate X-Memory-Actor-* header bridge — two identity types for the same
write.

Collapse them onto the core type:
- AdmissionContext.actor is now ai_memory_core::ActorContext; the duplicate
  wiki type is deleted.
- Wiki::write_page fills the webhook context's actor from req.actor, so the
  on-disk last_modified_by block and the webhook payload share one identity.
- The MCP write handler feeds the header-derived actor into req.actor (so MCP
  writes now attribute, not just the admission webhook), and no longer stamps
  it separately into the admission context.
- actor_from_headers returns the core type. The X-Memory-Actor-* bridge is now
  just one optional way to populate the actor — the middleware's
  Extension<ActorContext> can replace it as a follow-up.

Wire shape unchanged (webhook ctx.actor still serialises agent/user/…), so the
git-mirror keeps working. Gates green except the ambient reset tests.
2026-05-30 16:04:55 -03:00
Djalma Júnior 4805986397 Merge remote-tracking branch 'upstream/main' into feature/admission-webhooks
# Conflicts:
#	crates/ai-memory-cli/src/commands/serve.rs
#	crates/ai-memory-consolidate/src/bootstrap.rs
#	crates/ai-memory-consolidate/src/consolidator.rs
#	crates/ai-memory-consolidate/src/lint.rs
#	crates/ai-memory-consolidate/tests/embeddings.rs
#	crates/ai-memory-consolidate/tests/lifecycle.rs
#	crates/ai-memory-consolidate/tests/recall_eval.rs
#	crates/ai-memory-hooks/src/router.rs
#	crates/ai-memory-mcp/src/admin.rs
#	crates/ai-memory-mcp/src/server.rs
#	crates/ai-memory-mcp/tests/admin_backup.rs
#	crates/ai-memory-mcp/tests/admin_phase3.rs
#	crates/ai-memory-mcp/tests/admin_purge.rs
#	crates/ai-memory-mcp/tests/admin_rename.rs
#	crates/ai-memory-web/tests/routes.rs
#	crates/ai-memory-wiki/src/wiki.rs
2026-05-30 15:56:57 -03:00
Djalma Júnior 67ce82247a Merge remote-tracking branch 'upstream/main' into feature/admission-webhooks
# Conflicts:
#	crates/ai-memory-mcp/src/admin.rs
2026-05-30 11:26:08 -03:00
Djalma Júnior 1907e7d377 Merge remote-tracking branch 'upstream/main' into feature/admission-webhooks
# Conflicts:
#	crates/ai-memory-store/src/writer.rs
2026-05-30 11:16:15 -03:00
Djalma Júnior fc95f816b8 Merge remote-tracking branch 'upstream/main' into feature/admission-webhooks
# Conflicts:
#	crates/ai-memory-mcp/src/server.rs
2026-05-30 10:02:34 -03:00
Djalma JúniorandClaude Opus 4.8 9808a01d3a feat(mcp): cross-project recall — global search + broaden-on-miss guidance (#56)
* feat(routing): teach cross-project search strategy

memory_query searches only the current project — there is no global
search. An agent that searches one project and stops misses knowledge
that lives in a sibling project (a shared infra/ops project). Both prompt
surfaces now teach: broaden via `scopes`, and that query returns snippets
(not full bodies) so read the whole page with `memory_read_page`.

- SNIPPET_BODY (routing_snippet.rs) + MEMORY_INSTRUCTIONS (server.rs):
  new "broaden when the current project comes up empty" guidance.
- Regression test prompts_teach_cross_project_search_strategy.

(The read-page tool this guidance points at shipped upstream as
memory_read_page in v0.7.0; the earlier design doc is dropped.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(mcp): add global (cross-project) search to memory_query

memory_query searched only one project (current, or an explicit scopes
list) — there was no way to search every project at once, so an agent
that didn't know which project held a fact would miss it. The engine
already had the primitive (ReaderPool::search_pages_with_meta, used by
/api/v1 SearchMode::Global); this just exposes it over MCP.

- New `global: bool` arg (default false). When true, project/workspace/
  scopes must be omitted; returns `global_hits`, each annotated with its
  workspace + project name so the agent knows where each came from.
- Prompt surfaces (MEMORY_INSTRUCTIONS + SNIPPET_BODY) + ARCHITECTURE.md
  updated to mention scopes vs global.
- Tests: global search spans projects across workspaces;
  global+explicit-scope is rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 02:11:56 -03:00
Djalma Júnior 61581d9ffb fix(mcp): write_page / lint / forget_sweep honor the session's project (and explicit scope) (#59)
* fix(mcp): memory_write_page honors explicit workspace+project (creates them)

The write target resolved the explicit `project` with find_project (find-only):
a name that didn't exist yet silently fell back to the current project, so a
write meant for project X landed in the session's active project instead.

Add write_target_ids (get_or_create) used only by memory_write_page — reads
keep find-only effective_ids. New optional `workspace` arg (created if absent,
honored only with an explicit project). With no explicit project the
active-project-wins behavior (issue #2) is preserved. Regression test: a write
to a non-existent project creates it and does not leak into the current one.

* fix(mcp): memory_lint + memory_forget_sweep honor the session's project

Both handlers passed self.workspace_id/self.project_id — the ids baked at
construction — to run_lint/run_sweep, and neither accepted a project/workspace
arg. So lint and the retention sweep always targeted the static default
project, ignoring the hook-published active project and any explicit request.
A cross-project dangling-link audit, for instance, could never reach the
project that actually held the broken link.

Route both through effective_ids_for_read_args (the same find-only resolution
the read tools use) and add optional project/workspace args. Regression test:
an episodic page in project `audited` is a sweep candidate only when the sweep
is pointed there, never against the baked default.
2026-05-30 02:08:56 -03:00
Djalma Júnior f197231001 fix(admin): purge-project admission carries the project name
handle_purge_project deleted the project's DB rows (writer.purge_project)
before calling wiki.purge_project, so the admission chain's name-resolution
fallback (project_name_by_id on an already-deleted row) returned nothing. The
op=purge_project notify then went out with an empty project name, and a
name-based mirror purged the wrong ("_unscoped") path instead of the project.

Seed the AdmissionContext with the request's workspace/project names before
the wiki call; resolve_admission_names leaves pre-set names untouched.
Regression test captures the purge webhook and asserts the real name rides
through.
2026-05-29 22:39:42 -03:00