@tangle-network/harness-sessions 0.1.4 → 0.1.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.
@@ -12,7 +12,7 @@ import { basename, join } from 'node:path';
12
12
  import { SessionBuilder, count, isRecord, sessionError, str, textOf, usageOrNull } from '../builder.js';
13
13
  import { fileRecords } from '../source.js';
14
14
  import { runFold } from './fold.js';
15
- import { EVIDENCE, STORES, mtimeMs, storeGlob, walkFiles, withinPath } from '../stores.js';
15
+ import { EVIDENCE, LOCATE_READS, STORES, mapLimit, mtimeMs, storeGlob, walkFiles, withinPath } from '../stores.js';
16
16
  import { headRecord } from './head.js';
17
17
  import { claudeRecordIsMachine, claudeTypedPromptText } from './claude-records.js';
18
18
  const FORMAT = 'claude-code.projects-jsonl';
@@ -254,27 +254,25 @@ async function locate(home, opts) {
254
254
  const key = `${project}/${id}`;
255
255
  subagentsBySession.set(key, [...(subagentsBySession.get(key) ?? []), join(root, rel)]);
256
256
  }
257
- const refs = [];
258
- for (const rel of all) {
259
- if (!main.test(rel))
260
- continue;
257
+ const sessions = await mapLimit(all.filter((rel) => main.test(rel)), LOCATE_READS, async (rel) => {
261
258
  const path = join(root, rel);
262
259
  const id = basename(rel, '.jsonl');
263
260
  const mtime = await mtimeMs(path);
264
261
  if (opts.sinceMs && mtime < opts.sinceMs)
265
- continue;
262
+ return [];
266
263
  const head = await headRecord(path, (r) => typeof r.cwd === 'string');
267
264
  const cwd = head ? str(head.cwd) : null;
268
265
  if (opts.cwd && (cwd === null || !withinPath(cwd, opts.cwd)))
269
- continue;
266
+ return [];
270
267
  const subagents = subagentsBySession.get(`${rel.split('/')[0]}/${id}`) ?? [];
271
- refs.push({ harness: HARNESS, format: FORMAT, nativeSessionId: id, path, files: [path, ...subagents], home, cwd, mtimeMs: mtime, parentNativeSessionId: null });
268
+ const refs = [{ harness: HARNESS, format: FORMAT, nativeSessionId: id, path, files: [path, ...subagents], home, cwd, mtimeMs: mtime, parentNativeSessionId: null }];
272
269
  for (const file of subagents) {
273
270
  const agentId = basename(file, '.jsonl').replace(/^agent-/u, '');
274
271
  refs.push({ harness: HARNESS, format: FORMAT, nativeSessionId: agentId, path: file, files: [file], home, cwd, mtimeMs: await mtimeMs(file), parentNativeSessionId: id });
275
272
  }
276
- }
277
- return refs.sort((a, b) => b.mtimeMs - a.mtimeMs);
273
+ return refs;
274
+ });
275
+ return sessions.flat().sort((a, b) => b.mtimeMs - a.mtimeMs);
278
276
  }
