Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1500,7 +1500,7 @@ Create `~/.agentmemory/.env`:

<h2 id="api"><picture><source media="(prefers-color-scheme: dark)" srcset="assets/tags/light/section-api.svg"><img src="assets/tags/section-api.svg" alt="API" height="32" /></picture></h2>

128 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer <secret>` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers.
129 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer <secret>` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers.

<details>
<summary>Key endpoints</summary>
Expand Down
4 changes: 3 additions & 1 deletion src/functions/migrate-vector-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface MigrateVectorIndexResult {
failed: number;
vectorSize: number;
failedSessions: string[];
index: VectorIndex;
}

// Validate one embedding's shape against the provider's declared dimensions
Expand Down Expand Up @@ -111,7 +112,7 @@ export async function migrateVectorIndex(
// failedSessions list and can't tell apart "0 sessions, all OK"
// from "kv.list itself blew up".
failedSessions.push("<sessions-list-failed>");
return { success: false, totalProcessed: processed, failed, vectorSize: newIndex.size, failedSessions };
return { success: false, totalProcessed: processed, failed, vectorSize: newIndex.size, failedSessions, index: newIndex };
}

for (const session of sessions) {
Expand Down Expand Up @@ -148,5 +149,6 @@ export async function migrateVectorIndex(
failed,
vectorSize: newIndex.size,
failedSessions,
index: newIndex,
};
}
82 changes: 82 additions & 0 deletions src/functions/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { SearchIndex } from '../state/search-index.js'
import { VectorIndex } from '../state/vector-index.js'
import type { EmbeddingProvider } from '../types.js'
import { memoryToObservation } from '../state/memory-utils.js'
import { migrateVectorIndex } from './migrate-vector-index.js'
import { recordAccessBatch } from './access-tracker.js'
import { recordAudit } from './audit.js'
import { logger } from "../logger.js";
import { getAgentId, isAgentScopeIsolated } from "../config.js";

Expand Down Expand Up @@ -319,7 +321,87 @@ export async function rebuildIndex(kv: StateKV): Promise<number> {
return count
}

