@manyoars/oars 0.4.1 → 0.5.1

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 (183) hide show
  1. package/README.md +79 -29
  2. package/dist/bin.js +3 -1
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +206 -20
  5. package/dist/club.d.ts +19 -0
  6. package/dist/club.js +132 -0
  7. package/dist/gate/hook-fill-main.js +1 -1
  8. package/dist/gate/hook-fill.d.ts +1 -1
  9. package/dist/gate/hook-fill.js +1 -1
  10. package/dist/gate/hook-main.js +7 -5
  11. package/dist/gate/hook.d.ts +12 -1
  12. package/dist/gate/hook.js +38 -7
  13. package/dist/hook-bin.js +23 -4
  14. package/dist/main.js +5 -0
  15. package/dist/node-floor.d.ts +10 -0
  16. package/dist/node-floor.js +23 -0
  17. package/dist/oars-hook.d.ts +2 -0
  18. package/dist/oars-hook.js +7 -0
  19. package/dist/sessions/allow.d.ts +1 -0
  20. package/dist/sessions/allow.js +1 -1
  21. package/dist/sessions/call-shape.d.ts +111 -0
  22. package/dist/sessions/call-shape.js +464 -0
  23. package/dist/sessions/candidates.d.ts +65 -0
  24. package/dist/sessions/candidates.js +236 -0
  25. package/dist/sessions/card-command.d.ts +24 -0
  26. package/dist/sessions/card-command.js +71 -0
  27. package/dist/sessions/card-font.d.ts +6 -0
  28. package/dist/sessions/card-font.js +36 -0
  29. package/dist/sessions/card-line.d.ts +30 -0
  30. package/dist/sessions/card-line.js +105 -0
  31. package/dist/sessions/card-render.d.ts +15 -0
  32. package/dist/sessions/card-render.js +146 -0
  33. package/dist/sessions/card.d.ts +59 -0
  34. package/dist/sessions/card.js +107 -0
  35. package/dist/sessions/cell-increments.d.ts +14 -3
  36. package/dist/sessions/cell-increments.js +79 -12
  37. package/dist/sessions/cells.d.ts +42 -4
  38. package/dist/sessions/cells.js +72 -18
  39. package/dist/sessions/codex-hook-bin.d.ts +1 -0
  40. package/dist/sessions/codex-hook-bin.js +24 -0
  41. package/dist/sessions/codex-hook-install.d.ts +21 -0
  42. package/dist/sessions/codex-hook-install.js +47 -0
  43. package/dist/sessions/codex-hook-view.d.ts +20 -0
  44. package/dist/sessions/codex-hook-view.js +20 -0
  45. package/dist/sessions/codex-hook.d.ts +77 -0
  46. package/dist/sessions/codex-hook.js +255 -0
  47. package/dist/sessions/codex.d.ts +9 -0
  48. package/dist/sessions/codex.js +104 -18
  49. package/dist/sessions/command.d.ts +17 -2
  50. package/dist/sessions/command.js +145 -33
  51. package/dist/sessions/copilot.d.ts +3 -0
  52. package/dist/sessions/copilot.js +12 -4
  53. package/dist/sessions/derive.js +2 -1
  54. package/dist/sessions/empty-state.d.ts +17 -0
  55. package/dist/sessions/empty-state.js +38 -0
  56. package/dist/sessions/exclusion.js +48 -3
  57. package/dist/sessions/extract.d.ts +16 -3
  58. package/dist/sessions/extract.js +281 -24
  59. package/dist/sessions/gemini.d.ts +2 -0
  60. package/dist/sessions/gemini.js +29 -2
  61. package/dist/sessions/harness-files.d.ts +27 -0
  62. package/dist/sessions/harness-files.js +109 -0
  63. package/dist/sessions/head-only.d.ts +7 -0
  64. package/dist/sessions/head-only.js +21 -0
  65. package/dist/sessions/hook-calls.js +4 -3
  66. package/dist/sessions/hooks.d.ts +1 -1
  67. package/dist/sessions/hooks.js +6 -2
  68. package/dist/sessions/identity.d.ts +6 -0
  69. package/dist/sessions/injected.d.ts +13 -0
  70. package/dist/sessions/injected.js +212 -0
  71. package/dist/sessions/install.d.ts +31 -20
  72. package/dist/sessions/install.js +80 -82
  73. package/dist/sessions/load-status.d.ts +41 -0
  74. package/dist/sessions/load-status.js +156 -0
  75. package/dist/sessions/machine-child-deps.d.ts +5 -0
  76. package/dist/sessions/machine-child-deps.js +83 -0
  77. package/dist/sessions/machine-child.d.ts +43 -0
  78. package/dist/sessions/machine-child.js +120 -0
  79. package/dist/sessions/machine-credential.d.ts +10 -0
  80. package/dist/sessions/machine-credential.js +20 -0
  81. package/dist/sessions/machine-enroll.d.ts +47 -0
  82. package/dist/sessions/machine-enroll.js +230 -0
  83. package/dist/sessions/machine-hold.d.ts +49 -0
  84. package/dist/sessions/machine-hold.js +88 -0
  85. package/dist/sessions/machine-ids.d.ts +6 -0
  86. package/dist/sessions/machine-ids.js +9 -0
  87. package/dist/sessions/machine-notice.d.ts +21 -0
  88. package/dist/sessions/machine-notice.js +89 -0
  89. package/dist/sessions/machine-pull.d.ts +40 -0
  90. package/dist/sessions/machine-pull.js +220 -0
  91. package/dist/sessions/machine-repo.d.ts +7 -0
  92. package/dist/sessions/machine-repo.js +30 -0
  93. package/dist/sessions/machine-scan-cache.d.ts +12 -0
  94. package/dist/sessions/machine-scan-cache.js +47 -0
  95. package/dist/sessions/machine-send.d.ts +12 -0
  96. package/dist/sessions/machine-send.js +156 -0
  97. package/dist/sessions/machine-stop.d.ts +22 -0
  98. package/dist/sessions/machine-stop.js +53 -0
  99. package/dist/sessions/machine.d.ts +41 -0
  100. package/dist/sessions/machine.js +89 -0
  101. package/dist/sessions/opencode.d.ts +2 -0
  102. package/dist/sessions/opencode.js +6 -0
  103. package/dist/sessions/otel.d.ts +21 -0
  104. package/dist/sessions/otel.js +109 -38
  105. package/dist/sessions/owner-only.d.ts +7 -0
  106. package/dist/sessions/owner-only.js +34 -0
  107. package/dist/sessions/pi.d.ts +13 -1
  108. package/dist/sessions/pi.js +27 -13
  109. package/dist/sessions/pool-books.d.ts +1 -1
  110. package/dist/sessions/pool-books.js +1 -1
  111. package/dist/sessions/pool-choices.d.ts +4 -2
  112. package/dist/sessions/pool-choices.js +4 -2
  113. package/dist/sessions/pool-command.d.ts +46 -6
  114. package/dist/sessions/pool-command.js +577 -133
  115. package/dist/sessions/pool-delta.d.ts +7 -0
  116. package/dist/sessions/pool-delta.js +28 -5
  117. package/dist/sessions/pool-due.d.ts +43 -0
  118. package/dist/sessions/pool-due.js +113 -0
  119. package/dist/sessions/pool-nightly.d.ts +9 -1
  120. package/dist/sessions/pool-nightly.js +65 -21
  121. package/dist/sessions/pool-outbox.d.ts +51 -5
  122. package/dist/sessions/pool-outbox.js +217 -24
  123. package/dist/sessions/pool-print.d.ts +1 -1
  124. package/dist/sessions/pool-raw.js +4 -3
  125. package/dist/sessions/pool-replica.d.ts +4 -4
  126. package/dist/sessions/pool-replica.js +26 -7
  127. package/dist/sessions/pool-send-lock.js +1 -1
  128. package/dist/sessions/pool-sent.d.ts +2 -0
  129. package/dist/sessions/pool-sent.js +21 -4
  130. package/dist/sessions/pool-wire.d.ts +172 -12
  131. package/dist/sessions/pool-wire.js +284 -30
  132. package/dist/sessions/pool.js +4 -2
  133. package/dist/sessions/public-vocabulary.d.ts +5 -0
  134. package/dist/sessions/public-vocabulary.js +16 -0
  135. package/dist/sessions/read-call-shape.js +12 -6
  136. package/dist/sessions/read-fit.d.ts +5 -0
  137. package/dist/sessions/read-fit.js +23 -2
  138. package/dist/sessions/read.d.ts +1 -1
  139. package/dist/sessions/recall-consequence.d.ts +2 -2
  140. package/dist/sessions/recall-consequence.js +14 -9
  141. package/dist/sessions/recall-object.d.ts +9 -2
  142. package/dist/sessions/receipt.d.ts +8 -0
  143. package/dist/sessions/receipt.js +78 -0
  144. package/dist/sessions/recovery-calls.d.ts +3 -2
  145. package/dist/sessions/recovery-calls.js +9 -3
  146. package/dist/sessions/redact.d.ts +2 -2
  147. package/dist/sessions/redact.js +116 -14
  148. package/dist/sessions/reversion.d.ts +8 -0
  149. package/dist/sessions/reversion.js +114 -0
  150. package/dist/sessions/schema.d.ts +78 -4
  151. package/dist/sessions/schema.js +31 -1
  152. package/dist/sessions/session-shape.d.ts +2 -1
  153. package/dist/sessions/session-shape.js +15 -0
  154. package/dist/sessions/sources.d.ts +9 -0
  155. package/dist/sessions/sources.js +16 -5
  156. package/dist/sessions/steps.js +3 -2
  157. package/dist/sessions/store.d.ts +41 -2
  158. package/dist/sessions/store.js +68 -16
  159. package/dist/sessions/try-demo.d.ts +8 -0
  160. package/dist/sessions/try-demo.js +90 -0
  161. package/dist/sessions/try.d.ts +13 -0
  162. package/dist/sessions/try.js +50 -0
  163. package/dist/sessions/vscode-copilot.d.ts +4 -0
  164. package/dist/sessions/vscode-copilot.js +25 -18
  165. package/dist/telemetry/command.js +12 -6
  166. package/dist/telemetry/consent.d.ts +9 -3
  167. package/dist/telemetry/consent.js +25 -9
  168. package/dist/telemetry/fields.d.ts +11 -0
  169. package/dist/telemetry/fields.js +56 -0
  170. package/dist/telemetry/measures.d.ts +13 -0
  171. package/dist/telemetry/measures.js +30 -0
  172. package/dist/telemetry/observe.d.ts +15 -2
  173. package/dist/telemetry/observe.js +34 -7
  174. package/dist/telemetry/region.d.ts +6 -0
  175. package/dist/telemetry/region.js +28 -0
  176. package/dist/telemetry/report.js +1 -1
  177. package/dist/telemetry/schema.d.ts +3 -0
  178. package/dist/telemetry/schema.js +11 -1
  179. package/dist/telemetry/send.d.ts +5 -2
  180. package/dist/telemetry/send.js +15 -7
  181. package/dist/telemetry/words.d.ts +6 -0
  182. package/dist/telemetry/words.js +6 -0
  183. package/package.json +23 -4
