@manyoars/oars 0.5.4 → 0.5.5

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.
Files changed (47) hide show
  1. package/README.md +4 -4
  2. package/dist/cli.js +18 -7
  3. package/dist/club.d.ts +2 -0
  4. package/dist/club.js +10 -4
  5. package/dist/sessions/archive.js +1 -1
  6. package/dist/sessions/card-command.d.ts +2 -2
  7. package/dist/sessions/card-command.js +7 -5
  8. package/dist/sessions/card-line.d.ts +6 -2
  9. package/dist/sessions/card-line.js +6 -3
  10. package/dist/sessions/cell-increments.d.ts +7 -0
  11. package/dist/sessions/cell-increments.js +25 -10
  12. package/dist/sessions/cells.d.ts +7 -0
  13. package/dist/sessions/cells.js +118 -107
  14. package/dist/sessions/crew-mark.d.ts +6 -0
  15. package/dist/sessions/crew-mark.js +22 -0
  16. package/dist/sessions/hook-index.d.ts +2 -1
  17. package/dist/sessions/hook-index.js +2 -2
  18. package/dist/sessions/pool-command.d.ts +22 -4
  19. package/dist/sessions/pool-command.js +516 -82
  20. package/dist/sessions/pool-delta.d.ts +5 -0
  21. package/dist/sessions/pool-delta.js +32 -12
  22. package/dist/sessions/pool-fold.d.ts +22 -0
  23. package/dist/sessions/pool-fold.js +65 -0
  24. package/dist/sessions/pool-outbox.d.ts +9 -0
  25. package/dist/sessions/pool-outbox.js +33 -12
  26. package/dist/sessions/pool.d.ts +10 -5
  27. package/dist/sessions/pool.js +16 -15
  28. package/dist/sessions/read-call-shape.js +3 -2
  29. package/dist/sessions/read-folds.d.ts +21 -0
  30. package/dist/sessions/read-folds.js +37 -0
  31. package/dist/sessions/read-recovery.d.ts +1 -1
  32. package/dist/sessions/read-recovery.js +6 -5
  33. package/dist/sessions/read-support.d.ts +4 -1
  34. package/dist/sessions/recall-consequence.d.ts +2 -1
  35. package/dist/sessions/recall-consequence.js +1 -2
  36. package/dist/sessions/recall-reads.d.ts +23 -1
  37. package/dist/sessions/recall-reads.js +91 -63
  38. package/dist/sessions/reversion.d.ts +41 -2
  39. package/dist/sessions/reversion.js +17 -10
  40. package/dist/sessions/store.d.ts +55 -2
  41. package/dist/sessions/store.js +312 -61
  42. package/dist/telemetry/measures.d.ts +5 -0
  43. package/dist/telemetry/measures.js +16 -2
  44. package/dist/telemetry/schema.js +3 -1
  45. package/dist/telemetry/words.d.ts +6 -0
  46. package/dist/telemetry/words.js +4 -0
  47. package/package.json +1 -1
@@ -85,14 +85,14 @@ export const readState = async (storeDir) => {
85
85
  folders[entry] = mode;
86
86
  const yesVersion = typeof pool.rawConsentVersion === 'string' && pool.rawConsentVersion !== '' ? pool.rawConsentVersion : null;
87
87
  const granted = pool.rawConsent === 'granted' && yesVersion !== null;
88
- return { files, redactDomains: domains, allow, pool: { rawConsent: granted ? 'granted' : pool.rawConsent === 'declined' ? 'declined' : null, rawConsentVersion: granted ? yesVersion : null, withdrawalPending: pool.withdrawalPending === true && !granted, folders } };
88
+ return { files, redactDomains: domains, allow, pool: { rawConsent: granted ? 'granted' : pool.rawConsent === 'declined' ? 'declined' : null, rawConsentVersion: granted ? yesVersion : null, withdrawalPending: pool.withdrawalPending === true && !granted, folders, euEeaUk: pool.euEeaUk === 'yes' || pool.euEeaUk === 'no' ? pool.euEeaUk : null } };
89
89
  }
90
90
  }
91
91
  catch (error) {
92
92
  if (!isMissing(error) && !(error instanceof SyntaxError))
93
93
  throw error;
94
94
  }
95
- return { files: {}, redactDomains: [], allow: [], pool: { rawConsent: null, rawConsentVersion: null, withdrawalPending: false, folders: {} } };
95
+ return { files: {}, redactDomains: [], allow: [], pool: { rawConsent: null, rawConsentVersion: null, withdrawalPending: false, folders: {}, euEeaUk: null } };
96
96
  };
97
97
  /** Changes the pool choices under the store lock, so a deposit running at the same time keeps them (it writes the state it read). */
