@manyoars/oars 0.3.0 → 0.4.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 (161) hide show
  1. package/README.md +44 -29
  2. package/dist/cli.d.ts +4 -1
  3. package/dist/cli.js +102 -32
  4. package/dist/commands/login.d.ts +1 -1
  5. package/dist/commands/login.js +1 -1
  6. package/dist/gate/hook-main.js +13 -1
  7. package/dist/gate/hook-process.d.ts +11 -1
  8. package/dist/gate/hook-process.js +50 -2
  9. package/dist/gate/hook-record.d.ts +3 -0
  10. package/dist/gate/hook-record.js +8 -0
  11. package/dist/gate/hook.d.ts +2 -0
  12. package/dist/gate/hook.js +3 -0
  13. package/dist/gate/replay.js +2 -5
  14. package/dist/gate/report.js +1 -1
  15. package/dist/gate/transcripts.js +1 -1
  16. package/dist/hook-bin.js +27 -2
  17. package/dist/issuer.d.ts +7 -9
  18. package/dist/issuer.js +10 -32
  19. package/dist/main.js +3 -0
  20. package/dist/sessions/actions.js +78 -0
  21. package/dist/sessions/allow.d.ts +10 -1
  22. package/dist/sessions/allow.js +29 -9
  23. package/dist/sessions/baseline.d.ts +1 -1
  24. package/dist/sessions/baseline.js +6 -4
  25. package/dist/sessions/call-shape.d.ts +19 -0
  26. package/dist/sessions/call-shape.js +85 -0
  27. package/dist/sessions/cell-increments.d.ts +5 -1
  28. package/dist/sessions/cell-increments.js +84 -50
  29. package/dist/sessions/cells.d.ts +33 -3
  30. package/dist/sessions/cells.js +89 -9
  31. package/dist/sessions/codex.js +93 -27
  32. package/dist/sessions/command.d.ts +11 -1
  33. package/dist/sessions/command.js +64 -18
  34. package/dist/sessions/coverage.d.ts +19 -0
  35. package/dist/sessions/coverage.js +56 -0
  36. package/dist/sessions/cursor.js +2 -2
  37. package/dist/sessions/derive.js +72 -6
  38. package/dist/sessions/exclusion.d.ts +6 -0
  39. package/dist/sessions/exclusion.js +8 -0
  40. package/dist/sessions/extract.d.ts +4 -2
  41. package/dist/sessions/extract.js +58 -26
  42. package/dist/sessions/hook-calls.js +1 -1
  43. package/dist/sessions/hook-index.d.ts +14 -1
  44. package/dist/sessions/hook-index.js +10 -3
  45. package/dist/sessions/hooks.d.ts +5 -1
  46. package/dist/sessions/hooks.js +133 -22
  47. package/dist/sessions/identity.d.ts +8 -3
  48. package/dist/sessions/identity.js +45 -15
  49. package/dist/sessions/install.d.ts +18 -2
  50. package/dist/sessions/install.js +53 -8
  51. package/dist/sessions/mcp.d.ts +3 -2
  52. package/dist/sessions/mcp.js +8 -4
  53. package/dist/sessions/motif.d.ts +2 -1
  54. package/dist/sessions/motif.js +2 -2
  55. package/dist/sessions/opencode.d.ts +33 -0
  56. package/dist/sessions/opencode.js +319 -0
  57. package/dist/sessions/otel.d.ts +60 -0
  58. package/dist/sessions/otel.js +494 -0
  59. package/dist/sessions/package-books.d.ts +48 -0
  60. package/dist/sessions/package-books.js +120 -0
  61. package/dist/sessions/package-resolve.d.ts +2 -0
  62. package/dist/sessions/package-resolve.js +3 -1
  63. package/dist/sessions/pi.d.ts +36 -0
  64. package/dist/sessions/pi.js +353 -0
  65. package/dist/sessions/pool-books.d.ts +21 -0
  66. package/dist/sessions/pool-books.js +75 -0
  67. package/dist/sessions/pool-choices.d.ts +5 -0
  68. package/dist/sessions/pool-choices.js +11 -0
  69. package/dist/sessions/pool-command.d.ts +76 -5
  70. package/dist/sessions/pool-command.js +263 -136
  71. package/dist/sessions/pool-nightly-deps.d.ts +3 -0
  72. package/dist/sessions/pool-nightly-deps.js +11 -0
  73. package/dist/sessions/pool-nightly.d.ts +10 -0
  74. package/dist/sessions/pool-nightly.js +85 -0
  75. package/dist/sessions/pool-outbox.d.ts +15 -2
  76. package/dist/sessions/pool-outbox.js +79 -26
  77. package/dist/sessions/pool-print.d.ts +1 -1
  78. package/dist/sessions/pool-raw.d.ts +27 -0
  79. package/dist/sessions/pool-raw.js +68 -0
  80. package/dist/sessions/pool-replica.d.ts +35 -0
  81. package/dist/sessions/pool-replica.js +91 -0
  82. package/dist/sessions/pool-send-lock.d.ts +20 -0
  83. package/dist/sessions/pool-send-lock.js +87 -0
  84. package/dist/sessions/pool-sent.d.ts +4 -0
  85. package/dist/sessions/pool-sent.js +26 -0
  86. package/dist/sessions/pool-totals.d.ts +10 -0
  87. package/dist/sessions/pool-totals.js +21 -0
  88. package/dist/sessions/pool-wire.d.ts +240 -30
  89. package/dist/sessions/pool-wire.js +695 -215
  90. package/dist/sessions/public-vocabulary.d.ts +2 -3
  91. package/dist/sessions/public-vocabulary.js +2 -9
  92. package/dist/sessions/query.d.ts +16 -4
  93. package/dist/sessions/query.js +32 -10
  94. package/dist/sessions/read-cost.js +14 -3
  95. package/dist/sessions/read-recovery-edge.d.ts +2 -0
  96. package/dist/sessions/read-recovery-edge.js +26 -0
  97. package/dist/sessions/read.d.ts +8 -1
  98. package/dist/sessions/read.js +6 -3
  99. package/dist/sessions/recall-bundle.d.ts +12 -0
  100. package/dist/sessions/recall-bundle.js +33 -0
  101. package/dist/sessions/recall-frames.d.ts +18 -0
  102. package/dist/sessions/recall-frames.js +44 -0
  103. package/dist/sessions/recall-next.d.ts +145 -0
  104. package/dist/sessions/recall-next.js +241 -0
  105. package/dist/sessions/recall-object.d.ts +2 -0
  106. package/dist/sessions/recall-object.js +1 -0
  107. package/dist/sessions/recall-push.d.ts +2 -2
  108. package/dist/sessions/recall-push.js +3 -51
  109. package/dist/sessions/recall-reads.d.ts +7 -0
  110. package/dist/sessions/recall-reads.js +7 -0
  111. package/dist/sessions/recovery-calls.d.ts +21 -0
  112. package/dist/sessions/recovery-calls.js +25 -0
  113. package/dist/sessions/redact.d.ts +11 -0
  114. package/dist/sessions/redact.js +59 -0
  115. package/dist/sessions/schema.d.ts +59 -9
  116. package/dist/sessions/schema.js +22 -3
  117. package/dist/sessions/session-shape.d.ts +6 -3
  118. package/dist/sessions/session-shape.js +19 -6
  119. package/dist/sessions/sources.js +75 -1
  120. package/dist/sessions/store.d.ts +13 -1
  121. package/dist/sessions/store.js +36 -16
  122. package/dist/sessions/survival.d.ts +2 -0
  123. package/dist/sessions/survival.js +2 -2
  124. package/dist/sessions/transcript-tail.d.ts +14 -0
  125. package/dist/sessions/transcript-tail.js +64 -0
  126. package/dist/sessions/vscode-copilot.js +24 -7
  127. package/dist/telemetry/command.d.ts +8 -0
  128. package/dist/telemetry/command.js +85 -0
  129. package/dist/telemetry/consent.d.ts +17 -0
  130. package/dist/telemetry/consent.js +63 -0
  131. package/dist/telemetry/crash.d.ts +8 -0
  132. package/dist/telemetry/crash.js +52 -0
  133. package/dist/telemetry/emit.d.ts +31 -0
  134. package/dist/telemetry/emit.js +42 -0
  135. package/dist/telemetry/fields.d.ts +16 -0
  136. package/dist/telemetry/fields.js +26 -0
  137. package/dist/telemetry/health.d.ts +21 -0
  138. package/dist/telemetry/health.js +108 -0
  139. package/dist/telemetry/hook-emit.d.ts +4 -0
  140. package/dist/telemetry/hook-emit.js +18 -0
  141. package/dist/telemetry/measures.d.ts +32 -0
  142. package/dist/telemetry/measures.js +128 -0
  143. package/dist/telemetry/observe.d.ts +13 -0
  144. package/dist/telemetry/observe.js +51 -0
  145. package/dist/telemetry/read-events.d.ts +9 -0
  146. package/dist/telemetry/read-events.js +17 -0
  147. package/dist/telemetry/record.d.ts +17 -0
  148. package/dist/telemetry/record.js +26 -0
  149. package/dist/telemetry/report.d.ts +77 -0
  150. package/dist/telemetry/report.js +188 -0
  151. package/dist/telemetry/schema.d.ts +40 -0
  152. package/dist/telemetry/schema.js +187 -0
  153. package/dist/telemetry/send-main.d.ts +1 -0
  154. package/dist/telemetry/send-main.js +11 -0
  155. package/dist/telemetry/send.d.ts +36 -0
  156. package/dist/telemetry/send.js +172 -0
  157. package/dist/telemetry/spool.d.ts +17 -0
  158. package/dist/telemetry/spool.js +70 -0
  159. package/dist/telemetry/wire.d.ts +25 -0
  160. package/dist/telemetry/wire.js +76 -0
  161. package/package.json +2 -2