@@ -7,8 +7,8 @@ import { posix } from 'node:path';
7
7
  import { ACTION_TABLES, actionOf, shellOf } from './actions.js';
8
8
  import { exploreCount, strataOf } from './strata.js';
9
9
  import { closedFrame, frameIdOf, repoFilesFromIndex, repoSizeOf, stackOf, utcDay } from './frame.js';
10
- import { argClassOf, targetClassOf } from './call-shape.js';
11
- import { LITERAL } from './public-vocabulary.js';
10
+ import { argClassOf, argFeaturesOf, argNamesOf, argValuesOf, changeKindOf, diffShapeOf, targetClassOf, templatesOf, variableOf } from './call-shape.js';
11
+ import { CLOSED_ARG_VALUES, LITERAL } from './public-vocabulary.js';
12
12
  import { linkSteps } from './graph.js';
13
13
  import { callIdentity, shellIdentity, UNREAD_SHELL } from './identity.js';
14
14
  import { manifestDelta, manifestFileOf, manifestNames, packagesOf } from './package-resolve.js';
@@ -94,6 +94,23 @@ export const isErrorResult = (block, text) => {
94
94
  const exit = EXIT_RE.exec(text.trim());
95
95
  return exit !== null && exit[1] !== '0';
96
96
  };
97
+ /** The first 32 hex digits of the SHA-256 of a text's exact UTF-8 bytes: plain, not keyed, so the same text matches across sessions and accounts, and a line ending or a trailing space is a difference. */
98
+ const textDigest = (text) => createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 32);
99
+ /**
100
+ * The digests of an edit call's text, from the input: an Edit has one old and new pair, a MultiEdit one per edit in order, a Write a new digest only (its old text is not in the call, so no old digest is held, not the digest of an
101
+ * empty string). Any other tool, and an edit whose input holds no text for the side it needs, has none: a malformed entry of a MultiEdit is skipped.
102
+ */
103
+ const editDigestsOf = (tool, input) => {
104
+ if (tool === 'Write') {
105
+ const content = str(input.content);
106
+ return content === undefined ? [] : [{ new: textDigest(content) }];
107
+ }
108
+ const edits = tool === 'MultiEdit' && Array.isArray(input.edits) ? input.edits.filter(isObj) : tool === 'Edit' ? [input] : [];
109
+ return edits.flatMap((edit) => {
110
+ const [old, replacement] = [str(edit.old_string), str(edit.new_string)];
111
+ return old === undefined || replacement === undefined ? [] : [{ old: textDigest(old), new: textDigest(replacement) }];
112
+ });
113
+ };
97
114
  /** What an Edit, MultiEdit, or Write shows of a manifest: the names it adds and removes (an edit), or lists (a write). Undefined when it shows none. */
