@manyoars/oars 0.2.0 → 0.3.0

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 (241) hide show
  1. package/README.md +132 -28
  2. package/dist/api.js +1 -1
  3. package/dist/bin.js +6 -29
  4. package/dist/cli.d.ts +6 -3
  5. package/dist/cli.js +367 -88
  6. package/dist/commands/keys.d.ts +31 -0
  7. package/dist/commands/keys.js +65 -0
  8. package/dist/commands/login.js +2 -2
  9. package/dist/errors.d.ts +13 -13
  10. package/dist/errors.js +28 -11
  11. package/dist/gate/checks.d.ts +68 -0
  12. package/dist/gate/checks.js +584 -0
  13. package/dist/gate/command.d.ts +14 -0
  14. package/dist/gate/command.js +61 -0
  15. package/dist/gate/hook-fill-main.d.ts +1 -0
  16. package/dist/gate/hook-fill-main.js +16 -0
  17. package/dist/gate/hook-fill.d.ts +38 -0
  18. package/dist/gate/hook-fill.js +94 -0
  19. package/dist/gate/hook-main.d.ts +1 -0
  20. package/dist/gate/hook-main.js +35 -0
  21. package/dist/gate/hook-process.d.ts +14 -0
  22. package/dist/gate/hook-process.js +64 -0
  23. package/dist/gate/hook.d.ts +46 -0
  24. package/dist/gate/hook.js +256 -0
  25. package/dist/gate/install.d.ts +25 -0
  26. package/dist/gate/install.js +126 -0
  27. package/dist/gate/local-bin.d.ts +8 -0
  28. package/dist/gate/local-bin.js +36 -0
  29. package/dist/gate/notes.d.ts +19 -0
  30. package/dist/gate/notes.js +27 -0
  31. package/dist/gate/novelty.d.ts +35 -0
  32. package/dist/gate/novelty.js +99 -0
  33. package/dist/gate/policy.d.ts +25 -0
  34. package/dist/gate/policy.js +73 -0
  35. package/dist/gate/record.d.ts +120 -0
  36. package/dist/gate/record.js +125 -0
  37. package/dist/gate/registry-state.d.ts +10 -0
  38. package/dist/gate/registry-state.js +30 -0
  39. package/dist/gate/registry.d.ts +56 -0
  40. package/dist/gate/registry.js +94 -0
  41. package/dist/gate/replay.d.ts +20 -0
  42. package/dist/gate/replay.js +257 -0
  43. package/dist/gate/report.d.ts +50 -0
  44. package/dist/gate/report.js +89 -0
  45. package/dist/gate/scope.d.ts +7 -0
  46. package/dist/gate/scope.js +27 -0
  47. package/dist/gate/transcripts.d.ts +68 -0
  48. package/dist/gate/transcripts.js +232 -0
  49. package/dist/hook-bin.d.ts +1 -0
  50. package/dist/hook-bin.js +25 -0
  51. package/dist/issuer.d.ts +7 -1
  52. package/dist/issuer.js +18 -1
  53. package/dist/main.d.ts +1 -0
  54. package/dist/main.js +64 -0
  55. package/dist/oidc.js +1 -1
  56. package/dist/sessions/actions.d.ts +35 -0
  57. package/dist/sessions/actions.js +351 -0
  58. package/dist/sessions/allow.d.ts +14 -0
  59. package/dist/sessions/allow.js +51 -0
  60. package/dist/sessions/archive.d.ts +11 -0
  61. package/dist/sessions/archive.js +38 -0
  62. package/dist/sessions/audit.d.ts +42 -0
  63. package/dist/sessions/audit.js +104 -0
  64. package/dist/sessions/baseline.d.ts +62 -0
  65. package/dist/sessions/baseline.js +188 -0
  66. package/dist/sessions/capture-runtime.d.ts +10 -0
  67. package/dist/sessions/capture-runtime.js +12 -0
  68. package/dist/sessions/cell-increments.d.ts +43 -0
  69. package/dist/sessions/cell-increments.js +209 -0
  70. package/dist/sessions/cells.d.ts +79 -0
  71. package/dist/sessions/cells.js +181 -0
  72. package/dist/sessions/codex.d.ts +53 -0
  73. package/dist/sessions/codex.js +679 -0
  74. package/dist/sessions/command.d.ts +117 -0
  75. package/dist/sessions/command.js +373 -0
  76. package/dist/sessions/copilot.d.ts +40 -0
  77. package/dist/sessions/copilot.js +432 -0
  78. package/dist/sessions/cursor.d.ts +24 -0
  79. package/dist/sessions/cursor.js +324 -0
  80. package/dist/sessions/derive.d.ts +57 -0
  81. package/dist/sessions/derive.js +320 -0
  82. package/dist/sessions/exclusion.d.ts +81 -0
  83. package/dist/sessions/exclusion.js +353 -0
  84. package/dist/sessions/extract.d.ts +179 -0
  85. package/dist/sessions/extract.js +1774 -0
  86. package/dist/sessions/fold.d.ts +17 -0
  87. package/dist/sessions/fold.js +71 -0
  88. package/dist/sessions/frame.d.ts +24 -0
  89. package/dist/sessions/frame.js +95 -0
  90. package/dist/sessions/gemini.d.ts +37 -0
  91. package/dist/sessions/gemini.js +613 -0
  92. package/dist/sessions/gold-arms.d.ts +67 -0
  93. package/dist/sessions/gold-arms.js +172 -0
  94. package/dist/sessions/gold.d.ts +197 -0
  95. package/dist/sessions/gold.js +463 -0
  96. package/dist/sessions/graph.d.ts +12 -0
  97. package/dist/sessions/graph.js +34 -0
  98. package/dist/sessions/grok.d.ts +36 -0
  99. package/dist/sessions/grok.js +421 -0
  100. package/dist/sessions/hook-calls.d.ts +3 -0
  101. package/dist/sessions/hook-calls.js +47 -0
  102. package/dist/sessions/hook-index.d.ts +53 -0
  103. package/dist/sessions/hook-index.js +157 -0
  104. package/dist/sessions/hooks.d.ts +13 -0
  105. package/dist/sessions/hooks.js +170 -0
  106. package/dist/sessions/identity.d.ts +22 -0
  107. package/dist/sessions/identity.js +101 -0
  108. package/dist/sessions/install.d.ts +54 -0
  109. package/dist/sessions/install.js +201 -0
  110. package/dist/sessions/lineage.d.ts +19 -0
  111. package/dist/sessions/lineage.js +118 -0
  112. package/dist/sessions/mcp.d.ts +6 -0
  113. package/dist/sessions/mcp.js +153 -0
  114. package/dist/sessions/merkle.d.ts +7 -0
  115. package/dist/sessions/merkle.js +28 -0
  116. package/dist/sessions/motif.d.ts +43 -0
  117. package/dist/sessions/motif.js +59 -0
  118. package/dist/sessions/package-resolve.d.ts +30 -0
  119. package/dist/sessions/package-resolve.js +183 -0
  120. package/dist/sessions/pending.d.ts +34 -0
  121. package/dist/sessions/pending.js +142 -0
  122. package/dist/sessions/pool-command.d.ts +44 -0
  123. package/dist/sessions/pool-command.js +542 -0
  124. package/dist/sessions/pool-delta.d.ts +54 -0
  125. package/dist/sessions/pool-delta.js +190 -0
  126. package/dist/sessions/pool-outbox.d.ts +63 -0
  127. package/dist/sessions/pool-outbox.js +260 -0
  128. package/dist/sessions/pool-print.d.ts +26 -0
  129. package/dist/sessions/pool-print.js +68 -0
  130. package/dist/sessions/pool-wire.d.ts +225 -0
  131. package/dist/sessions/pool-wire.js +620 -0
  132. package/dist/sessions/pool.d.ts +32 -0
  133. package/dist/sessions/pool.js +164 -0
  134. package/dist/sessions/projection.d.ts +75 -0
  135. package/dist/sessions/projection.js +165 -0
  136. package/dist/sessions/public-vocabulary.d.ts +73 -0
  137. package/dist/sessions/public-vocabulary.js +302 -0
  138. package/dist/sessions/query.d.ts +111 -0
  139. package/dist/sessions/query.js +246 -0
  140. package/dist/sessions/read-adoption.d.ts +2 -0
  141. package/dist/sessions/read-adoption.js +27 -0
  142. package/dist/sessions/read-call-shape.d.ts +2 -0
  143. package/dist/sessions/read-call-shape.js +25 -0
  144. package/dist/sessions/read-change.d.ts +2 -0
  145. package/dist/sessions/read-change.js +30 -0
  146. package/dist/sessions/read-cost.d.ts +4 -0
  147. package/dist/sessions/read-cost.js +37 -0
  148. package/dist/sessions/read-day-move.d.ts +3 -0
  149. package/dist/sessions/read-day-move.js +65 -0
  150. package/dist/sessions/read-fit.d.ts +7 -0
  151. package/dist/sessions/read-fit.js +41 -0
  152. package/dist/sessions/read-interaction.d.ts +5 -0
  153. package/dist/sessions/read-interaction.js +62 -0
  154. package/dist/sessions/read-lead-lag.d.ts +15 -0
  155. package/dist/sessions/read-lead-lag.js +81 -0
  156. package/dist/sessions/read-live.d.ts +6 -0
  157. package/dist/sessions/read-live.js +38 -0
  158. package/dist/sessions/read-method.d.ts +2 -0
  159. package/dist/sessions/read-method.js +29 -0
  160. package/dist/sessions/read-next-step.d.ts +2 -0
  161. package/dist/sessions/read-next-step.js +35 -0
  162. package/dist/sessions/read-order.d.ts +54 -0
  163. package/dist/sessions/read-order.js +62 -0
  164. package/dist/sessions/read-readable.d.ts +8 -0
  165. package/dist/sessions/read-readable.js +24 -0
  166. package/dist/sessions/read-recovery.d.ts +20 -0
  167. package/dist/sessions/read-recovery.js +97 -0
  168. package/dist/sessions/read-rows.d.ts +13 -0
  169. package/dist/sessions/read-rows.js +47 -0
  170. package/dist/sessions/read-substitution.d.ts +5 -0
  171. package/dist/sessions/read-substitution.js +51 -0
  172. package/dist/sessions/read-support.d.ts +48 -0
  173. package/dist/sessions/read-support.js +73 -0
  174. package/dist/sessions/read-tool-day.d.ts +2 -0
  175. package/dist/sessions/read-tool-day.js +26 -0
  176. package/dist/sessions/read-version-boundary.d.ts +31 -0
  177. package/dist/sessions/read-version-boundary.js +95 -0
  178. package/dist/sessions/read.d.ts +79 -0
  179. package/dist/sessions/read.js +84 -0
  180. package/dist/sessions/recall-consequence.d.ts +45 -0
  181. package/dist/sessions/recall-consequence.js +179 -0
  182. package/dist/sessions/recall-object.d.ts +137 -0
  183. package/dist/sessions/recall-object.js +77 -0
  184. package/dist/sessions/recall-pool.d.ts +19 -0
  185. package/dist/sessions/recall-pool.js +96 -0
  186. package/dist/sessions/recall-push.d.ts +26 -0
  187. package/dist/sessions/recall-push.js +95 -0
  188. package/dist/sessions/recall-reads.d.ts +117 -0
  189. package/dist/sessions/recall-reads.js +410 -0
  190. package/dist/sessions/recall-render.d.ts +19 -0
  191. package/dist/sessions/recall-render.js +168 -0
  192. package/dist/sessions/redact.d.ts +6 -0
  193. package/dist/sessions/redact.js +54 -0
  194. package/dist/sessions/repo-root.d.ts +10 -0
  195. package/dist/sessions/repo-root.js +101 -0
  196. package/dist/sessions/schema.d.ts +644 -0
  197. package/dist/sessions/schema.js +148 -0
  198. package/dist/sessions/session-shape.d.ts +73 -0
  199. package/dist/sessions/session-shape.js +342 -0
  200. package/dist/sessions/sources.d.ts +50 -0
  201. package/dist/sessions/sources.js +159 -0
  202. package/dist/sessions/steps.d.ts +105 -0
  203. package/dist/sessions/steps.js +250 -0
  204. package/dist/sessions/store.d.ts +297 -0
  205. package/dist/sessions/store.js +1478 -0
  206. package/dist/sessions/strata.d.ts +26 -0
  207. package/dist/sessions/strata.js +77 -0
  208. package/dist/sessions/survival.d.ts +25 -0
  209. package/dist/sessions/survival.js +230 -0
  210. package/dist/sessions/verify.d.ts +53 -0
  211. package/dist/sessions/verify.js +286 -0
  212. package/dist/sessions/vocab-load.d.ts +26 -0
  213. package/dist/sessions/vocab-load.js +61 -0
  214. package/dist/sessions/vscode-copilot.d.ts +38 -0
  215. package/dist/sessions/vscode-copilot.js +477 -0
  216. package/package.json +4 -5
  217. package/vocab/crates.json +1 -0
  218. package/vocab/go.json +1 -0
  219. package/vocab/harness.json +1 -0
  220. package/vocab/homebrew.json +1 -0
  221. package/vocab/manifest.json +1 -0
  222. package/vocab/maven.json +1 -0
  223. package/vocab/models.json +1 -0
  224. package/vocab/npm.json +1 -0
  225. package/vocab/programs.json +1 -0
  226. package/vocab/pypi.json +1 -0
  227. package/vocab/rubygems.json +1 -0
  228. package/dist/commands/deploy-input.d.ts +0 -38
  229. package/dist/commands/deploy-input.js +0 -150
  230. package/dist/commands/deploy.d.ts +0 -22
  231. package/dist/commands/deploy.js +0 -124
  232. package/dist/commands/status.d.ts +0 -7
  233. package/dist/commands/status.js +0 -18
  234. package/dist/commands/templates.d.ts +0 -11
  235. package/dist/commands/templates.js +0 -32
  236. package/dist/commands/test-charge.d.ts +0 -11
  237. package/dist/commands/test-charge.js +0 -34
  238. package/dist/journal.d.ts +0 -26
  239. package/dist/journal.js +0 -58
  240. package/parity/deploy-plans/paid-api.json +0 -34
  241. package/parity/deploy-plans/per-task-agent.json +0 -34
