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
3 changes: 3 additions & 0 deletions plugins/openclaw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ The plugin exposes these tools to OpenClaw:
- `memory_branch`, `memory_branches`, `memory_checkout`, `memory_branch_delete`, `memory_merge`, `memory_diff`
- Compatibility CLI aliases under `openclaw ltm ...`

In API mode, snapshot discovery follows `has_more` pages up to `maxListPages`.
If that bound is reached or pagination fails, listing reports an error and snapshot statistics remain unavailable rather than reporting a partial count as complete.

## Common Pitfalls

**`openclaw memoria` vs `openclaw memory`:** This plugin uses `openclaw memoria`. The `openclaw memory` namespace is OpenClaw's built-in file memory — a different system entirely.
Expand Down
116 changes: 116 additions & 0 deletions plugins/openclaw/openclaw/__tests__/snapshot-pagination.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import { MemoriaClient } from "../client.js";
import { MemoriaHttpTransport } from "../http-client.js";
import { buildApiConfig } from "./helpers.js";

const servers: Server[] = [];

async function serveSnapshots(total: number, mode = "normal") {
const offsets: number[] = [];
const snapshots = Array.from({ length: total }, (_, i) => ({
name: `release_${i}`, snapshot_name: `mem_snap_release_${i}`,
timestamp: "2026-10-21T00:00:00Z",
}));
const server = createServer((request, response) => {
const url = new URL(request.url!, "http://localhost");
response.setHeader("Content-Type", "application/json");
if (url.pathname === "/v1/memories") {
response.end(JSON.stringify({ items: [], next_cursor: null }));
return;
}
if (url.pathname === "/v1/branches") {
response.end(JSON.stringify({ branches: [{ name: "main", active: true }] }));
return;
}
const offset = Number(url.searchParams.get("offset") ?? 0);
const limit = Number(url.searchParams.get("limit") ?? 20);
offsets.push(offset);
if (mode === "fail-later" && offset > 0) {
response.writeHead(503);
response.end("unavailable");
return;
}
const items = mode === "empty-more" ? [] : snapshots.slice(offset, offset + limit);
response.end(JSON.stringify({
snapshots: items, total, offset, limit,
has_more: mode === "empty-more" || offset + items.length < total,
}));
});
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
return {
apiUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
offsets, snapshots,
};
}

afterEach(async () => {
await Promise.all(servers.splice(0).map(async (server) => {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
});
}));
});

describe("snapshot discovery pagination (real HTTP)", () => {
it("lists snapshots beyond the first 20 and counts them in stats", async () => {
const fixture = await serveSnapshots(45);
const client = new MemoriaClient(buildApiConfig({ apiUrl: fixture.apiUrl }));
try {
expect((await client.listSnapshots("u")).map((s) => s.name))
.toEqual(fixture.snapshots.map((s) => s.name));
expect(fixture.offsets).toEqual([0, 20, 40]);
expect((await client.stats("u")).snapshotCount).toBe(45);
} finally {
client.close();
}
});

it("rejects a truncated scan rather than returning a false complete count", async () => {
const fixture = await serveSnapshots(21);
const client = new MemoriaClient(buildApiConfig({ apiUrl: fixture.apiUrl, maxListPages: 1 }));
try {
await expect(client.listSnapshots("u")).rejects.toThrow(/maxListPages/);
const stats = await client.stats("u");
expect(stats.snapshotCount).toBeNull();
expect(stats.limitations?.join(" ")).toMatch(/Snapshot statistics unavailable/);
expect(fixture.offsets).toEqual([0, 0]);
} finally {
client.close();
}
});

it("fails without looping when an empty page claims more snapshots", async () => {
const fixture = await serveSnapshots(21, "empty-more");
const client = new MemoriaClient(buildApiConfig({ apiUrl: fixture.apiUrl }));
try {
await expect(client.listSnapshots("u")).rejects.toThrow(/empty.*page/i);
expect(fixture.offsets).toEqual([0]);
} finally {
client.close();
}
});

it("propagates a later-page HTTP error instead of returning the first page", async () => {
const fixture = await serveSnapshots(21, "fail-later");
const client = new MemoriaClient(buildApiConfig({ apiUrl: fixture.apiUrl }));
try {
await expect(client.listSnapshots("u")).rejects.toThrow(/503/);
expect(fixture.offsets).toEqual([0, 20]);
} finally {
client.close();
}
});

it("retains explicit low-level page selection", async () => {
const fixture = await serveSnapshots(45);
const transport = new MemoriaHttpTransport(buildApiConfig({ apiUrl: fixture.apiUrl }), "u");
const result = await transport.callTool("memory_snapshots", { limit: 2, offset: 20 });
expect(JSON.stringify(result)).toContain("release_20");
expect(JSON.stringify(result)).not.toContain("release_22");
expect(fixture.offsets).toEqual([20]);
});
});
27 changes: 23 additions & 4 deletions plugins/openclaw/openclaw/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,10 +291,29 @@ export class MemoriaHttpTransport {

private async listSnapshots(args: Record<string, unknown>) {
const limit = typeof args.limit === "number" ? args.limit : 20;
const offset = typeof args.offset === "number" ? args.offset : 0;
const data = await this.get(`/v1/snapshots?limit=${limit}&offset=${offset}`);
// Format as MCP-compatible text so parseSnapshotList() in client.ts works
return this.formatSnapshotList(data);
let offset = typeof args.offset === "number" ? args.offset : 0;
// Explicit transport-level pagination remains a single page. The public
// client/tool supplies neither argument and promises all known snapshots.
if (typeof args.limit === "number" || typeof args.offset === "number") {
return this.formatSnapshotList(await this.get(`/v1/snapshots?limit=${limit}&offset=${offset}`));
}
const snapshots: unknown[] = [];
for (let page = 0; page < this.maxListPages; page++) {
const data = await this.get(`/v1/snapshots?limit=${limit}&offset=${offset}`);
const rec = this.asRecord(data);
if (!Array.isArray(rec?.snapshots)) {
// Preserve older unpaginated response formats on the first request.
if (page === 0) return this.formatSnapshotList(data);
throw new Error("Memoria snapshot listing returned a malformed page");
}
const items = rec.snapshots;
snapshots.push(...items);
if (rec.has_more !== true) return this.formatSnapshotList({ snapshots });
if (items.length === 0) throw new Error("Memoria snapshot listing returned an empty page with has_more");
offset += items.length;
}
// Do not let listSnapshots/stats present a bounded prefix as a complete list.
throw new Error(`Memoria snapshot listing exceeded maxListPages (${this.maxListPages})`);
}

private async rollbackSnapshot(args: Record<string, unknown>) {
Expand Down
Loading