// Re-embed the whole corpus against the active embedding provider and swap
// the result into the live vector index. Used after switching embedding
// model/dimensions: the new model produces a different vector space, so old
// vectors must be recomputed (see the dimension restore guard in index.ts).
// The new index is built off to the side via migrateVectorIndex so the live
// index keeps serving during the (possibly long) rebuild, then swapped in
// place with restoreFrom, since IndexPersistence holds a reference to the live
// VectorIndex, so replacing the reference would desync persistence. The swap
// only happens on a fully clean rebuild (failed === 0); on any failure the
// live index is left untouched and the caller gets failedSessions to retry.
export async function reindexVectors(kv: StateKV): Promise<{
success: boolean
swapped: boolean
totalProcessed: number
failed: number
vectorSize: number
failedSessions: string[]
provider: string | null
dimensions: number | null
error?: string
}> {
const ep = currentEmbeddingProvider
if (!ep) {
return {
success: false,
swapped: false,
totalProcessed: 0,
failed: 0,
vectorSize: 0,
failedSessions: [],
provider: null,
dimensions: null,
error:
'no embedding provider configured; set EMBEDDING_PROVIDER or a provider API key and restart',
}
}
const vi = vectorIndex
if (!vi) {
return {
success: false,
swapped: false,
totalProcessed: 0,
failed: 0,
vectorSize: 0,
failedSessions: [],
provider: ep.name,
dimensions: ep.dimensions,
error: 'vector index not initialized',
}
}
const { index, ...stats } = await migrateVectorIndex(kv, ep)
let swapped = false
if (stats.success) {
vi.restoreFrom(index)
swapped = true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try {
await flushIndexSave()
} catch (err) {
return {
...stats,
success: false,
swapped,
provider: ep.name,
dimensions: ep.dimensions,
error: `index swapped but persistence failed: ${err instanceof Error ? err.message : String(err)}`,
}
}
await recordAudit(kv, 'vector_index_swap', 'mem::reindex-vectors', [ep.name], {
provider: ep.name,
dimensions: ep.dimensions,
totalProcessed: stats.totalProcessed,
})
}
return { ...stats, swapped, provider: ep.name, dimensions: ep.dimensions }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export function registerSearchFunction(sdk: ISdk, kv: StateKV): void {
sdk.registerFunction('mem::reindex-vectors', async () => {
return await reindexVectors(kv)
})

sdk.registerFunction(
'mem::search',
async (data: {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ async function main() {
`Ready. ${embeddingProvider ? "Triple-stream (BM25+Vector+Graph)" : "BM25+Graph"} search active.`,
);
bootLog(
`REST API: 128 endpoints at http://localhost:${config.restPort}/agentmemory/*`,
`REST API: 129 endpoints at http://localhost:${config.restPort}/agentmemory/*`,
);
bootLog(
`MCP surface (opt-in via \`npx @agentmemory/mcp\`): ${getAllTools().length} tools · 6 resources · 3 prompts`,
Expand Down
17 changes: 17 additions & 0 deletions src/triggers/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,23 @@ export function registerApiTriggers(
config: { api_path: "/agentmemory/migrate", http_method: "POST" },
});

sdk.registerFunction("api::reindex-vectors",
async (req: ApiRequest<Record<string, unknown>>): Promise<Response> => {
const authErr = checkAuth(req, secret);
if (authErr) return authErr;
const result = await sdk.trigger({
function_id: "mem::reindex-vectors",
payload: {},
});
return { status_code: 200, body: result };
},
);
sdk.registerTrigger({
type: "http",
function_id: "api::reindex-vectors",
config: { api_path: "/agentmemory/reindex-vectors", http_method: "POST" },
});

sdk.registerFunction("api::evict",
async (req: ApiRequest<{ dryRun?: boolean }>): Promise<Response> => {
const authErr = checkAuth(req, secret);
Expand Down
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,8 @@ export interface AuditEntry {
| "slot_replace"
| "slot_create"
| "slot_delete"
| "slot_reflect";
| "slot_reflect"
| "vector_index_swap";
userId?: string;
functionId: string;
targetIds: string[];
Expand Down
123 changes: 123 additions & 0 deletions test/reindex-vectors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));

import { VectorIndex } from "../src/state/vector-index.js";
import {
reindexVectors,
setVectorIndex,
setEmbeddingProvider,
getVectorIndex,
} from "../src/functions/search.js";
import type { EmbeddingProvider } from "../src/types.js";

const fourDimProvider: EmbeddingProvider = {
name: "test-4d",
dimensions: 4,
embed: async (_text: string) => new Float32Array([0.1, 0.2, 0.3, 0.4]),
embedBatch: async (texts: string[]) =>
texts.map(() => new Float32Array([0.1, 0.2, 0.3, 0.4])),
};

function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (_scope: string, _key: string): Promise<void> => {},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}

async function seedCorpus(kv: ReturnType<typeof mockKV>) {
await kv.set("mem:sessions", "ses_1", { id: "ses_1" });
await kv.set("mem:obs:ses_1", "obs_1", {
id: "obs_1",
sessionId: "ses_1",
timestamp: new Date().toISOString(),
type: "decision",
title: "reindex observation",
facts: ["x"],
narrative: "to be re-embedded",
concepts: [],
files: [],
importance: 5,
});
await kv.set("mem:memories", "mem_1", {
id: "mem_1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
type: "fact",
title: "reindex memory",
content: "this memory will be re-embedded",
concepts: [],
files: [],
sessionIds: ["ses_1"],
strength: 7,
version: 1,
isLatest: true,
});
}

describe("reindexVectors", () => {
beforeEach(() => {
setVectorIndex(null);
setEmbeddingProvider(null);
});

it("re-embeds the corpus and swaps it into the live vector index", async () => {
const kv = mockKV();
await seedCorpus(kv);
const live = new VectorIndex();
setVectorIndex(live);
setEmbeddingProvider(fourDimProvider);

const result = await reindexVectors(kv as never);

expect(result.success).toBe(true);
expect(result.swapped).toBe(true);
expect(result.failed).toBe(0);
expect(result.totalProcessed).toBe(2);
expect(result.vectorSize).toBe(2);
expect(result.provider).toBe("test-4d");
expect(result.dimensions).toBe(4);
expect(getVectorIndex()!.size).toBe(2);
});

it("returns success:false without swapping when no embedding provider is configured", async () => {
const kv = mockKV();
await seedCorpus(kv);
const live = new VectorIndex();
setVectorIndex(live);
setEmbeddingProvider(null);

const result = await reindexVectors(kv as never);

expect(result.success).toBe(false);
expect(result.swapped).toBe(false);
expect(result.error).toBeTruthy();
expect(getVectorIndex()!.size).toBe(0);
});

it("returns success:false without throwing when the vector index is not initialized", async () => {
const kv = mockKV();
await seedCorpus(kv);
setVectorIndex(null);
setEmbeddingProvider(fourDimProvider);

const result = await reindexVectors(kv as never);

expect(result.success).toBe(false);
expect(result.swapped).toBe(false);
});
});
22 changes: 22 additions & 0 deletions test/vector-index-dimensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,26 @@ describe("migrateVectorIndex", () => {
expect(result.vectorSize).toBe(0);
expect(result.failed).toBe(0);
});

it("returns the rebuilt index so callers can swap it into the live index", async () => {
const kv = mockKV();
await kv.set("mem:sessions", "ses_1", { id: "ses_1" });
await kv.set("mem:obs:ses_1", "obs_1", {
id: "obs_1",
sessionId: "ses_1",
timestamp: new Date().toISOString(),
type: "decision",
title: "swap test",
facts: ["x"],
narrative: "to be re-embedded",
concepts: [],
files: [],
importance: 5,
});

const result = await migrateVectorIndex(kv as never, newProvider);
expect(result.index).toBeInstanceOf(VectorIndex);
expect(result.index.size).toBe(result.vectorSize);
expect(result.index.size).toBe(1);
});
});