Merge branch 'main' into release/2.5

Forward-merge the 2.4.x fixes (#875, #824, #861, #873, #866, #871, #872) so main stays a subset of release/2.5.

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

# Conflicts:
#	CHANGELOG.md
#	crates/ai-memory-cli/src/commands/serve.rs
#	crates/ai-memory-mcp/src/server.rs
This commit is contained in:
AkitaOnRails
2026-09-24 17:06:25 -03:00
17 changed files with 2176 additions and 86 deletions
+56 -1
View File
@@ -80,6 +80,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(not bare JSON 401), forms call existing `POST /auth/login` /
`/auth/password` / `/auth/logout`, and `--web-ui-dir` custom SPAs stay
unchanged. (#811)
- `docs/jev-reranker-adapter.md` documents a stdlib-only adapter
(`docs/examples/jev-reranker-adapter/jev_rerank_shim.py`) that serves the
`AI_MEMORY_RERANKER=llm` request leg from a Jev `/v1/systemone` judge
endpoint while reverse-proxying consolidation/lint/bootstrap traffic to
the configured provider unchanged. In the contributor's own 102-query
golden-set benchmark the judge matched the hosted reranker's
hit@1/MRR/NDCG@10 (0.778/0.838/0.873 vs 0.778/0.840/0.875) at 0.205 s
mean latency instead of 20.2 s — in that run the hosted mean sat on the
server's 20 s completion timeout, which made the reranker stall every
query before falling back. (#873)
### Changed
- Grok Build CLI shows a pending handoff, and an opted-in `[briefing]`, as
@@ -103,7 +113,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
instead of `embed_query` on the configured embedder, so a
query/document-asymmetric embedder (Google's task-typed embeddings, or
the new query/document prefixes above) embedded the search query on the
document side instead of the query side. (#859)
document side instead of the query side. Indexed writes are unchanged;
only the query-side helper moves onto the query task. (#859, #861)
- The Linux/macOS Docker wrapper now keeps its native host client in
`${XDG_DATA_HOME:-~/.local/share}/ai-memory/native-runner` instead of
`~/.cache/ai-memory/native-runner`. `ai-memory run` auto-wires hooks whose
command is that client's path (Claude Code, Codex, Kimi Code, Command Code,
Kiro CLI v3, Grok, Antigravity CLI), so flushing `~/.cache` left every hook of
those harnesses pointing at a missing binary. The wrapper also keeps the
release's `hooks/` bundle beside the client, so auto-wire no longer fails
with "could not locate hooks directory" for script-based harnesses on a host
where `install-hooks` never ran. (#874)
- Fixed pre-push installation from linked worktrees and preserved the managed
block's position during reinstallation. Configured `core.hooksPath` overrides
and ambiguous markers are rejected without replacing the existing hook. The
block now keeps its shell options and `SSL_CERT_FILE` inside its subshell and
propagates a failure explicitly, so user hook commands after it keep their
own semantics and a failing test run still blocks the push. (#824)
- Isolated the pre-push test process from Git's repository environment and
global/system configuration so fixture commands use their own repositories.
Existing installations need to run `scripts/install-git-hooks.sh` again. (#824)
- A Windows service running as `LocalSystem` over a user-owned data
directory no longer breaks the wiki git history silently. libgit2's
dubious-ownership guard (CVE-2022-24765) fails every wiki commit with
`code=Owner` when the process account does not own the repository, but
the failure was WARN-only, so capture and search kept working while no
wiki checkpoint was ever committed. The startup baseline checkpoint now
surfaces an owner-check failure at ERROR with the remedy (run the
service as the owning user), and `docs/windows.md` Scenario E documents
running the service under a `<serviceaccount>`, corrects the claim that
only the data directory is account-sensitive, and notes the WinSW
error-1069 / stale-password gotcha for Microsoft-account / PIN / Hello
users. The owner check itself is deliberately left enabled. (#872)
- A Windows folder no longer splits into two projects. The hook router
derived a project's *name* from the cwd after
`normalize_project_path_key` had ASCII-lowercased the whole
drive-letter/UNC path — basename included — so a session in
`D:\...\Default Project` was captured under `default project` while the
CLI (which keeps the raw basename) used `Default Project`. Because
`get_or_create_project` matches names case-sensitively, one folder
minted two projects. The router now takes the name from the raw cwd; the
cache key and cwd-prefix match keep the case-folded path, so #806 handoff
stickiness is unaffected. (#871)
- Auto-improve review no longer stages a proposal whose LLM-produced page
path contains a Windows-illegal character (e.g. a `:` copied from a
conventional-commit subject). That path passed the deliberately tolerant
@@ -176,6 +227,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
checksum block, which concatenates every platform's file. The zip's smoke
test now requires LF rather than tolerating either, so the format the
release claims is the format it ships. (#838)
- `purge-session` now removes every page version the session owns at
`sessions/<id>.md` (including versions written before OKF sources existed
and summaries of sessions that never recorded a summary pointer), while a
manual page at the same path survives. (#862)
## [2.4.0] - 2026-09-21
+15 -3
View File
@@ -77,9 +77,21 @@ Skipped tests still count as "skipped" in the summary, never hidden, and two
independent things run them anyway: the pre-push hook and CI.
Install the hook once per clone with `scripts/install-git-hooks.sh` (from Git
Bash on Windows). It appends or updates only ai-memory's managed block in
`.git/hooks/pre-push`, preserving any existing hook body. Bypass it on a
work-in-progress branch with `git push --no-verify`.
Bash on Windows). It can run from the main checkout or a linked worktree;
both use the shared repository hook directory. Reinstallation replaces
ai-memory's managed block in place, preserving surrounding user commands and
their order. If `core.hooksPath` is set, the installer stops before writing;
integrate the block through your existing hook manager instead. Incomplete or
duplicate managed markers also stop installation and leave the hook unchanged.
Bypass it on a work-in-progress branch with `git push --no-verify`.
The managed test block clears Git's repository environment and disables global
and system Git configuration for Cargo and its children. Fixture commands can
then use their own repositories without inheriting the checkout being pushed.
The publishing Git process and other hook code retain their configuration, and
the block's shell options stay inside it; a failing test run still fails the
hook even when your own commands follow the block without `set -e`.
Run the installer again to update an existing installation.
Integration tests live in `tests/suite/` per crate and compile into the
crate's own test harness (declare a new file with `mod name;` in
+15 -4
View File
@@ -9,7 +9,7 @@
#
# Special wrapper-only subcommands (not forwarded to the binary):
# ai-memory upgrade Pull the latest image + remind to re-stage hooks.
# ai-memory run ... Use a cached native client so it can exec host agents.
# ai-memory run ... Use a local native client so it can exec host agents.
# ai-memory show ... Use that client for host project/harness discovery.
# ai-memory continue ... Use that client to resume the newest linked checkout.
# ai-memory workstreams Use that client to inspect the host checkout identity.
@@ -55,6 +55,10 @@ DATA_VOLUME="${AI_MEMORY_DATA_VOLUME:-ai-memory-data}"
CACHE_DIR="${XDG_CACHE_HOME:-${HOME}/.cache}/ai-memory"
VERSION_CHECK_FILE="${CACHE_DIR}/last-version-check"
HOOKS_STAGE_DIR="${HOME}/.local/share/ai-memory/hooks"
# `run` auto-wires hooks whose command is this client's own path, so a cache
# flush would break every hook until the next managed launch. Keep it with the
# host's ai-memory data instead of under CACHE_DIR.
NATIVE_RUNNER_DIR="${XDG_DATA_HOME:-${HOME}/.local/share}/ai-memory/native-runner"
WRAPPER_URL="${AI_MEMORY_WRAPPER_URL:-https://github.com/akitaonrails/ai-memory/releases/latest/download/ai-memory-wrapper}"
WRAPPER_SHA256_URL="${AI_MEMORY_WRAPPER_SHA256_URL:-${WRAPPER_URL}.sha256}"
@@ -354,7 +358,7 @@ cmd_upgrade() {
fi
echo "→ pulling ${IMAGE}"
"${DOCKER}" pull "${IMAGE}"
rm -f "${CACHE_DIR}/native-runner/last-check"
rm -f "${NATIVE_RUNNER_DIR}/last-check"
local found_agents=()
if [ -d "${HOOKS_STAGE_DIR}" ]; then
@@ -455,7 +459,7 @@ cmd_upgrade() {
# Managed launch/discovery commands must execute on the host: checkouts, harnesses,
# and native transcript stores are host resources, not contents of the helper
# container. Keep a checksum-verified release client beside the wrapper cache.
# container. Keep a checksum-verified release client under NATIVE_RUNNER_DIR.
native_host_binary() {
if [ -n "${AI_MEMORY_NATIVE_BIN:-}" ]; then
[ -x "${AI_MEMORY_NATIVE_BIN}" ] || {
@@ -486,7 +490,7 @@ native_host_binary() {
esac
artifact="ai-memory-${os}-${arch}"
base="https://github.com/akitaonrails/ai-memory/releases/latest/download/${artifact}.tar.gz"
native_dir="${CACHE_DIR}/native-runner"
native_dir="${NATIVE_RUNNER_DIR}"
binary="${native_dir}/ai-memory"
archive="${native_dir}/${artifact}.tar.gz"
check_file="${native_dir}/last-check"
@@ -533,6 +537,13 @@ native_host_binary() {
[ -x "${tmp}/ai-memory" ] || chmod +x "${tmp}/ai-memory"
mv "${tmp}/${artifact}.tar.gz" "${archive}"
mv "${tmp}/ai-memory" "${binary}"
# `run` auto-wires hooks through this client, and install-hooks looks for
# its script bundle beside the binary. Without it, auto-wiring fails for
# every script-based harness on a host where nothing was staged yet.
if [ -d "${tmp}/hooks" ]; then
rm -rf "${native_dir}/hooks"
mv "${tmp}/hooks" "${native_dir}/hooks"
fi
rm -rf "${tmp}"
touch "${check_file}"
fi
+84 -6
View File
@@ -30,7 +30,7 @@ use ai_memory_web::{
HtmlAuthRedirectConfig, WebMountSpec, html_auth_redirect_mw, normalize_prefix,
split_web_routers, web_base_href,
};
use ai_memory_wiki::{WatcherHandle, Wiki, migrations, run_wiki_migrations};
use ai_memory_wiki::{WatcherHandle, Wiki, WikiError, WikiResult, migrations, run_wiki_migrations};
use anyhow::{Context, Result};
use axum::body::Body;
use axum::extract::{DefaultBodyLimit, State};
@@ -1045,12 +1045,30 @@ pub async fn run(config: &Config, args: ServeArgs) -> Result<()> {
Ok(n) => tracing::info!(count = n, "wrote _meta.md scope manifests"),
Err(e) => tracing::warn!(error = %e, "scope-manifest backfill failed (non-fatal)"),
}
match wiki.ensure_upgrade_baseline_checkpoint() {
Ok(Some(oid)) => {
tracing::info!(checkpoint = %oid, "created wiki upgrade baseline checkpoint")
let baseline_checkpoint = wiki.ensure_upgrade_baseline_checkpoint();
match classify_baseline_checkpoint(&baseline_checkpoint) {
BaselineCheckpointLog::Created => {
if let Ok(Some(oid)) = &baseline_checkpoint {
tracing::info!(checkpoint = %oid, "created wiki upgrade baseline checkpoint");
}
}
BaselineCheckpointLog::Clean => {}
// An owner-check failure is silent-but-fatal to the wiki git history:
// capture keeps working, but no checkpoint is ever committed, so it
// must not hide in a WARN. This is the Windows LocalSystem-service /
// user-owned-data-dir case (docs/windows.md Scenario E).
BaselineCheckpointLog::OwnerFailure => tracing::error!(
error = %baseline_checkpoint.as_ref().err().map(ToString::to_string).unwrap_or_default(),
"wiki git commits are failing libgit2's owner check: the wiki repository is not \
owned by the account running ai-memory. Run the service AS THE OWNING USER (see \
docs/windows.md Scenario E) — capture continues, but no wiki checkpoints will be \
committed until this is fixed."
),
BaselineCheckpointLog::OtherFailure => {
if let Err(e) = &baseline_checkpoint {
tracing::warn!(error = %e, "wiki upgrade baseline checkpoint failed (non-fatal)");
}
}
Ok(None) => {}
Err(e) => tracing::warn!(error = %e, "wiki upgrade baseline checkpoint failed (non-fatal)"),
}
// Keep the guard alive for the lifetime of `serve`.
@@ -2718,6 +2736,31 @@ async fn seed_active_project_fallback(reader: &ReaderPool, active_project: &Acti
}
}
/// How a wiki baseline-checkpoint attempt should be surfaced at startup.
/// Extracted from the logging call so the owner-vs-other classification is
/// unit-testable without standing up a server: a real LocalSystem-service
/// owner failure needs a native Windows box, which is out of scope here.
#[derive(Debug, PartialEq, Eq)]
enum BaselineCheckpointLog {
/// A checkpoint commit was created (INFO).
Created,
/// Nothing to commit (silent).
Clean,
/// libgit2 refused on its ownership guard — actionable, logged at ERROR.
OwnerFailure,
/// Any other failure — non-fatal, logged at WARN as before.
OtherFailure,
}
fn classify_baseline_checkpoint<T>(result: &WikiResult<Option<T>>) -> BaselineCheckpointLog {
match result {
Ok(Some(_)) => BaselineCheckpointLog::Created,
Ok(None) => BaselineCheckpointLog::Clean,
Err(WikiError::GitOwner(_)) => BaselineCheckpointLog::OwnerFailure,
Err(_) => BaselineCheckpointLog::OtherFailure,
}
}
fn host_without_port(host: &str) -> &str {
if let Some(rest) = host.strip_prefix('[')
&& let Some((inside, _)) = rest.split_once(']')
@@ -2748,6 +2791,41 @@ mod tests {
use tempfile::TempDir;
use tower::ServiceExt;
/// An owner-check failure of the startup wiki baseline checkpoint must be
/// classified as an ERROR-worthy `OwnerFailure`, not the WARN-only
/// `OtherFailure` that hid it before (#872). A generic error stays
/// `OtherFailure`, and the success/clean cases are unchanged — this is the
/// seam that decides the log level, so it bites here.
///
/// A full native-Windows LocalSystem-service repro (the environment that
/// actually raises `code=Owner`) is out of scope; this covers the mapping
/// from `WikiError::GitOwner` to the ERROR branch.
#[test]
fn owner_failure_baseline_checkpoint_is_error_not_warn() {
let owner: WikiResult<Option<()>> = Err(WikiError::GitOwner("not owned".into()));
assert_eq!(
classify_baseline_checkpoint(&owner),
BaselineCheckpointLog::OwnerFailure,
"owner-check failure must be surfaced at ERROR, not buried in a WARN"
);
let other: WikiResult<Option<()>> = Err(WikiError::Io(std::io::Error::other("disk gone")));
assert_eq!(
classify_baseline_checkpoint(&other),
BaselineCheckpointLog::OtherFailure,
"a non-owner failure stays a non-fatal WARN"
);
assert_eq!(
classify_baseline_checkpoint(&Ok::<_, WikiError>(Some(()))),
BaselineCheckpointLog::Created
);
assert_eq!(
classify_baseline_checkpoint(&Ok::<Option<()>, WikiError>(None)),
BaselineCheckpointLog::Clean
);
}
async fn wait_for_maintenance_success(
store: &Store,
job: ai_memory_store::MaintenanceJob,
File diff suppressed because it is too large Load Diff
+109 -13
View File
@@ -2020,9 +2020,8 @@ async fn resolve_project_ids_inner(
project_override: Option<&str>,
project_strategy: ProjectStrategy,
) -> anyhow::Result<(WorkspaceId, ProjectId)> {
let cwd_norm = cwd
.filter(|s| !s.is_empty())
.map(normalize_project_path_key);
let cwd_raw = cwd.filter(|s| !s.is_empty());
let cwd_norm = cwd_raw.map(normalize_project_path_key);
// Without cwd AND without a project override, there's nothing to
// resolve — fall through to the server defaults.
@@ -2048,23 +2047,32 @@ async fn resolve_project_ids_inner(
.unwrap_or(DEFAULT_WORKSPACE_NAME)
.to_string();
let (project_name, repo_path) = match (project_override, cwd_norm.as_deref()) {
(Some(p), Some(c)) => (
// The project NAME must come from the RAW cwd: `normalize_project_path_key`
// ASCII-lowercases the *entire* Windows drive-letter/UNC path — basename
// included — so deriving the name from `cwd_norm` mints "default project"
// for a folder the CLI (which keeps the raw basename) calls "Default
// Project". `get_or_create_project` matches names case-sensitively, so one
// folder becomes two projects (#871). The cache key and the cwd-prefix
// match below deliberately keep `cwd_norm` (case-folded) so #806 handoff
// stickiness is unaffected, and `derive_project_from_cwd` re-normalizes the
// repo_path it returns regardless of the input casing.
let (project_name, repo_path) = match (project_override, cwd_raw, cwd_norm.as_deref()) {
(Some(p), _, Some(c)) => (
p.to_string(),
repo_path_from_project_override(c, p, project_strategy),
),
(Some(p), None) => (p.to_string(), None),
(None, Some(c)) => match derive_project_from_cwd(c, project_strategy) {
(Some(p), _, None) => (p.to_string(), None),
(None, Some(raw), Some(_)) => match derive_project_from_cwd(raw, project_strategy) {
Some(resolved) => resolved,
None => return Ok((state.workspace_id, state.project_id)),
},
(None, None) => {
_ => {
// The early-return at the top of the function guards
// against this branch; the explicit fallback here keeps
// the resolver panic-free if that guard ever moves or
// gets refactored. Same effect as `unreachable!`, but
// visible at compile time instead of inside the panic
// message.
// against a cwd-less, override-less event; the explicit
// fallback here keeps the resolver panic-free if that guard
// ever moves or gets refactored. Same effect as
// `unreachable!`, but visible at compile time instead of
// inside the panic message.
return Ok((state.workspace_id, state.project_id));
}
};
@@ -11120,6 +11128,94 @@ mod tests {
);
}
/// A Windows cwd whose basename carries uppercase letters must resolve
/// to the SAME project the CLI would (which keeps the raw basename), not
/// a lowercased twin. `normalize_project_path_key` folds the whole
/// drive-letter path, so deriving the name from the normalized cwd used
/// to mint "default project" beside the CLI's "Default Project" for one
/// folder (#871). The cache key must stay case-folded so #806 handoff
/// stickiness is not regressed.
#[tokio::test]
async fn windows_casing_does_not_split_project() {
let tmp = TempDir::new().unwrap();
let state = make_state(&tmp).await;
let cwd_win = r"D:\path\to\Default Project";
// What the CLI derives for the same raw cwd (original case).
let (cli_name, _) = ai_memory_consolidate::derive_project_name(
std::path::Path::new(cwd_win),
ai_memory_consolidate::ProjectNameStrategy::Basename,
)
.expect("CLI derives a basename for a Windows path");
assert_eq!(cli_name, "Default Project");
let (_, proj_hook) = resolve_project_ids(
&state,
Some(cwd_win),
None,
None,
ProjectStrategy::Basename,
&ai_memory_core::ActorKey::default(),
)
.await
.unwrap();
// The hook-derived project must equal the one the CLI's name maps to.
let (_, proj_cli) = resolve_project_ids(
&state,
Some(cwd_win),
None,
Some(&cli_name),
ProjectStrategy::Basename,
&ai_memory_core::ActorKey::default(),
)
.await
.unwrap();
assert_eq!(
proj_hook, proj_cli,
"hook must resolve the case-preserved CLI project, not a lowercased twin"
);
// Prove it bites: the lowercased name is a *different* project.
let (_, proj_lower) = resolve_project_ids(
&state,
Some(cwd_win),
None,
Some("default project"),
ProjectStrategy::Basename,
&ai_memory_core::ActorKey::default(),
)
.await
.unwrap();
assert_ne!(
proj_hook, proj_lower,
"case-folded basename must NOT be the project the hook resolves"
);
// Paired assertion: the cache key stays case-folded (repo_path /
// stickiness namespace unchanged) even though the NAME is preserved.
let cache = state.project_cache.lock().await;
let strat = ProjectStrategy::Basename.as_str().to_string();
assert!(
cache.contains_key(&(
"d:/path/to/default project".to_string(),
String::new(),
String::new(),
strat.clone(),
)),
"cache key must remain case-folded for #806 stickiness"
);
assert!(
!cache.contains_key(&(
"D:/path/to/Default Project".to_string(),
String::new(),
String::new(),
strat,
)),
"cache key must not carry the original-case basename"
);
}
/// Two events resolved with overrides land in the same `(ws, proj)`
/// pair as long as the override names match — even if the `cwd`
/// differs. Confirms the override is the source of truth.
+149 -17
View File
@@ -3937,7 +3937,7 @@ pub struct PurgeSessionSummary {
pub observations_deleted: u64,
/// `handoffs` rows removed — only those this session *authored*.
pub handoffs_deleted: u64,
/// `pages` rows removed, counting every version in the supersession chain.
/// `pages` rows removed, counting every version the session wrote.
pub pages_deleted: u64,
/// `auto_improve_runs` rows removed.
pub auto_improve_runs_deleted: u64,
@@ -3961,9 +3961,10 @@ pub struct PurgeSessionSummary {
/// `project_id` wherever the table carries them, so even a mismatched id
/// cannot reach a row in another workspace or project;
/// - derived pages are deleted by **id**, from a set collected and
/// scope-checked first — never by path. Two projects may hold the same
/// `sessions/<uuid>.md` path, and a hand-written page can occupy the path a
/// session later claims; deleting by path would take those with it.
/// scope-checked first — never by path alone. Two projects may hold the
/// same `sessions/<uuid>.md` path, and hand-written versions can share the
/// path with the session's own; only the recorded summary and versions
/// whose frontmatter `session_id` names this session are collected.
///
/// # Ordering
///
@@ -4010,22 +4011,27 @@ pub fn purge_session(
// ---- collect, before anything is cut ----
// The derived page and every version of it. Walk from the recorded
// summary page across the supersession chain, staying inside the scope.
// The recorded summary, plus every version at the session's page path
// whose frontmatter names this session — the key each session-page
// writer stamps, and the one OKF derives `sources` from. Manual edits at
// that path, before, after or between summaries, carry no such key.
let page_ids: Vec<Vec<u8>> = {
let mut stmt = tx.prepare(
"WITH RECURSIVE chain(id) AS ( \
SELECT summary_page_id FROM sessions \
WHERE id = ?1 AND summary_page_id IS NOT NULL \
UNION \
SELECT p.id FROM pages p JOIN chain c ON p.supersedes = c.id \
) \
SELECT p.id FROM pages p JOIN chain c ON p.id = c.id \
WHERE p.workspace_id = ?2 AND p.project_id = ?3",
"SELECT id FROM pages \
WHERE workspace_id = ?2 AND project_id = ?3 \
AND (id = (SELECT summary_page_id FROM sessions WHERE id = ?1) \
OR (path = ?4 AND json_extract(frontmatter_json, '$.session_id') = ?5))",
)?;
stmt.query_map(rusqlite::params![&sid[..], &wid[..], &pid[..]], |row| {
row.get::<_, Vec<u8>>(0)
})?
stmt.query_map(
rusqlite::params![
&sid[..],
&wid[..],
&pid[..],
format!("sessions/{session_id}.md"),
session_id.to_string()
],
|row| row.get::<_, Vec<u8>>(0),
)?
.collect::<rusqlite::Result<Vec<_>>>()?
};
@@ -4094,6 +4100,24 @@ pub fn purge_session(
rusqlite::params![&id[..], &wid[..], &pid[..]],
)? as u64;
}
// A later manual rewrite at this path is still the live wiki file.
let removed_paths = {
let mut stmt = tx.prepare(
"SELECT EXISTS(SELECT 1 FROM pages WHERE workspace_id = ?1 AND project_id = ?2 \
AND path = ?3 AND is_latest = 1)",
)?;
let mut paths_to_remove = Vec::new();
for path in removed_paths {
let has_live_page: bool = stmt.query_row(
rusqlite::params![&wid[..], &pid[..], path.as_str()],
|row| row.get(0),
)?;
if !has_live_page {
paths_to_remove.push(path);
}
}
paths_to_remove
};
// Deleted explicitly rather than left to the cascade so the row count is
// known and can be reported. Measured: this does *not* change what the
@@ -6113,6 +6137,114 @@ pub(crate) mod tests {
);
}
/// A session page as its writers stamp it (synth, consolidator): the
/// frontmatter names the session, and OKF derives `sources` from that.
fn session_page(ws: WorkspaceId, proj: ProjectId, sid: SessionId, body: &str) -> NewPage {
let mut generated = page(ws, proj, &format!("sessions/{sid}.md"), body);
generated.frontmatter_json = serde_json::json!({"session_id": sid.to_string()});
generated
}
#[test]
fn purge_session_removes_older_summary_versions_without_deleting_prior_manual_page() {
let (_tmp, mut conn, ws, proj) = fresh_db();
let sid = SessionId::new();
let path = format!("sessions/{sid}.md");
let manual = upsert_page(&mut conn, &page(ws, proj, &path, "manual")).unwrap();
begin_session(&mut conn, &hook_session(sid, ws, proj, None)).unwrap();
let mut latest = None;
for version in 1..=3 {
let generated = session_page(ws, proj, sid, &format!("summary {version}"));
latest = Some(upsert_page(&mut conn, &generated).unwrap());
}
end_session(&mut conn, &sid, latest.as_ref()).unwrap();
let summary = purge_session(&mut conn, ws, proj, sid, None, Compaction::Skip).unwrap();
assert_eq!(summary.pages_deleted, 3);
// The manual version survives, but as history: nothing is latest at
// the path any more, so its wiki file is unlinked with the summary.
assert_eq!(summary.removed_paths, vec![PagePath::new(path).unwrap()]);
let survivor: (Vec<u8>, bool) = conn
.query_row("SELECT id, is_latest FROM pages", [], |row| {
Ok((row.get(0)?, row.get(1)?))
})
.unwrap();
assert_eq!(survivor, (manual.as_bytes().to_vec(), false));
}
#[test]
fn purge_session_keeps_later_manual_page_and_its_wiki_path() {
let (_tmp, mut conn, ws, proj) = fresh_db();
let sid = SessionId::new();
let path = format!("sessions/{sid}.md");
begin_session(&mut conn, &hook_session(sid, ws, proj, None)).unwrap();
let first = upsert_page(&mut conn, &session_page(ws, proj, sid, "summary")).unwrap();
let middle_manual =
upsert_page(&mut conn, &page(ws, proj, &path, "manual interim")).unwrap();
let latest =
upsert_page(&mut conn, &session_page(ws, proj, sid, "updated summary")).unwrap();
end_session(&mut conn, &sid, Some(&latest)).unwrap();
let manual = upsert_page(&mut conn, &page(ws, proj, &path, "manual rewrite")).unwrap();
let summary = purge_session(&mut conn, ws, proj, sid, None, Compaction::Skip).unwrap();
assert_eq!(summary.pages_deleted, 2);
assert!(summary.removed_paths.is_empty());
assert_eq!(count(&conn, "SELECT COUNT(*) FROM pages"), 2);
let survivor: (Vec<u8>, bool) = conn
.query_row(
"SELECT id, is_latest FROM pages WHERE is_latest = 1",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!(survivor, (manual.as_bytes().to_vec(), true));
assert_ne!(first, manual);
assert_ne!(middle_manual, manual);
}
/// A session page can exist while `summary_page_id` is NULL: the session
/// has not ended, or a move cleared the link. Its pages are still the
/// session's and must go with it.
#[test]
fn purge_session_removes_pages_of_a_session_without_a_recorded_summary() {
let (_tmp, mut conn, ws, proj) = fresh_db();
let sid = SessionId::new();
begin_session(&mut conn, &hook_session(sid, ws, proj, None)).unwrap();
upsert_page(&mut conn, &session_page(ws, proj, sid, "checkpoint 1")).unwrap();
upsert_page(&mut conn, &session_page(ws, proj, sid, "checkpoint 2")).unwrap();
let summary = purge_session(&mut conn, ws, proj, sid, None, Compaction::Skip).unwrap();
assert_eq!(summary.pages_deleted, 2);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM pages"), 0);
assert_eq!(
summary.removed_paths,
vec![PagePath::new(format!("sessions/{sid}.md")).unwrap()]
);
}
/// The OKF migration conformed only latest rows, so a version superseded
/// before it ran carries `session_id` but no derived `sources`.
#[test]
fn purge_session_removes_summary_versions_written_before_okf_sources() {
let (_tmp, mut conn, ws, proj) = fresh_db();
let sid = SessionId::new();
begin_session(&mut conn, &hook_session(sid, ws, proj, None)).unwrap();
let old = upsert_page(&mut conn, &session_page(ws, proj, sid, "old summary")).unwrap();
conn.execute(
"UPDATE pages SET frontmatter_json = json_remove(frontmatter_json, '$.sources') \
WHERE id = ?1",
params![old.as_bytes()],
)
.unwrap();
let latest = upsert_page(&mut conn, &session_page(ws, proj, sid, "new summary")).unwrap();
end_session(&mut conn, &sid, Some(&latest)).unwrap();
let summary = purge_session(&mut conn, ws, proj, sid, None, Compaction::Skip).unwrap();
assert_eq!(summary.pages_deleted, 2);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM pages"), 0);
}
/// The blast radius must stop at the session. A sibling session in the
/// same project keeps every row it owns.
#[test]
+15
View File
@@ -60,6 +60,21 @@ pub enum WikiError {
/// `409 Conflict` at the admin layer.
#[error("destination page file already exists: {0}")]
DestinationPageExists(String),
/// libgit2 refused a wiki-repository operation because the repository is
/// not owned by the account this process runs as (`code=Owner`, the
/// CVE-2022-24765 dubious-ownership guard). Kept distinct from a generic
/// I/O error so startup can surface it at ERROR with a fix: on a Windows
/// LocalSystem service over a user-owned data dir every wiki commit fails
/// this check, and it was previously logged WARN-only so nothing surfaced.
/// The owner check is deliberately left enabled (disabling it is
/// `unsafe` and reopens the CVE); the remedy is to run the service as the
/// owning user.
#[error(
"wiki git repository is not owned by the current account \
(libgit2 owner check, code=Owner): {0}"
)]
GitOwner(String),
}
impl From<serde_yaml::Error> for WikiError {
+53
View File
@@ -708,6 +708,14 @@ fn should_try_commit_cli_fallback(error: &git2::Error) -> bool {
return true;
}
// An owner-check failure must NOT fall back to the git CLI: the CLI runs
// the same CVE-2022-24765 ownership guard and would refuse identically, so
// a fallback would only mask the real cause. Let it map through to
// `WikiError::GitOwner` and surface at ERROR instead.
if matches!(error.code(), ErrorCode::Owner) {
return false;
}
#[cfg(windows)]
{
// On native Windows, libgit2 can fail to reopen a freshly initialised
@@ -873,6 +881,14 @@ fn git_output<const N: usize>(root: &Path, args: [&str; N]) -> WikiResult<std::p
}
fn map_git_err(e: git2::Error) -> WikiError {
// An owner-check failure (`code=Owner`, CVE-2022-24765 dubious-ownership
// guard) is kept as a distinct variant so startup can log it at ERROR with
// an actionable fix instead of burying it in a generic WARN. Everything
// else stays an opaque I/O error, matching prior behaviour.
if matches!(e.code(), ErrorCode::Owner) {
warn!(error = %e, "libgit2 wiki owner-validation error");
return WikiError::GitOwner(e.to_string());
}
warn!(error = %e, "libgit2 error");
WikiError::Io(std::io::Error::other(e.to_string()))
}
@@ -1270,6 +1286,43 @@ mod tests {
assert!(!is_racy_read(&other_message));
}
/// A libgit2 owner-check failure is not silently "recovered": it must not
/// trigger the git CLI fallback (the CLI enforces the same CVE-2022-24765
/// guard and would refuse identically), and it must map to the distinct
/// `GitOwner` variant so startup can surface it at ERROR. A generic error
/// still maps to the opaque I/O variant — proving the classification bites.
#[test]
fn owner_error_is_surfaced_not_recovered() {
let owner = git2::Error::new(
ErrorCode::Owner,
git2::ErrorClass::Config,
"repository path is not owned by current user",
);
assert!(
!should_try_commit_cli_fallback(&owner),
"an owner error must not fall back to the git CLI (same guard)"
);
assert!(
matches!(map_git_err(owner), WikiError::GitOwner(_)),
"an owner error must map to the distinct GitOwner variant"
);
// Control: a NotFound error still asks for the CLI fallback, and a
// generic error is still the opaque I/O variant (not GitOwner).
let not_found = git2::Error::new(
ErrorCode::NotFound,
git2::ErrorClass::Repository,
"not found",
);
assert!(should_try_commit_cli_fallback(&not_found));
let generic = git2::Error::new(
ErrorCode::GenericError,
git2::ErrorClass::Os,
"some other failure",
);
assert!(matches!(map_git_err(generic), WikiError::Io(_)));
}
/// A racy read keeps the commit path-scoped; any other failure walks.
#[test]
fn a_racy_read_failure_does_not_escalate_to_a_walk() {
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""OpenAI-compat -> Jev `/v1/systemone` reranker adapter for ai-memory.
ai-memory's LLM reranker (`AI_MEMORY_RERANKER=llm`) sends one structured chat
request per `memory_query`: a fixed system prompt plus a user JSON payload
`{"query", "candidates":[{"candidate","title","text"}]}`, and expects the
first balanced JSON object in the reply content to be
`{"scores":[{"candidate","relevance"}]}` (1-based indices, relevance in
[0,1]; a timeout, error, or invalid score set preserves the server's own
order).
This adapter recognises exactly that request (by the system-prompt prefix),
scores every candidate with one batched Jev `score` question whose rubric
mirrors the reranker prompt's own 1.0 / 0.7 / 0.3 / 0.0 guidance, and returns
the judgement as plain chat content. Everything else — consolidation, lint,
bootstrap — is reverse-proxied unchanged to the configured upstream, so only
reranking rides the Jev endpoint.
Why: a hosted reranker model that answers in tens of seconds dwarfs the rest
of a `memory_query`. A judge endpoint that scores a fixed rubric answers in
well under a second, which keeps `AI_MEMORY_RERANKER=llm` usable in
interactive sessions. On a 102-query golden set (see
docs/jev-reranker-adapter.md) this adapter matched the hosted reranker's
hit@1 / MRR / NDCG@10 while cutting mean rerank latency from ~20s to ~0.2s.
Env:
JEV_URL Jev systemone endpoint (default http://127.0.0.1:18095/v1/systemone)
JEV_MODEL model name sent to Jev (default jev-latest)
UPSTREAM OpenAI-compat upstream base (default http://127.0.0.1:8000)
LISTEN host:port to bind (default 127.0.0.1:18097)
Stdlib only. No secrets are stored: Authorization headers are forwarded
verbatim to the upstream.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
JEV_URL = os.environ.get("JEV_URL", "http://127.0.0.1:18095/v1/systemone").rstrip("/")
JEV_MODEL = os.environ.get("JEV_MODEL", "jev-latest")
UPSTREAM = os.environ.get("UPSTREAM", "http://127.0.0.1:8000").rstrip("/")
LISTEN = os.environ.get("LISTEN", "127.0.0.1:18097")
# Fixed prefix of the reranker's system prompt; see
# crates/ai-memory-llm/src/reranker.rs in the ai-memory tree.
RERANK_SYSTEM_PREFIX = "You are a retrieval reranker for a software project's memory wiki."
# Mirrors the four grades of the reranker prompt (1.0 / 0.7 / 0.3 / 0.0).
RUBRIC = [
"0.0 unrelated — does not address the query at all",
"0.3 tangential — loosely related to the query",
"0.7 same topic — same subsystem or topic, useful supporting context",
"1.0 direct answer — directly answers the query",
]
def log(msg):
sys.stderr.write(f"[jev-rerank] {time.strftime('%H:%M:%S')} {msg}\n")
sys.stderr.flush()
def jev_rerank(payload):
"""Translate a reranker chat request into one batched Jev score call."""
user = next((m.get("content", "") for m in payload.get("messages", [])
if m.get("role") == "user"), "")
data = json.loads(user)
query, cands = data["query"], data["candidates"]
lines = [f"User query: {query}", "", "Candidate documents:"]
questions = {}
for c in cands:
n = c["candidate"]
lines.append(f"{n}. {c['title']} — {c['text']}")
questions[f"c{n}"] = {
"type": "score",
"instructions": (
f"Apply the relevance rubric to candidate {n}: how well "
"does it answer the user query?"
),
"criteria": RUBRIC,
}
body = {"model": JEV_MODEL, "state": "\n".join(lines), "questions": questions}
t0 = time.perf_counter()
req = urllib.request.Request(JEV_URL, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=25) as resp:
out = json.loads(resp.read())
dt = time.perf_counter() - t0
scores = []
for c in cands:
ans = (out.get("answers") or {}).get(f"c{c['candidate']}") or {}
raw = ans.get("score")
# criteria index 0..3 -> relevance 0.0 / 0.3 / 0.7 / 1.0
relevance = max(0.0, min(1.0, raw / (len(RUBRIC) - 1))) \
if isinstance(raw, (int, float)) else 0.0
scores.append({"candidate": c["candidate"], "relevance": round(relevance, 4)})
log(f"jev {len(cands)} candidates in {dt:.3f}s query={query[:60]!r}")
return {"scores": scores}
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args):
pass
def _reply(self, status, body=b"", content_type="application/json"):
self.send_response(status)
if content_type:
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if body:
self.wfile.write(body)
def _proxy(self, raw):
"""Reverse-proxy this request unchanged to the upstream."""
req = urllib.request.Request(UPSTREAM + self.path, data=raw, method=self.command)
for k, v in self.headers.items():
if k.lower() in ("host", "content-length", "connection", "accept-encoding"):
continue
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=900) as resp:
body = resp.read()
self.send_response(resp.status)
for k, v in resp.headers.items():
if k.lower() in ("transfer-encoding", "connection", "content-length"):
continue
self.send_header(k, v)
self._reply_finish(body)
except urllib.error.HTTPError as e:
self._reply(e.code, e.read())
except Exception as e: # transport failure upstream
log(f"forward error {self.command} {self.path}: {e}")
self._reply(502, json.dumps({"error": str(e)[:200]}).encode())
def _reply_finish(self, body):
# headers besides Content-Length were already sent by the caller
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b""
if not self.path.rstrip("/").endswith("/chat/completions"):
self._proxy(raw)
return
try:
payload = json.loads(raw or b"{}")
except json.JSONDecodeError:
self._reply(400)
return
system = next((m.get("content", "") for m in payload.get("messages", [])
if m.get("role") == "system"), "")
if not system.startswith(RERANK_SYSTEM_PREFIX):
self._proxy(raw) # consolidation / lint / bootstrap traffic
return
try:
result = jev_rerank(payload)
out = {
"id": "jev-rerank",
"object": "chat.completion",
"model": payload.get("model", JEV_MODEL),
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": json.dumps(result)},
"finish_reason": "stop",
}],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
}
self._reply(200, json.dumps(out).encode())
except Exception as e:
# The server treats any failure as "keep the original order".
log(f"jev rerank failed: {e}")
self._reply(500)
def do_GET(self):
self._proxy(None)
def main():
host, port = LISTEN.rsplit(":", 1)
srv = ThreadingHTTPServer((host, int(port)), Handler)
log(f"listening {LISTEN} jev={JEV_URL} upstream={UPSTREAM}")
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
+143
View File
@@ -0,0 +1,143 @@
# Jev Reranker Adapter
> Route ai-memory's LLM reranker to a Jev judge endpoint while keeping the
> hosted LLM for everything else. Stdlib-only Python, no Rust changes.
[`AI_MEMORY_RERANKER=llm`](llm-providers.md) reorders `memory_query`
candidates with the configured chat provider. That works until the provider
is a hosted reasoning model: the reranker prompt is long, a graded judgement
over up to 30 candidates, and a slow model answers in tens of seconds —
longer than one `memory_query` should ever take, and long enough to trip the
server's completion timeout, which turns the reranker into a dead feature
that stalls every query before falling back to the original order.
The reranker prompt, though, is already a scoring rubric: grade each
candidate 1.0 (direct answer) / 0.7 (same topic) / 0.3 (tangential) /
0.0 (unrelated). A judge endpoint that scores a fixed rubric answers the
same question without autoregressive decoding. The adapter in
[`docs/examples/jev-reranker-adapter/jev_rerank_shim.py`](examples/jev-reranker-adapter/jev_rerank_shim.py)
sits between ai-memory and the provider, translates exactly that request
into one batched Jev `score` call, and proxies everything else unchanged.
## How it works
ai-memory 2.4.0 has a single chat-provider configuration, so the reranker
and consolidation share one base URL. The adapter does not try to split
configuration; it splits traffic by request shape:
1. The reranker's system prompt starts with the fixed sentence
`You are a retrieval reranker for a software project's memory wiki.`
(see `crates/ai-memory-llm/src/reranker.rs`). Requests whose system
message has that prefix are reranker requests.
2. The user message is `{"query", "candidates": [{"candidate", "title",
"text"}]}` with 1-based indices. The adapter renders one `state` block
(query + numbered candidates) and one `score` question per candidate
whose `criteria` mirror the reranker prompt's four grades, then makes a
single POST to the Jev `/v1/systemone` endpoint.
3. Each answer's rubric index maps back to `relevance` 0.0 / 0.3 / 0.7 /
1.0 (index / 3), and the adapter returns
`{"scores": [{"candidate": n, "relevance": f}]}` as plain chat-completion
content — the exact shape the reranker's tolerant parser expects.
4. Every other request — consolidation, lint, bootstrap, plain chats — is
reverse-proxied to the real upstream byte-for-byte, `Authorization`
forwarded verbatim. The adapter stores no secrets.
Failure semantics match the server contract: if the Jev call fails, the
adapter answers HTTP 500 and ai-memory keeps its own candidate order, the
same as any provider outage. A judge endpoint that is down degrades to
"no reranking", never to "no search".
One cosmetic note: the server logs still show the provider's configured
model name for reranking (it comes from provider config, not from the
adapter's reply), so the reranker leg will be attributed to your hosted
model in `docker logs` even while the adapter serves it.
## Deploy
The adapter is stdlib-only Python 3. Configure with environment variables
(all defaults are loopback examples):
| Env | Meaning | Default |
|---|---|---|
| `JEV_URL` | Jev `/v1/systemone` endpoint | `http://127.0.0.1:18095/v1/systemone` |
| `JEV_MODEL` | model name sent to Jev | `jev-latest` |
| `UPSTREAM` | real OpenAI-compat base URL | `http://127.0.0.1:8000` |
| `LISTEN` | adapter bind address | `127.0.0.1:18097` |
Run it next to the server (systemd unit adapted to your paths):
```ini
[Unit]
Description=ai-memory Jev reranker adapter
After=network-online.target
[Service]
ExecStart=/usr/bin/python3 /opt/ai-memory/ops/jev_rerank_shim.py
Environment=JEV_URL=http://127.0.0.1:18095/v1/systemone
Environment=UPSTREAM=http://127.0.0.1:8000
Environment=LISTEN=127.0.0.1:18097
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
Then point the provider at the adapter instead of the upstream:
```console
AI_MEMORY_LLM_PROVIDER=openai-compat
AI_MEMORY_LLM_BASE_URL=http://127.0.0.1:18097/v1
AI_MEMORY_RERANKER=llm
```
Verify the split with `journalctl -u <unit>`: reranker requests log
`jev N candidates in X.XXXs`, everything else is silent (proxied).
## Benchmarks
Offline A/B, same candidate pool per query, 102 queries / 99 scored against
a hand-curated golden set for a real production wiki (FTS5 + vector + graph
hybrid, `explain=true` score details, identical server build):
| Reranker | hit@1 | MRR | NDCG@10 | mean rerank latency |
|---|---|---|---|---|
| none (server order) | 0.495 | 0.651 | 0.739 | — |
| hosted model A (reasoning, max) | 0.778 | 0.840 | 0.875 | **20.2 s** |
| hosted model B (reasoning) | 0.768 | 0.834 | 0.870 | 13.9 s |
| **Jev via this adapter** | **0.778** | **0.838** | **0.873** | **0.205 s** |
The judge endpoint matches the hosted reasoning models' quality at
~100× lower latency. Hosted model A's 20.2 s mean sat exactly on the
server's 20 s completion timeout, so in production every rerank call timed
out and the feature was effectively off (queries stalled 20 s, then fell
back to the original order).
Live end-to-end (production server, `AI_MEMORY_RERANKER=llm` through the
adapter, full golden set): hit@1 0.657 / MRR 0.788 / NDCG@10 0.843,
0 errors in 102 queries, mean `memory_query` latency 2.2 s (p50 2.21 s,
max 3.07 s), 110/110 rerank translations served, 0 adapter failures. The
gap to the offline arm is candidate-pool shape, not scoring: the live
server over-fetches 15–30 candidates with its own bounded snippets (the
offline arm rescored a fixed top-10 pool), and in 32 of 34 non-top-1
queries the expected page was still returned — mostly at rank 2 — with
hit@5 at 0.949. Versus the no-reranker baseline that is +16.2 points
hit@1 and +10.4 points NDCG@10 end to end.
Judge latency scales with the candidate count in the batch (the rubric
question is asked once per candidate): ~0.2 s for 10 candidates, ~1.6 s
mean for the 15–30-candidate batches the live server sends, still an order
of magnitude under any hosted reasoning model.
## Caveats
- The adapter keys on the exact system-prompt prefix above. If the
reranker prompt wording changes in a future release, detection (not
scoring) breaks first: reranker requests would be proxied to the hosted
model, which is the pre-adapter behavior, not an outage.
- Scoring uses Jev's calibrated rubric grades. Do not substitute
per-candidate choice probabilities: those are normalized across options
(winner ≈ 0.99, rest ≈ 0.001) and do not fit the reranker's 0–1
relevance semantics.
- Run the adapter on loopback or a trusted private network. It forwards
`Authorization` headers verbatim and adds no authentication of its own.
+3
View File
@@ -33,6 +33,9 @@ fundamentally cannot run while another process holds the SQLite WAL writer. See
`purge-session` answers *"forget this conversation"*: after it runs, the
session is gone from the API, from `status` counts and from search.
It removes both earlier and later summary versions identified as belonging to
that session; hand-written versions at the same path remain, including a later
live wiki file.
```bash
ai-memory purge-session \
+5 -2
View File
@@ -191,8 +191,11 @@ request sends the query plus at most 30 bounded page titles and search snippets
to the configured provider; all values are JSON-encoded and treated as
untrusted data. A timeout, provider error, or incomplete/invalid score set
preserves the normal order. `global=true` and supplemental global-preference
hits keep their existing non-RRF ranking. Concurrent provider calls are capped
at four; saturated queries keep their local ranking without waiting.
at four; saturated queries keep their local ranking without waiting. If the
configured provider is too slow for interactive reranking, a judge-endpoint
adapter can serve the reranker leg in sub-second time while consolidation
keeps the hosted model — see
[`docs/jev-reranker-adapter.md`](jev-reranker-adapter.md).
Embeddings are optional and separate from the LLM provider. Set
`AI_MEMORY_EMBEDDING_PROVIDER=openai`, `voyage`, `google`/`gemini`,
+10 -2
View File
@@ -572,8 +572,16 @@ the launched Crush process continues its normal native session writes.
The Linux/macOS Docker shell wrapper cannot inspect host projects or execute a
host agent from inside its helper container. For `run`, `show`, `continue`,
`resume`, and `workstreams`, it downloads the matching native release into
`~/.cache/ai-memory/native-runner`, verifies the published SHA-256 checksum, and
executes that host client. Set `AI_MEMORY_NATIVE_BIN=/path/to/ai-memory` to use a
`${XDG_DATA_HOME:-~/.local/share}/ai-memory/native-runner`, verifies the
published SHA-256 checksum, and executes that host client. The release's
`hooks/` bundle is kept beside it so auto-wire can stage hook scripts on a host
where `install-hooks` never ran. The client lives with the host's ai-memory data
rather than under `~/.cache` because auto-wired hook configuration runs it
directly: a cache flush must not break capture. A client downloaded by an older
wrapper stays in `~/.cache/ai-memory/native-runner`, and hooks auto-wired from it
keep that path until a newer client version auto-wires again. To move them now,
re-run `ai-memory install-hooks --agent <agent> --apply`, then delete the old
directory. Set `AI_MEMORY_NATIVE_BIN=/path/to/ai-memory` to use a
specific native build. Native package, release, and source installs need no
shim. On native Windows, use the published `ai-memory.exe` or a source build.
+1
View File
@@ -37,6 +37,7 @@ boundary not yet built.
| 8d | Messaging: inferred-scope read is diagnosed (#854) | `scope.rs` `is_inferred`; `server.rs` `inferred_scope_hint` on empty pop/list | `agent_messages_briefing.rs` `no_scope_pop_that_misses_the_mail_is_diagnosed_not_a_silent_null` | STRONG |
| 9 | Scope resolution fail-closed | `ai-memory-store/src/scope.rs` no-create `lookup_existing_*`; create only via `create_explicit_scope` | `scope.rs` no-auto-create + `unscoped_write_with_unresolvable_coordinate_errors` | STRONG |
| 10 | Destructive-op live-process refusal + confirm flags (invariant #9) | `ai-memory-cli/src/commands/process_guard.rs` `sibling_processes` + confirm flags in `reset`/`restore`/`reindex`/`uninstall --purge-data`/`purge_project` | `admin_purge.rs` confirm→400; `removal.rs` — injected live-sibling makes each destructive command bail before touching the data dir | STRONG |
| 10b | Session purge is scope+owner-bound (no cross-session/project over-delete) | `ai-memory-store/src/ops.rs` `purge_session` — selection scoped to `(workspace_id, project_id)` and keyed on this session's own `summary_page_id` **or** `path='sessions/<sid>.md'` + `json_extract(frontmatter_json,'$.session_id')=<sid>` (frontmatter owner, not the recursive latest-chain); `in_scope==0 → NotFound` fail-closed; whole op in one transaction | `ops.rs` `purge_session_leaves_a_sibling_session_in_the_same_project_intact`, `…refuses_a_session_from_another_project_and_deletes_nothing`, `…refuses_a_session_from_another_workspace`, `…does_not_delete_an_identically_pathed_page_in_another_project`, `…removes_older_summary_versions_without_deleting_prior_manual_page` (#862) | STRONG |
| 11a | Hook backpressure (202/429) + bounded fan-out (invariant #5) | `ai-memory-hooks/src/router.rs` semaphore→429, 202 immediately, `MAX_HOOK_BATCH_ITEMS`, bounded LRU limiter | `router.rs` `handle_hook_returns_429_when_ingest_saturated`, `ingest_rate_limiter_is_bounded` | STRONG |
| 11b | Capture exclusions drop before storage | `ai-memory-hooks` `capture_policy.rs` `inspect`→`Drop` (before semaphore/spawn) | `capture_policy.rs` per-agent `…honors_exclusions` tests | STRONG |
| 11c | Capture hook ≤200ms budget (invariant #5) | `hooks/_lib.sh` capture path `curl --max-time 0.2` (context-fetch 1.0s and background drain 2.0s are separate, larger-budget paths) | none (shell-script timeout; hard to unit-test) — watch on any capture-path change | WATCH |
+45 -3
View File
@@ -414,9 +414,51 @@ ai-memory status
`stop`, `restart`, and `uninstall` are the remaining commands. Because
the bind is loopback, the service account does not affect reachability:
agents running as your user still reach `127.0.0.1:49374`. Only the data
directory is account-sensitive, which is what the absolute path above
settles.
agents running as your user still reach `127.0.0.1:49374`.
### ⚠️ Run the service as the owning user when the data dir is in a profile
The absolute `--data-dir` above stops the *empty-directory* trap, but it
does **not** make a `LocalSystem` service safe over a data directory that
lives under your user profile (`C:\Users\you\AppData\Local\ai-memory`).
The data directory is account-sensitive, and so is the **wiki's git
repository inside it**. libgit2 enforces the same dubious-ownership guard
as Git itself (CVE-2022-24765): when the process account is not the owner
of the repository, every commit fails with `code=Owner (-36)`. The server
still starts, capture still works, and search still answers — but the
wiki git history silently stops advancing, because a wiki commit failure
is not fatal. As of ai-memory 2.4.x the server logs this at **ERROR** on
startup with the same remedy below; on older builds it was a WARN that was
easy to miss.
**Whenever the data directory lives under a user profile, run the service
as that user** rather than as `LocalSystem`. In the WinSW XML add a
`<serviceaccount>` block:
```xml
<serviceaccount>
<username>.\you</username>
<password>your-account-password</password>
</serviceaccount>
```
(or set it from the service's **Log On** tab in `services.msc` after
install, then `restart`). Use `.\you` for a local account or
`DOMAIN\you` for a domain account. `LocalSystem` is only appropriate when
the data directory is in a location that account owns outright (e.g. a
dedicated `C:\ProgramData\ai-memory` created and owned by the service
account).
> **WinSW error 1069 / stale password.** If `start` fails with *"The
> service did not start due to a logon failure"* (error 1069), the
> `<serviceaccount>` credentials are wrong or stale. This bites
> **Microsoft-account, PIN, and Windows Hello** users especially: the
> WinSW `<password>` must be your *account password*, which for a
> Microsoft account is your online Microsoft password (not your PIN or
> Hello gesture), and it must be updated in the service config whenever
> that password changes — Windows does not roll it forward. Consider a
> local account, or a dedicated service account with a non-expiring
> password, for an unattended service.
Keep running `install-mcp` and `install-hooks` **as your own user**, not
as the service — they write per-user agent config, and the rule at the
+108 -35
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Installs this repo's pre-push hook into .git/hooks without discarding an
# existing user hook. Run once per clone (from Git Bash on Windows):
# Installs this repo's pre-push hook without moving existing user commands.
# Run once per clone (from Git Bash on Windows):
#
# scripts/install-git-hooks.sh
#
@@ -12,48 +12,121 @@
set -euo pipefail
repo_root=$(git rev-parse --show-toplevel)
hook="$repo_root/.git/hooks/pre-push"
cd "$repo_root"
if git config --get core.hooksPath >/dev/null; then
echo 'core.hooksPath is set; this installer only manages the shared repository hook directory' >&2
exit 1
else
# `$?` is still the condition's status here: 1 means the key is unset, and
# anything else is a Git failure the installer must not guess past.
config_status=$?
if [[ "$config_status" -ne 1 ]]; then
exit "$config_status"
fi
fi
hook=$(git rev-parse --git-path hooks/pre-push)
begin="# >>> ai-memory pre-push >>>"
end="# <<< ai-memory pre-push <<<"
tmp=$(mktemp "${hook}.XXXXXX")
trap 'rm -f "$tmp"' EXIT
if [[ -f "$hook" ]]; then
awk -v begin="$begin" -v end="$end" '
$0 == begin { skip = 1; next }
$0 == end { skip = 0; next }
!skip { print }
' "$hook" > "$tmp"
if grep -q '[^[:space:]]' "$tmp"; then
printf '\n' >> "$tmp"
managed_block=$(cat <<'HOOK'
# >>> ai-memory pre-push >>>
# Runs the full test tier before a push. See scripts/install-git-hooks.sh.
# Git's hook environment would redirect fixture commands into this checkout.
# Isolate Cargo and its children, and keep this block's shell options and
# exports away from user hook code around it.
(
set -euo pipefail
# macOS: stop reqwest re-reading the Keychain in every test process.
if [ "$(uname -s 2>/dev/null || true)" = "Darwin" ] && [ -z "${SSL_CERT_FILE:-}" ] && [ -f /etc/ssl/cert.pem ]; then
export SSL_CERT_FILE=/etc/ssl/cert.pem
fi
# A plain assignment, so `set -e` still aborts if `git rev-parse` fails.
git_local_env_vars=$(git rev-parse --local-env-vars)
# A surrounding user hook may have narrowed IFS; the list below is split on
# newlines, so fall back to the default before splitting it. Unset rather
# than assigned: Bash 3.2 expands ANSI-C quoting inside this heredoc.
unset IFS
# Unquoted on purpose: the output is one variable name per line.
for git_local_env_var in $git_local_env_vars; do
unset "$git_local_env_var"
done
# Fixture repositories must not inherit machine settings such as signing.
# Git for Windows maps /dev/null to nul.
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
if command -v cargo-nextest >/dev/null 2>&1; then
echo "pre-push: cargo nextest run --workspace -P full"
cargo nextest run --workspace -P full
else
printf '%s\n\n' '#!/usr/bin/env bash' '# Installed by scripts/install-git-hooks.sh.' > "$tmp"
echo "pre-push: cargo test --workspace --all-targets (nextest not installed)"
cargo test --workspace --all-targets
fi
)
# Bash ignores `set -e` inside a subshell tested by `||`, `&&`, `!` or `if`, so
# the subshell stays a plain command and its status is propagated here. A user
# hook without `set -e` would otherwise run on and let the push through.
ai_memory_pre_push_status=$?
if [ "$ai_memory_pre_push_status" -ne 0 ]; then
exit "$ai_memory_pre_push_status"
fi
unset ai_memory_pre_push_status
# <<< ai-memory pre-push <<<
HOOK
)
has_content=0
if [[ -f "$hook" ]]; then
if grep -q '[^[:space:]]' "$hook"; then
has_content=1
else
# As above, `$?` is grep's status: 1 is a blank hook, 2 a read error.
read_status=$?
if [[ "$read_status" -ne 1 ]]; then
exit "$read_status"
fi
fi
fi
mkdir -p "${hook%/*}"
tmp=$(mktemp "${hook}.XXXXXX")
if [[ "$has_content" -eq 1 ]]; then
# ENVIRON preserves backslashes; BINMODE prevents Windows CRLF translation.
if ! AI_MEMORY_PRE_PUSH_BLOCK="$managed_block" awk -v BINMODE=3 -v begin="$begin" -v end="$end" '
{
marker = $0
sub(/\r$/, "", marker)
if (marker == begin) {
if (inside || seen) { invalid = 1; exit 1 }
inside = seen = 1
print ENVIRON["AI_MEMORY_PRE_PUSH_BLOCK"]
next
}
if (marker == end) {
if (!inside) { invalid = 1; exit 1 }
inside = 0
next
}
if (!inside) print
}
END {
if (invalid || inside) exit 1
if (!seen) {
print ""
print ENVIRON["AI_MEMORY_PRE_PUSH_BLOCK"]
}
}
' "$hook" > "$tmp"; then
printf 'invalid managed markers in %s; original unchanged, temporary file retained at %s\n' "$hook" "$tmp" >&2
exit 1
fi
else
printf '%s\n\n' '#!/usr/bin/env bash' '# Installed by scripts/install-git-hooks.sh.' > "$tmp"
printf '%s\n' "$managed_block" >> "$tmp"
fi
cat >> "$tmp" <<'HOOK'
# >>> ai-memory pre-push >>>
# Runs the full test tier before a push. See scripts/install-git-hooks.sh.
set -euo pipefail
# macOS: stop reqwest re-reading the Keychain in every test process.
if [ "$(uname -s 2>/dev/null || true)" = "Darwin" ] && [ -z "${SSL_CERT_FILE:-}" ] && [ -f /etc/ssl/cert.pem ]; then
export SSL_CERT_FILE=/etc/ssl/cert.pem
fi
if command -v cargo-nextest >/dev/null 2>&1; then
echo "pre-push: cargo nextest run --workspace -P full"
cargo nextest run --workspace -P full
else
echo "pre-push: cargo test --workspace --all-targets (nextest not installed)"
cargo test --workspace --all-targets
fi
# <<< ai-memory pre-push <<<
HOOK
mv "$tmp" "$hook"
trap - EXIT
chmod +x "$hook"
echo "installed $hook"