98
115
  const manifestOfInput = (file, tool, input, redact) => {
99
116
  if (tool === 'Write') {
@@ -222,6 +239,28 @@ const readUsage = (message) => {
222
239
  observed: reported.filter(([, value]) => value !== undefined).map(([field]) => field),
223
240
  };
224
241
  };
242
+ /** The usage categories a line reported, as the fields of a `MessageUsage`; a category the source left out is not written. */
243
+ const reportedUsage = (line) => {
244
+ const fields = {};
245
+ for (const field of USAGE_FIELDS)
246
+ if (line.observed.includes(field))
247
+ fields[field] = line.usage[field];
248
+ return fields;
249
+ };
250
+ const planWindowOf = (raw) => {
251
+ if (!isObj(raw))
252
+ return undefined;
253
+ const usedPercent = num(raw.usedPercent);
254
+ const windowMinutes = num(raw.windowMinutes);
255
+ return usedPercent === undefined ? undefined : { usedPercent, ...(windowMinutes === undefined ? {} : { windowMinutes }) };
256
+ };
257
+ /** The plan share a Codex line carries (`rateLimits`, written by `rolloutLines`), or undefined when it carries none. */
258
+ const planOf = (line) => {
259
+ const limits = isObj(line.rateLimits) ? line.rateLimits : undefined;
260
+ const primary = planWindowOf(limits?.primary);
261
+ const secondary = planWindowOf(limits?.secondary);
262
+ return primary === undefined && secondary === undefined ? undefined : { ...(primary === undefined ? {} : { primary }), ...(secondary === undefined ? {} : { secondary }) };
263
+ };
225
264
  const toSeconds = (timestamp) => {
226
265
  if (timestamp === undefined)
227
266
  return undefined;
@@ -289,7 +328,7 @@ const hasToolResult = (content) => Array.isArray(content) && content.some((block
289
328
  const hasImage = (content) => Array.isArray(content) && content.some((block) => isObj(block) && block.type === 'image');
290
329
  // The tags a local command (a slash command the harness ran itself) leaves as user lines. A model's answer to a command makes it a prompt, and that case never reaches
291
330
  // the run: a tag listed here that is still on a line there was not answered.
292
- const LOCAL_COMMAND_TAGS = new Set(['command-name', 'command-message', 'local-command-stdout', 'local-command-stderr', 'local-command-caveat']);
331
+ export const LOCAL_COMMAND_TAGS = new Set(['command-name', 'command-message', 'local-command-stdout', 'local-command-stderr', 'local-command-caveat']);
293
332
  const firstTextBlock = (content) => {
294
333
  if (typeof content === 'string')
295
334
  return content;
@@ -545,22 +584,106 @@ const VERSIONED = /^(@?[^@:\s]+)@(?!\d+\.\d+\.\d+\.\d+$)(v?\d+(?:\.(?:\d+|x|\*))
545
584
  const redactCommand = (command, redact) => redact(command.replace(/[^\s"'`]+/g, (word) => word.replace(VERSIONED, `$1${VERSION_AT}$2`))).replaceAll(VERSION_AT, '@');
546
585
  const MASK = /^<(?:secret|email|org-domain)>/;
547
586
  const ONLY_MASKS = /^(?:<(?:secret|email|org-domain)>)+$/;
548
- const tokenize = (text) => {
587
+ /** Where the single-quoted text that opens at `open` ends: the closing quote, or the end of the text. Bash's ANSI-C string (`$'it\'s'`) honors a backslash, so its `\'` is inside it; any other single-quoted text ends at the next `'`. */
588
+ const singleQuotedEnd = (text, open, ansiC = true) => {
589
+ const ansi = ansiC && text.charAt(open - 1) === '$';
590
+ let end = open + 1;
591
+ while (end < text.length && text.charAt(end) !== "'")
592
+ end += ansi && text.charAt(end) === '\\' ? 2 : 1;
593
+ return Math.min(end, text.length);
594
+ };
595
+ /** What closes each opener that can hold quotes of its own inside a double-quoted piece: a command substitution (`$(`, a backtick) or a parameter expansion (`${x:-"a b"}`). */
596
+ const CLOSERS = new Map([
597
+ ['$(', ')'],
598
+ ['${', '}'],
599
+ ['`', '`'],
600
+ ]);
601
+ /**
602
+ * Where the substitution or expansion that began just before `from`, closed by `first`, ends: past its closing character, or -1 when it never closes (PowerShell's backtick is an escape, not an opener, and a torn command has no end).
603
+ * Whatever quoting it holds is its own: a quote inside it never ends the double-quoted piece that carries it, so a quote cannot move the reader's state. An explicit stack keeps a crafted command that nests without end from
604
+ * overflowing the call stack, and `budget` (characters left to scan across one read) keeps one that opens thousands of them from costing a scan to the end of the text each.
605
+ */
606
+ const substitutionEnd = (text, from, first, budget, powershell) => {
607
+ const stack = [first];
608
+ let at = from;
609
+ // PowerShell's escape is the backtick, and a backslash is an ordinary character there.
610
+ const escape = powershell ? '`' : '\\';
611
+ // Inside a double-quoted piece or an expansion a single quote and a bare parenthesis are text; inside a substitution they quote and group.
612
+ while (at < text.length && stack.length > 0 && budget.left > 0) {
613
+ budget.left -= 1;
614
+ const char = text.charAt(at);
615
+ const top = stack.at(-1);
616
+ const opener = CLOSERS.get(char === '$' ? char + text.charAt(at + 1) : char);
617
+ at += 1;
618
+ if (char === escape)
619
+ at += 1;
620
+ else if (char === top)
621
+ stack.pop();
622
+ else if (opener !== undefined) {
623
+ stack.push(opener);
624
+ at += char === '$' ? 1 : 0;
625
+ }
626
+ else if (char === '"')
627
+ stack.push('"');
628
+ else if (char === "'" && (top === ')' || top === '`'))
629
+ at = singleQuotedEnd(text, at - 1, !powershell) + 1;
630
+ else if (char === '(' && (top === ')' || top === '`'))
631
+ stack.push(')');
632
+ }
633
+ return stack.length === 0 ? at : -1;
634
+ };
635
+ /** What an unquoted word holds before its first quote when it is an assignment or a joined long flag: `API_REGION=` and `--region=`, with whatever unquoted text follows the `=`. */
636
+ const JOINED_PREFIX = /^(?:[A-Za-z_][A-Za-z0-9_]*|--[A-Za-z][A-Za-z0-9_-]*)=/;
637
+ /**
638
+ * The command's opaque point: its first single quote, heredoc marker, typographic quote (U+2018 to U+201E), block comment opener (`<#`), or stop-parsing token (`--%`). An unexpanded `$name` can only come from literal text, and
639
+ * these are where literal text begins in either shell, so a word that starts before this point is read as the shell reads it, and every word at or after it is opaque: it holds no variable and no flag value, whatever the reader made
640
+ * of the quotes before it. The point does not depend on the dialect, since a harness may run a PowerShell command and not say so (Codex, a nested `pwsh -c`); a PowerShell form in a POSIX command is rare, and the point only sends less.
641
+ */
642
+ const OPAQUE_POINT = /['‘-„]|<<|<#|--%/;
643
+ /**
644
+ * `opaqueFrom` is where that point sits in `text` (-1 for none, 0 for a text that is opaque throughout, as a word an opaque word holds is); left out, it is the first point of the text. A substitution opened inside double quotes
645
+ * lowers it to where it opens, since its end is read by a scan that can close early: every word that starts after it is opaque, as is every word that starts after an unquoted backtick or an escaped double quote, which count only in a POSIX shell (PowerShell's backtick is its escape).
646
+ */
647
+ const tokenize = (text, opaqueFrom, dialect = 'posix') => {
648
+ let opaque = opaqueFrom ?? text.search(OPAQUE_POINT);
549
649
  const tokens = [];
650
+ // PowerShell's escape is the backtick, a backslash is an ordinary character in and out of quotes, `$'` is no ANSI-C string, and a backtick opens no substitution.
651
+ const powershell = dialect === 'powershell';
652
+ const escape = powershell ? '`' : '\\';
550
653
  let word = '';
551
654
  let quoted = false;
655
+ // The word's raw source starts with an unquoted `NAME=` or `--flag=` before its first quote: the prefix is as written, whatever the quote makes of the rest.
656
+ let joined = false;
552
657
  let started = false;
553
658
  let redirected = false;
659
+ let at = 0;
660
+ // Where the word's raw source starts in `text`: a word that is exactly one variable reference carries that variable, and any other word carries none, whatever its quotes and `$` marks hold.
661
+ let start = -1;
662
+ const budget = { left: 16 * text.length + 1024 };
554
663
  const flush = () => {
555
664
  if (started && redirected)
556
665
  redirected = false;
557
666
  else if (started && !quoted && (word === '{' || word === '}'))
558
667
  tokens.push(word === '{' ? 'open' : 'close');
559
- else if (started)
560
- tokens.push({ text: word, quoted });
668
+ else if (started) {
669
+ const held = { text: word, quoted };
670
+ if (opaque >= 0 && start >= opaque)
671
+ held.opaque = true;
672
+ else {
673
+ const variable = variableOf(text.slice(start, at));
674
+ if (variable !== undefined)
675
+ held.variable = variable;
676
+ // A single quote or a substitution after the `=` is an opaque point inside the word (the value is literal text there), so only a plain double-quoted value is read.
677
+ if (joined && (opaque < 0 || opaque >= at))
678
+ held.joined = true;
679
+ }
680
+ tokens.push(held);
681
+ }
561
682
  word = '';
562
683
  quoted = false;
684
+ joined = false;
563
685
  started = false;
686
+ start = -1;
564
687
  };
565
688
  const op = (token) => {
566
689
  flush();
@@ -571,13 +694,17 @@ const tokenize = (text) => {
571
694
  return;
572
695
  tokens.push(token);
573
696
  };
574
- let at = 0;
575
697
  while (at < text.length) {
576
698
  const char = text.charAt(at);
577
699
  const next = text.charAt(at + 1);
700
+ const here = at;
701
+ if ((char === "'" || char === '"') && !quoted && JOINED_PREFIX.test(word))
702
+ joined = true;
703
+ // An unquoted backtick opens a substitution whose end the reader does not scan for: an escaped quote inside it reads as a bare one, so every word that starts after it is opaque, as after a substitution inside double quotes.
704
+ if (!powershell && char === '`')
705
+ opaque = opaque < 0 ? at : Math.min(opaque, at);
578
706
  if (char === "'") {
579
- const end = text.indexOf("'", at + 1);
580
- const stop = end < 0 ? text.length : end;
707
+ const stop = singleQuotedEnd(text, at, !powershell);
581
708
  word += text.slice(at + 1, stop);
582
709
  quoted = true;
583
710
  started = true;
@@ -588,23 +715,49 @@ const tokenize = (text) => {
588
715
  started = true;
589
716
  at += 1;
590
717
  while (at < text.length && text.charAt(at) !== '"') {
591
- if (text.charAt(at) === '\\' && text.charAt(at + 1) === '\n')
718
+ const inner = text.charAt(at);
719
+ const after = text.charAt(at + 1);
720
+ const opener = inner === '$' ? inner + after : inner;
721
+ const closer = powershell && inner === '`' ? undefined : CLOSERS.get(opener);
722
+ if (closer === ')' || closer === '`')
723
+ opaque = opaque < 0 ? at : Math.min(opaque, at);
724
+ const end = closer === undefined ? -1 : substitutionEnd(text, at + opener.length, closer, budget, powershell);
725
+ if (inner === escape && (after === escape || after === '$' || after === '`')) {
726
+ // A backslash pair is one escaped backslash, so the second never escapes the quote after it; an escaped dollar or backtick opens nothing.
727
+ word += inner + after;
592
728
  at += 2;
593
- else if (text.charAt(at) === '\\' && text.charAt(at + 1) === '"') {
729
+ }
730
+ else if (inner === escape && after === '\n') {
731
+ at += 2;
732
+ }
733
+ else if (inner === escape && after === '"') {
734
+ // POSIX reads the escaped quote as data, but the same text under PowerShell (a Codex call is stored as `Bash` whatever shell ran it) ends the string there, so every word after it may be read with the quote state inverted.
735
+ if (!powershell)
736
+ opaque = opaque < 0 ? at : Math.min(opaque, at);
594
737
  word += '"';
595
738
  at += 2;
596
739
  }
740
+ else if (end >= 0) {
741
+ // A command substitution stays whole in this word: a quote inside it is its own, and never closes this piece. One that never closes is read as the text it is.
742
+ word += text.slice(at, end);
743
+ at = end;
744
+ }
597
745
  else {
598
- word += text.charAt(at);
746
+ word += inner;
599
747
  at += 1;
600
748
  }
601
749
  }
602
750
  at += 1;
603
751
  }
604
- else if ((char === '\\' || char === '`') && next === '\n') {
752
+ else if (!powershell && char === '\\' && next === '\\') {
753
+ word += '\\\\';
754
+ started = true;
755
+ at += 2;
756
+ }
757
+ else if ((char === escape || char === '`') && next === '\n') {
605
758
  at += 2;
606
759
  }
607
- else if (char === '\\' && next !== '' && /[\s&|;()<>'"]/.test(next)) {
760
+ else if (char === escape && next !== '' && (powershell || /[\s&|;()<>'"]/.test(next))) {
608
761
  word += next;
609
762
  started = true;
610
763
  at += 2;
@@ -668,6 +821,8 @@ const tokenize = (text) => {
668
821
  started = true;
669
822
  at += 1;
670
823
  }
824
+ if (started && start < 0)
825
+ start = here;
671
826
  }
672
827
  flush();
673
828
  return tokens;
@@ -701,7 +856,7 @@ const afterPrelude = (words) => {
701
856
  const word = words[at];
702
857
  if (word === undefined)
703
858
  return [];
704
- if (word.quoted)
859
+ if (word.quoted && word.joined !== true)
705
860
  return words.slice(at);
706
861
  const name = programOf(word) ?? word.text;
707
862
  if (BLOCK_WORDS.has(word.text.toLowerCase()) || ASSIGNMENT.test(word.text))
@@ -749,7 +904,8 @@ const shellArgument = (program, args) => {
749
904
  const first = flag < 0 ? undefined : args[flag + 1];
750
905
  if (first === undefined)
751
906
  return null;
752
- return JOINING_SHELLS.has(program) && !first.quoted ? args.slice(flag + 1) : tokenize(first.text);
907
+ // The argument's own text is read for its own first quote or marker, unless the word is opaque already: then nothing in it is read.
908
+ return JOINING_SHELLS.has(program) && !first.quoted ? args.slice(flag + 1) : tokenize(first.text, first.opaque === true ? 0 : undefined);
753
909
  };
754
910
  /** How many shells deep a command is read. Real commands nest two or three; the cap keeps a torn or hostile one from exhausting the stack. */
755
911
  const SHELL_DEPTH_MAX = 8;
@@ -769,10 +925,11 @@ const readCommands = (tokens, depth = 0) => {
769
925
  const owned = new Map();
770
926
  const close = () => {
771
927
  const real = afterPrelude(words);
928
+ const before = words.slice(0, words.length - real.length);
772
929
  words = [];
773
930
  const [head, ...args] = real;
774
931
  const program = head === undefined ? null : programOf(head);
775
- if (program === null)
932
+ if (program === null || head === undefined)
776
933
  return;
777
934
  const previous = commands.at(-1);
778
935
  if (previous !== undefined)
@@ -791,7 +948,7 @@ const readCommands = (tokens, depth = 0) => {
791
948
  });
792
949
  owned.clear();
793
950
  pending = [];
794
- commands.push({ program, args, read: true, link: 'end', hidden: false });
951
+ commands.push({ program, args, before, head, read: true, link: 'end', hidden: false });
795
952
  };
796
953
  for (const token of tokens) {
797
954
  if (typeof token === 'string') {
@@ -1085,17 +1242,73 @@ const isReadBack = (commands, from, scope) => {
1085
1242
  }
1086
1243
  return false;
1087
1244
  };
1245
+ const RESTORE_VALUE_FLAGS = new Set(['-s', '--source', '--pathspec-from-file']);
1246
+ const STAGED_FLAG = /^(?:--staged|-[a-zA-Z]*S[a-zA-Z]*)$/;
1247
+ const WORKTREE_FLAG = /^(?:--worktree|-[a-zA-Z]*W[a-zA-Z]*)$/;
1248
+ const PATCH_FLAG = /^(?:--patch|-[a-zA-Z]*p[a-zA-Z]*)$/;
1249
+ /** The words a `git restore` or `git checkout --` takes as paths to the working file. `git restore --staged` alone restores the index, and `--patch` restores part of a file, so neither names a whole file; `git checkout` with no `--` may name a branch. */
1250
+ const restoredBy = (git) => {
1251
+ const words = git.args;
1252
+ if (git.sub === 'checkout') {
1253
+ const dashes = words.findIndex((word) => !word.quoted && word.text === '--');
1254
+ // `git checkout --patch` takes part of a file, as `git restore --patch` does, so it names no whole file.
1255
+ if (dashes < 0 || words.slice(0, dashes).some((word) => !word.quoted && PATCH_FLAG.test(word.text)))
1256
+ return [];
1257
+ return words.slice(dashes + 1).map((word) => word.text);
1258
+ }
1259
+ if (git.sub !== 'restore')
1260
+ return [];
1261
+ const paths = [];
1262
+ let staged = false;
1263
+ let worktree = false;
1264
+ for (let at = 0; at < words.length; at += 1) {
1265
+ const text = words[at]?.text ?? '';
1266
+ if (text === '--')
1267
+ return staged && !worktree ? [] : [...paths, ...words.slice(at + 1).map((word) => word.text)];
1268
+ if (PATCH_FLAG.test(text))
1269
+ return [];
1270
+ if (RESTORE_VALUE_FLAGS.has(text))
1271
+ at += 1;
1272
+ else if (text.startsWith('-')) {
1273
+ staged ||= STAGED_FLAG.test(text);
1274
+ worktree ||= WORKTREE_FLAG.test(text);
1275
+ }
1276
+ else
1277
+ paths.push(text);
1278
+ }
1279
+ return staged && !worktree ? [] : paths;
1280
+ };
1281
+ /**
1282
+ * The paths the call's `git restore` and `git checkout --` commands name, for a command whose status the call reports and that runs in the call's folder: after a `cd`, or under a repository flag (`-C`), a relative path means another folder.
1283
+ * A word that holds a variable is no path.
1284
+ */
1285
+ const restoredPaths = (commands) => {
1286
+ const folderAt = commands.findIndex((command) => CHANGES_FOLDER.has(command.program));
1287
+ return (folderAt < 0 ? commands : commands.slice(0, folderAt)).flatMap((command) => {
1288
+ const git = command.read ? gitCall(command) : null;
1289
+ return git === null || git.scope !== '' ? [] : restoredBy(git).filter((path) => path !== '' && !path.includes('$'));
1290
+ });
1291
+ };
1088
1292
  const commitPlanOf = (commands) => commands.flatMap((command, at) => {
1089
1293
  const git = gitCall(command);
1090
1294
  if (git === null || git.sub !== 'commit' || git.args.some((word) => !word.quoted && NOT_A_COMMIT.test(word.text)))
1091
1295
  return [];
1092
1296
  return [{ quiet: git.args.some((word) => !word.quoted && QUIET.test(word.text)), readBack: isReadBack(commands, at, git.scope) }];
1093
1297
  });
1094
- /** Every simple command of a shell call, in order, after the bodies are dropped and the command is redacted. `oars gate` reads the same commands as capture. */
1095
- export const shellCommands = (command, redact) => readCommands(tokenize(redactCommand(dropBodies(command.replace(/\r\n/g, '\n')), redact)));
1298
+ /** Every simple command of a shell call, in order, after the bodies are dropped and the command is redacted. Capture passes the dialect of the tool; `gate/checks.ts:524` and `projection.ts:95` call it with the POSIX default. */
1299
+ export const shellCommands = (command, redact, dialect = 'posix') => {
1300
+ const whole = command.replace(/\r\n/g, '\n');
1301
+ const kept = dropBodies(whole);
1302
+ const text = redactCommand(kept, redact);
1303
+ // The command's opaque point (`tokenize`) is read on the whole command. Dropping a body or redacting can remove it and leave a later one in its place, and the words between would read as before it: when the first point
1304
+ // moved or a point is gone, the whole text is opaque.
1305
+ const points = (held) => held.match(new RegExp(OPAQUE_POINT.source, 'g'))?.length ?? 0;
1306
+ const moved = kept.search(OPAQUE_POINT) !== whole.search(OPAQUE_POINT) || points(text) !== points(kept);
1307
+ return readCommands(tokenize(text, moved ? 0 : undefined, dialect));
1308
+ };
1096
1309
  /** A shell call's segments, pulls, and the commits it holds, from the command alone. The command is redacted before anything is kept. */
1097
- export const readShell = (command, redact) => {
1098
- const commands = shellCommands(command, redact);
1310
+ export const readShell = (command, redact, dialect = 'posix') => {
1311
+ const commands = shellCommands(command, redact, dialect);
1099
1312
  const kept = commands.slice(-SEGMENTS_PER_CALL_MAX);
1100
1313
  const pulls = new Map();
1101
1314
  for (const { program, args } of commands) {
@@ -1103,10 +1316,12 @@ export const readShell = (command, redact) => {
1103
1316
  pulls.set(JSON.stringify(pull), pull);
1104
1317
  }
1105
1318
  return {
1319
+ words: commands.map(({ before, head, args }) => ({ before, head, args })),
1106
1320
  segments: kept.map((held) => ({ run: runOf(held), read: held.read })),
1107
1321
  steps: kept.map((held) => ({ identity: shellIdentity(held.program, held.args, pullsOfCommand(held.program, held.args).filter(present), redact), read: held.read, run: runOf(held) })),
1108
1322
  pulls: [...pulls.values()],
1109
1323
  commitPlan: commitPlanOf(commands),
1324
+ restores: restoredPaths(commands),
1110
1325
  };
1111
1326
  };
1112
1327
  const COMMIT_LINE = /^\[[^\]\n]* ([0-9a-f]{7,40})\]/gm;
@@ -1330,6 +1545,8 @@ export const captureSession = (input) => {
1330
1545
  const skeletonLines = [];
1331
1546
  const toolById = new Map();
1332
1547
  const callById = new Map();
1548
+ // The last call of each tool in each prompt, so the next one states what changed. Its raw input is held only while this transcript is read.
1549
+ const lastCall = new Map();
1333
1550
  // Packages named so far in this file, by package and version, so a call's `first` mark is true once per version.
1334
1551
  const seenPackages = new Set();
1335
1552
  // A Read of a manifest shows its names in the result, so the call waits here for it, by tool_use id.
@@ -1348,6 +1565,9 @@ export const captureSession = (input) => {
1348
1565
  const callsPerResponse = new Map();
1349
1566
  const outputPerResponse = new Map();
1350
1567
  const seenResponses = new Set();
1568
+ /** The distinct messages that reported usage, by message key, and the tool-use ids each made (kept apart so a call on a line before the first usage line is still tied). */
1569
+ const messageGroups = new Map();
1570
+ const callsOfMessage = new Map();
1351
1571
  let promptFingerprint = null;
1352
1572
  let cur;
1353
1573
  // The setup, funnel, and trust shape. A subagent's run is the parent's, so it has no run of its own alone.
@@ -1482,6 +1702,13 @@ export const captureSession = (input) => {
1482
1702
  session.userTurns += 1;
1483
1703
  }
1484
1704
  }
1705
+ // A message is the unit usage belongs to: the same id on every content-block line is one message, and a replayed line is the same one again.
1706
+ // Only Claude Code and Codex report per-message counts; another harness's totals are not a message's, and a synthetic line is the harness speaking.
1707
+ const messageKey = (harness === 'claude-code' || harness === 'codex') && messageModel !== '<synthetic>' && type === 'assistant' && role === 'assistant' ? str(message.id) || str(o.requestId) || `line ${String(position)}` : undefined;
1708
+ if (messageKey !== undefined && lineUsage !== undefined && !messageGroups.has(messageKey)) {
1709
+ const plan = planOf(o);
1710
+ messageGroups.set(messageKey, { id: str(message.id) || str(o.requestId) || null, calls: [], ...(side ? { side: true } : {}), ...reportedUsage(lineUsage), ...(plan === undefined ? {} : { plan }) });
1711
+ }
1485
1712
  if (type === 'assistant' && role === 'assistant') {
1486
1713
  session.assistantTurns += 1;
1487
1714
  if (!side && lineUsage !== undefined && firstOfResponse) {
@@ -1619,7 +1846,7 @@ export const captureSession = (input) => {
1619
1846
  else if (SHELL.has(name))
1620
1847
  session.bashCmds += 1;
1621
1848
  const aimed = targetOf(name, toolInput, lineCwd, salt, redact, lineRoot);
1622
- const shell = SHELL.has(name) || named.action === 'shell.exec' ? readShell(str(toolInput.command) ?? '', redact) : undefined;
1849
+ const shell = SHELL.has(name) || named.action === 'shell.exec' ? readShell(str(toolInput.command) ?? '', redact, shellName === 'powershell' ? 'powershell' : 'posix') : undefined;
1623
1850
  for (const held of shell?.steps ?? [])
1624
1851
  framePrograms.add(held.identity.program);
1625
1852
  drafts.push(draftsOf(actions, named, native, shell, aimed.target, toolInput, redact));
@@ -1634,6 +1861,22 @@ export const captureSession = (input) => {
1634
1861
  // Who made the call, from its own line: the model is not the prompt's majority, and a line inside a skill's run names the skill (the first line after the run does not).
1635
1862
  const callModel = requested !== undefined && requested !== '' ? requested : realMessageModel;
1636
1863
  const callSkill = truthy(o.attributionSkill) ? String(o.attributionSkill) : undefined;
1864
+ // Depth reads below the repository root, else the working folder, as the target's hash does; a relative value is taken from the working folder first.
1865
+ const argFeatures = argFeaturesOf(native, toolInput, lineRoot ?? lineCwd, lineCwd);
1866
+ const argValues = argValuesOf(native, toolInput, CLOSED_ARG_VALUES);
1867
+ // A name is stored as the line's text is: masked at capture, so a credential-shaped folder or file name never reaches the store. The mask has no name shape, so the wire still drops it and counts it.
1868
+ const names = argNamesOf(native, toolInput, lineRoot ?? lineCwd, lineCwd, shell?.words ?? [], CLOSED_ARG_VALUES, shell?.restores ?? []).map(({ kind, name }) => ({ kind, name: redact(name) }));
1869
+ const digests = editDigestsOf(name, toolInput);
1870
+ // A restored path is hashed as an edit's target is, so the two compare; the same call's folder and repository anchor a relative one.
1871
+ const restores = [...new Set((shell?.restores ?? []).map((path) => hashPath(path, lineCwd, lineRoot, salt).hash))];
1872
+ // A template keeps a program, a subcommand, and flags as written and every literal as a slot; its kept words are masked as a name is, and the wire gates each one again.
1873
+ const templates = templatesOf(shell?.words ?? [], redact);
1874
+ // An edit is stored as the shapes of its change from a closed list, read from the old and new text and never kept.
1875
+ const diffShape = diffShapeOf(name, toolInput);
1876
+ const compared = { input: toolInput, target: aimed.target, features: argFeatures, values: argValues };
1877
+ const lastKey = `${String(cur?.index ?? null)}\n${String(side)}\n${stored}`;
1878
+ const before = lastCall.get(lastKey);
1879
+ lastCall.set(lastKey, compared);
1637
1880
  const call = {
1638
1881
  kind: 'tool',
1639
1882
  cv: CAPTURE_VERSION,
@@ -1652,6 +1895,14 @@ export const captureSession = (input) => {
1652
1895
  // The class reads the key the target is hashed on (relative to the repository or the cwd), never the absolute path: the folders above a repository say nothing about the file.
1653
1896
  targetClass: targetClassOf(aimed.key, aimed.base),
1654
1897
  argClass: argClassOf(toolInput),
1898
+ argFeatures,
1899
+ ...(argValues.length === 0 ? {} : { argValues }),
1900
+ ...(names.length === 0 ? {} : { names }),
1901
+ ...(digests.length === 0 ? {} : { digests }),
1902
+ ...(restores.length === 0 ? {} : { restores }),
1903
+ ...(templates.length === 0 ? {} : { templates }),
1904
+ ...(diffShape.length === 0 ? {} : { diffShape }),
1905
+ ...(before === undefined ? {} : { changeKind: changeKindOf(before, compared) }),
1655
1906
  durationMs: null,
1656
1907
  toolUseId: id ?? null,
1657
1908
  harnessVersion: version ?? null,
@@ -1665,6 +1916,8 @@ export const captureSession = (input) => {
1665
1916
  ...(manifest === undefined ? {} : { manifest }),
1666
1917
  };
1667
1918
  tools.push(call);
1919
+ if (id !== undefined && messageKey !== undefined)
1920
+ callsOfMessage.set(messageKey, [...(callsOfMessage.get(messageKey) ?? []), id]);
1668
1921
  if (id !== undefined)
1669
1922
  callById.set(id, { call, at: lineSeconds, plan: shell?.commitPlan ?? [] });
1670
1923
  }
@@ -1718,7 +1971,7 @@ export const captureSession = (input) => {
1718
1971
  const kept = [...files].sort().slice(0, FILES_PER_SESSION_MAX);
1719
1972
  session.files = kept;
1720
1973
  session.fileHashes = [...new Set(kept.flatMap((path) => [...(fileHashes.get(path) ?? [])]))].sort();
1721
- session.repo = sessionRoot === null ? null : sha256(`${salt}\nrepo:${identityOf(sessionRoot)}`).slice(0, 16);
1974
+ session.repo = sessionRoot === null ? null : sha256(`${input.repoSalt ?? salt}\nrepo:${identityOf(sessionRoot)}`).slice(0, 16);
1722
1975
  session.urls = [...urls].sort();
1723
1976
  session.searches = [...searches].sort();
1724
1977
  const repoFiles = input.repoFiles ?? repoFilesFromIndex;
@@ -1769,6 +2022,10 @@ export const captureSession = (input) => {
1769
2022
  frameId,
1770
2023
  }));
1771
2024
  }
2025
+ for (const [key, group] of messageGroups)
2026
+ group.calls = callsOfMessage.get(key) ?? [];
2027
+ if (messageGroups.size > 0)
2028
+ session.messageUsage = [...messageGroups.values()];
1772
2029
  // The strata: the file's evidence once, and each prompt's own `explore` from one pass over the calls. The size is the one the frame holds, so a re-capture that carries `repoSize` keeps `repoFiles` too.
1773
2030
  const packageNames = tools.flatMap((call) => [...(call.packages ?? []).map((pkg) => pkg.package), ...(call.manifest?.seen ?? []), ...(call.manifest?.added ?? [])]);
1774
2031
  const fileStrata = strataOf({ paths: framePaths, packages: packageNames, size });
@@ -23,6 +23,8 @@ export interface GeminiStore {
23
23
  transcript: (session: GeminiSession) => Promise<string>;
24
24
  /** Sessions skipped because their id is not a safe file name (it names a skeleton), counted so the skip is never silent. */
25
25
  rejected: number;
26
+ /** Files dropped as holding nothing that held a record of a shape the reader does not know (a renamed message type or content key). Read only by `sessions verify`; selection is unchanged. */
27
+ shapeMoved: number;
26
28
  /** Items that could not be inspected, reported once each and never fatal; the reason names no path. */
27
29
  unreadable: {
28
30
  file: string;
@@ -474,6 +474,28 @@ const holdsNothing = (text, id) => {
474
474
  return false;
475
475
  }
476
476
  };
477
+ /** The message types the CLI writes (chatRecordingService.ts): a prompt, a response, and the notices that carry no turn. */
478
+ const KNOWN_MESSAGE_TYPES = new Set(['user', 'gemini', 'info', 'warning', 'error']);
479
+ /** True when a prompt carries parts and none of them is a `text`, a `functionResponse`, or an `inlineData` or `fileData` (an image-only prompt): the parts' key moved. */
480
+ const holdsUnreadParts = (message) => {
481
+ const parts = partsOf(message.content);
482
+ return message.type === 'user' && parts.length > 0 && !parts.some((part) => typeof part.text === 'string' || 'functionResponse' in part || 'inlineData' in part || 'fileData' in part);
483
+ };
484
+ /**
485
+ * True when a chat dropped as holding nothing held records in a shape the reader does not know, not a launch and quit. Four shapes: a folded message of a type outside the known ones,
486
+ * or a prompt or response with no `content`; a prompt whose parts carry no `text`, `functionResponse`, `inlineData`, or `fileData`; a line that is an object but not a head, `$set`, `$patch`, `$rewindTo`, or a
487
+ * message with a string `id` (`foldChat` discards it); and a legacy `.json` head with no `messages` list (a line-format head carries none).
488
+ */
489
+ const holdsMovedShape = (text, file) => {
490
+ const whole = wholeObject(text);
491
+ if (whole !== undefined && extname(file) === '.json' && !Array.isArray(whole.messages))
492
+ return true;
493
+ const records = whole === undefined ? lineTexts(text).map(parse) : [whole];
494
+ const unplaced = records.some((record) => isObj(record) && str(record.sessionId) === undefined && str(record.id) === undefined && !isObj(record.$set) && !isObj(record.$patch) && typeof record.$rewindTo !== 'string');
495
+ if (unplaced)
496
+ return true;
497
+ return foldChat(text).messages.some((message) => typeof message.type !== 'string' || !KNOWN_MESSAGE_TYPES.has(message.type) || ((message.type === 'user' || message.type === 'gemini') && !('content' in message)) || holdsUnreadParts(message));
498
+ };
477
499
  /** The project roots a runtime folder's registry knows (`projects.json`, config/projectRegistry.ts:15-22): its keys. A missing or torn registry names none. */
478
500
  const registered = async (runtime) => {
479
501
  const registry = parse(await readFile(join(runtime, 'projects.json'), 'utf8').catch(() => ''));
@@ -497,11 +519,12 @@ export const openGeminiStore = async (root) => {
497
519
  if (bases.length === 0) {
498
520
  if (unreadable.length === 0)
499
521
  return undefined;
500
- return { sessions: [], transcript: () => Promise.reject(new Error('no sessions')), rejected: 0, unreadable };
522
+ return { sessions: [], transcript: () => Promise.reject(new Error('no sessions')), rejected: 0, shapeMoved: 0, unreadable };
501
523
  }
502
524
  // A file is a candidate under its session id.
503
525
  const found = new Map();
504
526
  let rejected = 0;
527
+ let shapeMoved = 0;
505
528
  const list = async (dir) => {
506
529
  try {
507
530
  return (await readdir(dir, { withFileTypes: true })).map((entry) => ({ name: entry.name, directory: entry.isDirectory() })).sort((a, b) => a.name.localeCompare(b.name));
@@ -530,8 +553,11 @@ export const openGeminiStore = async (root) => {
530
553
  return;
531
554
  }
532
555
  // Every new chat appends a head (geminiChat.ts:453), so a launch and quit, or a /clear, leaves a file with nothing in it: no session, and not an unreadable one.
533
- if (holdsNothing(text, named))
556
+ if (holdsNothing(text, named)) {
557
+ if (holdsMovedShape(text, file))
558
+ shapeMoved += 1;
534
559
  return;
560
+ }
535
561
  const session = { id, folder, file, size: info.size, mtimeMs: Math.floor(info.mtimeMs), parent };
536
562
  // A `.json` file migrated to `.jsonl` can leave both, with one name; the newer one is the session. Files of other names under one id are other chats (below).
537
563
  const key = `${id}/${name}`;
@@ -603,6 +629,7 @@ export const openGeminiStore = async (root) => {
603
629
  return {
604
630
  sessions,
605
631
  rejected,
632
+ shapeMoved,
606
633
  unreadable,
607
634
  transcript: async (session) => {
608
635
  const raw = await readFile(session.file, 'utf8');
@@ -0,0 +1,27 @@
1
+ import { type SessionsRuntime } from './store.js';
2
+ export interface FileCounts {
3
+ /** Files the reader selected and opened: `yielded + unreadable`, always. */
4
+ seen: number;
5
+ /** Seen files that gave at least one record (a user or assistant turn). */
6
+ yielded: number;
7
+ /** Seen files the reader could not read, or that gave no record from a form it does not know. */
8
+ unreadable: number;
9
+ }
10
+ /** What the docs and the tests quote: where each kind of file goes, so `seen` is the sum of the outcomes. */
11
+ export declare const FILE_COUNT_RULES: string;
12
+ export type FileOutcome = 'yielded' | 'unreadable' | 'empty';
13
+ /** `records` is undefined when the read itself failed; `headOnly` is the reader's word that a file with no turn holds only head records. */
14
+ export declare const outcomeOf: (file: {
15
+ size: number;
16
+ records: number | undefined;
17
+ headOnly?: boolean;
18
+ }) => FileOutcome;
19
+ export declare const tally: (outcomes: readonly FileOutcome[]) => FileCounts;
20
+ export interface HarnessFiles {
21
+ [harness: string]: FileCounts;
22
+ }
23
+ /**
24
+ * The counts for every harness the store reads that has files on this machine, and that the saved allowlist lets a deposit read. A harness with nothing
25
+ * to read has no entry. Read-only: it opens each store as the deposit does, reads each transcript, and writes nothing.
26
+ */
27
+ export declare const harnessFileCounts: (runtime: SessionsRuntime) => Promise<HarnessFiles>;