@@ -0,0 +1,190 @@
1
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { isMissing } from './pool-outbox.js';
4
+ import { contentHash } from './read.js';
5
+ import { bucketOf, PACKAGE_REGISTRIES } from './pool-wire.js';
6
+ import { PRIVATE, PUBLIC_PACKAGES, publicEffort } from './public-vocabulary.js';
7
+ import { harnessOf } from './schema.js';
8
+ // Local deltas (spec Section 2, "Deltas"): the client compares its own before and after windows and builds each change once. Nothing here reads a store or sends a byte; `pool deposit` hands it the
9
+ // sessions that passed the session gates and carries what comes back in the batch of the session that filled the after window.
10
+ /** `delta_days` and `delta_min_sessions` (spec Section 7): a week on each side spans a work cycle, and five sessions keep one session from being the delta. */
11
+ export const DELTA_DAYS = 7;
12
+ export const DELTA_MIN_SESSIONS = 5;
13
+ const DAY_MS = 24 * 60 * 60 * 1000;
14
+ /**
15
+ * The pairs of windows a set of sessions holds. Windows are consecutive spans of `days` on a grid counted from the epoch, not from any session, so a session belongs to exactly one window and the grid does
16
+ * not move when a session is added, lost to a gate, or not yet ended: the `index` of a window is the same whenever the deposit runs. A pair is two windows next to each other that each hold at least
17
+ * `min` sessions; a gap of an empty window, or of one with too few, compares nothing across it. The `filler` is the `min`th session of the after window in time order. It chooses which batch carries
18
+ * the delta and can change from one deposit to the next, so nothing that must hold still is keyed on it.
19
+ */
20
+ export const windowsOf = (sessions, days = DELTA_DAYS, min = DELTA_MIN_SESSIONS) => {
21
+ const ordered = [...sessions].sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
22
+ const span = days * DAY_MS;
23
+ const windows = new Map();
24
+ for (const session of ordered) {
25
+ const index = Math.floor(session.at / span);
26
+ const held = windows.get(index);
27
+ if (held === undefined)
28
+ windows.set(index, [session]);
29
+ else
30
+ held.push(session);
31
+ }
32
+ const pairs = [];
33
+ for (const [index, after] of windows) {
34
+ const before = windows.get(index - 1);
35
+ const filler = after[min - 1];
36
+ if (before !== undefined && before.length >= min && filler !== undefined)
37
+ pairs.push({ index, before, after, filler });
38
+ }
39
+ return pairs;
40
+ };
41
+ const sum = (values) => values.reduce((total, value) => total + value, 0);
42
+ const mean = (values, count) => (count === 0 ? 0 : sum(values) / count);
43
+ /** The signed bucket of a move: the bucket of its size, negative when it fell. Zero stays zero, never negative zero. */
44
+ const signed = (kind, move) => {
45
+ const bucket = bucketOf(kind, Math.abs(move));
46
+ return move < 0 && bucket > 0 ? -bucket : bucket;
47
+ };
48
+ /** The value most sessions hold; the smaller of two that tie, so two runs agree. */
49
+ const modal = (values) => {
50
+ const counts = new Map();
51
+ for (const value of values)
52
+ counts.set(value, (counts.get(value) ?? 0) + 1);
53
+ return [...counts].sort(([a, countA], [b, countB]) => countB - countA || (a < b ? -1 : 1))[0]?.[0];
54
+ };
55
+ const callsOf = (window) => mean(window.map((records) => sum(records.transcripts.map(({ tools }) => tools.length))), window.length);
56
+ const thinkingOf = (window) => {
57
+ const prompts = window.flatMap((records) => records.transcripts.flatMap((transcript) => transcript.prompts));
58
+ return mean(prompts.map(({ usage }) => usage.thinking), prompts.length);
59
+ };
60
+ /** The effort a session ran at: the one most of its main run's prompts held, read through the public list so a value outside it is one word. */
61
+ const effortOf = (records) => modal((records.transcripts[0]?.prompts ?? []).flatMap(({ efforts }) => efforts.flatMap((effort) => publicEffort(effort) ?? [])));
62
+ const entryOf = (name, field, deltaKind, metric, change, registry = null) => ({ deltaKind, field, name, registry, metric, change });
63
+ /** Only Claude Code records `setup.plugins`; another harness holds none because it records none, not because none were loaded, so it says nothing of a plugin. */
64
+ const recordsPlugins = ({ main }) => harnessOf(main) === 'claude-code';
65
+ const pluginsIn = (window) => new Set(window.filter(recordsPlugins).flatMap(({ main }) => main.setup?.plugins ?? []));
66
+ /** A stored `<ecosystem>:<name>` as the wire spells it: the public name and its registry, else the word `private` with no registry. */
67
+ const packageNamed = (stored) => {
68
+ const colon = stored.indexOf(':');
69
+ const registry = PACKAGE_REGISTRIES.find((entry) => entry === stored.slice(0, colon));
70
+ const name = stored.slice(colon + 1);
71
+ return registry !== undefined && PUBLIC_PACKAGES.has(name) ? { name, registry } : { name: PRIVATE, registry: null };
72
+ };
73
+ /** The manifests a window's calls showed, in the order they ran. A call that did not come back clean shows none. */
74
+ const manifestsIn = (window) => window.flatMap((records) => records.transcripts.flatMap(({ tools }) => [...tools].sort((a, b) => a.n - b.n).flatMap(({ manifest, status, target, tool }) => (manifest === undefined || status !== 'ok' ? [] : [{ tool, target, seen: manifest.seen, added: manifest.added, removed: manifest.removed }]))));
75
+ /**
76
+ * Whether the manifests of the after window leave a package gone. An edit that removes it, or a Write that lists a file that held it in the before window and no longer does, says gone; a later edit that
77
+ * adds it back, or any listing that holds it, says it stayed. A Read says nothing of what is missing, since it may have shown part of the file (an `offset` or a `limit`), and a listing of any other
78
+ * file says nothing, since one repository's manifest is not another's.
79
+ */
80
+ const goneFrom = (stored, before, after) => {
81
+ const held = new Set(before.flatMap(({ target, seen }) => (target !== null && seen.includes(stored) ? [target] : [])));
82
+ let gone = false;
83
+ for (const { tool, target, seen, added, removed } of after) {
84
+ if (removed.includes(stored))
85
+ gone = true;
86
+ else if (added.includes(stored) || seen.includes(stored))
87
+ gone = false;
88
+ else if (tool === 'Write' && seen.length > 0 && target !== null && held.has(target))
89
+ gone = true;
90
+ }
91
+ return gone;
92
+ };
93
+ /** A package's calls per session: tool calls of the window that install or run it. */
94
+ const callsNaming = (window, stored) => mean(window.map((records) => sum(records.transcripts.map(({ tools }) => tools.filter(({ packages }) => packages?.some(({ package: held }) => held === stored) === true).length))), window.length);
95
+ const packagesCalledIn = (window) => [...new Set(window.flatMap((records) => records.transcripts.flatMap(({ tools }) => tools.flatMap(({ packages }) => (packages ?? []).map(({ package: held }) => held)))))].sort();
96
+ const order = (a, b) => {
97
+ const key = (entry) => [entry.field, entry.deltaKind, entry.name ?? '', entry.registry ?? ''].join(' ');
98
+ return key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0;
99
+ };
100
+ /**
101
+ * The entries a pair of windows holds, each window in time order (the caller sorts). A plugin is added or removed, a `permissionMode` or `effort` changed by name alone (the most common value of each
102
+ * window differs, a value outside its list read as one word), and a `package` removed. Each entry carries the move of one metric in its field's own buckets: calls per session for a plugin, a permission
103
+ * mode, and a package, thinking per prompt for an effort. A name outside its list is `private`, and everything that is `private` in one field is one entry.
104
+ */
105
+ export const deltasOf = (before, after) => {
106
+ const calls = signed('count', callsOf(after) - callsOf(before));
107
+ const entries = [];
108
+ // No plugin registry is pinned, so a plugin's name never passes its row: it goes as the word `private`. Plugins are compared only when both windows hold a session that records them.
109
+ if (before.some(recordsPlugins) && after.some(recordsPlugins)) {
110
+ const pluginsBefore = pluginsIn(before);
111
+ const pluginsAfter = pluginsIn(after);
112
+ if ([...pluginsAfter].some((plugin) => !pluginsBefore.has(plugin)))
113
+ entries.push(entryOf(PRIVATE, 'plugin', 'added', 'callsPerSession', calls));
114
+ if ([...pluginsBefore].some((plugin) => !pluginsAfter.has(plugin)))
115
+ entries.push(entryOf(PRIVATE, 'plugin', 'removed', 'callsPerSession', calls));
116
+ }
117
+ const modeBefore = modal(before.flatMap(({ main }) => main.setup?.permissionMode ?? []));
118
+ const modeAfter = modal(after.flatMap(({ main }) => main.setup?.permissionMode ?? []));
119
+ if (modeBefore !== undefined && modeAfter !== undefined && modeBefore !== modeAfter)
120
+ entries.push(entryOf(null, 'permissionMode', 'changed', 'callsPerSession', calls));
121
+ const effortBefore = modal(before.flatMap((records) => effortOf(records) ?? []));
122
+ const effortAfter = modal(after.flatMap((records) => effortOf(records) ?? []));
123
+ if (effortBefore !== undefined && effortAfter !== undefined && effortBefore !== effortAfter)
124
+ entries.push(entryOf(null, 'effort', 'changed', 'thinking', signed('tokens', thinkingOf(after) - thinkingOf(before))));
125
+ const manifestsBefore = manifestsIn(before);
126
+ const manifestsAfter = manifestsIn(after);
127
+ const moves = new Map();
128
+ for (const stored of packagesCalledIn(before)) {
129
+ if (!goneFrom(stored, manifestsBefore, manifestsAfter))
130
+ continue;
131
+ const { name, registry } = packageNamed(stored);
132
+ const key = `${registry ?? ''}:${name}`;
133
+ moves.set(key, { name, registry, move: (moves.get(key)?.move ?? 0) + callsNaming(after, stored) - callsNaming(before, stored) });
134
+ }
135
+ for (const { name, registry, move } of moves.values())
136
+ entries.push(entryOf(name, 'package', 'removed', 'callsPerSession', signed('count', move), registry));
137
+ return entries.sort(order);
138
+ };
139
+ const MARKS_FILE = 'deltas-sent.log';
140
+ /**
141
+ * The once-only mark of a delta: a hash of the account, the entry without the size of its move, and the index of its after window on the grid. The move is left out so a recount of the same windows is
142
+ * the same delta, and the index is in so a later pair that shows the same change is a new one. The filler is not in it: which session fills a window moves with the sessions ready, a window's index does not.
143
+ */
144
+ export const deltaMark = (account, { deltaKind, field, name, registry, metric }, index) => contentHash([account, deltaKind, field, name, registry, metric, index]);
145
+ /** The marks of the deltas this outbox has already carried in a written batch. A folder with no log holds none; a log that cannot be read is an error, not an empty one, so a delta is never built twice by mistake. */
146
+ export const sentDeltas = async (outbox) => {
147
+ try {
148
+ return new Set((await readFile(join(outbox, MARKS_FILE), 'utf8')).split('\n').filter((line) => line !== ''));
149
+ }
150
+ catch (error) {
151
+ if (isMissing(error))
152
+ return new Set();
153
+ throw error;
154
+ }
155
+ };
156
+ /** Marks deltas as sent, one line each. The caller writes this only after the batch that carries them is written, so a deposit that stopped before it builds them again. */
157
+ export const markDeltasSent = async (outbox, marks) => {
158
+ if (marks.length === 0)
159
+ return;
160
+ await mkdir(outbox, { recursive: true });
161
+ await appendFile(join(outbox, MARKS_FILE), marks.map((mark) => `${mark}\n`).join(''), 'utf8');
162
+ };
163
+ /** The session placed by when it began (epoch milliseconds) and its own id; none when it has no start that reads as a time, since no window can hold it. */
164
+ export const placedOf = (records) => {
165
+ const at = records.main.tsFirst === null ? Number.NaN : Date.parse(records.main.tsFirst);
166
+ return Number.isNaN(at) ? undefined : { id: records.main.session, at, records };
167
+ };
168
+ /**
169
+ * The deltas not yet sent, by the id of the session that filled their after window: that session's batch carries them, and the mark is the window's index, so it holds whichever session fills. A delta whose mark is held is dropped, so a rebuilt session, or a batch written
170
+ * again under the same key, builds none it already carried. Nothing is marked here: the caller marks them once the batch is written.
171
+ */
172
+ export const carriedDeltas = (account, sessions, sent) => {
173
+ const carried = new Map();
174
+ for (const { index, before, after, filler } of windowsOf(sessions)) {
175
+ const entries = [];
176
+ const marks = [];
177
+ for (const entry of deltasOf(before.map(({ records }) => records),
178
+ // The outcome is fixed at the fill: sessions after the filler change nothing, so a delta is the same whenever it is built.
179
+ after.slice(0, DELTA_MIN_SESSIONS).map(({ records }) => records))) {
180
+ const mark = deltaMark(account, entry, index);
181
+ if (sent.has(mark))
182
+ continue;
183
+ entries.push(entry);
184
+ marks.push(mark);
185
+ }
186
+ if (entries.length > 0)
187
+ carried.set(filler.id, { entries, marks });
188
+ }
189
+ return carried;
190
+ };
@@ -0,0 +1,63 @@
1
+ /** The batch as one canonical JSON line: sorted keys, no optional whitespace, UTF-8 when written, and a newline at the end. These are the bytes that are hashed, printed, and sent. */
2
+ export declare const canonicalBytes: (batch: unknown) => string;
3
+ export declare const batchHash: (bytes: string) => string;
4
+ /**
5
+ * `oars pool outbox --explain`: one row for each field name a batch holds, at any depth, saying what admits its values. A field with no row fails the command, so a field added to the batch adds its row here.
6
+ * A name that sits at more than one depth (`status`, `harness`, `frame`) has one row that reads true at each.
7
+ */
8
+ export declare const FIELD_ROWS: {
9
+ readonly [field: string]: string;
10
+ };
11
+ /**
12
+ * Every field name a batch holds, at any depth, sorted: the rows above. A batch is filed under this list, the skeleton format, and `VOCAB_VERSION`, so one built under an older list, form, or vocabulary is never printed or sent.
13
+ * A test holds this list equal to the fields of a real batch, so a field the sample holds without its row fails there.
14
+ */
15
+ export declare const BATCH_FIELDS: readonly string[];
16
+ /** The folder name of the batches this client builds now. A folder of any other name holds batches built under an older vocabulary, field list, or skeleton format. */
17
+ export declare const currentStamp: (fields?: readonly string[], format?: number) => string;
18
+ /** One server's outbox: batches are per server, since a printed batch carries that server's credential. */
19
+ export declare const outboxFolder: (storeDir: string, server: string) => string;
20
+ /** Writes one session's batch under the current stamp. A session has one batch: an older batch of it is removed, and an identical one is written again, so an edited file is restored. */
21
+ export declare const writeBatch: (outbox: string, key: string, batch: unknown, stamp?: string) => Promise<{
22
+ hash: string;
23
+ folder: string;
24
+ }>;
25
+ export declare const isMissing: (error: unknown) => boolean;
26
+ /** Removes every batch under this stamp whose session is not in `kept`, and returns how many went. A session that fails a gate, leaves the allowlist, or is gone from the store never enters the outbox, so an earlier pass's batch of it cannot stay. */
27
+ export declare const removeBatchesExcept: (outbox: string, kept: ReadonlySet<string>, stamp?: string, only?: string) => Promise<number>;
28
+ /** Removes the batch of one session, if it has one under this stamp, and returns how many files went. */
29
+ export declare const removeBatchOf: (outbox: string, key: string, stamp?: string) => Promise<number>;
30
+ /** The bytes of a batch file, when they still hash to the name the file was written under; undefined when the file is gone or was edited. */
31
+ export declare const unchangedBytes: (file: string, hash: string) => Promise<string | undefined>;
32
+ export interface Held {
33
+ /** The session's key in the outbox (the batch's account-hashed id). */
34
+ key: string;
35
+ hash: string;
36
+ file: string;
37
+ bytes: string;
38
+ }
39
+ /** The batches built under this client's vocabulary, field list, and skeleton format, and how many of their files no longer hash to their names. An edited file is not printed and cannot be sent: run `deposit` again to rebuild it. */
40
+ export declare const heldBatches: (outbox: string, stamp?: string) => Promise<{
41
+ batches: Held[];
42
+ edited: number;
43
+ }>;
44
+ /** Records that this hash was printed, one line for each print: the lock below sends only a hash that appears here. */
45
+ export declare const markShown: (outbox: string, hash: string, at: number) => Promise<void>;
46
+ export type LockRefusal = 'batch_unknown' | 'batch_not_shown' | 'batch_changed';
47
+ /** The lock on `deposit --send`: a batch goes only if it is in the outbox, its hash was printed, and its file still hashes to that hash. The first check that fails names why. */
48
+ export declare const lockedBatch: (outbox: string, hash: string, stamp?: string) => Promise<{
49
+ ok: true;
50
+ file: string;
51
+ bytes: string;
52
+ } | {
53
+ ok: false;
54
+ code: LockRefusal;
55
+ }>;
56
+ /** Batches in one server's outbox that were built under another vocabulary, field list, or skeleton format: never printed, never sent. */
57
+ export declare const staleBatches: (outbox: string, stamp?: string) => Promise<number>;
58
+ /** Every server's stale batches, for `verify`. */
59
+ export declare const staleBatchesAnywhere: (storeDir: string, stamp?: string) => Promise<number>;
60
+ /** Removes one server's stale batches, and returns how many there were. */
61
+ export declare const discardStale: (outbox: string, stamp?: string) => Promise<number>;
62
+ /** Every field name in a batch, at any depth, each once and sorted. */
63
+ export declare const fieldsOf: (batchBytes: string) => string[];
@@ -0,0 +1,260 @@
1
+ // The outbox (`oars pool deposit` and `oars pool outbox`): every batch that may leave this machine is first written here, as canonical JSON lines, and `deposit` stops. Nothing in this file
2
+ // looks inside a batch except to name its fields, at every depth, for `--explain`; what a batch holds is the projection's business.
3
+ import { createHash } from 'node:crypto';
4
+ import { appendFile, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { SKELETON_FORMAT, VOCAB_VERSION } from './pool-wire.js';
7
+ import { contentHash } from './read.js';
8
+ /** What `JSON.stringify` writes for a value, or undefined for one JSON cannot hold (the lib types call this a string always). */
9
+ const encoded = (value) => JSON.stringify(value);
10
+ /**
11
+ * JSON with keys sorted at every depth, so two writers of one batch write the same bytes. The sorted entries are written out directly: rebuilding an object would put an integer-like key such as `10` before `9` whatever the sort said.
12
+ * A value JSON cannot hold is left out of an object and written as null in an array, as `JSON.stringify` does.
13
+ */
14
+ const canonical = (value) => {
15
+ if (Array.isArray(value))
16
+ return `[${value.map(canonical).join(',')}]`;
17
+ if (typeof value === 'object' && value !== null) {
18
+ const entries = Object.entries(value)
19
+ .filter(([, entry]) => encoded(entry) !== undefined)
20
+ .sort(([a], [b]) => (a < b ? -1 : 1));
21
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`).join(',')}}`;
22
+ }
23
+ return encoded(value) ?? 'null';
24
+ };
25
+ /** The batch as one canonical JSON line: sorted keys, no optional whitespace, UTF-8 when written, and a newline at the end. These are the bytes that are hashed, printed, and sent. */
26
+ export const canonicalBytes = (batch) => `${canonical(batch)}\n`;
27
+ export const batchHash = (bytes) => createHash('sha256').update(Buffer.from(bytes, 'utf8')).digest('hex');
28
+ /**
29
+ * `oars pool outbox --explain`: one row for each field name a batch holds, at any depth, saying what admits its values. A field with no row fails the command, so a field added to the batch adds its row here.
30
+ * A name that sits at more than one depth (`status`, `harness`, `frame`) has one row that reads true at each.
31
+ */
32
+ export const FIELD_ROWS = {
33
+ action: 'The canonical action of a step, one of a closed list this client holds; never the harness tool text.',
34
+ agentType: 'The type of the subagent whose run holds a step: a built-in type of the harness, else the word custom; null on a step of the main run.',
35
+ cacheRead: 'A bucket of the tokens read from the cache in a prompt, a number.',
36
+ cacheWrite: 'A bucket of the tokens written to the cache in a prompt, a number.',
37
+ change: 'How far a delta moved, a signed bucket of its metric (a lower bound) in a number from -7 to 7 for calls per session and from -11 to 11 for thinking tokens; never the exact count.',
38
+ cls: 'The class of a prompt, one of a closed list of eight, computed from the kinds of tools it used; never its words.',
39
+ day: 'The UTC calendar day of the session as YYYY-MM-DD, or null; never a time of day.',
40
+ deltaKind: 'How a delta changed what the client ran: added, removed, or changed, one of a closed list the field allows; never a value.',
41
+ deltas: 'The changes the client found between its own before and after windows, each sent once with the session that filled the after window: a kind, a field, a name, a registry, a metric, and a bucketed change; never a session id, a root, a date, or an exact value.',
42
+ durationMs: 'The time of one tool call in milliseconds, exact, a number or null.',
43
+ effort: 'The effort level the model ran at, only when it is a public one; else the literal placeholder.',
44
+ errors: 'A bucket of how many tool calls failed in a prompt, a number.',
45
+ explore: 'A bucket of how many steps of a prompt only looked (read a file, searched files, searched the web, or fetched a page), a number or null; never the exact count.',
46
+ field: 'What a delta is about, one of plugin, package, permissionMode, and effort: the field alone, never its value.',
47
+ flags: 'The flags of a shell command, each a public flag or a short flag; never an argument.',
48
+ format: 'The skeleton format number: a constant of this client, never a value from a session.',
49
+ frame: 'Where a session ran: the harness and its version, the model, the effort, the day, the platform, the shell, and the stack, every value checked against the closed vocabulary.',
50
+ frameworks: 'The frameworks of the project, each from a closed public list; never a path or a name of yours.',
51
+ harness: 'Which harness wrote the session, one of a closed list.',
52
+ harnessVersion: 'The harness version, only when it is a public release; else the literal placeholder.',
53
+ host: 'The host of a web fetch or search, only when it is on the public list; else private, or null when the step names no host.',
54
+ id: "A hash the client computes per account from the account and the session, so a redeposit replaces and two accounts' sessions cannot be joined. Never the session id.",
55
+ identity: 'What a step called, in closed vocabulary: a shell program with its subcommand and flags, an MCP server and tool, a skill, or a web host; null for an action with none.',
56
+ input: 'A bucket of the input tokens in a prompt, a number.',
57
+ language: 'A programming language of the project, from a closed public list, or other for any language off the list, beside its share bucket.',
58
+ languageShare: 'How much of the files a session touched are in one language, one of four buckets: none, under 10 percent, 10 up to 50, or 50 and over; never a count of files.',
59
+ languageShares: 'The share bucket of each language of the project whose share is known; a language with no share is in the languages list alone.',
60
+ languages: 'The programming languages of the project, each from a closed public list.',
61
+ leaves: 'How many lines of a transcript its root covers, a count.',
62
+ merkleRoot: 'The Merkle root of a transcript: a SHA-256 over the hashes of its lines, one root for each transcript, the main one first; never a line.',
63
+ metric: 'What a delta measures, calls per session or thinking tokens, one of a closed list the field fixes.',
64
+ model: 'The model name, only when it is a public one; else the literal placeholder.',
65
+ ms: 'A bucket of the milliseconds a step took (a lower bound), a number or null; never the exact time.',
66
+ name: 'The name of a package a shell command pulled or a delta removed, only when it is on the public list; else private. A plugin in a delta is always private, and null when a delta names its field alone.',
67
+ native: "The name the harness gave the tool, only when it is in this client's action table; else private.",
68
+ output: 'A bucket of the output tokens in a prompt, a number.',
69
+ parent: 'The position of the main-run call that spawned this step, a number, or null (also null for a subagent a subagent spawned).',
70
+ platform: 'The operating system family, one of a closed list.',
71
+ program: 'The program a shell command ran, when it is on the public list; else the word private.',
72
+ prompt: 'Which prompt of the batch a tool call follows, a position in this batch, or null.',
73
+ prompts: 'The prompts of a transcript: each a class, bucketed counts and tokens, and a frame; never their words.',
74
+ pulls: 'The packages a shell command pulled from a registry: the registry, the public package name, and its version.',
75
+ registry: 'Which registry a pull or a removed package came from, one of a closed list; null for a delta whose name is private or absent.',
76
+ remainingTokens: 'A bucket of the tokens the run spent after a step (a lower bound), a number: the bucket of the exact sum, with the subagent runs a main-run step spawned later in the session.',
77
+ server: 'The MCP server a call went to, only when it is on the public list; else private.',
78
+ shell: 'The shell a command ran in, one of a closed list, or null.',
79
+ side: 'Whether a tool call ran as a side call, true or false.',
80
+ size: 'A size class of the repository, one of a closed list, or null; never a count of files.',
81
+ skill: 'The skill a step ran, only when it is on the public list; else private.',
82
+ stack: 'The languages, frameworks, and size class of the project.',
83
+ status: 'Whether a tool call or a step succeeded: ok, error, or none.',
84
+ steps: 'The steps of a tool call, each an action in closed vocabulary with its bucketed cost and spawner.',
85
+ sub: 'The subcommand of a shell program, only when it is on the public list for that program; else null.',
86
+ thinking: 'A bucket of the thinking tokens in a prompt, a number.',
87
+ timingPrint: 'A hash of the call durations of the main transcript in the order they ran, or null under 50 timed calls; the calls of a subagent are not in it.',
88
+ tokens: 'A bucket of tokens (a lower bound of the count), a number or null; a step holds one, never the exact count.',
89
+ tool: 'The tool of an MCP call, only when it is on the public list for that server; else the literal placeholder.',
90
+ tools: 'The tool calls of a transcript, each a status and duration and its steps, in the order they ran; never their targets or arguments.',
91
+ transcripts: 'The main transcript, then each subagent: its frame, prompts, and tool calls, every value checked against the closed vocabulary before the batch is built.',
92
+ usage: 'The tokens of a prompt as buckets (lower bounds): input, cache write, cache read, output, and thinking.',
93
+ version: 'The version of a pulled package, only when it is a registry version; else the literal placeholder or null.',
94
+ wallSec: 'A bucket of the seconds a prompt took, or null.',
95
+ };
96
+ /**
97
+ * Every field name a batch holds, at any depth, sorted: the rows above. A batch is filed under this list, the skeleton format, and `VOCAB_VERSION`, so one built under an older list, form, or vocabulary is never printed or sent.
98
+ * A test holds this list equal to the fields of a real batch, so a field the sample holds without its row fails there.
99
+ */
100
+ export const BATCH_FIELDS = Object.keys(FIELD_ROWS).sort();
101
+ const fieldsStamp = (fields, format) => createHash('sha256').update(`${String(format)}:${fields.join(',')}`).digest('hex').slice(0, 8);
102
+ /** The folder name of the batches this client builds now. A folder of any other name holds batches built under an older vocabulary, field list, or skeleton format. */
103
+ export const currentStamp = (fields = BATCH_FIELDS, format = SKELETON_FORMAT) => `v${VOCAB_VERSION}-${fieldsStamp(fields, format)}`;
104
+ /** One server's outbox: batches are per server, since a printed batch carries that server's credential. */
105
+ export const outboxFolder = (storeDir, server) => join(storeDir, 'pool-outbox', contentHash(server));
106
+ const BATCH_FILE = /^([0-9a-f]{16})\.([0-9a-f]{64})\.jsonl$/;
107
+ /** Writes one session's batch under the current stamp. A session has one batch: an older batch of it is removed, and an identical one is written again, so an edited file is restored. */
108
+ export const writeBatch = async (outbox, key, batch, stamp = currentStamp()) => {
109
+ const bytes = canonicalBytes(batch);
110
+ const hash = batchHash(bytes);
111
+ const folder = join(outbox, stamp);
112
+ await mkdir(folder, { recursive: true });
113
+ for (const name of await readdir(folder)) {
114
+ const found = BATCH_FILE.exec(name);
115
+ if (found?.[1] === key && found[2] !== hash)
116
+ await rm(join(folder, name), { force: true });
117
+ }
118
+ await writeFile(join(folder, `${key}.${hash}.jsonl`), bytes, 'utf8');
119
+ return { hash, folder };
120
+ };
121
+ export const isMissing = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
122
+ /** A path that is missing, or has a file where a folder should be, holds nothing. */
123
+ const isAbsent = (error) => isMissing(error) || (error instanceof Error && 'code' in error && error.code === 'ENOTDIR');
124
+ /** The names in a folder; a folder that does not exist yet holds none. */
125
+ const namesIn = async (folder) => {
126
+ try {
127
+ return (await readdir(folder)).sort();
128
+ }
129
+ catch (error) {
130
+ if (isAbsent(error))
131
+ return [];
132
+ throw error;
133
+ }
134
+ };
135
+ /** Removes every batch under this stamp whose session is not in `kept`, and returns how many went. A session that fails a gate, leaves the allowlist, or is gone from the store never enters the outbox, so an earlier pass's batch of it cannot stay. */
136
+ export const removeBatchesExcept = async (outbox, kept, stamp = currentStamp(), only) => {
137
+ const folder = join(outbox, stamp);
138
+ let removed = 0;
139
+ for (const name of await namesIn(folder)) {
140
+ const found = BATCH_FILE.exec(name);
141
+ if (found !== null && !kept.has(found[1] ?? '') && (only === undefined || found[1] === only)) {
142
+ await rm(join(folder, name), { force: true });
143
+ removed += 1;
144
+ }
145
+ }
146
+ return removed;
147
+ };
148
+ /** Removes the batch of one session, if it has one under this stamp, and returns how many files went. */
149
+ export const removeBatchOf = async (outbox, key, stamp = currentStamp()) => removeBatchesExcept(outbox, new Set(), stamp, key);
150
+ /** The bytes of a batch file, when they still hash to the name the file was written under; undefined when the file is gone or was edited. */
151
+ export const unchangedBytes = async (file, hash) => {
152
+ let bytes;
153
+ try {
154
+ bytes = await readFile(file, 'utf8');
155
+ }
156
+ catch (error) {
157
+ if (isMissing(error))
158
+ return undefined;
159
+ throw error;
160
+ }
161
+ return batchHash(bytes) === hash ? bytes : undefined;
162
+ };
163
+ /** The batches built under this client's vocabulary, field list, and skeleton format, and how many of their files no longer hash to their names. An edited file is not printed and cannot be sent: run `deposit` again to rebuild it. */
164
+ export const heldBatches = async (outbox, stamp = currentStamp()) => {
165
+ const folder = join(outbox, stamp);
166
+ const batches = [];
167
+ let edited = 0;
168
+ for (const name of await namesIn(folder)) {
169
+ const found = BATCH_FILE.exec(name);
170
+ if (found === null)
171
+ continue;
172
+ const [, key = '', hash = ''] = found;
173
+ const file = join(folder, name);
174
+ const bytes = await unchangedBytes(file, hash);
175
+ if (bytes === undefined)
176
+ edited += 1;
177
+ else
178
+ batches.push({ key, hash, file, bytes });
179
+ }
180
+ return { batches, edited };
181
+ };
182
+ const shownFile = (outbox) => join(outbox, 'shown.log');
183
+ /** Records that this hash was printed, one line for each print: the lock below sends only a hash that appears here. */
184
+ export const markShown = async (outbox, hash, at) => {
185
+ await mkdir(outbox, { recursive: true });
186
+ await appendFile(shownFile(outbox), `${hash} ${new Date(at).toISOString()}\n`, 'utf8');
187
+ };
188
+ const wasShown = async (outbox, hash) => {
189
+ try {
190
+ return (await readFile(shownFile(outbox), 'utf8')).split('\n').some((line) => line.split(' ')[0] === hash);
191
+ }
192
+ catch (error) {
193
+ if (isMissing(error))
194
+ return false;
195
+ throw error;
196
+ }
197
+ };
198
+ /** The lock on `deposit --send`: a batch goes only if it is in the outbox, its hash was printed, and its file still hashes to that hash. The first check that fails names why. */
199
+ export const lockedBatch = async (outbox, hash, stamp = currentStamp()) => {
200
+ const folder = join(outbox, stamp);
201
+ const name = (await namesIn(folder)).find((entry) => BATCH_FILE.exec(entry)?.[2] === hash);
202
+ if (name === undefined)
203
+ return { ok: false, code: 'batch_unknown' };
204
+ if (!(await wasShown(outbox, hash)))
205
+ return { ok: false, code: 'batch_not_shown' };
206
+ const file = join(folder, name);
207
+ const bytes = await unchangedBytes(file, hash);
208
+ return bytes === undefined ? { ok: false, code: 'batch_changed' } : { ok: true, file, bytes };
209
+ };
210
+ /** Folders of an outbox other than the current stamp's: batches built under another vocabulary, field list, or skeleton format. */
211
+ const staleFolders = async (outbox, stamp) => {
212
+ const names = [];
213
+ for (const name of await namesIn(outbox)) {
214
+ if (name !== stamp && STAMP_FOLDER.test(name))
215
+ names.push(join(outbox, name));
216
+ }
217
+ return names;
218
+ };
219
+ const STAMP_FOLDER = /^v[^./]+-[0-9a-f]{8}$/;
220
+ const batchesIn = async (folder) => (await namesIn(folder)).filter((name) => BATCH_FILE.test(name)).length;
221
+ /** Batches in one server's outbox that were built under another vocabulary, field list, or skeleton format: never printed, never sent. */
222
+ export const staleBatches = async (outbox, stamp = currentStamp()) => {
223
+ let count = 0;
224
+ for (const folder of await staleFolders(outbox, stamp))
225
+ count += await batchesIn(folder);
226
+ return count;
227
+ };
228
+ /** Every server's stale batches, for `verify`. */
229
+ export const staleBatchesAnywhere = async (storeDir, stamp = currentStamp()) => {
230
+ const root = join(storeDir, 'pool-outbox');
231
+ let count = 0;
232
+ for (const name of await namesIn(root))
233
+ count += await staleBatches(join(root, name), stamp);
234
+ return count;
235
+ };
236
+ /** Removes one server's stale batches, and returns how many there were. */
237
+ export const discardStale = async (outbox, stamp = currentStamp()) => {
238
+ const count = await staleBatches(outbox, stamp);
239
+ for (const folder of await staleFolders(outbox, stamp))
240
+ await rm(folder, { recursive: true, force: true });
241
+ return count;
242
+ };
243
+ const collect = (value, into) => {
244
+ if (Array.isArray(value)) {
245
+ for (const entry of value)
246
+ collect(entry, into);
247
+ }
248
+ else if (typeof value === 'object' && value !== null) {
249
+ for (const [key, entry] of Object.entries(value)) {
250
+ into.add(key);
251
+ collect(entry, into);
252
+ }
253
+ }
254
+ };
255
+ /** Every field name in a batch, at any depth, each once and sorted. */
256
+ export const fieldsOf = (batchBytes) => {
257
+ const names = new Set();
258
+ collect(JSON.parse(batchBytes), names);
259
+ return [...names].sort();
260
+ };
@@ -0,0 +1,26 @@
1
+ /** One request as it will leave this machine. */
2
+ export interface Outgoing {
3
+ method: 'GET' | 'POST';
4
+ url: URL;
5
+ /** Every header this code sets. The HTTP layer adds `host`, `connection`, and `content-length` itself, and `printed` lists those too. */
6
+ headers: Array<[string, string]>;
7
+ body?: string;
8
+ }
9
+ /**
10
+ * The headers Node's `fetch` would otherwise add on its own, set here so the print lists all of them. `identity` also means a reply is never compressed, so the bytes read are the bytes signed.
11
+ * `sec-fetch-mode` and `accept-language` carry no information; they are pinned to what `fetch` sends anyway. A conditional request also gets `cache-control: no-cache` and `pragma: no-cache` from `fetch`; `ask` sets both beside `if-none-match`.
12
+ * `pool-socket.test.ts` compares the print with the headers a loopback server reads off the socket, so a header a later Node adds fails there.
13
+ */
14
+ export declare const TRANSPORT_HEADERS: ReadonlyArray<[string, string]>;
15
+ /** The credential's size, and nothing else about it. */
16
+ export declare const maskOf: (secret: string) => string;
17
+ /** The secrets of one run. A value added once is masked in every print after, wherever it appears. */
18
+ export declare class Masker {
19
+ private readonly secrets;
20
+ add(secret: string): void;
21
+ mask(text: string): string;
22
+ }
23
+ /** The request as the wire carries it: the request line, `host` and `connection` first as the HTTP layer writes them, then the headers set here, then the length of a body. */
24
+ export declare const printed: (request: Outgoing, masker: Masker) => string;
25
+ /** The line the operator reads when the pool says to wait: a count of seconds, or an HTTP date, in plain words. Undefined when it is neither. */
26
+ export declare const waitWords: (retryAfter: string, now: number) => string | undefined;
@@ -0,0 +1,68 @@
1
+ // What `oars pool fetch` and `oars pool login` print before they send: the request line and every header, then the body, with a credential
2
+ // masked as `<credential: n bytes>`. The text printed is built from the very headers and body handed to `fetch`, so the two cannot drift apart.
3
+ /**
4
+ * The headers Node's `fetch` would otherwise add on its own, set here so the print lists all of them. `identity` also means a reply is never compressed, so the bytes read are the bytes signed.
5
+ * `sec-fetch-mode` and `accept-language` carry no information; they are pinned to what `fetch` sends anyway. A conditional request also gets `cache-control: no-cache` and `pragma: no-cache` from `fetch`; `ask` sets both beside `if-none-match`.
6
+ * `pool-socket.test.ts` compares the print with the headers a loopback server reads off the socket, so a header a later Node adds fails there.
7
+ */
8
+ export const TRANSPORT_HEADERS = [
9
+ ['accept-encoding', 'identity'],
10
+ ['accept-language', '*'],
11
+ ['sec-fetch-mode', 'cors'],
12
+ ];
13
+ /** The credential's size, and nothing else about it. */
14
+ export const maskOf = (secret) => `<credential: ${String(Buffer.byteLength(secret))} bytes>`;
15
+ /** Every way a secret can be written into a header or a body: bare, form-encoded, percent-encoded, and as the inside of a JSON string. */
16
+ const spellings = (secret) => {
17
+ const form = new URLSearchParams({ k: secret }).toString().slice('k='.length);
18
+ const inJson = JSON.stringify(secret).slice(1, -1);
19
+ return [...new Set([secret, form, encodeURIComponent(secret), inJson])];
20
+ };
21
+ /** The secrets of one run. A value added once is masked in every print after, wherever it appears. */
22
+ export class Masker {
23
+ secrets = new Set();
24
+ add(secret) {
25
+ if (secret !== '')
26
+ this.secrets.add(secret);
27
+ }
28
+ mask(text) {
29
+ // Longest first, so a secret that holds another is replaced whole.
30
+ const found = [...this.secrets].flatMap((secret) => spellings(secret).map((spelling) => ({ spelling, mask: maskOf(secret) }))).sort((a, b) => b.spelling.length - a.spelling.length);
31
+ return found.reduce((masked, { spelling, mask }) => masked.split(spelling).join(mask), text);
32
+ }
33
+ }
34
+ /** The request as the wire carries it: the request line, `host` and `connection` first as the HTTP layer writes them, then the headers set here, then the length of a body. */
35
+ export const printed = (request, masker) => {
36
+ const { method, url, headers, body } = request;
37
+ const lines = [`${method} ${url.pathname}${url.search} HTTP/1.1`, `host: ${url.host}`, 'connection: keep-alive', ...headers.map(([name, value]) => `${name}: ${value}`)];
38
+ if (body !== undefined)
39
+ lines.push(`content-length: ${String(Buffer.byteLength(body))}`);
40
+ return masker.mask(`${lines.join('\n')}\n${body === undefined ? '' : `\n${body}\n`}`);
41
+ };
42
+ /** The line the operator reads when the pool says to wait: a count of seconds, or an HTTP date, in plain words. Undefined when it is neither. */
43
+ export const waitWords = (retryAfter, now) => {
44
+ const seconds = /^\d+$/.test(retryAfter.trim()) ? Number(retryAfter.trim()) : Math.ceil((Date.parse(retryAfter) - now) / 1000);
45
+ if (!Number.isFinite(seconds))
46
+ return undefined;
47
+ if (seconds <= 0)
48
+ return 'now';
49
+ const units = [
50
+ ['day', 86_400],
51
+ ['hour', 3_600],
52
+ ['minute', 60],
53
+ ];
54
+ // Only two units are shown, so the last one shown is rounded up: an operator who retries at the time named must not get another 429.
55
+ const lead = units.findIndex(([, size]) => seconds >= size);
56
+ const step = lead < 0 ? 1 : (units[Math.min(lead + 1, units.length - 1)]?.[1] ?? 1);
57
+ const parts = [];
58
+ let left = Math.ceil(seconds / step) * step;
59
+ for (const [name, size] of units) {
60
+ const count = Math.floor(left / size);
61
+ left -= count * size;
62
+ if (count > 0)
63
+ parts.push(`${String(count)} ${name}${count === 1 ? '' : 's'}`);
64
+ }
65
+ if (parts.length === 0)
66
+ parts.push(`${String(left)} second${left === 1 ? '' : 's'}`);
67
+ return `in ${parts.slice(0, 2).join(' and ')}`;
68
+ };