Skip to content

Commit d1b9d16

Browse files
committed
feat: add 8-char cursor IDs for query pagination
Replace raw timestamp pagination with short cursor IDs. Cursors are 8-char hashes stored in ~/.agentcrumbs/.cursors.json with 1-hour TTL. Flow: query --since 5m → output shows 'Next: --cursor a1b2c3d4' → query --since 5m --cursor a1b2c3d4 for next page. Keep --after/--before for explicit time windows.
1 parent 6b0664e commit d1b9d16

File tree

5 files changed

+101
-14
lines changed

5 files changed

+101
-14
lines changed

docs/content/docs/cli/query.mdx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ agentcrumbs query --since 5m
1616
| Flag | Description |
1717
| --- | --- |
1818
| `--since <duration>` | Relative time window (e.g., `5m`, `1h`, `24h`, `7d`) |
19-
| `--after <timestamp>` | Crumbs after this ISO timestamp (cursor for pagination) |
19+
| `--after <timestamp>` | Crumbs after this ISO timestamp |
2020
| `--before <timestamp>` | Crumbs before this ISO timestamp |
21+
| `--cursor <id>` | Resume from a previous page (8-char ID from output) |
2122
| `--limit <n>` | Results per page (default: 50) |
2223
| `--ns <pattern>` | Filter by namespace |
2324
| `--tag <tag>` | Filter by tag |
@@ -31,27 +32,33 @@ Time units: `s` (seconds), `m` (minutes), `h` (hours), `d` (days).
3132

3233
### Pagination
3334

34-
Results are returned oldest-first, capped at `--limit` (default 50). When there are more results, the output includes a `Next page:` line with an `--after` timestamp you can use to fetch the next page.
35+
Results are returned oldest-first, capped at `--limit` (default 50). When there are more results, the output includes a short cursor ID for the next page.
3536

3637
```bash
3738
# First page
3839
agentcrumbs query --since 5m
39-
# Output: 50 of 128 crumbs. Next page: --after 2026-03-11T14:20:00.123Z
40+
# Output: 50 crumbs (1-50 of 128). Next: --cursor a1b2c3d4
4041

4142
# Next page
42-
agentcrumbs query --since 5m --after 2026-03-11T14:20:00.123Z
43+
agentcrumbs query --since 5m --cursor a1b2c3d4
44+
# Output: 50 crumbs (51-100 of 128). Next: --cursor e5f6g7h8
4345
```
4446

47+
Cursors expire after 1 hour. You can also use `--after` / `--before` with ISO timestamps for explicit time windows without cursors.
48+
4549
### Examples
4650

4751
```bash
4852
# Last 5 minutes (all namespaces)
4953
agentcrumbs query --since 5m
5054

55+
# Paginate through results
56+
agentcrumbs query --since 5m --cursor a1b2c3d4
57+
5158
# Time window with absolute timestamps
5259
agentcrumbs query --after 2026-03-11T14:00:00Z --before 2026-03-11T14:05:00Z
5360

54-
# Paginate through results
61+
# Smaller pages
5562
agentcrumbs query --since 1h --limit 25
5663

5764
# Filter by session

packages/agentcrumbs/skills/agentcrumbs/SKILL.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@ Start broad — query a time window across all namespaces, then paginate if ther
101101
# Start here: get recent crumbs across all services
102102
agentcrumbs query --since 5m
103103

104-
# Paginate forward (timestamp from "Next page:" in output)
105-
agentcrumbs query --since 5m --after 2026-03-11T14:20:00.123Z
104+
# Paginate forward using the cursor from the output
105+
agentcrumbs query --since 5m --cursor a1b2c3d4
106106

107107
# Time window with absolute bounds
108108
agentcrumbs query --after 2026-03-11T14:00:00Z --before 2026-03-11T14:05:00Z
@@ -115,6 +115,8 @@ agentcrumbs query --since 5m --tag error
115115
agentcrumbs query --session a1b2c3
116116
```
117117

118+
Results are paginated (50 per page by default). When there are more results, the output includes a `--cursor` ID for the next page. Pass it back to get the next page.
119+
118120
Run `agentcrumbs <command> --help` for detailed options on any command.
119121

120122
## Enable tracing

packages/agentcrumbs/src/cli/commands/query.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import type { Crumb } from "../../types.js";
22
import { formatCrumbPretty, formatCrumbJson } from "../format.js";
33
import { getFlag, hasFlag } from "../args.js";
44
import { parseAppFlags, readAllCrumbs } from "../app-store.js";
5+
import { saveCursor, resolveCursor } from "../cursor.js";
56