@@ -6,6 +6,8 @@ export declare const NORMALIZE: ReadonlyMap<string, (name: string) => string>;
6
6
  export declare const resolveName: (ecosystem: string, name: string) => string | null;
7
7
  /** One pull as the `package` and `packageVersion` it names, or null when it names no package. */
8
8
  export declare const resolvePull: (pull: ToolPull) => Omit<ToolPackage, "first"> | null;
9
+ /** An MCP server pull as `mcp:<server>`, or null for any other ecosystem. Stored `packages` never hold it, since a server is no registry package. */
10
+ export declare const resolveMcpPull: (pull: ToolPull) => string | null;
9
11
  /**
10
12
  * The packages one call installs or runs, each marked `first` when no earlier call of the transcript file named that package at that version. `seen` is the
11
13
  * file's running set, so the caller passes the same one for every call in order. A version that fails on first try still counts: the mark is made
@@ -9,7 +9,7 @@ export const NORMALIZE = new Map([
9
9
  ['rubygems', (name) => name],
10
10
  ['homebrew', (name) => name.toLowerCase()],
11
11
  ]);
12
- /** A capture ecosystem to the vocabulary's. `skill`, `plugin`, and `mcp` are not package registries and are absent. */
12
+ /** A capture ecosystem to the vocabulary's. `skill`, `plugin`, and `mcp` are not package registries and are absent; `books --by package` reads `mcp` through `resolveMcpPull`. */
13
13
  const ECOSYSTEM_OF = new Map([
14
14
  ['npm', 'npm'],
15
15
  ['pypi', 'pypi'],
@@ -44,6 +44,8 @@ export const resolvePull = (pull) => {
44
44
  return null;
45
45
  return { package: resolved, packageVersion: pull.version !== null && EXACT_VERSION.test(pull.version) ? pull.version : null };
46
46
  };
47
+ /** An MCP server pull as `mcp:<server>`, or null for any other ecosystem. Stored `packages` never hold it, since a server is no registry package. */
48
+ export const resolveMcpPull = (pull) => (pull.ecosystem === 'mcp' ? `mcp:${pull.name}` : null);
47
49
  /**
48
50
  * The packages one call installs or runs, each marked `first` when no earlier call of the transcript file named that package at that version. `seen` is the
49
51
  * file's running set, so the caller passes the same one for every call in order. A version that fails on first try still counts: the mark is made
@@ -0,0 +1,36 @@
1
+ /** The Pi agent folder for an environment; a test passes its own so it never reads the real one. */
2
+ export declare const piRoot: (env: {
3
+ [name: string]: string | undefined;
4
+ }, home?: string) => string;
5
+ type Obj = {
6
+ [key: string]: unknown;
7
+ };
8
+ /** A session found in the store, before its lines are read. */
9
+ export interface PiSession {
10
+ /** The id in the file name, which names the skeleton. */
11
+ id: string;
12
+ /** The header's `cwd`; null when it names none. */
13
+ folder: string | null;
14
+ /** The session file. */
15
+ file: string;
16
+ size: number;
17
+ mtimeMs: number;
18
+ }
19
+ export interface PiStore {
20
+ sessions: PiSession[];
21
+ /** The session as transcript text for `captureSession`; throws when the file cannot be read or holds no parseable line. */
22
+ transcript: (session: PiSession) => Promise<string>;
23
+ /** Files skipped because their id is not a safe file name (it names a skeleton), counted so the skip is never silent. */
24
+ rejected: number;
25
+ /** Sessions that could not be inspected, reported once each and never fatal; the reason names no path. */
26
+ unreadable: {
27
+ file: string;
28
+ reason: string;
29
+ }[];
30
+ }
31
+ /** The non-blank lines that are not JSON (a torn write), kept so `captureSession` counts them and the deposit line reports them. */
32
+ export declare const piBadLines: (text: string) => string[];
33
+ /** One session as the lines `captureSession` reads. */
34
+ export declare const piLines: (text: string, fallbackId: string) => Obj[];
35
+ export declare const openPiStore: (root: string) => Promise<PiStore | undefined>;
36
+ export {};
@@ -0,0 +1,353 @@
1
+ // The Pi source: a Pi coding agent session file becomes the same capture records a Claude Code transcript does.
2
+ //
3
+ // FIXTURE-DERIVED: written from the public source and docs of the agent (github.com/badlogic/pi-mono, now earendil-works/pi, @ 085c84e98e7f4c950f2be606466fff4f75d8fe7c,
4
+ // packages/coding-agent/, npm `@earendil-works/pi-coding-agent` 1.5.0; the earlier `@mariozechner/pi-coding-agent` wrote the same files), not from a live store. Nothing was copied; the
5
+ // reader is original. No vendor text promises the format (docs/session-format.md names the file version and says an older file is migrated when loaded, and sends programs to the SDK),
6
+ // so `docs/sessions-store.md` says what a live run would still have to prove.
7
+ //
8
+ // Where it reads from (`piRoot`): `$PI_CODING_AGENT_DIR`, else `~/.pi/agent` (`%USERPROFILE%\.pi\agent` on Windows) (src/config.ts:610, :613-614, :630-636, docs/environment-variables.md:81). A session is
9
+ // `sessions/--<cwd with its leading separator removed and / \ : turned into ->--/<ISO time with : . turned into ->_<session id>.jsonl` (docs/session-format.md:10-14,
10
+ // session-manager.ts:590-593). The sessions folder can be moved by `--session-dir`, `PI_CODING_AGENT_SESSION_DIR`, or a setting (docs/sessions.md:60); this reader does not follow those.
11
+ // A file is written only once a conversation exists, then appended to (session-manager.ts:1176-1190). Line 1 is the header and every later line is an entry:
12
+ // session {version, id, timestamp, cwd, parentSession?}: `version` is the FILE format's (1 linear, 2 tree, 3 `hookMessage` renamed `custom`), not Pi's; no app version is recorded.
13
+ // The session's cwd is fixed (docs/sessions.md:15): a `cd` inside a command is not tracked, and a resume from another folder runs in this one.
14
+ // message {id, parentId, timestamp (ISO), message}: `message.timestamp` is Unix ms (docs/message-types.md:5). Roles read here: `user` (content a string or text/image
15
+ // blocks), `assistant` (content text/thinking/toolCall blocks, `model`, `responseModel?`, `usage`, `stopReason`), `toolResult` (toolCallId, content text blocks,
16
+ // `isError`), and `bashExecution` (the operator's own `!cmd`: command, output, exitCode, cancelled). Every other role (`custom`, `hookMessage`, `branchSummary`,
17
+ // `compactionSummary`, `system`) is an extension's or the harness's text, not a prompt.
18
+ // every other type `model_change`, `thinking_level_change`, `usage`, `compaction`, `branch_summary`, `custom`, `custom_message`, `context_edit`, `label`, `session_info`: not read. The usage
19
+ // on a `compaction`, a `branch_summary`, and a `usage` entry is model work that is not a response, and a tool result's `usage` and `nestedCalls` are nested work
20
+ // (docs/message-types.md:177): none is a response of this session.
21
+ // Entries form a tree (`id`, `parentId`) and the leaf is the LAST entry in the file (session-manager.ts:1104-1112), so the branch the session is on is the path from the last entry to its
22
+ // root; that path is what is read, and an abandoned branch is not. A version 1 file has no ids and is one chain in file order (session-manager.ts:287-313). A fork or clone is a new file
23
+ // that starts with its parent's path and keeps the old entry times (createBranchedSession, session-manager.ts:1643-1735); its header names `parentSession` and carries the time the
24
+ // copy was made, so an entry timed before that is the parent's and is not this session's.
25
+ // `usage.input` is the uncached part already: Pi takes the cache reads and writes out of the provider's prompt count (packages/ai/src/api/openai-completions.ts:1536-1550, packages/ai/src/api/openai-responses-shared.ts:636-642;
26
+ // Anthropic's `input_tokens` is disjoint), and `reasoning` is inside `output` (docs/message-types.md:90), so the figures map one for one onto capture's disjoint fields. A response that
27
+ // ended in an error or an abort with every figure zero never reached the provider's count (packages/ai/src/api/anthropic-messages.ts:824 starts them at zero), so it has no usage rather than a zero one.
28
+ // Pi has no sub-agent in core (README.md:19); the example extension runs `pi --no-session` (examples/extensions/subagent/index.ts:389), which writes no file, so the parent's call is the record.
29
+ import { open, readdir, readFile, stat } from 'node:fs/promises';
30
+ import { homedir } from 'node:os';
31
+ import { basename, join, posix, win32 } from 'node:path';
32
+ /** The Pi agent folder for an environment; a test passes its own so it never reads the real one. */
33
+ export const piRoot = (env, home = homedir()) => {
34
+ const set = env.PI_CODING_AGENT_DIR;
35
+ if (set === undefined || set === '')
36
+ return join(home, '.pi', 'agent');
37
+ // Pi expands a leading ~ (config.ts:614).
38
+ return set === '~' ? home : /^~[/\\]/.test(set) ? join(home, set.slice(2)) : set;
39
+ };
40
+ const isObj = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
41
+ const str = (value) => (typeof value === 'string' && value !== '' ? value : undefined);
42
+ const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
43
+ const parse = (text) => {
44
+ try {
45
+ return JSON.parse(text);
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ };
51
+ const SAFE_ID = /^(?!\.+$)[A-Za-z0-9._-]+$/;
52
+ /** The tools that run a command (docs/cli.md:144-146); `bashExecution` is the operator's own. */
53
+ const SHELL_TOOLS = new Set(['bash', 'powershell']);
54
+ /** The tools that change a file; each names it in `path` (tools/edit.ts:29-31, tools/write.ts:10-12). */
55
+ const EDIT_TOOLS = new Set(['edit', 'write']);
56
+ /** What a non-zero exit appends to the output (tools/bash.ts:436). */
57
+ const EXIT_SUFFIX = /(?:^|\n\n)Command exited with code (-?\d+)$/;
58
+ /** The header is one short line; a file whose first 64 KiB hold no line end is not a Pi file. */
59
+ const HEADER_BYTES = 65536;
60
+ /** An error's message with `file` cut to its base name, so a reason that is logged names no folder. */
61
+ const reasonOf = (error, file) => (error instanceof Error ? error.message : String(error)).split(file).join(basename(file));
62
+ const missing = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
63
+ /** The non-blank lines that are not JSON (a torn write), kept so `captureSession` counts them and the deposit line reports them. */
64
+ export const piBadLines = (text) => text.split('\n').filter((raw) => raw.trim() !== '' && parse(raw) === undefined);
65
+ /** A time in milliseconds, or undefined when it is not an absolute time after 2000. */
66
+ const MIN_MS = Date.parse('2000-01-01T00:00:00Z');
67
+ const absoluteMs = (ms) => (ms !== undefined && ms >= MIN_MS ? ms : undefined);
68
+ const isoMs = (value) => {
69
+ const text = str(value);
70
+ return text === undefined ? undefined : absoluteMs(Date.parse(text) || undefined);
71
+ };
72
+ /** An entry's time: its ISO `timestamp`, else the message's own Unix ms. */
73
+ const timeOf = (entry) => isoMs(entry.timestamp) ?? (isObj(entry.message) ? absoluteMs(num(entry.message.timestamp)) : undefined);
74
+ /** A failed shell call's error line: a nonzero exit says so first, then the output. */
75
+ const failureOf = (exit, output) => (exit < 0 && output.trim() !== '' ? output : `Exit code ${String(exit)}${output === '' ? '' : `\n${output}`}`);
76
+ /** The text of a content value: a string, or the text blocks of an array joined by line ends. */
77
+ const textOf = (content) => {
78
+ if (typeof content === 'string')
79
+ return content;
80
+ if (!Array.isArray(content))
81
+ return '';
82
+ return content.flatMap((block) => (isObj(block) && block.type === 'text' && typeof block.text === 'string' ? [block.text] : [])).join('\n');
83
+ };
84
+ /** A path the model named, made absolute against the session's folder; the folder's own style (POSIX or Windows) decides the join. */
85
+ const absolute = (path, cwd) => {
86
+ if (cwd === undefined || posix.isAbsolute(path) || win32.isAbsolute(path))
87
+ return path;
88
+ return (cwd.startsWith('/') ? posix : win32).join(cwd, path);
89
+ };
90
+ /** The entries on the branch the session is on, in order: the path from the last entry to its root (version 2 and later), or every entry (version 1, which has no ids). */
91
+ const activePath = (header, body) => {
92
+ const version = num(header?.version) ?? 1;
93
+ if (version < 2)
94
+ return body;
95
+ const byId = new Map();
96
+ for (const entry of body) {
97
+ const id = str(entry.id);
98
+ if (id !== undefined)
99
+ byId.set(id, entry);
100
+ }
101
+ const path = [];
102
+ const seen = new Set();
103
+ // The leaf is the last entry written (session-manager.ts:1104-1112); an id seen twice ends a cycle, and a parent that is gone ends a broken chain.
104
+ let current = body.findLast((entry) => str(entry.id) !== undefined);
105
+ while (current !== undefined) {
106
+ const id = str(current.id);
107
+ if (id === undefined || seen.has(id))
108
+ break;
109
+ seen.add(id);
110
+ path.push(current);
111
+ const parent = str(current.parentId);
112
+ current = parent === undefined ? undefined : byId.get(parent);
113
+ }
114
+ return path.reverse();
115
+ };
116
+ /** One session as the lines `captureSession` reads. */
117
+ export const piLines = (text, fallbackId) => {
118
+ const nonblank = text.split('\n').filter((raw) => raw.trim() !== '');
119
+ if (nonblank.length > 0 && !nonblank.some((raw) => isObj(parse(raw))))
120
+ throw new Error('no parseable line');
121
+ const entries = nonblank.map(parse).filter(isObj);
122
+ const header = entries.find((entry) => entry.type === 'session');
123
+ const headerMs = isoMs(header?.timestamp);
124
+ // A fork or clone starts with its parent's path, which keeps the old times; the header's time is when the copy was made (session-manager.ts:1643-1735).
125
+ const copiedBefore = str(header?.parentSession) === undefined ? undefined : headerMs;
126
+ const kept = activePath(header, entries.filter((entry) => entry.type !== 'session')).filter((entry) => {
127
+ const when = timeOf(entry);
128
+ return copiedBefore === undefined || when === undefined || when >= copiedBefore;
129
+ });
130
+ const cwd = str(header?.cwd);
131
+ const base = { sessionId: str(header?.id) ?? fallbackId, ...(cwd === undefined ? {} : { cwd }) };
132
+ const lines = [];
133
+ const calls = new Map();
134
+ const settled = new Set();
135
+ let sequence = 0;
136
+ let at = headerMs ?? 0;
137
+ // The last assistant line since the last prompt, which the operator's own `!cmd` joins rather than counting as a response of its own.
138
+ let lastResponse;
139
+ const stamp = (entry) => {
140
+ const when = timeOf(entry);
141
+ // A missing or earlier time says nothing about when: the latest time so far stands.
142
+ if (when !== undefined && when > at)
143
+ at = when;
144
+ return at === 0 ? undefined : new Date(at).toISOString();
145
+ };
146
+ const result = (id, when, failure, output = '') => {
147
+ sequence += 1;
148
+ lines.push({
149
+ ...base,
150
+ type: 'user',
151
+ uuid: `${fallbackId}-r${String(sequence)}`,
152
+ ...(when === undefined ? {} : { timestamp: when }),
153
+ // The result is stamped with its own time, but the call's start is not paired to it here, so no duration is claimed.
154
+ noDuration: true,
155
+ // The output follows an `Exit code 0` line, the shape a Claude Code Bash result has, so a `[branch sha]` line is still found for commit survival.
156
+ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, is_error: failure !== undefined, content: failure ?? (output === '' ? '' : `Exit code 0\n${output}`) }] },
157
+ });
158
+ };
159
+ const response = (when, content, extra = {}, message = {}) => {
160
+ sequence += 1;
161
+ lines.push({
162
+ ...base,
163
+ type: 'assistant',
164
+ uuid: `${fallbackId}-a${String(sequence)}`,
165
+ // Every assistant line carries a requestId (`verify --strict` needs one), and each response has its own, so its usage counts once.
166
+ requestId: `${fallbackId}-resp${String(sequence)}`,
167
+ ...(when === undefined ? {} : { timestamp: when }),
168
+ ...extra,
169
+ message: { role: 'assistant', content, ...message },
170
+ });
171
+ lastResponse = { content };
172
+ };
173
+ const toolUse = (block) => {
174
+ const id = str(block.id) ?? '';
175
+ const name = str(block.name) ?? '?';
176
+ const input = isObj(block.arguments) ? block.arguments : {};
177
+ if (SHELL_TOOLS.has(name)) {
178
+ calls.set(id, { shell: true });
179
+ const command = str(input.command);
180
+ return { type: 'tool_use', id, name: 'Bash', native: name, input: command === undefined ? {} : { command } };
181
+ }
182
+ calls.set(id, { shell: false });
183
+ if (EDIT_TOOLS.has(name)) {
184
+ const path = str(input.path);
185
+ return { type: 'tool_use', id, name: 'Edit', native: name, input: path === undefined ? {} : { file_path: absolute(path, cwd) } };
186
+ }
187
+ return { type: 'tool_use', id, name, input };
188
+ };
189
+ /** The usage a response reported; undefined when it reported none, or errored before the provider counted anything. */
190
+ const usageOf = (message) => {
191
+ const raw = message.usage;
192
+ if (!isObj(raw))
193
+ return undefined;
194
+ const figures = { input: num(raw.input), read: num(raw.cacheRead), write: num(raw.cacheWrite), output: num(raw.output), thinking: num(raw.reasoning) };
195
+ const stopped = message.stopReason === 'error' || message.stopReason === 'aborted';
196
+ if (stopped && [figures.input, figures.read, figures.write, figures.output].every((figure) => (figure ?? 0) === 0))
197
+ return undefined;
198
+ return {
199
+ ...(figures.input === undefined ? {} : { input_tokens: figures.input }),
200
+ ...(figures.read === undefined ? {} : { cache_read_input_tokens: figures.read }),
201
+ ...(figures.write === undefined ? {} : { cache_creation_input_tokens: figures.write }),
202
+ ...(figures.output === undefined ? {} : { output_tokens: figures.output }),
203
+ ...(figures.thinking === undefined ? {} : { output_tokens_details: { thinking_tokens: figures.thinking } }),
204
+ };
205
+ };
206
+ for (const entry of kept) {
207
+ if (entry.type !== 'message' || !isObj(entry.message))
208
+ continue;
209
+ const message = entry.message;
210
+ const role = str(message.role);
211
+ const when = stamp(entry);
212
+ if (role === 'user') {
213
+ const body = textOf(message.content);
214
+ const image = Array.isArray(message.content) && message.content.some((block) => isObj(block) && block.type === 'image');
215
+ if (body === '' && !image)
216
+ continue;
217
+ sequence += 1;
218
+ lastResponse = undefined;
219
+ // An image-only prompt keeps its line (an image block with no data); capture counts a turn only for a prompt with text, so it is not a counted turn.
220
+ lines.push({ ...base, type: 'user', uuid: `${fallbackId}-u${String(sequence)}`, ...(when === undefined ? {} : { timestamp: when }), message: { role: 'user', content: [body === '' ? { type: 'image' } : { type: 'text', text: body }] } });
221
+ }
222
+ else if (role === 'assistant') {
223
+ const content = [];
224
+ for (const block of Array.isArray(message.content) ? message.content : []) {
225
+ if (!isObj(block))
226
+ continue;
227
+ if (block.type === 'text' && str(block.text) !== undefined)
228
+ content.push({ type: 'text', text: String(block.text) });
229
+ else if (block.type === 'toolCall')
230
+ content.push(toolUse(block));
231
+ }
232
+ const model = str(message.model);
233
+ const reported = str(message.responseModel);
234
+ const usage = usageOf(message);
235
+ response(when, content, model === undefined ? {} : { requestedModel: model }, { ...(reported === undefined ? {} : { model: reported }), ...(usage === undefined ? {} : { usage }) });
236
+ }
237
+ else if (role === 'toolResult') {
238
+ const id = str(message.toolCallId);
239
+ const call = id === undefined ? undefined : calls.get(id);
240
+ if (id === undefined || call === undefined || settled.has(id))
241
+ continue;
242
+ settled.add(id);
243
+ const output = textOf(message.content);
244
+ if (message.isError !== true) {
245
+ result(id, when, undefined, call.shell ? output : '');
246
+ }
247
+ else if (call.shell) {
248
+ // A non-zero exit ends the text with the code (tools/bash.ts:434-440), the one place the persisted result keeps it; an abort, a timeout, and a spawn error carry no such suffix and stand as written.
249
+ const exit = EXIT_SUFFIX.exec(output);
250
+ result(id, when, exit === null ? (output === '' ? 'tool failed' : output) : failureOf(Number(exit[1]), output.slice(0, exit.index)));
251
+ }
252
+ else {
253
+ result(id, when, output === '' ? 'tool failed' : output);
254
+ }
255
+ }
256
+ else if (role === 'bashExecution') {
257
+ // The operator's own `!cmd`: a real exit code, no model behind it.
258
+ sequence += 1;
259
+ const id = `${fallbackId}-bang${String(sequence)}`;
260
+ const command = str(message.command);
261
+ const block = { type: 'tool_use', id, name: 'Bash', native: 'bash', input: command === undefined ? {} : { command } };
262
+ if (lastResponse === undefined)
263
+ response(when, [block]);
264
+ else
265
+ lastResponse.content.push(block);
266
+ const output = typeof message.output === 'string' ? message.output : '';
267
+ const exit = num(message.exitCode);
268
+ let failure;
269
+ if (message.cancelled === true)
270
+ failure = output === '' ? 'Command cancelled' : output;
271
+ else if (exit === undefined)
272
+ failure = output === '' ? 'Command ended without an exit code' : output;
273
+ else if (exit !== 0)
274
+ failure = failureOf(exit, output);
275
+ result(id, when, failure, output);
276
+ }
277
+ }
278
+ return lines;
279
+ };
280
+ /** The first line of a file, read from its first 64 KiB; undefined when the file is empty. */
281
+ const firstLine = async (file) => {
282
+ const handle = await open(file, 'r');
283
+ try {
284
+ const buffer = Buffer.alloc(HEADER_BYTES);
285
+ const { bytesRead } = await handle.read(buffer, 0, HEADER_BYTES, 0);
286
+ const text = buffer.subarray(0, bytesRead).toString('utf8');
287
+ const line = text.split('\n', 1)[0] ?? '';
288
+ return line.trim() === '' ? undefined : line;
289
+ }
290
+ finally {
291
+ await handle.close();
292
+ }
293
+ };
294
+ export const openPiStore = async (root) => {
295
+ const base = join(root, 'sessions');
296
+ const unreadable = [];
297
+ let folders;
298
+ try {
299
+ folders = await readdir(base, { withFileTypes: true });
300
+ }
301
+ catch (error) {
302
+ if (missing(error))
303
+ return undefined;
304
+ return { sessions: [], transcript: () => Promise.reject(new Error('no sessions')), rejected: 0, unreadable: [{ file: base, reason: reasonOf(error, base) }] };
305
+ }
306
+ const sessions = [];
307
+ let rejected = 0;
308
+ for (const folder of folders.filter((candidate) => candidate.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
309
+ let files;
310
+ try {
311
+ files = await readdir(join(base, folder.name), { withFileTypes: true });
312
+ }
313
+ catch (error) {
314
+ unreadable.push({ file: join(base, folder.name), reason: reasonOf(error, join(base, folder.name)) });
315
+ continue;
316
+ }
317
+ for (const entry of files.filter((candidate) => candidate.isFile() && candidate.name.endsWith('.jsonl')).sort((a, b) => a.name.localeCompare(b.name))) {
318
+ // `<timestamp>_<id>.jsonl`: the timestamp has no underscore, so the id is everything after the first one (docs/session-format.md:11-14).
319
+ const stem = entry.name.slice(0, -'.jsonl'.length);
320
+ const id = stem.includes('_') ? stem.slice(stem.indexOf('_') + 1) : stem;
321
+ if (!SAFE_ID.test(id)) {
322
+ rejected += 1;
323
+ continue;
324
+ }
325
+ const file = join(base, folder.name, entry.name);
326
+ try {
327
+ const first = await firstLine(file);
328
+ const header = first === undefined ? undefined : parse(first);
329
+ if (!isObj(header) || header.type !== 'session') {
330
+ unreadable.push({ file, reason: 'no session header on the first line' });
331
+ continue;
332
+ }
333
+ const info = await stat(file);
334
+ sessions.push({ id, folder: str(header.cwd) ?? null, file, size: info.size, mtimeMs: Math.floor(info.mtimeMs) });
335
+ }
336
+ catch (error) {
337
+ if (!missing(error))
338
+ unreadable.push({ file, reason: reasonOf(error, file) });
339
+ }
340
+ }
341
+ }
342
+ sessions.sort((a, b) => a.mtimeMs - b.mtimeMs || a.id.localeCompare(b.id));
343
+ return {
344
+ sessions,
345
+ rejected,
346
+ unreadable,
347
+ transcript: async (session) => {
348
+ const text = await readFile(session.file, 'utf8');
349
+ const lines = [...piLines(text, session.id).map((line) => JSON.stringify(line)), ...piBadLines(text)];
350
+ return lines.join('\n') + (lines.length === 0 ? '' : '\n');
351
+ },
352
+ };
353
+ };
@@ -0,0 +1,21 @@
1
+ import { type Bundle } from './pool-wire.js';
2
+ /** The newest kept bundle that still verifies against the key pinned for its server; undefined when none was fetched or none verifies. An unreadable sign-ins file stops the command (`loadSignIns`) and is not read as no bundle. */
3
+ export declare const lastFetchedBundle: (storeDir: string) => Promise<Bundle | undefined>;
4
+ /** The pool lines of `books`: the four, or one plain line when there is nothing to state them from. */
5
+ export declare const booksLines: (bundle: Bundle | undefined) => string[];
6
+ /** The totals as flat numbers for the health batch to read; a field with no source is null and never 0, and a bundle with no books gives null. */
7
+ export interface BooksTotals {
8
+ /** Lineage (section 17 rule 6): the floor the served count was held to, the day it was as of, and the day the panel was counted, which a 304 can leave days old. */
9
+ floor: number;
10
+ asOfDay: string | null;
11
+ panelDay: string | null;
12
+ servedCells: number;
13
+ belowFloor: number;
14
+ panel: number | null;
15
+ comparisonReadOwners: number | null;
16
+ followOnReads: number | null;
17
+ readYield: number | null;
18
+ costPerServedCell: number | null;
19
+ yieldPerServedCell: number | null;
20
+ }
21
+ export declare const booksTotals: (bundle: Bundle | undefined) => BooksTotals | null;
@@ -0,0 +1,75 @@
1
+ // `oars sessions books`, the pool's side: the set of four (the pool design notes, set of four) from the last fetched bundle. The bundle is the replica a fetch kept, checked again against the key pinned at login, so a
2
+ // file edited on disk prints nothing. A field with no source says so and is never printed as zero.
3
+ import { readdir, readFile, stat } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { loadSignIns } from './pool-command.js';
6
+ import { isHarness, verifyBundle } from './pool-wire.js';
7
+ import { contentHash } from './read.js';
8
+ const names = async (folder) => readdir(folder).catch(() => []);
9
+ const candidatesOf = async (storeDir) => {
10
+ const root = join(storeDir, 'pool-replica');
11
+ const found = [];
12
+ for (const server of await names(root)) {
13
+ for (const harness of await names(join(root, server))) {
14
+ for (const name of await names(join(root, server, harness))) {
15
+ if (!name.endsWith('.json'))
16
+ continue;
17
+ const file = join(root, server, harness, name);
18
+ found.push({ file, server, harness, version: decodeURIComponent(name.slice(0, -'.json'.length)), modified: (await stat(file)).mtimeMs });
19
+ }
20
+ }
21
+ }
22
+ return found.sort((a, b) => b.modified - a.modified);
23
+ };
24
+ /** The newest kept bundle that still verifies against the key pinned for its server; undefined when none was fetched or none verifies. An unreadable sign-ins file stops the command (`loadSignIns`) and is not read as no bundle. */
25
+ export const lastFetchedBundle = async (storeDir) => {
26
+ const keys = await loadSignIns(storeDir, 'books');
27
+ for (const { file, server, harness, version } of await candidatesOf(storeDir)) {
28
+ const key = keys.find((entry) => contentHash(entry.server) === server)?.signingKey;
29
+ if (key === undefined || !isHarness(harness))
30
+ continue;
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(await readFile(file, 'utf8'));
34
+ }
35
+ catch {
36
+ continue;
37
+ }
38
+ const verified = await verifyBundle(parsed, key, harness, version);
39
+ if (verified.ok)
40
+ return verified.bundle;
41
+ }
42
+ return undefined;
43
+ };
44
+ const shown = (field, unit) => (field.state === 'value' ? `${String(field.value)}${unit}` : `no source yet (${field.reason})`);
45
+ const linesOf = (books) => [
46
+ `served cells ${String(books.served.total)}, below floor ${String(books.backlog)}, as of ${books.asOf.day ?? 'no known day'}`,
47
+ books.panel.state === 'value' ? `panel: ${String(books.panel.value)} accounts deposited in the last four weeks${books.panelDay === null ? '' : `, as of ${books.panelDay}`}` : `panel: ${shown(books.panel, '')}`,
48
+ `demand: comparison-read owners ${shown(books.demand.comparisonReadOwners, '')}, follow-on reads ${shown(books.demand.followOnReads, '')}`,
49
+ `goal metric: read yield ${shown(books.goal.readYield, '')}, cost per served cell ${shown(books.goal.costPerServedCell, '')}, yield per served cell ${shown(books.goal.yieldPerServedCell, '')}`,
50
+ ];
51
+ /** The pool lines of `books`: the four, or one plain line when there is nothing to state them from. */
52
+ export const booksLines = (bundle) => {
53
+ if (bundle === undefined)
54
+ return ['no pool bundle yet'];
55
+ return bundle.books === undefined ? ['the last fetched bundle carries no pool books; run oars pool fetch again'] : linesOf(bundle.books);
56
+ };
57
+ const valueOf = (field) => (field.state === 'value' ? field.value : null);
58
+ export const booksTotals = (bundle) => {
59
+ const books = bundle?.books;
60
+ if (books === undefined)
61
+ return null;
62
+ return {
63
+ floor: books.floor,
64
+ asOfDay: books.asOf.day,
65
+ panelDay: books.panelDay,
66
+ servedCells: books.served.total,
67
+ belowFloor: books.backlog,
68
+ panel: valueOf(books.panel),
69
+ comparisonReadOwners: valueOf(books.demand.comparisonReadOwners),
70
+ followOnReads: valueOf(books.demand.followOnReads),
71
+ readYield: valueOf(books.goal.readYield),
72
+ costPerServedCell: valueOf(books.goal.costPerServedCell),
73
+ yieldPerServedCell: valueOf(books.goal.yieldPerServedCell),
74
+ };
75
+ };
@@ -0,0 +1,5 @@
1
+ import type { FolderSend, PoolChoices } from './store.js';
2
+ export type Sends = 'all' | FolderSend;
3
+ export declare const sendsOf: (choices: PoolChoices, project: string) => Sends;
4
+ /** Whether a raw copy of this folder's sessions may be built: raw consent granted, and the folder neither skeleton only nor off. */
5
+ export declare const rawAllowed: (choices: PoolChoices, project: string) => boolean;
@@ -0,0 +1,11 @@
1
+ // What a folder may send to a pool, from the choices in `state.json`: everything (the default after the covenant is accepted), skeletons only, or nothing.
2
+ // A project that more than one entry names takes the narrowest of them, so a switch that is off is never undone by a broader entry.
3
+ import { compileAllow } from './allow.js';
4
+ export const sendsOf = (choices, project) => {
5
+ const modes = Object.entries(choices.folders).filter(([entry]) => compileAllow([entry]).allows(project)).map(([, mode]) => mode);
6
+ if (modes.includes('off'))
7
+ return 'off';
8
+ return modes.includes('skeleton') ? 'skeleton' : 'all';
9
+ };
10
+ /** Whether a raw copy of this folder's sessions may be built: raw consent granted, and the folder neither skeleton only nor off. */
11
+ export const rawAllowed = (choices, project) => choices.rawConsent === 'granted' && sendsOf(choices, project) === 'all';