98
98
  export const updatePoolChoices = (storeDir, change) => withLock(storeDir, async (held) => {
@@ -372,6 +372,115 @@ const tally = (trace, extract, subagent) => {
372
372
  add('error-line', extract.errorLines.length);
373
373
  };
374
374
  export const digest = (parts) => createHash('sha256').update(parts.join('\n')).digest('hex').slice(0, 16);
375
+ /** Sessions a refresh loads and folds at a time. Peak memory is the sessions of a run plus what the folds keep (spec 3 option A), so this is the knob between the heap and the number of seeks. */
376
+ const REFRESH_RUN = 100;
377
+ const asError = (error) => (error instanceof Error ? error : new Error(String(error)));
378
+ /** The one place a pool fold's failure is caught: its result, or the error, so a step of the pool never stops a deposit. */
379
+ const attempt = (step) => {
380
+ try {
381
+ return step();
382
+ }
383
+ catch (error) {
384
+ return asError(error);
385
+ }
386
+ };
387
+ /** The pool fold settled after the last run, or the error that stopped it. */
388
+ const finished = (pool, runtime, runs) => {
389
+ if (pool instanceof Error)
390
+ return pool;
391
+ return attempt(() => {
392
+ const folds = pool.finish();
393
+ runtime.refreshStep?.('finish');
394
+ runtime.refreshed?.({ runs, reversions: reversionEntries(pool.index) });
395
+ return folds;
396
+ });
397
+ };
398
+ const reversionEntries = (index) => {
399
+ const lists = [...index.committed.values(), ...index.edited.values(), ...index.written.values()];
400
+ return index.commits.length + index.reverts.length + index.restored.length + lists.reduce((sum, list) => sum + list.length, 0);
401
+ };
402
+ /**
403
+ * `loadStore` and the folds over the whole store, one run of sessions at a time (t1243): the log is scanned once with spans, then each run of `runOrder` is read by seeking, folded, and let go. A subagent's parent
404
+ * outside its run is read as context for the agent's errors and folded in its own run only. Sessions and errors are held with their place in the whole read and put back in that order, which is the only order anything
405
+ * written depends on.
406
+ */
407
+ const foldRuns = async (runtime) => {
408
+ const scan = await scanLog(paths(runtime.storeDir).observations, undefined, true);
409
+ await runtime.afterScan?.();
410
+ runtime.refreshStep?.('scan');
411
+ const order = runOrder(scan);
412
+ const size = Math.max(1, Math.floor(runtime.refreshRun ?? REFRESH_RUN));
413
+ // Loaded here, not at the top: the pool imports every read family, and a lookup hook never reaches this step. A failure to load or fold costs the index only its fit reads' read IDs (the shapes come from the always-on read fold), as a failure of `currentPool` does.
414
+ let pool;
415
+ try {
416
+ const { poolFold } = await import('./pool-fold.js');
417
+ const { readRecalls } = await import('./recall-object.js');
418
+ pool = poolFold(await readRecalls(runtime.storeDir));
419
+ }
420
+ catch (error) {
421
+ pool = asError(error);
422
+ }
423
+ const derive = deriveFold();
424
+ // Also loaded here: it reaches the read families a lookup hook must not load.
425
+ const readFolding = (await import('./read-folds.js')).readFold();
426
+ const counts = { prompts: 0, tools: 0 };
427
+ const start = (session) => scan.spans?.get(session)?.[0] ?? Number.MAX_SAFE_INTEGER;
428
+ const sessions = [];
429
+ const subagents = [];
430
+ // Version 2 error rows come first in a whole read, then each readable session's in its place, then the agents' last.
431
+ const errors = [];
432
+ let runs = 0;
433
+ for (let from = 0; from < order.length; from += size) {
434
+ const own = new Set(order.slice(from, from + size));
435
+ const wanted = new Set(own);
436
+ for (const id of own) {
437
+ const parent = scan.parents.get(id);
438
+ if (parent !== undefined)
439
+ wanted.add(parent);
440
+ }
441
+ const loaded = await loadStore(runtime.storeDir, undefined, { only: wanted, latest: scan.latest, spans: scan.spans, identity: scan.identity });
442
+ const run = {
443
+ sessions: loaded.sessions.filter(({ session }) => own.has(session)),
444
+ subagents: loaded.subagents.filter(({ session }) => own.has(session)),
445
+ prompts: loaded.prompts.filter(({ session }) => own.has(session)),
446
+ tools: loaded.tools.filter(({ session }) => own.has(session)),
447
+ errors: loaded.errors.filter(({ session, agent }) => own.has(agent ?? session)),
448
+ newerCaptures: 0,
449
+ };
450
+ runs += 1;
451
+ derive.add(run.prompts);
452
+ readFolding.add(run);
453
+ counts.prompts += run.prompts.length;
454
+ counts.tools += run.tools.length;
455
+ for (const record of run.sessions)
456
+ sessions.push({ at: start(record.session), record });
457
+ for (const record of run.subagents)
458
+ subagents.push({ at: start(record.session), record });
459
+ for (const record of run.errors) {
460
+ const unreadable = !isReadable(scan.cvs.get(record.session) ?? '0');
461
+ errors.push(record.agent === undefined ? { group: unreadable ? 0 : 1, at: start(record.session), record } : { group: 2, at: start(record.agent), record });
462
+ }
463
+ if (!(pool instanceof Error)) {
464
+ const open = pool;
465
+ const failed = attempt(() => {
466
+ open.add(run);
467
+ });
468
+ if (failed instanceof Error)
469
+ pool = failed;
470
+ }
471
+ }
472
+ runtime.refreshStep?.('runs');
473
+ const byPlace = (list) => list.sort((a, b) => a.at - b.at);
474
+ const store = {
475
+ sessions: byPlace(sessions).map(({ record }) => record),
476
+ subagents: byPlace(subagents).map(({ record }) => record),
477
+ errors: errors.sort((a, b) => a.group - b.group || a.at - b.at).map(({ record }) => record),
478
+ prompts: [],
479
+ tools: [],
480
+ newerCaptures: scan.newerCaptures,
481
+ };
482
+ return { store, folds: finished(pool, runtime, runs), derive, reads: readFolding.finish(), counts };
483
+ };
375
484
  /**
376
485
  * Rewrites `derived.json` (the derive step), then the hook index (the index step), from the whole store. With a trace, a failure of
377
486
  * either is recorded on it instead of thrown, so the caller can log the deposit that already wrote its records, then the failed step
@@ -382,18 +491,21 @@ export const refresh = async (runtime, redactDomains, at, trace, held = () => Pr
382
491
  // The extractor version alone until the store is read: a derive that fails before the high-water mark is known still has a key (spec 4.4).
383
492
  let key = digest([EXTRACTOR_VERSION]);
384
493
  let store;
494
+ let folds;
495
+ let readFolds;
385
496
  try {
386
- store = await loadStore(runtime.storeDir);
497
+ const folded = await foldRuns(runtime);
498
+ ({ store, folds, reads: readFolds } = folded);
387
499
  // The key is the account's capture high-water mark (the highest rev of each session) and `ev` (spec 4.2); the index shares it.
388
500
  const highWater = [...store.sessions, ...store.subagents].map((session) => `${session.session}:${String(session.rev)}`).sort();
389
501
  key = digest([EXTRACTOR_VERSION, ...highWater]);
390
- const derived = derive(store, at);
502
+ const derived = folded.derive.finish(store, at);
391
503
  await requireHold(held);
392
504
  await writeAtomic(paths(runtime.storeDir).derived, JSON.stringify(derived));
393
505
  if (trace !== undefined) {
394
506
  trace.derive = {
395
507
  ms: Math.round(performance.now() - started),
396
- in: { session: store.sessions.length, subagent: store.subagents.length, 'user-prompt': store.prompts.length, tool: store.tools.length },
508
+ in: { session: store.sessions.length, subagent: store.subagents.length, 'user-prompt': folded.counts.prompts, tool: folded.counts.tools },
397
509
  out: { signature: derived.signatures.length, summary: derived.summaries.length },
398
510
  key,
399
511
  };
@@ -407,17 +519,18 @@ export const refresh = async (runtime, redactDomains, at, trace, held = () => Pr
407
519
  }
408
520
  // The pool is derived for the index's fit reads only: one that cannot be read or written costs the index those, never the index itself (`currentPool` says so on stderr). Loaded here, not at the top: the pool imports every read family, and a lookup hook never reaches this step. It runs before the clock starts, so the index's `ms` is the index and not the derive (the first pool run takes 12 to 17 s on a store copy).
409
521
  // A module that will not load costs the index the same fit reads, with one line on stderr, as `currentPool` does when the pool cannot be built.
410
- const current = await import('./pool.js').then(async ({ currentPool }) => currentPool(runtime.storeDir, store), (error) => {
522
+ const current = await import('./pool.js').then(async ({ currentPool }) => currentPool(runtime.storeDir, store, folds), (error) => {
411
523
  process.stderr.write(`oars: the read pool module could not be loaded, and the index keeps no fit reads: ${error instanceof Error ? error.message : String(error)}
412
524
  `);
413
525
  return undefined;
414
526
  });
527
+ runtime.refreshStep?.('pool');
415
528
  const reads = current?.reads ?? [];
416
529
  const indexStarted = performance.now();
417
530
  try {
418
531
  // The next-node reads for each harness and version, derived over the same pool; a deposit that has no pool leaves the index without them.
419
532
  const next = current === undefined ? undefined : (await import('./recall-frames.js')).nextByFrame(current.pool, store);
420
- const index = buildIndex(store, redactDomains, at, reads, next);
533
+ const index = buildIndex(store, redactDomains, at, reads, next, readFolds);
421
534
  await requireHold(held);
422
535
  await writeAtomic(indexPath(runtime.storeDir), JSON.stringify(index));
423
536
  if (trace !== undefined) {
@@ -1085,26 +1198,103 @@ const depositSessionLocked = async (runtime, transcript, now, held, trace) => {
1085
1198
  await removePending(runtime.storeDir, drained.settled);
1086
1199
  return own.result;
1087
1200
  };
1088
- export const scanLog = async (file, held) => {
1201
+ /** The log's lines with the byte range each takes (its newline included), read as bytes so an offset is a file position. The same split as `logLines`: on 0x0a alone. */
1202
+ const logSpans = async function* (file, held) {
1203
+ let rest = Buffer.alloc(0);
1204
+ let at = 0;
1205
+ try {
1206
+ const stream = held === undefined ? createReadStream(file, { highWaterMark: 1 << 20 }) : held.createReadStream({ highWaterMark: 1 << 20, start: 0, autoClose: false });
1207
+ const chunks = stream;
1208
+ for await (const chunk of chunks) {
1209
+ let buffer = Buffer.concat([rest, chunk]);
1210
+ let from = 0;
1211
+ for (let end = buffer.indexOf(10, from); end !== -1; end = buffer.indexOf(10, from)) {
1212
+ yield { line: buffer.toString('utf8', from, end), start: at + from, length: end + 1 - from };
1213
+ from = end + 1;
1214
+ }
1215
+ buffer = buffer.subarray(from);
1216
+ at += from;
1217
+ rest = buffer;
1218
+ }
1219
+ }
1220
+ catch (error) {
1221
+ if (!isMissing(error))
1222
+ throw error;
1223
+ }
1224
+ yield { line: rest.toString('utf8'), start: at, length: rest.length };
1225
+ };
1226
+ export const scanLog = async (file, held, withSpans = false) => {
1227
+ // Spans are byte offsets into one file: read it through a handle, and stat that handle, so the identity is the file the offsets belong to.
1228
+ const own = withSpans && held === undefined ? await openLog(file) : undefined;
1229
+ try {
1230
+ return await scanHandle(file, held ?? own, withSpans);
1231
+ }
1232
+ finally {
1233
+ await own?.close();
1234
+ }
1235
+ };
1236
+ const scanHandle = async (file, held, withSpans) => {
1089
1237
  const latest = new Map();
1090
1238
  const cvAt = new Map();
1239
+ // Every rev's lines are noted as `[start, length, rev, ...]` triples, then thinned to the highest rev once it is known.
1240
+ const seen = withSpans ? new Map() : undefined;
1241
+ const info = withSpans && held !== undefined ? await held.stat({ bigint: true }) : undefined;
1091
1242
  let superseded = false;
1092
- for await (const line of logLines(file, held)) {
1243
+ for await (const { line, start, length } of withSpans ? logSpans(file, held) : plainLines(file, held)) {
1093
1244
  const observation = observationOf(line);
1094
1245
  if (observation === undefined)
1095
1246
  continue;
1096
- const seen = latest.get(observation.session);
1097
- superseded ||= seen !== undefined && seen !== observation.rev;
1098
- latest.set(observation.session, Math.max(seen ?? 0, observation.rev));
1247
+ if (seen !== undefined) {
1248
+ const own = seen.get(observation.session) ?? [];
1249
+ own.push(start, length, observation.rev);
1250
+ seen.set(observation.session, own);
1251
+ }
1252
+ const before = latest.get(observation.session);
1253
+ superseded ||= before !== undefined && before !== observation.rev;
1254
+ latest.set(observation.session, Math.max(before ?? 0, observation.rev));
1099
1255
  // The last session record at the highest rev is the one `loadStore` reads.
1100
1256
  if (observation.kind === 'session' && observation.rev >= (cvAt.get(observation.session)?.rev ?? 0))
1101
- cvAt.set(observation.session, { rev: observation.rev, cv: observation.cv });
1257
+ cvAt.set(observation.session, { rev: observation.rev, cv: observation.cv, parent: observation.parent });
1102
1258
  }
1103
1259
  let newerCaptures = 0;
1104
- for (const [session, held] of cvAt)
1105
- if (held.rev === latest.get(session) && Number(held.cv) > Number(CAPTURE_VERSION))
1260
+ const parents = new Map();
1261
+ const cvs = new Map();
1262
+ for (const [session, held] of cvAt) {
1263
+ if (held.rev !== latest.get(session))
1264
+ continue;
1265
+ cvs.set(session, held.cv);
1266
+ if (held.parent !== null)
1267
+ parents.set(session, held.parent);
1268
+ if (Number(held.cv) > Number(CAPTURE_VERSION))
1106
1269
  newerCaptures += 1;
1107
- return { latest, superseded, newerCaptures };
1270
+ }
1271
+ if (seen === undefined)
1272
+ return { latest, superseded, newerCaptures, parents, cvs };
1273
+ // A superseded line is never read by a chunk load, so the index keeps only each session's lines at its highest rev.
1274
+ const spans = new Map();
1275
+ for (const [session, triples] of seen) {
1276
+ const kept = [];
1277
+ for (let n = 0; n < triples.length; n += 3)
1278
+ if (triples[n + 2] === latest.get(session))
1279
+ kept.push(triples[n] ?? 0, triples[n + 1] ?? 0);
1280
+ spans.set(session, kept);
1281
+ }
1282
+ return info === undefined ? { latest, superseded, newerCaptures, parents, cvs, spans } : { latest, superseded, newerCaptures, parents, cvs, spans, identity: { dev: info.dev, ino: info.ino, size: info.size } };
1283
+ };
1284
+ /**
1285
+ * The order `loadStore` visits sessions in, as one list: the sessions whose capture version cannot be read (version 2, whose prompts and errors the log holds as rows) first, then the rest, each group by where
1286
+ * the session's first line at its highest rev starts in the log, subagents and mains alike. Needs a scan made with spans. A run is a contiguous slice of this list.
1287
+ */
1288
+ export const runOrder = (scan) => {
1289
+ // Every key of `spans` holds at least one pair, so the fallback is never taken; it only satisfies the indexed read's type.
1290
+ const start = (session) => scan.spans?.get(session)?.[0] ?? Number.MAX_SAFE_INTEGER;
1291
+ const sessions = [...(scan.spans?.keys() ?? [])];
1292
+ const unreadable = (session) => !isReadable(scan.cvs.get(session) ?? '0');
1293
+ return sessions.sort((a, b) => Number(unreadable(b)) - Number(unreadable(a)) || start(a) - start(b));
1294
+ };
1295
+ const plainLines = async function* (file, held) {
1296
+ for await (const line of logLines(file, held))
1297
+ yield { line, start: 0, length: 0 };
1108
1298
  };
1109
1299
  /** Returns the first copy it was given of each string, so the strings a log repeats are held once. */
1110
1300
  const interner = () => {
@@ -1134,6 +1324,47 @@ const internTool = (tool, intern) => ({
1134
1324
  target: tool.target === null ? null : intern(tool.target),
1135
1325
  ...(tool.segments === undefined ? {} : { segments: tool.segments.map((segment) => ({ ...segment, run: intern(segment.run) })) }),
1136
1326
  });
1327
+ const SESSION_FIELD = /"session":"((?:[^"\\]|\\.)*)"/g;
1328
+ /** True when the line carries a `"session":"<id>"` field of one of `ids`. The writer emits compact JSON, so the field of the line's own session is always found; a nested match only keeps the line for the parse to decide. */
1329
+ export const namesAny = (line, ids) => {
1330
+ for (const match of line.matchAll(SESSION_FIELD)) {
1331
+ const value = match[1] ?? '';
1332
+ // An id that JSON escaped (a quote or a backslash in it) is not the raw id: keep the line and let the parse decide.
1333
+ if (value.includes('\\') || ids.has(value))
1334
+ return true;
1335
+ }
1336
+ return false;
1337
+ };
1338
+ /** True when `held` is the file `identity` describes and has not shrunk: only an append can have happened since. */
1339
+ const sameFile = async (held, identity) => {
1340
+ const now = await held.stat({ bigint: true });
1341
+ return now.dev === identity.dev && now.ino === identity.ino && now.size >= identity.size;
1342
+ };
1343
+ /** The wanted sessions' lines, in file order, read by seeking: adjacent spans are one read. */
1344
+ const spanLines = async function* (held, spans, ids) {
1345
+ const wanted = [];
1346
+ for (const id of ids) {
1347
+ const own = spans.get(id) ?? [];
1348
+ for (let n = 0; n < own.length; n += 2)
1349
+ wanted.push({ start: own[n] ?? 0, length: own[n + 1] ?? 0 });
1350
+ }
1351
+ wanted.sort((a, b) => a.start - b.start);
1352
+ for (let n = 0; n < wanted.length;) {
1353
+ const first = wanted[n] ?? { start: 0, length: 0 };
1354
+ let end = first.start + first.length;
1355
+ n += 1;
1356
+ for (let next = wanted[n]; next !== undefined && next.start === end; next = wanted[n]) {
1357
+ end += next.length;
1358
+ n += 1;
1359
+ }
1360
+ const buffer = Buffer.alloc(end - first.start);
1361
+ const { bytesRead } = await held.read(buffer, 0, buffer.length, first.start);
1362
+ // A short read (the log shrank) yields what is there; a torn last line fails its parse and is skipped.
1363
+ for (const line of buffer.toString('utf8', 0, bytesRead).split('\n'))
1364
+ if (line !== '')
1365
+ yield line;
1366
+ }
1367
+ };
1137
1368
  const withoutMessageUsage = (session) => {
1138
1369
  if (session.messageUsage === undefined)
1139
1370
  return session;
@@ -1163,12 +1394,22 @@ export const loadStore = async (storeDir, afterFirstPass, options = {}) => {
1163
1394
  };
1164
1395
  const held = await openLog(file);
1165
1396
  try {
1166
- const { latest } = await scanLog(file, held);
1397
+ const latest = options.latest ?? (await scanLog(file, held)).latest;
1167
1398
  await afterFirstPass?.();
1168
- for await (const line of logLines(file, held)) {
1399
+ const { only, spans, identity } = options;
1400
+ const seek = held !== undefined && only !== undefined && spans !== undefined && identity !== undefined && (await sameFile(held, identity));
1401
+ for await (const line of seek ? spanLines(held, spans, only) : logLines(file, held)) {
1402
+ options.lineRead?.();
1403
+ // A read of a few sessions skips the JSON parse of every other line: the cheap check can only keep a line, never drop one that names a wanted session.
1404
+ if (options.only !== undefined && !namesAny(line, options.only))
1405
+ continue;
1169
1406
  const observation = observationOf(line);
1170
1407
  if (observation === undefined || observation.rev !== latest.get(observation.session))
1171
1408
  continue;
1409
+ if (options.only !== undefined && !options.only.has(observation.session))
1410
+ continue;
1411
+ if (options.sessionsOnly === true && observation.kind !== 'session')
1412
+ continue;
1172
1413
  if (observation.kind === 'session')
1173
1414
  of(observation.session).session = options.messageUsage === true ? observation : withoutMessageUsage(observation);
1174
1415
  else if (observation.kind === 'user-prompt')
@@ -1201,12 +1442,12 @@ export const loadStore = async (storeDir, afterFirstPass, options = {}) => {
1201
1442
  store.tools.push(...tools);
1202
1443
  if (session.parent !== null) {
1203
1444
  store.subagents.push(session);
1204
- if (isReadable(session.cv))
1445
+ if (isReadable(session.cv) && options.sessionsOnly !== true)
1205
1446
  agents.push({ session, prompts, tools, errorLines, parent: session.parent });
1206
1447
  continue;
1207
1448
  }
1208
1449
  store.sessions.push(session);
1209
- if (!isReadable(session.cv))
1450
+ if (!isReadable(session.cv) || options.sessionsOnly === true)
1210
1451
  continue;
1211
1452
  const derived = deriveSession({ session, prompts, tools, errorLines }, redact);
1212
1453
  store.prompts.push(...derived.prompts);
@@ -1481,52 +1722,62 @@ export const readSkeletons = async (storeDir, only, lean = false) => {
1481
1722
  }
1482
1723
  return { skeletons, unreadable };
1483
1724
  };
1484
- /** The derived records, a pure function of the store. */
1485
- export const derive = (store, at) => {
1486
- const bySig = new Map();
1487
- for (const error of store.errors)
1488
- bySig.set(error.sig, [...(bySig.get(error.sig) ?? []), error]);
1489
- const signatures = [...bySig.entries()].map(([sig, list]) => signatureRecord(sig, list));
1490
- signatures.sort((a, b) => b.sessions - a.sessions || b.count - a.count || a.sig.localeCompare(b.sig));
1725
+ export const deriveFold = () => {
1491
1726
  const bigrams = {};
1492
1727
  const recovery = {};
1493
1728
  const classes = {};
1494
1729
  const ratio = new Map();
1495
- for (const prompt of store.prompts) {
1496
- for (const [pair, count] of Object.entries(prompt.bigrams))
1497
- bigrams[pair] = (bigrams[pair] ?? 0) + count;
1498
- if (prompt.recovery.length > 0) {
1499
- const pair = prompt.recovery.slice(0, 2).join('>');
1500
- recovery[pair] = (recovery[pair] ?? 0) + 1;
1501
- }
1502
- const entry = ratio.get(prompt.cls) ?? { explore: 0, act: 0 };
1503
- entry.explore += prompt.explore;
1504
- entry.act += prompt.act;
1505
- ratio.set(prompt.cls, entry);
1506
- classes[prompt.cls] = { prompts: (classes[prompt.cls]?.prompts ?? 0) + 1, exploreToAct: 0 };
1507
- }
1508
- for (const [cls, entry] of ratio) {
1509
- const row = classes[cls];
1510
- if (row !== undefined)
1511
- row.exploreToAct = Math.round((entry.explore / Math.max(entry.act, 1)) * 100) / 100;
1512
- }
1513
1730
  const promptCount = new Map();
1514
- for (const prompt of store.prompts)
1515
- promptCount.set(prompt.session, (promptCount.get(prompt.session) ?? 0) + 1);
1516
- const errorCount = new Map();
1517
- for (const error of store.errors)
1518
- errorCount.set(error.session, (errorCount.get(error.session) ?? 0) + 1);
1519
- const summaries = store.sessions.map((session) => ({
1520
- session: session.session,
1521
- project: session.project,
1522
- prompts: promptCount.get(session.session) ?? 0,
1523
- errors: errorCount.get(session.session) ?? 0,
1524
- tools: Object.values(session.tools).reduce((sum, count) => sum + count, 0),
1525
- tsFirst: session.tsFirst,
1526
- tsLast: session.tsLast,
1527
- tldrs: session.tldrs.length,
1528
- }));
1529
- return { v: EXTRACTOR_VERSION, at, sessions: store.sessions.length, signatures, bigrams, recovery, classes, summaries };
1731
+ return {
1732
+ add: (prompts) => {
1733
+ for (const prompt of prompts) {
1734
+ for (const [pair, count] of Object.entries(prompt.bigrams))
1735
+ bigrams[pair] = (bigrams[pair] ?? 0) + count;
1736
+ if (prompt.recovery.length > 0) {
1737
+ const pair = prompt.recovery.slice(0, 2).join('>');
1738
+ recovery[pair] = (recovery[pair] ?? 0) + 1;
1739
+ }
1740
+ const entry = ratio.get(prompt.cls) ?? { explore: 0, act: 0 };
1741
+ entry.explore += prompt.explore;
1742
+ entry.act += prompt.act;
1743
+ ratio.set(prompt.cls, entry);
1744
+ classes[prompt.cls] = { prompts: (classes[prompt.cls]?.prompts ?? 0) + 1, exploreToAct: 0 };
1745
+ promptCount.set(prompt.session, (promptCount.get(prompt.session) ?? 0) + 1);
1746
+ }
1747
+ },
1748
+ finish: (store, at) => {
1749
+ const bySig = new Map();
1750
+ for (const error of store.errors)
1751
+ bySig.set(error.sig, [...(bySig.get(error.sig) ?? []), error]);
1752
+ const signatures = [...bySig.entries()].map(([sig, list]) => signatureRecord(sig, list));
1753
+ signatures.sort((a, b) => b.sessions - a.sessions || b.count - a.count || a.sig.localeCompare(b.sig));
1754
+ for (const [cls, entry] of ratio) {
1755
+ const row = classes[cls];
1756
+ if (row !== undefined)
1757
+ row.exploreToAct = Math.round((entry.explore / Math.max(entry.act, 1)) * 100) / 100;
1758
+ }
1759
+ const errorCount = new Map();
1760
+ for (const error of store.errors)
1761
+ errorCount.set(error.session, (errorCount.get(error.session) ?? 0) + 1);
1762
+ const summaries = store.sessions.map((session) => ({
1763
+ session: session.session,
1764
+ project: session.project,
1765
+ prompts: promptCount.get(session.session) ?? 0,
1766
+ errors: errorCount.get(session.session) ?? 0,
1767
+ tools: Object.values(session.tools).reduce((sum, count) => sum + count, 0),
1768
+ tsFirst: session.tsFirst,
1769
+ tsLast: session.tsLast,
1770
+ tldrs: session.tldrs.length,
1771
+ }));
1772
+ return { v: EXTRACTOR_VERSION, at, sessions: store.sessions.length, signatures, bigrams, recovery, classes, summaries };
1773
+ },
1774
+ };
1775
+ };
1776
+ /** The derived records, a pure function of the store. */
1777
+ export const derive = (store, at) => {
1778
+ const fold = deriveFold();
1779
+ fold.add(store.prompts);
1780
+ return fold.finish(store, at);
1530
1781
  };
1531
1782
  export const signatureRecord = (sig, list) => {
1532
1783
  const stamps = list.map((error) => error.ts).filter((ts) => ts !== null).sort();
@@ -27,6 +27,11 @@ export declare const firstRunMeasures: (event: unknown) => Measure[];
27
27
  * for the operator's query, not a measure of the program. A version that is not a word reads `other`, and a missing one `unrecorded`; the show is still counted.
28
28
  */
29
29
  export declare const covenantShownMeasures: (event: unknown) => Measure[];
30
+ /**
31
+ * The cell of a `join-step` event: one yes under the oars version, in a metric named for the step and what it did (`join-step.send.done`), so the pool's rollup holds, for each install and day, which steps of the join it reached.
32
+ * Like the first-run and covenant-shown cells it is for the operator's query (`queries/join-funnel.sql`), so `measuresOf` leaves it out and the fleet read skips it. A word off the closed lists reads as `other`.
33
+ */
34
+ export declare const joinStepMeasures: (event: unknown) => Measure[];
30
35
  export declare const isRecordOnlyMetric: (metric: string) => boolean;
31
36
  /** What one event measures; an event of a type nothing measures, or that is not an object, measures nothing. */
32
37
  export declare const measuresOf: (event: unknown) => Measure[];
@@ -2,7 +2,7 @@
2
2
  // A measure is one observation in a cell: a latency in milliseconds, or a yes/no that rates are made from. The cell is (metric, dimension, value): `hook.pre-tool.latency` at harness
3
3
  // `claude-code`, version `2.1.5`. The reader is tolerant on purpose: the pool reads batches kept whole from any client, so a value that does not fit is skipped, never trusted.
4
4
  import { isHarness } from '../sessions/pool-wire.js';
5
- import { CI_WORDS, SURFACE_WORDS } from './words.js';
5
+ import { CI_WORDS, JOIN_STATES, JOIN_STEPS, SURFACE_WORDS } from './words.js';
6
6
  /** The windows the report shows and the pool serves, in days. */
7
7
  export const WINDOWS = [7, 30];
8
8
  const DAY_MS = 86_400_000;
@@ -100,8 +100,22 @@ export const covenantShownMeasures = (event) => {
100
100
  const version = valueOf(fieldOf(fieldOf(event, 'data'), 'version'));
101
101
  return [rate(`covenant-shown.${version}`, 'cli', valueOf(fieldOf(event, 'cli')), true)];
102
102
  };
103
+ /**
104
+ * The cell of a `join-step` event: one yes under the oars version, in a metric named for the step and what it did (`join-step.send.done`), so the pool's rollup holds, for each install and day, which steps of the join it reached.
105
+ * Like the first-run and covenant-shown cells it is for the operator's query (`queries/join-funnel.sql`), so `measuresOf` leaves it out and the fleet read skips it. A word off the closed lists reads as `other`.
106
+ */
107
+ export const joinStepMeasures = (event) => {
108
+ if (fieldOf(event, 'type') !== 'join-step')
109
+ return [];
110
+ const data = fieldOf(event, 'data');
111
+ const step = fieldOf(data, 'step');
112
+ const state = fieldOf(data, 'state');
113
+ const stepWord = typeof step === 'string' && JOIN_STEPS.some((word) => word === step) ? step : 'other';
114
+ const stateWord = typeof state === 'string' && JOIN_STATES.some((word) => word === state) ? state : 'other';
115
+ return [rate(`join-step.${stepWord}.${stateWord}`, 'cli', valueOf(fieldOf(event, 'cli')), true)];
116
+ };
103
117
  /** The metrics the rollup records for the operator's queries and the fleet read never serves: a cell of one event or one count per install measures nothing across installs. */
104
- const RECORD_ONLY_PREFIXES = ['first-run.', 'covenant-shown.'];
118
+ const RECORD_ONLY_PREFIXES = ['first-run.', 'covenant-shown.', 'join-step.'];
105
119
  export const isRecordOnlyMetric = (metric) => RECORD_ONLY_PREFIXES.some((prefix) => metric.startsWith(prefix));
106
120
  /** What one event measures; an event of a type nothing measures, or that is not an object, measures nothing. */
107
121
  export const measuresOf = (event) => {
@@ -6,7 +6,7 @@ import { ExitCode } from '../errors.js';
6
6
  import { FIELD_ROWS } from '../sessions/pool-outbox.js';
7
7
  import { OTHER, HARNESSES } from '../sessions/pool-wire.js';
8
8
  import { LITERAL, publicModel, publicVersion } from '../sessions/public-vocabulary.js';
9
- import { CI_WORDS, SURFACE_WORDS } from './words.js';
9
+ import { CI_WORDS, JOIN_STATES, JOIN_STEPS, SURFACE_WORDS } from './words.js';
10
10
  /** Bumped when an event type or field changes meaning; every spooled event carries it. */
11
11
  export const SCHEMA_VERSION = 1;
12
12
  /** A command or option name as the program defines it: letters, digits, and hyphens. No space, slash, dot, colon, or quote. */
@@ -138,6 +138,8 @@ const EVENTS = new Map([
138
138
  ['first-run', rules({ ci: oneOf(CI_WORDS), surface: oneOf(SURFACE_WORDS) })],
139
139
  // The CLI told the operator which covenant to read and accept: the version and nothing else.
140
140
  ['covenant-shown', rules({ version: covenantVersion })],
141
+ // One step of `oars pool join` in one run: which of the closed steps and what it did, and nothing else (docs/telemetry.md).
142
+ ['join-step', rules({ step: oneOf(JOIN_STEPS), state: oneOf(JOIN_STATES) })],
141
143
  ]);
142
144
  export const EVENT_TYPES = [...EVENTS.keys()];
143
145
  /** The field names a type admits, so a test can feed every one. */
@@ -4,3 +4,9 @@ export type CiWord = (typeof CI_WORDS)[number];
4
4
  /** How this copy of oars arrived, from the folder it runs in and the package manager that started it (`surfaceWord` in `fields.ts`); the path itself never reaches the spool. A folder it cannot place is `unknown`. */
5
5
  export declare const SURFACE_WORDS: readonly ["npm-global", "npx", "claude-plugin", "codex-plugin", "gemini-extension", "unknown"];
6
6
  export type SurfaceWord = (typeof SURFACE_WORDS)[number];
7
+ /** The steps of `oars pool join`, as the `join-step` event and the join plan name them. A name outside this list never reaches the spool. */
8
+ export declare const JOIN_STEPS: readonly ["signin", "region", "terms", "batch", "outbox", "covenant", "send"];
9
+ export type JoinStep = (typeof JOIN_STEPS)[number];
10
+ /** What a step did in one run: finished now, found already done and skipped, stopped the run, or had nothing to act on (no session ready to deposit; never counted as reached). */
11
+ export declare const JOIN_STATES: readonly ["done", "skipped", "stopped", "none"];
12
+ export type JoinState = (typeof JOIN_STATES)[number];
@@ -4,3 +4,7 @@
4
4
  export const CI_WORDS = ['none', 'github-actions', 'gitlab-ci', 'circleci', 'travis-ci', 'jenkins', 'buildkite', 'azure-pipelines', 'bitbucket', 'teamcity', 'appveyor', 'codebuild', 'other'];
5
5
  /** How this copy of oars arrived, from the folder it runs in and the package manager that started it (`surfaceWord` in `fields.ts`); the path itself never reaches the spool. A folder it cannot place is `unknown`. */
6
6
  export const SURFACE_WORDS = ['npm-global', 'npx', 'claude-plugin', 'codex-plugin', 'gemini-extension', 'unknown'];
7
+ /** The steps of `oars pool join`, as the `join-step` event and the join plan name them. A name outside this list never reaches the spool. */
8
+ export const JOIN_STEPS = ['signin', 'region', 'terms', 'batch', 'outbox', 'covenant', 'send'];
9
+ /** What a step did in one run: finished now, found already done and skipped, stopped the run, or had nothing to act on (no session ready to deposit; never counted as reached). */
10
+ export const JOIN_STATES = ['done', 'skipped', 'stopped', 'none'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manyoars/oars",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "mcpName": "io.github.manyoars/oars",
5
5
  "description": "A local, searchable record of your coding agent sessions. Reads Claude Code, Cursor, Codex, Copilot CLI, Gemini CLI, and more, and keeps the record on your machine.",
6
6
  "homepage": "https://manyoars.com/",