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 performance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@
cost without the network, MCP.
- [ci](ci/README.md): how long the pull-request checks take, and where the time goes.
- [crm](crm/README.md): CRM data across b24 and memo — relationships, deal parts, money and group locks, end to end.
- [memo](memo/README.md): memo's own lists — paging past 500 rows, and what a brief answer saves.
- [funnel](funnel/README.md): wirecat-funnel, the tsnet helper for Tailscale Funnel: whether Tailscale's
log upload stays off, and Funnel end to end.
6 changes: 6 additions & 0 deletions performance/memo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# memo

Measurements of `memo`, the CLI that joins what the owner knows across the stored sources.

- [lists-paging](lists-paging/README.md) — `memories`, `facts` and `actions list` past 500 rows with `--page`: a later
page costs the same as the first, and no brief flag beyond `actions list --brief` is needed (2026-11-10).
71 changes: 71 additions & 0 deletions performance/memo/lists-paging/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# memo lists past 500 rows: first page time with and without sources

## Why it was run

WireCat Desktop's Memory page reads memo's `memories list`, `facts list` and `actions list` with `--limit 500`, the
most they gave. A store with more than 500 of a kind showed only the newest 500. The owner ruled that these lists page
like `relationships list` does, with `--page` (NEED-2497). cli-core
[#2811](https://github.com/WireCatLabs/cli-core/pull/2811) added an `offset` to the store lists, and memo's
`feat/memory-lists-paging` branch adds `--page`.

The run answers two questions:

1. Does a later page cost more than the first one?
2. Would a brief form, without each row's sources and their excerpts, make the first load lighter enough to need a
new flag? memo has two candidates already: `actions list --brief` and field selection with `--json=<fields>`.

## Setup

- Machine: AMD Ryzen AI 9 HX 470, 30 GB RAM, NVMe disk, Linux 7.0, Node 24.19.0. Other jobs shared the machine
(load average about 10 on 24 cores), so differences under about 50 ms are noise.
- Code: memo's `feat/memory-lists-paging` branch (commit `cc3f868`), built with `pnpm build`. It ran against
`@wirecat/cli-core` 0.624.0 with the four SQL changes of cli-core #2811 applied by hand to its `dist`. No released
cli-core held them yet.
- Data: synthetic only. [`seed.mjs`](seed.mjs) opens a new store and writes 20 notes, a project and 2000 of each:
memories citing two notes, facts citing two notes, and evidence-findings batches. Each batch is one finding citing
two notes, so `actions list` without `--brief` reads two sources with excerpts per row.
- Each command is timed as a whole process, the way Desktop runs it: Node start, opening the store, the query, the
JSON on stdout. [`bench.sh`](bench.sh) gives the median of `RUNS` runs and the answer's size in bytes. It runs with
`HOME`, the XDG folders and `MESSAGING_STORE` in a throwaway folder and `DBUS_SESSION_BUS_ADDRESS=disabled:`, so it
never reaches the owner's store or keyring.

```sh
node performance/memo/lists-paging/seed.mjs <memo checkout> <scratch>/home/store.db 2000
RUNS=9 performance/memo/lists-paging/bench.sh <memo checkout>/dist/bin/memo.js <scratch>/home
```

## Results

Median of 9 runs, 2026-11-10.

| Command | Time | Answer |
|---|---|---|
| `memories list --limit 1 --json` (start, the floor) | 346 ms | 1 KB |
| `memories list --limit 500 --json` | 383 ms | 241 KB |
| `memories list --limit 500 --json=<10 fields>` | 320 ms | 105 KB |
| `memories list --limit 500 --page 4 --json` | 316 ms | 239 KB |
| `facts list --limit 500 --json` | 314 ms | 266 KB |
| `facts list --limit 500 --json=<10 fields>` | 314 ms | 113 KB |
| `actions list --limit 500 --json` | 657 ms | 965 KB |
| `actions list --limit 500 --brief --json` | 622 ms | 766 KB |
| `actions list --limit 500 --page 4 --brief --json` | 592 ms | 764 KB |

The 10 fields are `id,kind,text,status,scope,subject,subject_name,project,project_name,created_at`.

## What it showed

- Starting Node takes most of the time. A 500-row page of memories or facts adds less than the noise to the floor.
- A later page costs the same as the first. Desktop can read every page of a kind in a loop. Each page is one
more process, about 300 to 600 ms, and the kinds run side by side.
- `--json=<fields>` halves the bytes of a memory or fact page but saves no time: the store reads each row's evidence
either way. Desktop reads 18 of the about 21 fields of a row, so it would not get that saving.
- `actions list --brief` leaves out the sources and their excerpts: 20 % fewer bytes and about 35 ms. Desktop
already passes it. Most of an action row is its payload, not its sources.
- No new flag was needed. The decision: memo adds `--page` only, and Desktop keeps `--brief` for actions.

## What was not measured

- A real store. The rows are short synthetic texts; real memories with long text make every page larger.
- A store with more than 2000 rows of a kind, where `OFFSET` makes a deep page slower; at 2000 it does not show.
- Field selection inside the store. `--json=<fields>` trims the answer after the read, not the query.
- Memory use of the process, and the agent log (`agents list`), which Desktop reads with `--limit 30`.
36 changes: 36 additions & 0 deletions performance/memo/lists-paging/bench.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# First-page timings of memo's lists on a throwaway store; median of RUNS runs, and the answer's size.
set -euo pipefail
memo="$1"
home="$2"
runs="${RUNS:-5}"
[ "$home" != "$HOME" ] || { echo "give a throwaway folder, not your HOME" >&2; exit 1; }
export HOME="$home" XDG_CONFIG_HOME="$home/.config" XDG_STATE_HOME="$home/.state" XDG_DATA_HOME="$home/.data"
export XDG_CACHE_HOME="$home/.cache" MESSAGING_STORE="$home/store.db" DBUS_SESSION_BUS_ADDRESS=disabled: AI_AGENT= CLAUDECODE=

measure() {
local label="$1"
shift
local times=() bytes
for _ in $(seq "$runs"); do
local start end
start=$(date +%s%N)
bytes=$(node "$memo" "$@" | wc -c)
end=$(date +%s%N)
times+=($(((end - start) / 1000000)))
done
local median
median=$(printf "%s\n" "${times[@]}" | sort -n | sed -n "$(((runs + 1) / 2))p")
printf "| %-58s | %6s ms | %9s bytes |\n" "$label" "$median" "$bytes"
}

fields="id,kind,text,status,scope,subject,subject_name,project,project_name,created_at"
measure "memories list --limit 500 --json" memories list --limit 500 --json
measure "memories list --limit 500 --json=<10 fields>" memories list --limit 500 "--json=$fields"
measure "memories list --limit 500 --page 4 --json" memories list --limit 500 --page 4 --json
measure "facts list --limit 500 --json" facts list --limit 500 --json
measure "facts list --limit 500 --json=<10 fields>" facts list --limit 500 "--json=$fields"
measure "actions list --limit 500 --json" actions list --limit 500 --json
measure "actions list --limit 500 --brief --json" actions list --limit 500 --brief --json
measure "actions list --limit 500 --page 4 --brief --json" actions list --limit 500 --page 4 --brief --json
measure "memories list --limit 1 --json (start-up floor)" memories list --limit 1 --json
54 changes: 54 additions & 0 deletions performance/memo/lists-paging/seed.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Seeds a throwaway store: N memories, N facts and N evidence-findings batches, all synthetic.
const [, , memoRoot, storePath, countText = "2000"] = process.argv
const { openStore } = await import(`${memoRoot}/node_modules/@wirecat/cli-core/dist/store/index.js`)
const count = Number(countText)
if (!storePath || storePath.includes("/.local/share/WireCat"))
throw new Error("give a throwaway store path, never the real store")
const store = await openStore({ path: storePath })
const project = await store.knowledge.addProject({ name: "Project Synthetic", type: "work" })
const notes = []
for (let n = 0; n < 20; n++)
notes.push(await store.notes.addNote({ text: `Kai Sample note ${n}: the launch checklist moves to Friday ${n}` }))
const cited = []
for (const note of notes) {
const record = await store.evidenceFindings.citable(note.ref)
cited.push({ reference: note.ref, fingerprint: record.fingerprint })
}
const started = Date.now()
for (let n = 0; n < count; n++) {
const evidence = [notes[n % notes.length].ref, notes[(n + 1) % notes.length].ref]
await store.memories.add({
kind: "preference",
body: `Synthetic memory ${n}`,
scope: "work",
evidence,
author: "owner",
})
await store.facts.add({
type: "decision",
statement: `Synthetic fact ${n}`,
by: "owner",
evidence,
project: project.ref,
})
await store.evidenceFindings.stage(
{
schemaVersion: 1,
key: `bench-${n}`,
extractor: "synthetic",
findings: [
{
id: "f1",
kind: "fact",
type: "decision",
statement: `Synthetic finding ${n}`,
project: project.ref,
evidence: [cited[n % cited.length], cited[(n + 1) % cited.length]],
},
],
},
{ bot: "bench-agent", model: "synthetic-model" },
)
}
await store.close()
process.stdout.write(`seeded ${count} of each in ${Date.now() - started} ms\n`)
Loading