Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **Server-side capture payloads are bounded without flattening structured tool data.** `tool_input`, `tool_output`, prompts and the raw envelope are trimmed to configurable character budgets (`AGENTMEMORY_OBSERVE_*`); object fields such as `file_path` survive truncation, `0` disables an individual bound, and positive budgets below 2 are clamped to the smallest serialized string.
- **Graph and semantic provenance are bounded.** Graph nodes, edges, insights and semantic session provenance keep only the freshest source ids instead of growing for the life of the session.
- **Audit retention runs in a background sweep with bounded deletes.** `recordAudit` stays a single KV write. `mem::audit-sweep` trims on a timer, caps deletes per pass, retains rows whose delete failed, and never evicts rows that share the retention boundary's millisecond. The pinned iii engine only supports whole-scope `state::list`, so enabled sweeps and queries still load the entire audit log into memory before applying limits; large existing logs require a future paginated engine scan or separate storage migration. `AGENTMEMORY_AUDIT_MAX=0` and `AGENTMEMORY_AUDIT_SWEEP_DELETE_BATCH=0` skip the sweep scan entirely; query results are capped at 1000, with explicit `limit=0` retaining the historical 100-row default.
- **Index-persistence audit rows are opt-in (aligned with upstream #1182).** Shard writes, manifest publishes, and generation reclaims remove no user rows, yet a single save emits several `index_persist` audit rows — on a real store they reached 59876 of 84028 entries (71%). Left on by default they would dominate the new `AGENTMEMORY_AUDIT_MAX` window within hours of sustained capture and evict genuine deletion records, so they are now gated behind `AGENTMEMORY_AUDIT_INDEX_PERSIST=1` (trimmed, case-insensitive `1`/`true`; everything else, including `0` and `false`, stays off).
- **Stranded index generations are reclaimed safely (#1115).** Every published generation is recorded in a retry-preserving ledger before its first shard write. Boot reclaims at most `AGENTMEMORY_INDEX_RECLAIM_BOOT_MAX_SHARDS` shards in a background pass, later saves reclaim the remainder, and a failed delete keeps its ledger entry for the next attempt.
- **Index saves are serialised instead of interleaved.** Concurrent save callers collapse into one running save plus one queued save rather than several writers racing on the same shards.
- **Eviction removes observations from BM25 and vector search.** Deleted observations no longer remain as in-memory postings that are re-serialized on every index save; dry runs leave the index untouched.
Expand Down
3 changes: 2 additions & 1 deletion plugin/skills/agentmemory-config/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
Generated by scanning `src/` for `AGENTMEMORY_*` usage. Do not edit the block below by hand; run `npm run skills:gen` after adding or removing a variable. Internal markers ending in two underscores are excluded.

<!-- AUTOGEN:env START - generated by scripts/skills/generate.ts, do not edit by hand -->
Configuration is read from the environment and from `~/.agentmemory/.env` (no `export` prefix). 51 recognized variables:
Configuration is read from the environment and from `~/.agentmemory/.env` (no `export` prefix). 52 recognized variables:

- `AGENTMEMORY_AGENT_SCOPE`
- `AGENTMEMORY_ALLOW_AGENT_SDK`
- `AGENTMEMORY_AUDIT_INDEX_PERSIST`
- `AGENTMEMORY_AUDIT_MAX`
- `AGENTMEMORY_AUDIT_SWEEP_CONCURRENCY`
- `AGENTMEMORY_AUDIT_SWEEP_DELETE_BATCH`
Expand Down
15 changes: 15 additions & 0 deletions src/state/index-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ const INDEX_SHARD_KEY = "data";
const DEFAULT_INDEX_SHARD_CHARS = 2_000_000;
const DEFAULT_BOOT_RECLAIM_MAX_DELETES = 200;

// mem:audit exists to record structural deletions of user data — that is
// the policy stated at the top of src/functions/audit.ts. Index shard
// writes and manifest publishes remove no user rows, so they fall outside
// it, yet a single save() emits three of them: on a real store they
// reached 59876 of 84028 entries (71%), which is what makes the audit log
// slow to query and bloats startup. Off by default; set
// AGENTMEMORY_AUDIT_INDEX_PERSIST=1 when debugging index persistence.
function auditIndexPersistEnabled(): boolean {
const raw = process.env.AGENTMEMORY_AUDIT_INDEX_PERSIST;
if (!raw) return false;
const normalized = raw.trim().toLowerCase();
return normalized === "1" || normalized === "true";
}

type IndexShardManifest = {
v: 1;
generation?: string;
Expand Down Expand Up @@ -608,6 +622,7 @@ export class IndexPersistence {
targetIds: string[],
details: Record<string, unknown>,
): Promise<void> {
if (!auditIndexPersistEnabled()) return;
await safeAudit(
this.kv,
"index_persist",
Expand Down
91 changes: 91 additions & 0 deletions test/index-persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1274,3 +1274,94 @@ describe("IndexPersistence save coalescing", () => {
expect(manifestWrites).toBeLessThanOrEqual(2);
});
});

describe("index_persist audit gating", () => {
let kv: ReturnType<typeof mockKV>;
let previousFlag: string | undefined;

beforeEach(() => {
// AGENTMEMORY_* variables are documented as living in
// ~/.agentmemory/.env, so a developer running the suite on a
// configured machine can inherit this one. Clear it going in and put
// whatever was there back on the way out.
previousFlag = process.env.AGENTMEMORY_AUDIT_INDEX_PERSIST;
delete process.env.AGENTMEMORY_AUDIT_INDEX_PERSIST;
vi.useFakeTimers();
kv = mockKV();
});

afterEach(() => {
vi.useRealTimers();
if (previousFlag === undefined) {
delete process.env.AGENTMEMORY_AUDIT_INDEX_PERSIST;
} else {
process.env.AGENTMEMORY_AUDIT_INDEX_PERSIST = previousFlag;
}
});

async function indexPersistEntries(): Promise<Array<{ operation: string }>> {
const entries = await kv.list<{ operation: string }>("mem:audit");
return entries.filter((entry) => entry.operation === "index_persist");
}

it("writes no index_persist audit entries by default", async () => {
const persistence = new IndexPersistence(
kv as never,
makeBm25("obs_1", "auth handler"),
null,
);

await persistence.save();

expect(await indexPersistEntries()).toEqual([]);
});

it.each(["1", " 1 ", "true", "TRUE", " true "])(
"writes index_persist audit entries when set to %j",
async (value) => {
process.env.AGENTMEMORY_AUDIT_INDEX_PERSIST = value;
const persistence = new IndexPersistence(
kv as never,
makeBm25("obs_1", "auth handler"),
null,
);

await persistence.save();

expect((await indexPersistEntries()).length).toBeGreaterThan(0);
},
);

// Anything that is not an affirmative stays off. "0" and "false" are the
// ones an operator is likely to reach for to disable it, and they must
// not read as "present, therefore enabled".
it.each(["0", "false", "yes", "", " "])(
"keeps auditing off when set to %j",
async (value) => {
process.env.AGENTMEMORY_AUDIT_INDEX_PERSIST = value;
const persistence = new IndexPersistence(
kv as never,
makeBm25("obs_1", "auth handler"),
null,
);

await persistence.save();

expect(await indexPersistEntries()).toEqual([]);
},
);

it("still persists the index when auditing is off", async () => {
const persistence = new IndexPersistence(
kv as never,
makeBm25("obs_1", "auth handler"),
null,
);

await persistence.save();

const loaded = await persistence.load();
expect(loaded.bm25).not.toBeNull();
expect(loaded.bm25!.size).toBe(1);
});
});