@myagentroam/node 0.9.5 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,27 @@ export interface JsonlWindow {
3
3
  readonly truncated: boolean;
4
4
  readonly size: number;
5
5
  }
6
+ export interface JsonlBackwardWindow extends JsonlWindow {
7
+ readonly pageContent: string;
8
+ readonly start: number;
9
+ readonly end: number;
10
+ readonly snapshotEnd: number;
11
+ readonly fileAnchor: string;
12
+ readonly anchorBytes: number;
13
+ }
14
+ export declare function readJsonlFileAnchor(path: string, anchorBytes: number): Promise<string | undefined>;
15
+ /** Reads one newline-aligned window backwards from an opaque byte boundary. */
16
+ export declare function readJsonlBackwardWindow(path: string, options: {
17
+ readonly headBytes: number;
18
+ readonly targetLines: number;
19
+ readonly maxBytes: number;
20
+ readonly page?: {
21
+ readonly snapshotEnd: number;
22
+ readonly nextEnd: number;
23
+ readonly fileAnchor: string;
24
+ readonly anchorBytes: number;
25
+ };
26
+ }): Promise<JsonlBackwardWindow | undefined>;
6
27
  /** Reads bounded head/tail windows without ever loading an unbounded transcript. */
7
28
  export declare function readJsonlWindow(path: string, options: {
8
29
  readonly headBytes?: number;
@@ -1,5 +1,94 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { open, readdir } from 'node:fs/promises';
2
3
  import { join } from 'node:path';
4
+ export async function readJsonlFileAnchor(path, anchorBytes) {
5
+ try {
6
+ const handle = await open(path, 'r');
7
+ try {
8
+ const metadata = await handle.stat();
9
+ if (anchorBytes < 0 || anchorBytes > metadata.size)
10
+ return undefined;
11
+ return createHash('sha256')
12
+ .update(await readAt(handle, anchorBytes, 0))
13
+ .digest('base64url');
14
+ }
15
+ finally {
16
+ await handle.close();
17
+ }
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ /** Reads one newline-aligned window backwards from an opaque byte boundary. */
24
+ export async function readJsonlBackwardWindow(path, options) {
25
+ try {
26
+ const handle = await open(path, 'r');
27
+ try {
28
+ const metadata = await handle.stat();
29
+ const snapshotEnd = options.page?.snapshotEnd ?? metadata.size;
30
+ if (snapshotEnd > metadata.size)
31
+ return undefined;
32
+ const anchorBytes = options.page?.anchorBytes ?? Math.min(4096, snapshotEnd);
33
+ if (anchorBytes < 0 || anchorBytes > snapshotEnd)
34
+ return undefined;
35
+ const anchor = await readAt(handle, anchorBytes, 0);
36
+ const fileAnchor = createHash('sha256').update(anchor).digest('base64url');
37
+ if (options.page !== undefined && options.page.fileAnchor !== fileAnchor)
38
+ return undefined;
39
+ const headSize = Math.min(options.headBytes, snapshotEnd);
40
+ const end = Math.min(Math.max(options.page?.nextEnd ?? snapshotEnd, headSize), snapshotEnd);
41
+ const rawStart = Math.max(headSize, end - options.maxBytes);
42
+ const head = await readAt(handle, headSize, 0);
43
+ let tail = await readAt(handle, end - rawStart, rawStart);
44
+ let start = rawStart;
45
+ if (rawStart > headSize) {
46
+ const newline = tail.indexOf(0x0a);
47
+ if (newline < 0) {
48
+ tail = Buffer.alloc(0);
49
+ start = end;
50
+ }
51
+ else {
52
+ start += newline + 1;
53
+ tail = tail.subarray(newline + 1);
54
+ }
55
+ }
56
+ if (tail.length > 0) {
57
+ let lines = tail.at(-1) === 0x0a ? 0 : 1;
58
+ for (let index = tail.length - 1; index >= 0; index -= 1) {
59
+ if (tail[index] !== 0x0a)
60
+ continue;
61
+ lines += 1;
62
+ if (lines <= options.targetLines)
63
+ continue;
64
+ start += index + 1;
65
+ tail = tail.subarray(index + 1);
66
+ break;
67
+ }
68
+ }
69
+ const contiguous = start === headSize;
70
+ return {
71
+ content: contiguous
72
+ ? Buffer.concat([head, tail]).toString('utf8')
73
+ : `${head.toString('utf8')}${headSize > 0 && tail.length > 0 ? '\n' : ''}${tail.toString('utf8')}`,
74
+ truncated: start > headSize,
75
+ pageContent: tail.toString('utf8'),
76
+ size: metadata.size,
77
+ start,
78
+ end,
79
+ snapshotEnd,
80
+ fileAnchor,
81
+ anchorBytes
82
+ };
83
+ }
84
+ finally {
85
+ await handle.close();
86
+ }
87
+ }
88
+ catch {
89
+ return undefined;
90
+ }
91
+ }
3
92
  /** Reads bounded head/tail windows without ever loading an unbounded transcript. */
4
93
  export async function readJsonlWindow(path, options) {
5
94
  try {
@@ -59,6 +59,16 @@ export interface NativeSessionHistory {
59
59
  readonly lastActivityAt: number;
60
60
  /** The bounded reader omitted bytes between its header and tail windows. */
61
61
  readonly truncated?: boolean;
62
+ /** Opaque paging facts for reading the preceding newline-aligned JSONL window. */
63
+ readonly windowStart?: number;
64
+ readonly windowEnd?: number;
65
+ readonly snapshotEnd?: number;
66
+ readonly fileAnchor?: string;
67
+ readonly anchorBytes?: number;
68
+ /** The supplied page cursor no longer matched this transcript. */
69
+ readonly cursorReset?: boolean;
70
+ /** Page-size input used to derive the stable JSONL line window. */
71
+ readonly windowReadLimit?: number;
62
72
  }
63
73
  /** Actual context-window facts emitted by the Codex native transcript. */
64
74
  export interface NativeCodexContextUsage {
@@ -84,7 +94,13 @@ export declare function discoverNativeSessions(runner: RunnerName, workspacePath
84
94
  */
85
95
  export declare function discoverAllNativeSessions(runner: RunnerName): Promise<readonly NativeSessionHistory[]>;
86
96
  /** Reads one already-discovered external session again to follow appended JSONL records. */
87
- export declare function readNativeSession(runner: RunnerName, workspacePath: string, externalSessionId: string): Promise<NativeSessionHistory | undefined>;
97
+ export declare function readNativeSession(runner: RunnerName, workspacePath: string, externalSessionId: string, page?: {
98
+ readonly snapshotEnd: number;
99
+ readonly nextEnd: number;
100
+ readonly fileAnchor: string;
101
+ readonly anchorBytes: number;
102
+ readonly readLimit?: number;
103
+ }, limit?: number): Promise<NativeSessionHistory | undefined>;
88
104
  /**
89
105
  * Codex records the most recent prompt token count alongside the model context
90
106
  * window in `event_msg/token_count`. Read only the tail of an already
@@ -2,11 +2,15 @@ import { createHash } from 'node:crypto';
2
2
  import { readFile, readdir, stat, unlink } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { basename, join, resolve } from 'node:path';
5
- import { forEachJsonlRecord, listJsonlFiles, mapConcurrent, readJsonlWindow } from './native-jsonl-reader.js';
5
+ import { forEachJsonlRecord, listJsonlFiles, mapConcurrent, readJsonlBackwardWindow, readJsonlFileAnchor, readJsonlWindow } from './native-jsonl-reader.js';
6
6
  import { nodeLog } from './operational.js';
7
7
  import { stripClaudePlanTag } from './runner-command-engine.js';
8
8
  const DISCOVERY_READ_BYTES = 32 * 1024;
9
- const TRANSCRIPT_READ_BYTES = 4 * 1024 * 1024;
9
+ const TRANSCRIPT_HEADER_READ_BYTES = 32 * 1024;
10
+ const TRANSCRIPT_READ_MAX_BYTES = 16 * 1024 * 1024;
11
+ const TRANSCRIPT_MIN_LINES = 256;
12
+ const TRANSCRIPT_MAX_LINES = 4096;
13
+ const TRANSCRIPT_LINES_PER_TURN = 32;
10
14
  const CONTEXT_USAGE_READ_BYTES = 256 * 1024;
11
15
  const CLAUDE_CONTEXT_USAGE_READ_BYTES = [CONTEXT_USAGE_READ_BYTES, 1024 * 1024, 4 * 1024 * 1024];
12
16
  const TRANSCRIPT_INDEX_TTL_MS = 60 * 60_000;
@@ -157,51 +161,68 @@ async function performNativeSessionDiscovery(runner, previous) {
157
161
  };
158
162
  }
159
163
  /** Reads one already-discovered external session again to follow appended JSONL records. */
160
- export async function readNativeSession(runner, workspacePath, externalSessionId) {
164
+ export async function readNativeSession(runner, workspacePath, externalSessionId, page, limit = 30) {
161
165
  const wanted = canonical(workspacePath);
162
166
  const path = getTranscriptIndex(indexKey(runner, wanted, externalSessionId));
163
167
  if (path === undefined)
164
168
  return undefined;
165
- const parsed = await readTranscriptDetail(path, runner);
169
+ const detail = await readTranscriptDetail(path, runner, page, page?.readLimit ?? limit);
170
+ const parsed = detail?.history;
166
171
  if (parsed === undefined ||
167
172
  parsed.externalSessionId !== externalSessionId ||
168
173
  canonical(parsed.cwd) !== wanted) {
169
174
  deleteTranscriptIndex(indexKey(runner, wanted, externalSessionId));
170
175
  return undefined;
171
176
  }
172
- return parsed;
177
+ return detail?.cursorAccepted === false ? { ...parsed, cursorReset: true } : parsed;
173
178
  }
174
- async function readTranscriptDetail(path, runner) {
175
- const key = `${runner}\u0000${path}`;
179
+ async function readTranscriptDetail(path, runner, page, limit) {
176
180
  let fingerprint;
177
181
  try {
178
182
  const metadata = await stat(path);
179
183
  fingerprint = `${metadata.size}:${metadata.mtimeMs}`;
180
184
  }
181
185
  catch {
182
- transcriptDetailCache.delete(key);
183
- transcriptDetailReads.delete(key);
184
186
  return undefined;
185
187
  }
188
+ const metadataSize = Number(fingerprint.slice(0, fingerprint.indexOf(':')));
189
+ const validPage = page !== undefined &&
190
+ page.snapshotEnd <= metadataSize &&
191
+ (await readJsonlFileAnchor(path, page.anchorBytes)) === page.fileAnchor
192
+ ? page
193
+ : undefined;
194
+ const targetLines = Math.min(TRANSCRIPT_MAX_LINES, Math.max(TRANSCRIPT_MIN_LINES, Math.max(1, limit) * TRANSCRIPT_LINES_PER_TURN));
195
+ const pageKey = `${validPage === undefined
196
+ ? 'latest'
197
+ : `${validPage.fileAnchor}:${validPage.snapshotEnd}:${validPage.nextEnd}`}:${targetLines}`;
198
+ const key = `${runner}\u0000${path}\u0000${pageKey}`;
199
+ const cacheFingerprint = validPage === undefined ? fingerprint : `${validPage.fileAnchor}:${validPage.snapshotEnd}`;
186
200
  const cached = transcriptDetailCache.get(key);
187
- if (cached?.fingerprint === fingerprint && cached.expiresAt > Date.now()) {
201
+ if (cached?.fingerprint === cacheFingerprint && cached.expiresAt > Date.now()) {
188
202
  cached.expiresAt = Date.now() + TRANSCRIPT_DETAIL_CACHE_TTL_MS;
189
203
  transcriptDetailCache.delete(key);
190
204
  transcriptDetailCache.set(key, cached);
191
- return cached.parsed;
205
+ return {
206
+ history: cached.parsed,
207
+ cursorAccepted: page === undefined || validPage !== undefined
208
+ };
192
209
  }
193
210
  transcriptDetailCache.delete(key);
194
211
  const current = transcriptDetailReads.get(key);
195
- if (current?.fingerprint === fingerprint) {
212
+ if (current?.fingerprint === cacheFingerprint) {
196
213
  nodeLog('native.session.history.single-flight-joined', { runner });
197
- return current.read;
214
+ return {
215
+ history: await current.read,
216
+ cursorAccepted: page === undefined || validPage !== undefined
217
+ };
198
218
  }
199
219
  const startedAt = performance.now();
200
- const read = readTranscript(path, runner, TRANSCRIPT_READ_BYTES)
220
+ const read = readTranscript(path, runner, TRANSCRIPT_READ_MAX_BYTES, validPage, targetLines)
221
+ .then((parsed) => parsed === undefined ? undefined : { ...parsed, windowReadLimit: page?.readLimit ?? limit })
201
222
  .then((parsed) => {
202
223
  if (transcriptDetailReads.get(key)?.read === read) {
203
224
  transcriptDetailCache.set(key, {
204
- fingerprint,
225
+ fingerprint: cacheFingerprint,
205
226
  parsed,
206
227
  expiresAt: Date.now() + TRANSCRIPT_DETAIL_CACHE_TTL_MS
207
228
  });
@@ -220,8 +241,8 @@ async function readTranscriptDetail(path, runner) {
220
241
  if (transcriptDetailReads.get(key)?.read === read)
221
242
  transcriptDetailReads.delete(key);
222
243
  });
223
- transcriptDetailReads.set(key, { fingerprint, read });
224
- return read;
244
+ transcriptDetailReads.set(key, { fingerprint: cacheFingerprint, read });
245
+ return { history: await read, cursorAccepted: page === undefined || validPage !== undefined };
225
246
  }
226
247
  function pruneTranscriptDetailCache() {
227
248
  const now = Date.now();
@@ -354,7 +375,7 @@ export async function removeNativeSession(runner, workspacePath, externalSession
354
375
  }
355
376
  if (path === undefined)
356
377
  throw new Error('NATIVE_TRANSCRIPT_UNAVAILABLE');
357
- const parsed = await readTranscript(path, runner, TRANSCRIPT_READ_BYTES);
378
+ const parsed = await readTranscript(path, runner, DISCOVERY_READ_BYTES);
358
379
  if (parsed === undefined ||
359
380
  parsed.externalSessionId !== externalSessionId ||
360
381
  canonical(parsed.cwd) !== wanted) {
@@ -370,8 +391,13 @@ export async function removeNativeSession(runner, workspacePath, externalSession
370
391
  throw error;
371
392
  }
372
393
  deleteTranscriptIndex(indexKey(runner, wanted, externalSessionId));
373
- transcriptDetailCache.delete(`${runner}\u0000${path}`);
374
- transcriptDetailReads.delete(`${runner}\u0000${path}`);
394
+ const detailKeyPrefix = `${runner}\u0000${path}\u0000`;
395
+ for (const key of transcriptDetailCache.keys())
396
+ if (key.startsWith(detailKeyPrefix))
397
+ transcriptDetailCache.delete(key);
398
+ for (const key of transcriptDetailReads.keys())
399
+ if (key.startsWith(detailKeyPrefix))
400
+ transcriptDetailReads.delete(key);
375
401
  invalidateNativeDiscovery(runner);
376
402
  }
377
403
  function transcriptRoot(runner) {
@@ -388,8 +414,13 @@ function invalidateNativeDiscovery(runner) {
388
414
  discoverySnapshots.delete(key);
389
415
  discoveryReads.delete(key);
390
416
  }
391
- async function readTranscript(path, runner, byteLimit) {
392
- const window = await readJsonlWindow(path, { headBytes: byteLimit, tailBytes: byteLimit });
417
+ async function readTranscript(path, runner, byteLimit, page, targetLines = Number.MAX_SAFE_INTEGER) {
418
+ const window = await readJsonlBackwardWindow(path, {
419
+ headBytes: Math.min(byteLimit, TRANSCRIPT_HEADER_READ_BYTES),
420
+ targetLines,
421
+ maxBytes: byteLimit,
422
+ ...(page === undefined ? {} : { page })
423
+ });
393
424
  if (window === undefined)
394
425
  return undefined;
395
426
  const { content, truncated } = window;
@@ -405,6 +436,8 @@ async function readTranscript(path, runner, byteLimit) {
405
436
  derivedSession = true;
406
437
  cwd ??= workspacePath(value);
407
438
  id ??= sessionId(value);
439
+ });
440
+ forEachJsonlRecord(truncated ? window.pageContent : content, (value) => {
408
441
  const recordId = typeof value['uuid'] === 'string' ? value['uuid'] : undefined;
409
442
  const parentId = typeof value['parentUuid'] === 'string' ? value['parentUuid'] : undefined;
410
443
  if (value['isMeta'] === true && recordId !== undefined)
@@ -470,7 +503,12 @@ async function readTranscript(path, runner, byteLimit) {
470
503
  digest: createHash('sha256').update(content).digest('hex'),
471
504
  items,
472
505
  lastActivityAt: Math.max(0, ...items.map((item) => item.createdAt ?? 0)),
473
- truncated
506
+ truncated,
507
+ windowStart: window.start,
508
+ windowEnd: window.end,
509
+ snapshotEnd: window.snapshotEnd,
510
+ fileAnchor: window.fileAnchor,
511
+ anchorBytes: window.anchorBytes
474
512
  };
475
513
  }
476
514
  function derivedTranscriptRecord(runner, value) {
@@ -252,8 +252,9 @@ export class CodexRunner extends AbstractRunner {
252
252
  }
253
253
  async readConversationHistory(readers) {
254
254
  // Codex JSONL is the low-latency durable record for normal history reads,
255
- // while thread/read can take seconds on large threads. Keep the official
256
- // API as a fallback because a transcript can be missing or bounded/truncated.
255
+ // while thread/read can take seconds on large threads. A bounded tail is a
256
+ // valid recent-history page; use the official API only when JSONL is absent
257
+ // or cannot produce any visible history.
257
258
  const native = await readers.native();
258
259
  if (native !== undefined)
259
260
  return { ...native, source: 'native' };
@@ -272,7 +273,7 @@ export class CodexRunner extends AbstractRunner {
272
273
  }
273
274
  }
274
275
  acceptsTruncatedNativeHistory() {
275
- return false;
276
+ return true;
276
277
  }
277
278
  async readManagedRunnerTitle(session, nativeSessionId) {
278
279
  void session;
@@ -31,7 +31,7 @@ export declare class ConversationHistoryService {
31
31
  private prefetchActiveHistory;
32
32
  private takeActiveHistoryRead;
33
33
  refreshExternalActivity(session: NodeAgentSession): Promise<void>;
34
- readNative(session: NodeAgentSession): Promise<import("../native-session-history.js").NativeSessionHistory | undefined>;
34
+ readNative(session: NodeAgentSession, cursor?: string, limit?: number): Promise<import("../native-session-history.js").NativeSessionHistory | undefined>;
35
35
  private attachNativeImages;
36
36
  readOfficial(session: NodeAgentSession, input: {
37
37
  readonly cursor?: string;
@@ -158,7 +158,7 @@ export class ConversationHistoryService {
158
158
  const startedAt = performance.now();
159
159
  let history;
160
160
  try {
161
- history = await this.readNative(session);
161
+ history = await this.readNative(session, input.cursor, input.limit);
162
162
  }
163
163
  catch {
164
164
  return undefined;
@@ -174,7 +174,8 @@ export class ConversationHistoryService {
174
174
  }
175
175
  if (history === undefined)
176
176
  return undefined;
177
- if (history.truncated === true && !runner.acceptsTruncatedNativeHistory()) {
177
+ if (history.truncated === true &&
178
+ (!runner.acceptsTruncatedNativeHistory() || history.items.length === 0)) {
178
179
  incompleteNative = true;
179
180
  return undefined;
180
181
  }
@@ -182,7 +183,11 @@ export class ConversationHistoryService {
182
183
  ? []
183
184
  : this.options.runtime.listConversationTurns({ sessionId: session.id, limit: 500 })
184
185
  .turns;
185
- const native = nativeConversationPage(session, history, input, runtimeTurns, (images) => this.options.attachments.registerNative(session, images));
186
+ const native = nativeConversationPage(session, history, history.cursorReset === true
187
+ ? input.limit === undefined
188
+ ? {}
189
+ : { limit: input.limit }
190
+ : input, runtimeTurns, (images) => this.options.attachments.registerNative(session, images));
186
191
  return native;
187
192
  },
188
193
  official: () => this.readOfficial(session, input)
@@ -286,14 +291,15 @@ export class ConversationHistoryService {
286
291
  if (activity !== undefined)
287
292
  this.options.setExternalActivity(session.id, activity);
288
293
  }
289
- async readNative(session) {
294
+ async readNative(session, cursor, limit = 30) {
290
295
  if (session.externalSessionId === null)
291
296
  return undefined;
292
- const current = await readNativeSession(session.runner, session.cwd, session.externalSessionId);
297
+ const page = nativeHistoryPageCursor(cursor, session.id);
298
+ const current = await readNativeSession(session.runner, session.cwd, session.externalSessionId, page, limit);
293
299
  if (current !== undefined)
294
300
  return current;
295
301
  await discoverNativeSessions(session.runner, session.cwd);
296
- return readNativeSession(session.runner, session.cwd, session.externalSessionId);
302
+ return readNativeSession(session.runner, session.cwd, session.externalSessionId, page, limit);
297
303
  }
298
304
  async attachNativeImages(session, turns) {
299
305
  const startedAt = performance.now();
@@ -364,6 +370,38 @@ export class ConversationHistoryService {
364
370
  };
365
371
  }
366
372
  }
373
+ function nativeHistoryPageCursor(cursor, sessionId) {
374
+ if (cursor === undefined)
375
+ return undefined;
376
+ try {
377
+ const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
378
+ return value.sessionId === sessionId &&
379
+ Number.isInteger(value.snapshotEnd) &&
380
+ Number.isInteger(value.nextEnd) &&
381
+ Number.isInteger(value.anchorBytes) &&
382
+ value.snapshotEnd >= 0 &&
383
+ value.nextEnd >= 0 &&
384
+ value.nextEnd <= value.snapshotEnd &&
385
+ value.anchorBytes >= 0 &&
386
+ value.anchorBytes <= value.snapshotEnd &&
387
+ (value.readLimit === undefined ||
388
+ (Number.isInteger(value.readLimit) &&
389
+ value.readLimit >= 1 &&
390
+ value.readLimit <= 500)) &&
391
+ typeof value.fileAnchor === 'string'
392
+ ? {
393
+ snapshotEnd: value.snapshotEnd,
394
+ nextEnd: value.nextEnd,
395
+ fileAnchor: value.fileAnchor,
396
+ anchorBytes: value.anchorBytes,
397
+ ...(value.readLimit === undefined ? {} : { readLimit: value.readLimit })
398
+ }
399
+ : undefined;
400
+ }
401
+ catch {
402
+ return undefined;
403
+ }
404
+ }
367
405
  const ACTIVE_HISTORY_CURSOR_PREFIX = 'mar-active-history:1:';
368
406
  function encodeActiveHistoryCursor(cursor) {
369
407
  return `${ACTIVE_HISTORY_CURSOR_PREFIX}${Buffer.from(JSON.stringify(cursor)).toString('base64url')}`;
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { fileURLToPath } from 'node:url';
2
3
  import { stripClaudePlanTag } from '../runner-command-engine.js';
3
4
  import { compactRunnerText, isClaudeCommandTool, isPlainRecord, nativeClaudeCommand, nativePageEnd } from './node-operation-parsers.js';
@@ -152,26 +153,27 @@ export function deduplicateDirectSessions(sessions) {
152
153
  }
153
154
  export function nativeConversationPage(session, history, input, runtimeTurns = [], registerImages = () => []) {
154
155
  const limit = Math.min(Math.max(input.limit ?? 30, 1), 500);
156
+ const effectiveCursor = history.cursorReset === true ? undefined : input.cursor;
155
157
  const nativeTurns = nativeTranscriptTurns(history.items);
158
+ const nativeTurnIds = nativeTranscriptTurnIds(session.id, nativeTurns);
156
159
  const representedClientMessageIds = new Set(nativeTurns.flatMap((entries) => entries.flatMap(({ entry, index }) => {
157
160
  if (entry.kind !== 'message' || entry.role !== 'USER')
158
161
  return [];
159
162
  const clientMessageId = nativeUserClientMessageId(entry, entry.createdAt ?? index, runtimeTurns);
160
163
  return clientMessageId === null ? [] : [clientMessageId];
161
164
  })));
162
- const missingRuntimeErrorTurns = input.cursor === undefined
165
+ const missingRuntimeErrorTurns = effectiveCursor === undefined
163
166
  ? runtimeTurns.filter((turn) => {
164
167
  const clientMessageId = runtimeTurnClientMessageId(turn);
165
168
  return (turn.items.some((item) => item.kind === 'error') &&
166
169
  (clientMessageId === undefined || !representedClientMessageIds.has(clientMessageId)));
167
170
  })
168
171
  : [];
169
- const end = nativePageEnd(input.cursor, session.id, nativeTurns.length);
172
+ const end = nativePageEnd(effectiveCursor, session.id, nativeTurns.length);
170
173
  const start = Math.max(0, end - limit);
171
174
  const nativePageTurns = nativeTurns
172
175
  .slice(start, end)
173
176
  .map((entries, relativeIndex) => {
174
- const firstIndex = entries[0]?.index ?? 0;
175
177
  const nativeUser = entries.find(({ entry }) => entry.kind === 'message' && entry.role === 'USER')?.entry;
176
178
  const matchedClientMessageId = nativeUser?.kind === 'message'
177
179
  ? nativeUserClientMessageId(nativeUser, nativeUser.createdAt, runtimeTurns)
@@ -179,9 +181,10 @@ export function nativeConversationPage(session, history, input, runtimeTurns = [
179
181
  const runtimeTurn = typeof matchedClientMessageId === 'string'
180
182
  ? runtimeTurns.find((turn) => runtimeTurnClientMessageId(turn) === matchedClientMessageId)
181
183
  : undefined;
182
- const turnId = runtimeTurn?.id ?? `${session.id}:native:${firstIndex}`;
183
- const nativeItems = entries.map(({ entry, index }) => ({
184
- ...nativeTranscriptConversationItem(session, entry, index, turnId, runtimeTurns, registerImages),
184
+ const turnId = runtimeTurn?.id ?? nativeTurnIds[start + relativeIndex];
185
+ const itemIdentities = nativeTranscriptItemIdentities(entries);
186
+ const nativeItems = entries.map(({ entry, index }, entryIndex) => ({
187
+ ...nativeTranscriptConversationItem(session, entry, index, itemIdentities[entryIndex], turnId, runtimeTurns, registerImages),
185
188
  runId: runtimeTurn?.runId ?? null
186
189
  }));
187
190
  const runtimeErrors = (runtimeTurn?.items ?? [])
@@ -221,11 +224,33 @@ export function nativeConversationPage(session, history, input, runtimeTurns = [
221
224
  const turns = combined.slice(-limit);
222
225
  const nativePageIds = new Set(nativePageTurns.map((turn) => turn.id));
223
226
  const nextEnd = start + dropped.filter((turn) => nativePageIds.has(turn.id)).length;
227
+ const cursorBase = {
228
+ sessionId: session.id,
229
+ ...(history.snapshotEnd === undefined ? {} : { snapshotEnd: history.snapshotEnd }),
230
+ ...(history.windowEnd === undefined ? {} : { nextEnd: history.windowEnd }),
231
+ ...(history.fileAnchor === undefined ? {} : { fileAnchor: history.fileAnchor }),
232
+ ...(history.anchorBytes === undefined ? {} : { anchorBytes: history.anchorBytes }),
233
+ readLimit: history.windowReadLimit ?? limit
234
+ };
235
+ const precedingWindow = history.truncated === true &&
236
+ history.windowStart !== undefined &&
237
+ history.snapshotEnd !== undefined &&
238
+ history.fileAnchor !== undefined &&
239
+ history.anchorBytes !== undefined
240
+ ? Buffer.from(JSON.stringify({
241
+ sessionId: session.id,
242
+ snapshotEnd: history.snapshotEnd,
243
+ nextEnd: history.windowStart,
244
+ fileAnchor: history.fileAnchor,
245
+ anchorBytes: history.anchorBytes,
246
+ readLimit: history.windowReadLimit ?? limit
247
+ })).toString('base64url')
248
+ : null;
224
249
  return {
225
250
  turns,
226
251
  nextCursor: nextEnd > 0
227
- ? Buffer.from(JSON.stringify({ sessionId: session.id, end: nextEnd })).toString('base64url')
228
- : null
252
+ ? Buffer.from(JSON.stringify({ ...cursorBase, end: nextEnd })).toString('base64url')
253
+ : precedingWindow
229
254
  };
230
255
  }
231
256
  function runtimeTurnClientMessageId(turn) {
@@ -237,6 +262,38 @@ function runtimeTurnClientMessageId(turn) {
237
262
  function lastNativeItemId(entries) {
238
263
  return entries.findLast(({ entry }) => entry.nativeId !== undefined)?.entry.nativeId;
239
264
  }
265
+ function nativeTranscriptTurnIds(sessionId, turns) {
266
+ const occurrences = new Map();
267
+ return turns.map((entries) => {
268
+ const user = entries.find(({ entry }) => entry.kind === 'message' && entry.role === 'USER')?.entry;
269
+ const first = user ?? entries[0]?.entry;
270
+ const base = first?.nativeId ?? nativeTranscriptItemFingerprint(first);
271
+ const occurrence = occurrences.get(base) ?? 0;
272
+ occurrences.set(base, occurrence + 1);
273
+ return `${sessionId}:native:${base}${occurrence === 0 ? '' : `:${occurrence}`}`;
274
+ });
275
+ }
276
+ function nativeTranscriptItemIdentities(entries) {
277
+ const nativeIdCounts = new Map();
278
+ for (const { entry } of entries)
279
+ if (entry.nativeId !== undefined)
280
+ nativeIdCounts.set(entry.nativeId, (nativeIdCounts.get(entry.nativeId) ?? 0) + 1);
281
+ const occurrences = new Map();
282
+ return entries.map(({ entry }) => {
283
+ const base = entry.nativeId !== undefined && nativeIdCounts.get(entry.nativeId) === 1
284
+ ? entry.nativeId
285
+ : `${entry.nativeId === undefined ? '' : `${entry.nativeId}:`}${nativeTranscriptItemFingerprint(entry)}`;
286
+ const occurrence = occurrences.get(base) ?? 0;
287
+ occurrences.set(base, occurrence + 1);
288
+ return occurrence === 0 ? base : `${base}:${occurrence}`;
289
+ });
290
+ }
291
+ function nativeTranscriptItemFingerprint(entry) {
292
+ return createHash('sha256')
293
+ .update(JSON.stringify(entry ?? null))
294
+ .digest('base64url')
295
+ .slice(0, 24);
296
+ }
240
297
  function nativeTranscriptTurns(items) {
241
298
  const turns = [];
242
299
  let current = [];
@@ -251,7 +308,7 @@ function nativeTranscriptTurns(items) {
251
308
  turns.push(current);
252
309
  return turns;
253
310
  }
254
- function nativeTranscriptConversationItem(session, entry, index, turnId, runtimeTurns, registerImages) {
311
+ function nativeTranscriptConversationItem(session, entry, index, itemIdentity, turnId, runtimeTurns, registerImages) {
255
312
  const time = entry.createdAt;
256
313
  const isToolCall = entry.kind === 'tool_call';
257
314
  const isUnknown = entry.kind === 'unknown';
@@ -276,7 +333,7 @@ function nativeTranscriptConversationItem(session, entry, index, turnId, runtime
276
333
  : '';
277
334
  const planText = entry.kind === 'message' && entry.role !== 'USER' ? claudePlanText(entryText) : undefined;
278
335
  const item = {
279
- id: `${turnId}:item:${index}`,
336
+ id: `${turnId}:item:${itemIdentity}`,
280
337
  sessionId: session.id,
281
338
  turnId,
282
339
  runId: null,
@@ -301,7 +358,7 @@ function nativeTranscriptConversationItem(session, entry, index, turnId, runtime
301
358
  status: 'COMPLETED',
302
359
  payload: isUserInputRequest
303
360
  ? {
304
- requestId: `native:${entry.kind === 'tool_call' ? entry.toolUseId : `item-${index}`}`,
361
+ requestId: `native:${entry.kind === 'tool_call' ? entry.toolUseId : `item-${itemIdentity}`}`,
305
362
  questions: userInputQuestions
306
363
  }
307
364
  : isReasoning
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "type": "module",
6
6
  "files": [
@@ -24,7 +24,7 @@
24
24
  "node-pty": "1.1.0",
25
25
  "ws": "^8.21.3",
26
26
  "zod": "4.4.3",
27
- "@myagentroam/protocol": "^0.9.5"
27
+ "@myagentroam/protocol": "^0.9.6"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/ws": "^8.18.1"