@tangle-network/harness-sessions 0.0.0-stage → 0.1.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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -2
  3. package/dist/builder.d.ts +116 -0
  4. package/dist/builder.js +332 -0
  5. package/dist/capture.d.ts +44 -0
  6. package/dist/capture.js +210 -0
  7. package/dist/catalog.d.ts +163 -0
  8. package/dist/catalog.js +163 -0
  9. package/dist/cli.d.ts +2 -0
  10. package/dist/cli.js +115 -0
  11. package/dist/formats/claude-code.d.ts +9 -0
  12. package/dist/formats/claude-code.js +293 -0
  13. package/dist/formats/codex.d.ts +8 -0
  14. package/dist/formats/codex.js +379 -0
  15. package/dist/formats/factory.d.ts +13 -0
  16. package/dist/formats/factory.js +155 -0
  17. package/dist/formats/fold.d.ts +13 -0
  18. package/dist/formats/fold.js +5 -0
  19. package/dist/formats/head.d.ts +5 -0
  20. package/dist/formats/head.js +23 -0
  21. package/dist/formats/kimi.d.ts +9 -0
  22. package/dist/formats/kimi.js +205 -0
  23. package/dist/formats/opencode.d.ts +4 -0
  24. package/dist/formats/opencode.js +241 -0
  25. package/dist/formats/pi.d.ts +8 -0
  26. package/dist/formats/pi.js +201 -0
  27. package/dist/index.d.ts +27 -0
  28. package/dist/index.js +16 -0
  29. package/dist/input.d.ts +18 -0
  30. package/dist/input.js +46 -0
  31. package/dist/projections/chat.d.ts +31 -0
  32. package/dist/projections/chat.js +79 -0
  33. package/dist/projections/otlp.d.ts +12 -0
  34. package/dist/projections/otlp.js +91 -0
  35. package/dist/projections/turns.d.ts +51 -0
  36. package/dist/projections/turns.js +136 -0
  37. package/dist/registry.d.ts +11 -0
  38. package/dist/registry.js +39 -0
  39. package/dist/schema.d.ts +270 -0
  40. package/dist/schema.js +10 -0
  41. package/dist/source.d.ts +67 -0
  42. package/dist/source.js +254 -0
  43. package/dist/stores.d.ts +7 -0
  44. package/dist/stores.js +46 -0
  45. package/package.json +53 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tangle Network
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,71 @@
1
- # Temporary Holding Version
1
+ # @tangle-network/harness-sessions
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ One reader for every coding-agent harness's native session.
4
+
5
+ Each harness persists its session in its own store: Claude Code and Pi append JSONL, Codex appends a rollout JSONL, and OpenCode writes SQLite.
6
+ This package declares each store's layout once and parses each format into one normalized `HarnessSession`.
7
+ The session holds the messages, the tool calls with their inputs and results, every model call with the model that served it and its usage, and the error the session ended with.
8
+
9
+ ```bash
10
+ npm install @tangle-network/harness-sessions
11
+ ```
12
+
13
+ ## Where this fits
14
+
15
+ - The sidecar's harness session contract (agent-dev-container `packages/cli-agent-registry`) imports `STORES` and `SessionFormatId` from here; it keeps no second copy of where a harness writes.
16
+ - The capture path reads captures through `listCapture` / `readCapture`.
17
+ - Discovery's trace gate, agent-eval, agent-record and the blog read sessions only through this package.
18
+ - Its only dependencies are `@tangle-network/agent-trace-contract` and Node built-ins (`node:sqlite` for OpenCode).
19
+
20
+ ## Formats
21
+
22
+ | Harness | Format | Store under HOME | Served model from | Usage |
23
+ |---|---|---|---|---|
24
+ | `claude-code` | `claude-code.projects-jsonl` | `.claude/projects/*/{id}.jsonl`, subagents at `*/{id}/subagents/**/*.jsonl` | provider response (`message.model`) | per response, counted once per `message.id` |
25
+ | `codex` | `codex.rollout-jsonl` | `.codex/sessions/*/*/*/rollout-*-{id}.jsonl` | turn context (the configured model) | per response (`last_token_usage`); session total from `total_token_usage` |
26
+ | `opencode` | `opencode.sqlite` | `.local/share/opencode/opencode.db` (shared by every session) | session record (`providerID/modelID`) | per step (`step-finish`) |
27
+ | `pi` | `pi.session-jsonl` | `.pi/agent/sessions/**/*_{id}.jsonl` | session record (`provider/model`) | per assistant message |
28
+
29
+ Readers for Kimi, Factory, Gemini, Amp, Qwen, Copilot, Forge, Hermes, Prime and OpenClaw are added with the same real-data proof.
30
+
31
+ ## Use
32
+
33
+ ```ts
34
+ import { readerFor, listCapture, toChatMessages, toOtlpSpans, toTurns } from '@tangle-network/harness-sessions'
35
+
36
+ // Sessions in a HOME, through the harness's declared store
37
+ const reader = readerFor('pi')
38
+ for (const ref of await reader.locate('/home/agent')) {
39
+ const summary = await reader.summarize(ref) // constant memory; a 537 MB rollout reads in ~200 MB RSS
40
+ const session = await reader.read(ref) // full fidelity: nothing is truncated
41
+ }
42
+
43
+ // Sessions in a capture (provider retention layout today, raw-evidence archive v2 when given)
44
+ const { sessions, missing } = await listCapture(captureDir)
45
+ ```
46
+
47
+ Projections trim; the reader never does:
48
+
49
+ - `toChatMessages(session)`: agent-eval's chat-with-tools messages.
50
+ - `toOtlpSpans(session)`: contract spans (`@tangle-network/agent-trace-contract`), one LLM span per model call and one TOOL span per tool call.
51
+ - `toTurns(session)`: the blog's turns, with the model, usage and error of each assistant turn.
52
+
53
+ For callers that are not JavaScript:
54
+
55
+ ```bash
56
+ harness-sessions read <file|home|capture> [--harness h] [--session id] [--summary] [--json]
57
+ ```
58
+
59
+ ## Rules the readers keep
60
+
61
+ - The served model comes only from the provider response or the session record, never from the profile or the request. A failed request has no served model; `requestedModel` says which model it was sent to.
62
+ - `usage` is `null` when the harness recorded none; it is never reported as zero. `input` excludes cache reads and cache writes, which have their own fields.
63
+ - A session with zero model calls is not the trace of a turn.
64
+ - An unparsable record is counted in `integrity.unparsedRecords` and never stops a read (`corruption: 'strict'` throws instead). A record cut off at the end of a live copy is reported as a torn tail, not as corruption.
65
+ - `ending.status` is `completed`, `error`, `aborted` or `open`. `open` means the records stop mid-turn: the turn was still running or the process died.
66
+ - An OpenCode store is read from a private copy, never in place.
67
+
68
+ ## Tests
69
+
70
+ The fixtures are sessions that the real harness CLIs wrote while talking to the trace-proof scripted model; the tests assert that script.
71
+ The proof against the readers this package replaces is a comparison over stored sessions, recorded with each release.
@@ -0,0 +1,116 @@
1
+ import type { HarnessSession, ModelCall, Part, SessionEnding, SessionError, SessionFormatId, SessionMessage, SessionSummary, TokenUsage } from './schema.js';
2
+ import type { SourceStats } from './source.js';
3
+ export type BuildMode = 'full' | 'summary';
4
+ /** An ISO-8601 instant from an ISO string, epoch milliseconds or epoch seconds; null otherwise. */
5
+ export declare function isoTime(value: unknown): string | null;
6
+ export declare const isRecord: (value: unknown) => value is Record<string, unknown>;
7
+ export declare const str: (value: unknown) => string | null;
8
+ export declare function count(value: unknown): number | null;
9
+ /** Parse tool arguments recorded as text; keep the text when it is not JSON. */
10
+ export declare function argumentsOf(raw: unknown): {
11
+ input: unknown;
12
+ inputText: string | null;
13
+ };
14
+ /** The text of an output that is text, or of the text blocks in a content-block array. */
15
+ export declare function textOf(value: unknown): string | null;
16
+ /** The HTTP status in a provider error message such as `unexpected status 500 Internal Server Error`. */
17
+ export declare function httpStatusIn(text: string | null | undefined): number | null;
18
+ export declare function usageOrNull(usage: TokenUsage): TokenUsage | null;
19
+ /** The key a served model is counted under: `provider/model`, or the bare model with no provider. */
20
+ export declare function servedModelKey(call: Pick<ModelCall, 'provider' | 'servedModel'>): string | null;
21
+ /**
22
+ * Folds one session's records into a {@link HarnessSession}. Every format reader drives this
23
+ * builder, and `read` and `summarize` share the same fold: in summary mode the builder keeps the
24
+ * counts, the model calls and the ending, and drops message and tool-call content, so a 700 MB
25
+ * rollout is summarized without holding its outputs.
26
+ */
27
+ export declare class SessionBuilder {
28
+ readonly harness: string;
29
+ readonly format: SessionFormatId;
30
+ readonly modelSource: HarnessSession['modelSource'];
31
+ readonly full: boolean;
32
+ nativeSessionId: string;
33
+ parentNativeSessionId: string | null;
34
+ cwd: string | null;
35
+ private startedAt;
36
+ private endedAt;
37
+ private readonly messages;
38
+ private readonly messageById;
39
+ private readonly toolCalls;
40
+ private readonly toolCallIds;
41
+ private readonly openToolCalls;
42
+ private lastRole;
43
+ private inputsSinceLastCall;
44
+ private toolCallCount;
45
+ private replies;
46
+ readonly modelCalls: ModelCall[];
47
+ private readonly modelCallById;
48
+ private readonly children;
49
+ private readonly gaps;
50
+ private readonly sources;
51
+ private unparsed;
52
+ private truncated;
53
+ private sessionUsage;
54
+ private ending;
55
+ constructor(harness: string, format: SessionFormatId, modelSource: HarnessSession['modelSource'], mode: BuildMode, nativeSessionId: string, parentNativeSessionId?: string | null);
56
+ /** Record an instant the session was active. Returns the normalized instant. */
57
+ seen(value: unknown): string | null;
58
+ message(message: SessionMessage): SessionMessage;
59
+ /** Add parts to a message already recorded (one API response written over several records). */
60
+ appendParts(messageId: string, parts: Part[]): void;
61
+ /** Replace a tool call's arguments (a harness that streams them in parts). */
62
+ setToolInput(id: string, inputText: string): void;
63
+ hasMessage(messageId: string): boolean;
64
+ /** A tool call the agent made. Its part is added to `messageId` when the message is held. */
65
+ toolCall(call: {
66
+ id: string;
67
+ name: string;
68
+ input: unknown;
69
+ inputText: string | null;
70
+ at: string | null;
71
+ messageId: string;
72
+ }): void;
73
+ /** The result of a tool call. A result whose call was not recorded becomes a call of its own. */
74
+ toolResult(id: string, result: {
75
+ output: unknown;
76
+ text: string | null;
77
+ isError: boolean;
78
+ at: string | null;
79
+ name?: string;
80
+ messageId: string;
81
+ details?: unknown;
82
+ }): void;
83
+ modelCall(call: ModelCall): ModelCall;
84
+ getModelCall(id: string): ModelCall | undefined;
85
+ child(nativeSessionId: string, toolCallId: string | null): void;
86
+ gap(text: string): void;
87
+ source(stats: SourceStats | {
88
+ path: string;
89
+ sha256: string;
90
+ bytes: number;
91
+ unparsed?: number;
92
+ tornTail?: boolean;
93
+ unparsedLines?: number[];
94
+ }): void;
95
+ markTruncated(reason: string): void;
96
+ /** The harness's own session total, when it records one; it wins over the per-call sum. */
97
+ setSessionUsage(usage: TokenUsage | null): void;
98
+ end(ending: SessionEnding): void;
99
+ /**
100
+ * The ending every format shares, from what the session holds when the records run out:
101
+ * `aborted` when the harness recorded an abort last; `error` when the last model call failed
102
+ * and no new input followed it; `completed` when the last model call succeeded, every tool call
103
+ * has its result and the agent spoke last; `open` otherwise (the turn was still running, or the
104
+ * process died mid-turn).
105
+ */
106
+ inferEnding(aborted?: {
107
+ at: string | null;
108
+ error: SessionError | null;
109
+ }): void;
110
+ get endingStatus(): SessionEnding['status'];
111
+ private usage;
112
+ private servedModels;
113
+ build(): HarnessSession;
114
+ summary(): SessionSummary;
115
+ }
116
+ export declare function sessionError(kind: string, message: string | null | undefined, at: string | null, httpStatus?: number | null): SessionError;
@@ -0,0 +1,332 @@
1
+ import { HARNESS_SESSION_SCHEMA } from './schema.js';
2
+ /** An ISO-8601 instant from an ISO string, epoch milliseconds or epoch seconds; null otherwise. */
3
+ export function isoTime(value) {
4
+ if (typeof value === 'string' && value.length > 0) {
5
+ const ms = Date.parse(value);
6
+ return Number.isNaN(ms) ? null : new Date(ms).toISOString();
7
+ }
8
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
9
+ // Seconds before 2001-09-09 in milliseconds are below 1e12; nothing here predates that.
10
+ return new Date(value < 1e12 ? value * 1000 : value).toISOString();
11
+ }
12
+ return null;
13
+ }
14
+ export const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
15
+ export const str = (value) => (typeof value === 'string' ? value : null);
16
+ export function count(value) {
17
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
18
+ }
19
+ /** Parse tool arguments recorded as text; keep the text when it is not JSON. */
20
+ export function argumentsOf(raw) {
21
+ if (typeof raw !== 'string')
22
+ return { input: raw ?? null, inputText: null };
23
+ try {
24
+ return { input: JSON.parse(raw), inputText: raw };
25
+ }
26
+ catch {
27
+ return { input: raw, inputText: raw };
28
+ }
29
+ }
30
+ /** The text of an output that is text, or of the text blocks in a content-block array. */
31
+ export function textOf(value) {
32
+ if (typeof value === 'string')
33
+ return value;
34
+ if (!Array.isArray(value))
35
+ return null;
36
+ const texts = [];
37
+ for (const block of value) {
38
+ if (isRecord(block) && typeof block.text === 'string')
39
+ texts.push(block.text);
40
+ }
41
+ return texts.length > 0 ? texts.join('\n') : null;
42
+ }
43
+ /** The HTTP status in a provider error message such as `unexpected status 500 Internal Server Error`. */
44
+ export function httpStatusIn(text) {
45
+ if (!text)
46
+ return null;
47
+ const match = /\b(?:status(?: code)?|HTTP)[^\d]{0,4}([1-5]\d\d)\b/iu.exec(text) ?? /^([45]\d\d)\b/u.exec(text.trim());
48
+ return match ? Number(match[1]) : null;
49
+ }
50
+ function addUsage(total, usage) {
51
+ for (const key of ['input', 'output', 'reasoning', 'cacheRead', 'cacheWrite']) {
52
+ const value = usage[key];
53
+ if (value !== null)
54
+ total[key] = (total[key] ?? 0) + value;
55
+ }
56
+ }
57
+ export function usageOrNull(usage) {
58
+ return usage.input === null && usage.output === null && usage.reasoning === null
59
+ && usage.cacheRead === null && usage.cacheWrite === null
60
+ ? null
61
+ : usage;
62
+ }
63
+ /** The key a served model is counted under: `provider/model`, or the bare model with no provider. */
64
+ export function servedModelKey(call) {
65
+ if (call.servedModel === null)
66
+ return null;
67
+ return call.provider === null ? call.servedModel : `${call.provider}/${call.servedModel}`;
68
+ }
69
+ /**
70
+ * Folds one session's records into a {@link HarnessSession}. Every format reader drives this
71
+ * builder, and `read` and `summarize` share the same fold: in summary mode the builder keeps the
72
+ * counts, the model calls and the ending, and drops message and tool-call content, so a 700 MB
73
+ * rollout is summarized without holding its outputs.
74
+ */
75
+ export class SessionBuilder {
76
+ harness;
77
+ format;
78
+ modelSource;
79
+ full;
80
+ nativeSessionId;
81
+ parentNativeSessionId;
82
+ cwd = null;
83
+ startedAt = null;
84
+ endedAt = null;
85
+ messages = [];
86
+ messageById = new Map();
87
+ toolCalls = new Map();
88
+ toolCallIds = new Set();
89
+ openToolCalls = new Set();
90
+ lastRole = null;
91
+ inputsSinceLastCall = 0;
92
+ toolCallCount = 0;
93
+ replies = 0;
94
+ modelCalls = [];
95
+ modelCallById = new Map();
96
+ children = [];
97
+ gaps = [];
98
+ sources = [];
99
+ unparsed = 0;
100
+ truncated = false;
101
+ sessionUsage;
102
+ ending = { status: 'open', error: null, at: null };
103
+ constructor(harness, format, modelSource, mode, nativeSessionId, parentNativeSessionId = null) {
104
+ this.harness = harness;
105
+ this.format = format;
106
+ this.modelSource = modelSource;
107
+ this.full = mode === 'full';
108
+ this.nativeSessionId = nativeSessionId;
109
+ this.parentNativeSessionId = parentNativeSessionId;
110
+ }
111
+ /** Record an instant the session was active. Returns the normalized instant. */
112
+ seen(value) {
113
+ const at = isoTime(value);
114
+ if (at === null)
115
+ return null;
116
+ if (this.startedAt === null || at < this.startedAt)
117
+ this.startedAt = at;
118
+ if (this.endedAt === null || at > this.endedAt)
119
+ this.endedAt = at;
120
+ return at;
121
+ }
122
+ message(message) {
123
+ if (message.role === 'assistant' && message.actor === 'agent')
124
+ this.replies += 1;
125
+ // Only new input re-opens a turn: harness bookkeeping (a compaction summary, environment
126
+ // context) after a failed call does not.
127
+ if (message.role === 'user' && (message.actor === 'human' || message.actor === 'subagent-spawn'))
128
+ this.inputsSinceLastCall += 1;
129
+ this.lastRole = message.role;
130
+ if (this.full) {
131
+ this.messages.push(message);
132
+ this.messageById.set(message.id, message);
133
+ }
134
+ return message;
135
+ }
136
+ /** Add parts to a message already recorded (one API response written over several records). */
137
+ appendParts(messageId, parts) {
138
+ if (!this.full)
139
+ return;
140
+ const message = this.messageById.get(messageId);
141
+ if (message)
142
+ message.parts.push(...parts);
143
+ }
144
+ /** Replace a tool call's arguments (a harness that streams them in parts). */
145
+ setToolInput(id, inputText) {
146
+ const call = this.toolCalls.get(id);
147
+ if (!call)
148
+ return;
149
+ call.inputText = inputText;
150
+ call.input = argumentsOf(inputText).input;
151
+ }
152
+ hasMessage(messageId) {
153
+ return this.messageById.has(messageId);
154
+ }
155
+ /** A tool call the agent made. Its part is added to `messageId` when the message is held. */
156
+ toolCall(call) {
157
+ if (this.toolCallIds.has(call.id))
158
+ return;
159
+ this.toolCallIds.add(call.id);
160
+ this.openToolCalls.add(call.id);
161
+ this.toolCallCount += 1;
162
+ if (!this.full)
163
+ return;
164
+ this.toolCalls.set(call.id, {
165
+ id: call.id,
166
+ name: call.name,
167
+ input: call.input,
168
+ inputText: call.inputText,
169
+ result: null,
170
+ status: 'pending',
171
+ startedAt: call.at,
172
+ endedAt: null,
173
+ messageId: call.messageId,
174
+ });
175
+ }
176
+ /** The result of a tool call. A result whose call was not recorded becomes a call of its own. */
177
+ toolResult(id, result) {
178
+ if (!this.toolCallIds.has(id)) {
179
+ this.toolCall({ id, name: result.name ?? 'unknown', input: null, inputText: null, at: result.at, messageId: result.messageId });
180
+ this.gap(`tool result ${id} has no recorded call`);
181
+ }
182
+ this.openToolCalls.delete(id);
183
+ if (!this.full)
184
+ return;
185
+ const call = this.toolCalls.get(id);
186
+ if (!call)
187
+ return;
188
+ call.result = { output: result.output, text: result.text, isError: result.isError, at: result.at, details: result.details ?? null };
189
+ call.status = result.isError ? 'error' : 'completed';
190
+ call.endedAt = result.at;
191
+ }
192
+ modelCall(call) {
193
+ this.inputsSinceLastCall = 0;
194
+ this.modelCalls.push(call);
195
+ this.modelCallById.set(call.id, call);
196
+ return call;
197
+ }
198
+ getModelCall(id) {
199
+ return this.modelCallById.get(id);
200
+ }
201
+ child(nativeSessionId, toolCallId) {
202
+ if (this.children.some((c) => c.nativeSessionId === nativeSessionId))
203
+ return;
204
+ this.children.push({ nativeSessionId, toolCallId });
205
+ }
206
+ gap(text) {
207
+ // Keep the first occurrences; a damaged file can produce one gap per record.
208
+ if (this.gaps.length < 200)
209
+ this.gaps.push(text);
210
+ }
211
+ source(stats) {
212
+ this.sources.push({ path: stats.path, sha256: stats.sha256, bytes: stats.bytes });
213
+ this.unparsed += stats.unparsed ?? 0;
214
+ for (const line of stats.unparsedLines ?? [])
215
+ this.gap(`${stats.path}: line ${line} is not a JSON record`);
216
+ if (stats.tornTail) {
217
+ this.truncated = true;
218
+ this.gap(`${stats.path}: last record was still being written when the copy was taken`);
219
+ }
220
+ }
221
+ markTruncated(reason) {
222
+ this.truncated = true;
223
+ this.gap(reason);
224
+ }
225
+ /** The harness's own session total, when it records one; it wins over the per-call sum. */
226
+ setSessionUsage(usage) {
227
+ this.sessionUsage = usage;
228
+ }
229
+ end(ending) {
230
+ this.ending = ending;
231
+ }
232
+ /**
233
+ * The ending every format shares, from what the session holds when the records run out:
234
+ * `aborted` when the harness recorded an abort last; `error` when the last model call failed
235
+ * and no new input followed it; `completed` when the last model call succeeded, every tool call
236
+ * has its result and the agent spoke last; `open` otherwise (the turn was still running, or the
237
+ * process died mid-turn).
238
+ */
239
+ inferEnding(aborted) {
240
+ if (aborted) {
241
+ this.ending = { status: 'aborted', error: aborted.error, at: aborted.at };
242
+ return;
243
+ }
244
+ const last = this.modelCalls[this.modelCalls.length - 1];
245
+ if (last?.error && this.inputsSinceLastCall === 0) {
246
+ this.ending = { status: 'error', error: last.error, at: last.error.at ?? last.at };
247
+ return;
248
+ }
249
+ if (last && this.openToolCalls.size === 0 && this.lastRole === 'assistant') {
250
+ this.ending = { status: 'completed', error: null, at: this.endedAt };
251
+ return;
252
+ }
253
+ this.ending = { status: 'open', error: null, at: null };
254
+ }
255
+ get endingStatus() {
256
+ return this.ending.status;
257
+ }
258
+ usage() {
259
+ if (this.sessionUsage !== undefined)
260
+ return this.sessionUsage;
261
+ const total = { input: null, output: null, reasoning: null, cacheRead: null, cacheWrite: null };
262
+ for (const call of this.modelCalls)
263
+ if (call.usage)
264
+ addUsage(total, call.usage);
265
+ return usageOrNull(total);
266
+ }
267
+ servedModels() {
268
+ const models = {};
269
+ for (const call of this.modelCalls) {
270
+ const key = servedModelKey(call);
271
+ if (key !== null)
272
+ models[key] = (models[key] ?? 0) + 1;
273
+ }
274
+ return models;
275
+ }
276
+ build() {
277
+ const toolCalls = [...this.toolCalls.values()];
278
+ for (const call of toolCalls) {
279
+ if (call.status === 'pending' && this.ending.status !== 'open')
280
+ call.status = 'interrupted';
281
+ }
282
+ return {
283
+ schema: HARNESS_SESSION_SCHEMA,
284
+ harness: this.harness,
285
+ format: this.format,
286
+ nativeSessionId: this.nativeSessionId,
287
+ parentNativeSessionId: this.parentNativeSessionId,
288
+ cwd: this.cwd,
289
+ startedAt: this.startedAt,
290
+ endedAt: this.endedAt,
291
+ messages: this.messages,
292
+ toolCalls,
293
+ modelCalls: this.modelCalls,
294
+ servedModels: this.servedModels(),
295
+ modelSource: this.modelSource,
296
+ usage: this.usage(),
297
+ ending: this.ending,
298
+ children: this.children,
299
+ integrity: {
300
+ sourceFiles: this.sources,
301
+ unparsedRecords: this.unparsed,
302
+ truncated: this.truncated,
303
+ gaps: this.gaps,
304
+ },
305
+ };
306
+ }
307
+ summary() {
308
+ return {
309
+ harness: this.harness,
310
+ format: this.format,
311
+ nativeSessionId: this.nativeSessionId,
312
+ parentNativeSessionId: this.parentNativeSessionId,
313
+ modelCalls: this.modelCalls.length,
314
+ replies: this.replies,
315
+ toolCalls: this.toolCallCount,
316
+ servedModels: this.servedModels(),
317
+ lastServedModel: [...this.modelCalls].reverse().map(servedModelKey).find((key) => key !== null) ?? null,
318
+ modelSource: this.modelSource,
319
+ usage: this.usage(),
320
+ ending: this.ending,
321
+ unparsedRecords: this.unparsed,
322
+ bytes: this.sources.reduce((sum, s) => sum + s.bytes, 0),
323
+ truncated: this.truncated,
324
+ startedAt: this.startedAt,
325
+ endedAt: this.endedAt,
326
+ };
327
+ }
328
+ }
329
+ export function sessionError(kind, message, at, httpStatus) {
330
+ const text = message ?? '';
331
+ return { kind, message: text, httpStatus: httpStatus ?? httpStatusIn(text), at };
332
+ }
@@ -0,0 +1,44 @@
1
+ import type { HarnessSession, NativeSessionCopy, RawEvidenceArchiveManifestV2, SessionRef } from './schema.js';
2
+ export declare const RETENTION_SESSIONS: string;
3
+ export declare const RETENTION_MANIFEST_KIND = "tangle-native-session-evidence.v1";
4
+ /** One native session found in a capture, with the ids that join it to its execution. */
5
+ export interface CapturedSessionRef {
6
+ ref: SessionRef;
7
+ harness: string;
8
+ /** The sandbox (provider) session the capture belongs to. */
9
+ sandboxSessionId: string | null;
10
+ executionId: string | null;
11
+ attempt: number | null;
12
+ copy: NativeSessionCopy['copy'] | 'retained';
13
+ /** True when the sidecar recorded this native session id for the execution. */
14
+ recordedId: boolean;
15
+ /** The capture's native root the session was found in, relative to the capture directory. */
16
+ root: string;
17
+ sourceId: string | null;
18
+ rootScope: string | null;
19
+ }
20
+ export interface CaptureListing {
21
+ sessions: CapturedSessionRef[];
22
+ /** Executions whose harness has no reader, and ids recorded but not found in the copy. */
23
+ missing: Array<{
24
+ sandboxSessionId: string | null;
25
+ harness: string | null;
26
+ reason: string;
27
+ nativeSessionId?: string;
28
+ /** The capture root (relative to the capture directory) and store file a reason refers to. */
29
+ root?: string;
30
+ path?: string;
31
+ detail?: string;
32
+ }>;
33
+ }
34
+ /**
35
+ * Every native session in a capture: from `manifest.nativeSessions` when a v2 manifest is given
36
+ * (files relative to `dir`), otherwise from the retention layout under `dir`.
37
+ */
38
+ export declare function listCapture(dir: string, manifest?: RawEvidenceArchiveManifestV2 | null, options?: {
39
+ harness?: string;
40
+ }): Promise<CaptureListing>;
41
+ /** Every native session in a capture, read in full. */
42
+ export declare function readCapture(dir: string, manifest?: RawEvidenceArchiveManifestV2 | null, options?: {
43
+ harness?: string;
44
+ }): AsyncIterable<HarnessSession>;