mirror of
https://github.com/akitaonrails/ai-memory.git
synced 2026-10-02 03:24:46 +08:00
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:
+56
-1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¬_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()
|
||||
@@ -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.
|
||||
@@ -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 \
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user