67
export async function query(args: string[]): Promise<void> {
78
const ns = getFlag(args, "--ns");
89
const tag = getFlag(args, "--tag");
910
const since = getFlag(args, "--since");
1011
const after = getFlag(args, "--after");
1112
const before = getFlag(args, "--before");
13+
const cursor = getFlag(args, "--cursor");
1214
const session = getFlag(args, "--session");
1315
const match = getFlag(args, "--match");
1416
const json = hasFlag(args, "--json");
@@ -64,8 +66,18 @@ export async function query(args: string[]): Promise<void> {
6466

6567
const total = filtered.length;
6668

67-
// Take first `limit` crumbs (oldest first) for forward pagination
68-
const results = filtered.slice(0, limit);
69+
// Resolve cursor to skip offset
70+
let startIndex = 0;
71+
if (cursor) {
72+
const entry = resolveCursor(cursor);
73+
if (!entry) {
74+
process.stderr.write(`Cursor expired or invalid: ${cursor}\n`);
75+
process.exit(1);
76+
}
77+
startIndex = entry.offset;
78+
}
79+
80+
const results = filtered.slice(startIndex, startIndex + limit);
6981

7082
if (results.length === 0) {
7183
process.stderr.write("No crumbs found matching filters.\n");
@@ -81,14 +93,20 @@ export async function query(args: string[]): Promise<void> {
8193
}
8294

8395
// Pagination footer
84-
const hasMore = total > results.length;
96+
const endIndex = startIndex + results.length;
97+
const hasMore = endIndex < total;
8598
if (hasMore) {
8699
const lastTs = results[results.length - 1]!.ts;
100+
const nextCursor = saveCursor(lastTs, endIndex);
87101
process.stderr.write(
88-
`\n${results.length} of ${total} crumbs. Next page: --after ${lastTs}\n`
102+
`\n${results.length} crumbs (${startIndex + 1}-${endIndex} of ${total}). Next: --cursor ${nextCursor}\n`
89103
);
90104
} else {
91-
process.stderr.write(`\n${results.length} crumbs.\n`);
105+
if (startIndex > 0) {
106+
process.stderr.write(`\n${results.length} crumbs (${startIndex + 1}-${endIndex} of ${total}).\n`);
107+
} else {
108+
process.stderr.write(`\n${results.length} crumbs.\n`);
109+
}
92110
}
93111
}
94112

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import os from "node:os";
4+
import { createHash } from "node:crypto";
5+
6+
const CURSOR_FILE = path.join(os.homedir(), ".agentcrumbs", ".cursors.json");
7+
const MAX_CURSORS = 50;
8+
const CURSOR_TTL = 60 * 60 * 1000; // 1 hour
9+
10+
type CursorEntry = {
11+
ts: string;
12+
offset: number;
13+
createdAt: number;
14+
};
15+
16+
type CursorMap = Record<string, CursorEntry>;
17+
18+
function readCursors(): CursorMap {
19+
try {
20+
return JSON.parse(fs.readFileSync(CURSOR_FILE, "utf-8")) as CursorMap;
21+
} catch {
22+
return {};
23+
}
24+
}
25+
26+
function writeCursors(cursors: CursorMap): void {
27+
const dir = path.dirname(CURSOR_FILE);
28+
if (!fs.existsSync(dir)) {
29+
fs.mkdirSync(dir, { recursive: true });
30+
}
31+
fs.writeFileSync(CURSOR_FILE, JSON.stringify(cursors));
32+
}
33+
34+
function generateId(ts: string, offset: number): string {
35+
const hash = createHash("sha256").update(`${ts}:${offset}`).digest("hex");
36+
return hash.slice(0, 8);
37+
}
38+
39+
function pruneOld(cursors: CursorMap): CursorMap {
40+
const now = Date.now();
41+
const entries = Object.entries(cursors)
42+
.filter(([, v]) => now - v.createdAt < CURSOR_TTL)
43+
.sort((a, b) => b[1].createdAt - a[1].createdAt)
44+
.slice(0, MAX_CURSORS);
45+
return Object.fromEntries(entries);
46+
}
47+
48+
export function saveCursor(ts: string, offset: number): string {
49+
const id = generateId(ts, offset);
50+
const cursors = pruneOld(readCursors());
51+
cursors[id] = { ts, offset, createdAt: Date.now() };
52+
writeCursors(cursors);
53+
return id;
54+
}
55+
56+
export function resolveCursor(id: string): CursorEntry | undefined {
57+
const cursors = readCursors();
58+
return cursors[id];
59+
}

packages/agentcrumbs/src/cli/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@ Tail options:
3636
3737
Query options:
3838
--since <duration> Relative time window (e.g., 5m, 1h, 24h)
39-
--after <timestamp> Crumbs after this ISO timestamp (for pagination)
39+
--after <timestamp> Crumbs after this ISO timestamp
4040
--before <timestamp> Crumbs before this ISO timestamp
41-
--limit <n> Max results per page (default: 50)
41+
--cursor <id> Resume from a previous page (8-char ID from output)
42+
--limit <n> Results per page (default: 50)
4243
--ns <pattern> Filter by namespace
4344
--tag <tag> Filter by tag
4445
--session <id> Filter by session ID

0 commit comments

Comments
 (0)