279
277
  export const claudeCodeReader = {
280
278
  harness: HARNESS,
@@ -13,7 +13,7 @@ import { basename } from 'node:path';
13
13
  import { SessionBuilder, argumentsOf, count, isRecord, sessionError, str, textOf, usageOrNull, } from '../builder.js';
14
14
  import { fileRecords, isMissing } from '../source.js';
15
15
  import { runFold } from './fold.js';
16
- import { CODEX_HOME_RULE, EVIDENCE, STORES, codexHomes, storeGlob, walkFiles, withinPath } from '../stores.js';
16
+ import { CODEX_HOME_RULE, EVIDENCE, LOCATE_READS, STORES, codexHomes, mapLimit, storeGlob, walkFiles, withinPath } from '../stores.js';
17
17
  import { headRecord } from './head.js';
18
18
  import { stat } from 'node:fs/promises';
19
19
  import { join } from 'node:path';
@@ -339,27 +339,26 @@ function uuidV7Ms(id) {
339
339
  }
340
340
  /** A ref for each of `files` that passes the filters, from its `session_meta`, with the file's bytes. */
341
341
  async function refsOf(files, home, opts) {
342
- const found = [];
343
- for (const path of files) {
342
+ const found = await mapLimit(files, LOCATE_READS, async (path) => {
344
343
  let info;
345
344
  try {
346
345
  info = await stat(path);
347
346
  }
348
347
  catch (error) {
349
348
  if (isMissing(error))
350
- continue;
349
+ return null;
351
350
  throw error;
352
351
  }
353
352
  if (opts.sinceMs && info.mtimeMs < opts.sinceMs)
354
- continue;
353
+ return null;
355
354
  const meta = await headRecord(path, (r) => r.type === 'session_meta', 4);
356
355
  const payload = meta && isRecord(meta.payload) ? meta.payload : {};
357
356
  const cwd = str(payload.cwd);
358
357
  if (opts.cwd && (cwd === null || !withinPath(cwd, opts.cwd)))
359
- continue;
358
+ return null;
360
359
  const source = isRecord(payload.source) ? payload.source : {};
361
360
  const spawn = isRecord(source.subagent) && isRecord(source.subagent.thread_spawn) ? source.subagent.thread_spawn : {};
362
- found.push({
361
+ return {
363
362
  bytes: info.size,
364
363
  ref: {
365
364
  harness: HARNESS,
@@ -372,9 +371,9 @@ async function refsOf(files, home, opts) {
372
371
  mtimeMs: info.mtimeMs,
373
372
  parentNativeSessionId: str(payload.parent_thread_id) ?? str(spawn.parent_thread_id),
374
373
  },
375
- });
376
- }
377
- return found;
374
+ };
375
+ });
376
+ return found.filter((entry) => entry !== null);
378
377
  }
379
378
  /**
380
379
  * Sessions in every Codex home under `home`. A lookup by id takes the rollout named for it; when no
@@ -1,5 +1,8 @@
1
1
  /**
2
- * The first record among a session file's first `limit` records that satisfies `match`, read
3
- * without consuming the rest of the file. Used to learn a session's id and cwd while locating.
2
+ * The first record among a session file's first `limit` records that satisfies `match`. Locate
3
+ * reads one for every session in a store, so it reads the file a small step at a time and stops
4
+ * at the answer: never the rest of the file, and no digest of it. Lines parse as the session
5
+ * reader parses them: a blank, malformed or non-object line is skipped, and a last line with no
6
+ * newline counts when it is a whole record.
4
7
  */
5
8
  export declare function headRecord(path: string, match: (record: Record<string, unknown>) => boolean, limit?: number): Promise<Record<string, unknown> | null>;
@@ -1,23 +1,69 @@
1
- import { newSourceStats, readJsonlRecords } from '../source.js';
1
+ import { open } from 'node:fs/promises';
2
+ import { StringDecoder } from 'node:string_decoder';
3
+ import { isMissing } from '../source.js';
4
+ /** Bytes a head read takes at a time. A session's identifying record is almost always in the first. */
5
+ const HEAD_CHUNK = 64 * 1024;
2
6
  /**
3
- * The first record among a session file's first `limit` records that satisfies `match`, read
4
- * without consuming the rest of the file. Used to learn a session's id and cwd while locating.
7
+ * The first record among a session file's first `limit` records that satisfies `match`. Locate
8
+ * reads one for every session in a store, so it reads the file a small step at a time and stops
9
+ * at the answer: never the rest of the file, and no digest of it. Lines parse as the session
10
+ * reader parses them: a blank, malformed or non-object line is skipped, and a last line with no
11
+ * newline counts when it is a whole record.
5
12
  */
6
13
  export async function headRecord(path, match, limit = 64) {
7
- let seen = 0;
14
+ let handle;
8
15
  try {
9
- for await (const record of readJsonlRecords(path, newSourceStats(path))) {
10
- if (match(record))
11
- return record;
12
- seen += 1;
13
- if (seen >= limit)
14
- return null;
15
- }
16
+ handle = await open(path, 'r');
16
17
  }
17
18
  catch (error) {
18
- if (error?.code === 'ENOENT')
19
+ if (isMissing(error))
19
20
  return null;
20
21
  throw error;
21
22
  }
22
- return null;
23
+ let seen = 0;
24
+ // undefined: read on; null: `limit` records without a match; a record: the match.
25
+ const take = (line) => {
26
+ const text = line.replace(/\r$/u, '');
27
+ if (text.trim().length === 0)
28
+ return undefined;
29
+ let value;
30
+ try {
31
+ value = JSON.parse(text);
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
37
+ return undefined;
38
+ if (match(value))
39
+ return value;
40
+ seen += 1;
41
+ return seen >= limit ? null : undefined;
42
+ };
43
+ try {
44
+ const decoder = new StringDecoder('utf8');
45
+ const buffer = Buffer.allocUnsafe(HEAD_CHUNK);
46
+ // The line being read, in pieces, so a record longer than a step is joined once.
47
+ let pending = [];
48
+ let position = 0;
49
+ for (;;) {
50
+ const { bytesRead } = await handle.read(buffer, 0, HEAD_CHUNK, position);
51
+ if (bytesRead === 0)
52
+ return take(pending.join('') + decoder.end()) ?? null;
53
+ position += bytesRead;
54
+ const text = decoder.write(buffer.subarray(0, bytesRead));
55
+ let start = 0;
56
+ for (let end = text.indexOf('\n'); end !== -1; end = text.indexOf('\n', start)) {
57
+ const found = take(pending.join('') + text.slice(start, end));
58
+ pending = [];
59
+ if (found !== undefined)
60
+ return found;
61
+ start = end + 1;
62
+ }
63
+ pending.push(text.slice(start));
64
+ }
65
+ }
66
+ finally {
67
+ await handle.close();
68
+ }
23
69
  }
package/dist/input.d.ts CHANGED
@@ -11,6 +11,12 @@ export interface SessionInputInit {
11
11
  parentNativeSessionId?: string | null;
12
12
  /** Names the source in integrity records (a path, a blob ref). */
13
13
  label?: string;
14
+ /**
15
+ * The file line `input` starts on, when it continues a longer file (records appended since a
16
+ * previous read). A record without an id of its own is named by its line, so the same record
17
+ * gets the same id whichever way the file is read. Default 1.
18
+ */
19
+ firstLine?: number;
14
20
  }
15
21
  /** Read a JSONL-format session (Claude Code, Codex, Pi, Kimi) from text or parsed records, synchronously. */
16
22
  export declare function readSessionInput(harness: string, input: SessionInput, init?: SessionInputInit, options?: Pick<ReadOptions, 'corruption'>): HarnessSession;
package/dist/input.js CHANGED
@@ -31,7 +31,10 @@ function foldInput(harness, input, init, mode, options) {
31
31
  parentNativeSessionId: init.parentNativeSessionId ?? null,
32
32
  };
33
33
  const fold = create(ref, mode, stats);
34
- const records = 'text' in input ? textRecordsSync(input.text, stats, { strict: options.corruption === 'strict' }) : arrayRecordsSync(input.records, stats);
34
+ const firstLine = init.firstLine ?? 1;
35
+ const records = 'text' in input
36
+ ? textRecordsSync(input.text, stats, { strict: options.corruption === 'strict', firstLine })
37
+ : arrayRecordsSync(input.records, stats, firstLine);
35
38
  for (const record of records)
36
39
  fold.observe(record);
37
40
  return fold.finish();
package/dist/source.d.ts CHANGED
@@ -40,12 +40,16 @@ export declare function fileRecords(path: string, options?: {
40
40
  }): RecordSource;
41
41
  /** Stats for bytes already in memory. */
42
42
  export declare function memoryStats(label: string, text?: string): SourceStats;
43
- /** The records of JSONL text already in memory (a retained copy, a blob), read synchronously. */
43
+ /**
44
+ * The records of JSONL text already in memory (a retained copy, a blob), read synchronously.
45
+ * `firstLine` numbers the text's first line, for text that continues a longer file.
46
+ */
44
47
  export declare function textRecordsSync(text: string, stats: SourceStats, options?: {
45
48
  strict?: boolean;
49
+ firstLine?: number;
46
50
  }): Generator<Record<string, unknown>>;
47
- /** Records a caller already parsed (one per line, in order). Non-objects count as unparsed. */
48
- export declare function arrayRecordsSync(values: readonly unknown[], stats: SourceStats): Generator<Record<string, unknown>>;
51
+ /** Records a caller already parsed (one per line, in order, the first on line `firstLine`). Non-objects count as unparsed. */
52
+ export declare function arrayRecordsSync(values: readonly unknown[], stats: SourceStats, firstLine?: number): Generator<Record<string, unknown>>;
49
53
  /**
50
54
  * Run `use` against a SQLite store opened read-only in place ({@link openSqlite}). A store SQLite
51
55
  * cannot open in place is read from a private copy instead: a write-ahead log with no `-shm` in a
package/dist/source.js CHANGED
@@ -116,10 +116,15 @@ export function memoryStats(label, text) {
116
116
  const bytes = Buffer.from(text, 'utf8');
117
117
  return { path: label, sha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, bytes: bytes.length, unparsed: 0, tornTail: false, line: 0 };
118
118
  }
119
- /** The records of JSONL text already in memory (a retained copy, a blob), read synchronously. */
119
+ /**
120
+ * The records of JSONL text already in memory (a retained copy, a blob), read synchronously.
121
+ * `firstLine` numbers the text's first line, for text that continues a longer file.
122
+ */
120
123
  export function* textRecordsSync(text, stats, options = {}) {
121
124
  const lines = text.split('\n');
122
- for (const [index, raw] of lines.entries()) {
125
+ const first = options.firstLine ?? 1;
126
+ for (const [position, raw] of lines.entries()) {
127
+ const index = first - 1 + position;
123
128
  const line = raw.replace(/\r$/u, '');
124
129
  if (line.trim().length === 0)
125
130
  continue;
@@ -128,7 +133,7 @@ export function* textRecordsSync(text, stats, options = {}) {
128
133
  value = JSON.parse(line);
129
134
  }
130
135
  catch {
131
- if (index === lines.length - 1) {
136
+ if (position === lines.length - 1) {
132
137
  stats.tornTail = true;
133
138
  continue;
134
139
  }
@@ -147,9 +152,10 @@ export function* textRecordsSync(text, stats, options = {}) {
147
152
  yield value;
148
153
  }
149
154
  }
150
- /** Records a caller already parsed (one per line, in order). Non-objects count as unparsed. */
151
- export function* arrayRecordsSync(values, stats) {
152
- for (const [index, value] of values.entries()) {
155
+ /** Records a caller already parsed (one per line, in order, the first on line `firstLine`). Non-objects count as unparsed. */
156
+ export function* arrayRecordsSync(values, stats, firstLine = 1) {
157
+ for (const [position, value] of values.entries()) {
158
+ const index = firstLine - 1 + position;
153
159
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
154
160
  noteUnparsed(stats, index + 1);
155
161
  continue;
package/dist/stores.d.ts CHANGED
@@ -2,6 +2,10 @@ import type { NativeSessionStore } from './schema.js';
2
2
  export { assertStoreRoot, CODEX_HOME_RULE, EVIDENCE, isCodexHome, SESSION_FORMAT_IDS, STORES, storeGlob, withinPath } from './catalog.js';
3
3
  /** Every file under `dir`, as paths relative to it with `/` separators. Missing dirs yield none. */
4
4
  export declare function walkFiles(dir: string, signal?: AbortSignal): Promise<string[]>;
5
+ /** Session files a locate reads at once: a store holds thousands, and each read waits on the disk. */
6
+ export declare const LOCATE_READS = 32;
7
+ /** `fn` over `items`, at most `limit` at a time, with the results in the order of `items`. */
8
+ export declare function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]>;
5
9
  /** Files under `home` that one store glob matches, as absolute paths. */
6
10
  export declare function storeFiles(home: string, store: NativeSessionStore, glob: string, nativeSessionId?: string): Promise<string[]>;
7
11
  export declare function mtimeMs(path: string): Promise<number>;
package/dist/stores.js CHANGED
@@ -29,6 +29,22 @@ export async function walkFiles(dir, signal) {
29
29
  await visit(dir);
30
30
  return out.sort();
31
31
  }
32
+ /** Session files a locate reads at once: a store holds thousands, and each read waits on the disk. */
33
+ export const LOCATE_READS = 32;
34
+ /** `fn` over `items`, at most `limit` at a time, with the results in the order of `items`. */
35
+ export async function mapLimit(items, limit, fn) {
36
+ const results = new Array(items.length);
37
+ let next = 0;
38
+ const worker = async () => {
39
+ while (next < items.length) {
40
+ const index = next;
41
+ next += 1;
42
+ results[index] = await fn(items[index]);
43
+ }
44
+ };
45
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
46
+ return results;
47
+ }
32
48
  /** Files under `home` that one store glob matches, as absolute paths. */
33
49
  export async function storeFiles(home, store, glob, nativeSessionId) {
34
50
  const root = join(home, store.root);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/harness-sessions",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "One reader for every coding-agent harness's native session (Claude Code, Codex, OpenCode, Pi): store layouts, a normalized session with messages, tool calls, served models, usage and ending errors, and projections to chat messages, OTLP spans and turns",
5
5
  "type": "module",
6
6
  "sideEffects": false,