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
8 changes: 5 additions & 3 deletions packages/components/src/lib/conversation-view/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ long conversation never materializes every turn through a Mirror.
## Contracts

- `index(i)` is always loaded and comes from the turn map's shallow value plus
`summary`, `itemCount` and `planCount`. Add a field to `TURN_INDEX_FIELDS`
only when a reader that must stay O(1) needs it; every field costs one
shallow read per turn at open.
`summary`, `itemCount` (assistant turns only) and `planCount` (when a plan
exists). Open cost is two wasm calls per turn plus one per assistant turn
(~20 µs, ~50 ms for 2,400 turns); add a field to `TURN_INDEX_FIELDS` only
when a reader that must stay O(1) needs it, and never one that needs
another container read per turn.
- `turn(i)` is synchronous only for hydrated turns. Hydration is per-turn
`toJSON()`; the LRU never evicts the tail (`tailKeep`), a `retain()`ed
range, or the range an `ensureRange()` call just asked for. A caller that
Expand Down
31 changes: 23 additions & 8 deletions packages/components/src/lib/conversation-view/conversation-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export const TURN_INDEX_FIELDS = [

export type TurnIndexRow = Pick<SessionHistory, (typeof TURN_INDEX_FIELDS)[number]> & {
summary?: TurnSummary;
/**
* Read for assistant turns only: the empty-turn rule and the height
* estimate need it there, and each read is one more wasm call per turn
* at open. User turns always carry their prompt.
*/
itemCount?: number;
planCount?: number;
};
Expand Down Expand Up @@ -151,17 +156,25 @@ export function createConversationViewFromDoc(
? (doc.getContainerById(summary as never)?.toJSON() as TurnSummary | undefined)
: summary;
}
const itemCount = containerLength(doc, shallow.items);
if (itemCount !== undefined) row.itemCount = itemCount;
const planCount = containerLength(doc, shallow.plan);
if (planCount !== undefined) row.planCount = planCount;
if (shallow.role === 'assistant') {
const itemCount = containerLength(doc, shallow.items);
if (itemCount !== undefined) row.itemCount = itemCount;
}
if (shallow.plan !== undefined) {
const planCount = containerLength(doc, shallow.plan);
if (planCount !== undefined) row.planCount = planCount;
}
return row as TurnIndexRow;
};

/** Position of every container id in `ids`; rebuilt with the index. */
let positionByCid = new Map<string, number>();

const rebuildIndex = () => {
const shallow = list.getShallowValue() as unknown[];
const nextIds: (string | null)[] = new Array(shallow.length);
const nextRows: (TurnIndexRow | undefined)[] = new Array(shallow.length);
const nextPositionByCid = new Map<string, number>();
positionById.clear();
for (let i = 0; i < shallow.length; i += 1) {
const cid = shallow[i];
Expand All @@ -171,11 +184,12 @@ export function createConversationViewFromDoc(
continue;
}
nextIds[i] = cid;
nextPositionByCid.set(cid, i);
// Reuse the previous row when the container did not move so a structural
// change costs one shallow read for the list, not one per turn.
const previousPosition = ids.indexOf(cid);
const previousPosition = positionByCid.get(cid);
const row =
previousPosition >= 0 && previousPosition === i
previousPosition === i
? (indexRows[previousPosition] ?? readIndexRow(cid))
: readIndexRow(cid);
nextRows[i] = row;
Expand All @@ -194,6 +208,7 @@ export function createConversationViewFromDoc(
}
ids = nextIds;
indexRows = nextRows;
positionByCid = nextPositionByCid;
};

const materialize = (i: number): SessionHistory | undefined => {
Expand Down Expand Up @@ -230,8 +245,8 @@ export function createConversationViewFromDoc(
if (hydrated.size <= maxHydrated) return;
const candidates: { cid: string; lastUsed: number }[] = [];
for (const [cid, entry] of hydrated) {
const position = ids.indexOf(cid);
if (position >= 0 && isProtected(position, keep)) continue;
const position = positionByCid.get(cid);
if (position !== undefined && isProtected(position, keep)) continue;
candidates.push({ cid, lastUsed: entry.lastUsed });
}
candidates.sort((left, right) => left.lastUsed - right.lastUsed);
Expand Down
3 changes: 2 additions & 1 deletion packages/components/tests/conversation-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ describe('ConversationView', () => {
const doc = docWithTurns(60);
const view = createConversationViewFromDoc(doc, { sessionId, tailKeep: 5, maxHydrated: 10 });
expect(view.turnCount).toBe(60);
expect(view.index(0)).toMatchObject({ id: 'u0', role: 'user', itemCount: 1 });
expect(view.index(0)).toMatchObject({ id: 'u0', role: 'user' });
expect(view.index(0)?.itemCount).toBeUndefined();
expect(view.index(1)).toMatchObject({ id: 'a1', role: 'assistant', itemCount: 3 });
expect(view.indexOf('a59')).toBe(59);
expect(view.isHydrated(59)).toBe(true);
Expand Down
14 changes: 14 additions & 0 deletions packages/history-import/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,17 @@ Baseline on an M-series laptop, real ~170-turn / ~5k-item session doc: phase 2 i
~3ms `LoroDoc.import` plus ~445ms of Mirror construction, and phase 2 dominates.
Mirror init walks every container (one `LoroMap` per message item plus a
`LoroText` per text item), so its cost tracks container count, not bytes.

`pnpm --filter @lody/history-import bench:open` isolates that open cost and
compares it with the client's `ConversationView`
(`packages/components/src/lib/conversation-view`, imported by relative path:
the view depends only on loro-crdt and `@lody/shared` types, so it is the one
piece of client code a benchmark here may reach). Default fixture is the
synthetic replay at `--scale=1,10`; `--fixture=<file>` takes a desensitized
capture from `bench:capture` (never committed). Tasks: the Mirror baseline,
`view.open` (import + view + tail hydrate → one row per turn), `view.readAll`
(what a reader still on the `doc.history` bridge pays), `view.scroll` (a
30-turn `ensureRange` window advancing 20 times, p99), `view.stream` (100 text
deltas into the tail turn with the view attached, p99) and `view.append`. It
prints the phase 1b acceptance checks: `view.open` ≤ 50 ms at x10 and
`view.stream` p99 ≤ 4 ms.
Loading
Loading