@tangle-network/harness-sessions 0.0.0-stage → 0.1.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 (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
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `@tangle-network/harness-sessions` — one reader for every coding-agent harness's native session.
3
+ *
4
+ * Every harness persists its session in its own store (Claude Code and Pi append JSONL, Codex
5
+ * appends a rollout, OpenCode writes SQLite). This package declares each store's layout once and
6
+ * parses each format into one normalized {@link HarnessSession}: messages, tool calls with their
7
+ * inputs and results, the model calls with the model that served them, usage and the error a
8
+ * session ended with. The sidecar's capture contract, the Discovery gate, agent-eval,
9
+ * agent-record and the blog read sessions only through it.
10
+ */
11
+ export type { HarnessSession, HarnessSessionReader, LocateOptions, ModelCall, NativeSessionCopy, NativeSessionStore, Part, RawEvidenceArchiveManifestV2, ReadOptions, SessionEnding, SessionError, SessionEvidenceSources, SessionFormatId, SessionIntegrity, SessionMessage, SessionRef, SessionSummary, SessionToolCall, TokenUsage, } from './schema.js';
12
+ export { HARNESS_SESSION_SCHEMA } from './schema.js';
13
+ export { EVIDENCE, SESSION_FORMAT_IDS, STORES, assertStoreRoot, storeGlob } from './catalog.js';
14
+ export { knownHarnesses, listReaders, maybeReaderFor, readerFor, readerForFormat } from './registry.js';
15
+ export { claudeCodeReader, claudeCodeRefForFile } from './formats/claude-code.js';
16
+ export { codexReader, codexRefForFile } from './formats/codex.js';
17
+ export { opencodeReader, opencodeSessionsInStore } from './formats/opencode.js';
18
+ export { piReader, piRefForFile } from './formats/pi.js';
19
+ export { kimiReader, kimiRefForFile } from './formats/kimi.js';
20
+ export { factoryReader, factoryRefForFile } from './formats/factory.js';
21
+ export { listCapture, readCapture, RETENTION_MANIFEST_KIND, RETENTION_SESSIONS, type CaptureListing, type CapturedSessionRef, } from './capture.js';
22
+ export { SessionParseError } from './source.js';
23
+ export { servedModelKey } from './builder.js';
24
+ export { toChatMessages, type ChatMessage, type ChatProjectionOptions, type ChatToolCall } from './projections/chat.js';
25
+ export { primaryModel, summarizeText, toTurns, type Turn, type TurnOptions, type TurnToolCall } from './projections/turns.js';
26
+ export { toOtlpSpans, type OtlpProjectionOptions } from './projections/otlp.js';
27
+ export { readSessionInput, summarizeSessionInput, type SessionInput, type SessionInputInit } from './input.js';
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ export { HARNESS_SESSION_SCHEMA } from './schema.js';
2
+ export { EVIDENCE, SESSION_FORMAT_IDS, STORES, assertStoreRoot, storeGlob } from './catalog.js';
3
+ export { knownHarnesses, listReaders, maybeReaderFor, readerFor, readerForFormat } from './registry.js';
4
+ export { claudeCodeReader, claudeCodeRefForFile } from './formats/claude-code.js';
5
+ export { codexReader, codexRefForFile } from './formats/codex.js';
6
+ export { opencodeReader, opencodeSessionsInStore } from './formats/opencode.js';
7
+ export { piReader, piRefForFile } from './formats/pi.js';
8
+ export { kimiReader, kimiRefForFile } from './formats/kimi.js';
9
+ export { factoryReader, factoryRefForFile } from './formats/factory.js';
10
+ export { listCapture, readCapture, RETENTION_MANIFEST_KIND, RETENTION_SESSIONS, } from './capture.js';
11
+ export { SessionParseError } from './source.js';
12
+ export { servedModelKey } from './builder.js';
13
+ export { toChatMessages } from './projections/chat.js';
14
+ export { primaryModel, summarizeText, toTurns } from './projections/turns.js';
15
+ export { toOtlpSpans } from './projections/otlp.js';
16
+ export { readSessionInput, summarizeSessionInput } from './input.js';
@@ -0,0 +1,18 @@
1
+ import type { HarnessSession, ReadOptions, SessionSummary } from './schema.js';
2
+ export type SessionInput = {
3
+ text: string;
4
+ } | {
5
+ records: readonly unknown[];
6
+ };
7
+ export interface SessionInputInit {
8
+ /** The native session id when the caller knows it; JSONL formats also read it from their records. */
9
+ nativeSessionId?: string;
10
+ /** Set for a subagent's own transcript; its records are the subagent's, not sidechain noise. */
11
+ parentNativeSessionId?: string | null;
12
+ /** Names the source in integrity records (a path, a blob ref). */
13
+ label?: string;
14
+ }
15
+ /** Read a JSONL-format session (Claude Code, Codex, Pi, Kimi) from text or parsed records, synchronously. */
16
+ export declare function readSessionInput(harness: string, input: SessionInput, init?: SessionInputInit, options?: Pick<ReadOptions, 'corruption'>): HarnessSession;
17
+ /** Summarize a JSONL-format session from text or parsed records, synchronously. */
18
+ export declare function summarizeSessionInput(harness: string, input: SessionInput, init?: SessionInputInit, options?: Pick<ReadOptions, 'corruption'>): SessionSummary;
package/dist/input.js ADDED
@@ -0,0 +1,46 @@
1
+ import { createFold as claudeCodeFold } from './formats/claude-code.js';
2
+ import { createFold as codexFold } from './formats/codex.js';
3
+ import { createFold as kimiFold } from './formats/kimi.js';
4
+ import { createFold as factoryFold } from './formats/factory.js';
5
+ import { createFold as piFold } from './formats/pi.js';
6
+ import { readerFor } from './registry.js';
7
+ import { arrayRecordsSync, memoryStats, textRecordsSync } from './source.js';
8
+ const FOLDS = {
9
+ 'claude-code': claudeCodeFold,
10
+ codex: codexFold,
11
+ pi: piFold,
12
+ 'kimi-code': kimiFold,
13
+ 'factory-droids': (ref, mode, stats) => factoryFold(ref, mode, stats),
14
+ };
15
+ function foldInput(harness, input, init, mode, options) {
16
+ const reader = readerFor(harness);
17
+ const create = FOLDS[reader.harness];
18
+ if (!create)
19
+ throw new Error(`${reader.harness} sessions are a store, not a record stream; read them with readerFor('${reader.harness}').read`);
20
+ const label = init.label ?? `${reader.harness}:${init.nativeSessionId ?? 'input'}`;
21
+ const stats = memoryStats(label, 'text' in input ? input.text : undefined);
22
+ const ref = {
23
+ harness: reader.harness,
24
+ format: reader.formats[0],
25
+ nativeSessionId: init.nativeSessionId ?? label,
26
+ path: label,
27
+ files: [],
28
+ home: null,
29
+ cwd: null,
30
+ mtimeMs: 0,
31
+ parentNativeSessionId: init.parentNativeSessionId ?? null,
32
+ };
33
+ const fold = create(ref, mode, stats);
34
+ const records = 'text' in input ? textRecordsSync(input.text, stats, { strict: options.corruption === 'strict' }) : arrayRecordsSync(input.records, stats);
35
+ for (const record of records)
36
+ fold.observe(record);
37
+ return fold.finish();
38
+ }
39
+ /** Read a JSONL-format session (Claude Code, Codex, Pi, Kimi) from text or parsed records, synchronously. */
40
+ export function readSessionInput(harness, input, init = {}, options = {}) {
41
+ return foldInput(harness, input, init, 'full', options).build();
42
+ }
43
+ /** Summarize a JSONL-format session from text or parsed records, synchronously. */
44
+ export function summarizeSessionInput(harness, input, init = {}, options = {}) {
45
+ return foldInput(harness, input, init, 'summary', options).summary();
46
+ }
@@ -0,0 +1,31 @@
1
+ import type { HarnessSession } from '../schema.js';
2
+ /** agent-eval's chat-with-tools message (`src/rollout/schema.ts`), declared here structurally. */
3
+ export interface ChatToolCall {
4
+ id: string;
5
+ type: 'function';
6
+ function: {
7
+ name: string;
8
+ arguments: string;
9
+ };
10
+ }
11
+ export interface ChatMessage {
12
+ role: 'system' | 'user' | 'assistant' | 'tool';
13
+ content: string | null;
14
+ reasoning_content?: string;
15
+ tool_calls?: ChatToolCall[];
16
+ tool_call_id?: string;
17
+ name?: string;
18
+ is_copied_context?: boolean;
19
+ }
20
+ export interface ChatProjectionOptions {
21
+ /** Keep messages the harness injected (environment context, instructions). Default true. */
22
+ injected?: boolean;
23
+ /** Keep `system` messages. Default true. */
24
+ system?: boolean;
25
+ }
26
+ /**
27
+ * The session as chat-with-tools messages: each assistant message carries its text, reasoning
28
+ * and tool calls; each tool result is a `tool` message answering its call. Content is complete;
29
+ * a projection that needs less trims it itself.
30
+ */
31
+ export declare function toChatMessages(session: HarnessSession, options?: ChatProjectionOptions): ChatMessage[];
@@ -0,0 +1,79 @@
1
+ function texts(message, type) {
2
+ return message.parts
3
+ .filter((p) => p.type === type)
4
+ .map((p) => p.text)
5
+ .filter((t) => t.length > 0)
6
+ .join('\n');
7
+ }
8
+ function resultContent(output, text) {
9
+ if (text !== null)
10
+ return text;
11
+ if (typeof output === 'string')
12
+ return output;
13
+ if (output === null || output === undefined)
14
+ return '';
15
+ return JSON.stringify(output);
16
+ }
17
+ /**
18
+ * The session as chat-with-tools messages: each assistant message carries its text, reasoning
19
+ * and tool calls; each tool result is a `tool` message answering its call. Content is complete;
20
+ * a projection that needs less trims it itself.
21
+ */
22
+ export function toChatMessages(session, options = {}) {
23
+ const keepInjected = options.injected !== false;
24
+ const keepSystem = options.system !== false;
25
+ const calls = new Map(session.toolCalls.map((c) => [c.id, c]));
26
+ const out = [];
27
+ for (const message of session.messages) {
28
+ if (message.actor === 'injected' && !keepInjected)
29
+ continue;
30
+ if (message.role === 'system') {
31
+ if (keepSystem)
32
+ out.push({ role: 'system', content: texts(message, 'text') });
33
+ continue;
34
+ }
35
+ if (message.role === 'user') {
36
+ const content = texts(message, 'text');
37
+ if (content.length === 0 && !message.parts.some((p) => p.type === 'attachment'))
38
+ continue;
39
+ out.push({ role: 'user', content });
40
+ continue;
41
+ }
42
+ if (message.role === 'tool') {
43
+ for (const part of message.parts) {
44
+ if (part.type !== 'tool-result')
45
+ continue;
46
+ const call = calls.get(part.toolCallId);
47
+ out.push({
48
+ role: 'tool',
49
+ tool_call_id: part.toolCallId,
50
+ ...(call ? { name: call.name } : {}),
51
+ content: resultContent(call?.result?.output, call?.result?.text ?? null),
52
+ });
53
+ }
54
+ continue;
55
+ }
56
+ const text = texts(message, 'text');
57
+ const reasoning = texts(message, 'reasoning');
58
+ const toolCalls = [];
59
+ for (const part of message.parts) {
60
+ if (part.type !== 'tool-call')
61
+ continue;
62
+ const call = calls.get(part.toolCallId);
63
+ toolCalls.push({
64
+ id: part.toolCallId,
65
+ type: 'function',
66
+ function: { name: call?.name ?? 'unknown', arguments: call?.inputText ?? JSON.stringify(call?.input ?? {}) },
67
+ });
68
+ }
69
+ if (text.length === 0 && reasoning.length === 0 && toolCalls.length === 0)
70
+ continue;
71
+ out.push({
72
+ role: 'assistant',
73
+ content: text.length > 0 ? text : null,
74
+ ...(reasoning.length > 0 ? { reasoning_content: reasoning } : {}),
75
+ ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
76
+ });
77
+ }
78
+ return out;
79
+ }
@@ -0,0 +1,12 @@
1
+ import { type ContractSpan } from '@tangle-network/agent-trace-contract';
2
+ import type { HarnessSession } from '../schema.js';
3
+ export interface OtlpProjectionOptions {
4
+ /** Characters of message text kept per span attribute. Default 8000; 0 keeps none. */
5
+ contentChars?: number;
6
+ }
7
+ /**
8
+ * The session as contract spans: one AGENT span for the session, one LLM span per model call
9
+ * (served model, tokens, cost, error status) and one TOOL span per tool call under the model call
10
+ * that made it. Ids derive from the native session id, so two exports of one session join.
11
+ */
12
+ export declare function toOtlpSpans(session: HarnessSession, options?: OtlpProjectionOptions): ContractSpan[];
@@ -0,0 +1,91 @@
1
+ import { contractSpan, deriveHexId, llmSpan, toolSpan, } from '@tangle-network/agent-trace-contract';
2
+ const cap = (text, n) => (text.length <= n ? text : `${text.slice(0, n)}…`);
3
+ /**
4
+ * The session as contract spans: one AGENT span for the session, one LLM span per model call
5
+ * (served model, tokens, cost, error status) and one TOOL span per tool call under the model call
6
+ * that made it. Ids derive from the native session id, so two exports of one session join.
7
+ */
8
+ export function toOtlpSpans(session, options = {}) {
9
+ const chars = options.contentChars ?? 8000;
10
+ const traceId = deriveHexId(`${session.harness}:${session.nativeSessionId}`, 16);
11
+ const spanId = (key) => deriveHexId(`${session.harness}:${session.nativeSessionId}:${key}`, 8);
12
+ const rootId = spanId('session');
13
+ const start = session.startedAt ?? new Date(0).toISOString();
14
+ const end = session.endedAt ?? start;
15
+ const spans = [
16
+ contractSpan({
17
+ traceId,
18
+ spanId: rootId,
19
+ parentSpanId: null,
20
+ name: 'session',
21
+ kind: 'AGENT',
22
+ startTime: start,
23
+ endTime: end,
24
+ status: session.ending.status === 'error'
25
+ ? { code: 'STATUS_CODE_ERROR', message: session.ending.error?.message ?? 'session ended in error' }
26
+ : session.ending.status === 'completed' ? { code: 'STATUS_CODE_OK' } : { code: 'STATUS_CODE_UNSET' },
27
+ attributes: {
28
+ 'tangle.sessionId': session.nativeSessionId,
29
+ 'tangle.harness': session.harness,
30
+ 'tangle.harness_session.format': session.format,
31
+ 'tangle.harness_session.ending': session.ending.status,
32
+ ...(session.parentNativeSessionId ? { 'tangle.harness_session.parent': session.parentNativeSessionId } : {}),
33
+ },
34
+ }),
35
+ ];
36
+ const messageByCall = new Map();
37
+ for (const message of session.messages)
38
+ if (message.modelCallId)
39
+ messageByCall.set(message.modelCallId, message);
40
+ const llmIdByMessage = new Map();
41
+ for (const call of session.modelCalls) {
42
+ const id = spanId(`llm:${call.id}`);
43
+ const message = messageByCall.get(call.id);
44
+ if (message)
45
+ llmIdByMessage.set(message.id, id);
46
+ const text = message ? message.parts.filter((p) => p.type === 'text').map((p) => p.text).join('\n') : '';
47
+ spans.push(llmSpan({
48
+ traceId,
49
+ spanId: id,
50
+ parentSpanId: rootId,
51
+ name: 'llm.call',
52
+ startTime: call.at ?? start,
53
+ status: call.error ? { code: 'STATUS_CODE_ERROR', message: call.error.message } : { code: 'STATUS_CODE_OK' },
54
+ ...(call.servedModel ? { model: call.servedModel } : {}),
55
+ ...(call.provider ? { system: call.provider } : {}),
56
+ ...(call.usage?.input != null ? { inputTokens: call.usage.input } : {}),
57
+ ...(call.usage?.output != null ? { outputTokens: call.usage.output } : {}),
58
+ ...(call.costUsd != null ? { costUsd: call.costUsd } : {}),
59
+ attributes: {
60
+ ...(call.usage?.cacheRead != null ? { 'gen_ai.usage.cache_read.input_tokens': call.usage.cacheRead } : {}),
61
+ ...(call.usage?.cacheWrite != null ? { 'gen_ai.usage.cache_creation.input_tokens': call.usage.cacheWrite } : {}),
62
+ ...(call.usage?.reasoning != null ? { 'gen_ai.usage.reasoning_tokens': call.usage.reasoning } : {}),
63
+ ...(call.stopReason ? { 'gen_ai.response.finish_reasons': [call.stopReason] } : {}),
64
+ ...(call.error?.httpStatus != null ? { 'http.response.status_code': call.error.httpStatus } : {}),
65
+ 'tangle.harness_session.model_source': session.modelSource,
66
+ ...(chars > 0 && text ? { 'output.value': cap(text, chars) } : {}),
67
+ },
68
+ }));
69
+ }
70
+ for (const call of session.toolCalls) {
71
+ spans.push(toolSpan({
72
+ traceId,
73
+ spanId: spanId(`tool:${call.id}`),
74
+ parentSpanId: llmIdByMessage.get(call.messageId) ?? rootId,
75
+ name: `tool.${call.name}`,
76
+ toolName: call.name,
77
+ startTime: call.startedAt ?? start,
78
+ endTime: call.endedAt ?? call.startedAt ?? start,
79
+ status: call.status === 'error'
80
+ ? { code: 'STATUS_CODE_ERROR', message: cap(call.result?.text ?? 'tool reported an error', 500) }
81
+ : call.status === 'completed' ? { code: 'STATUS_CODE_OK' } : { code: 'STATUS_CODE_UNSET' },
82
+ attributes: {
83
+ 'gen_ai.tool.call.id': call.id,
84
+ 'tangle.tool.status': call.status,
85
+ ...(chars > 0 ? { 'input.value': cap(call.inputText ?? JSON.stringify(call.input ?? null), chars) } : {}),
86
+ ...(chars > 0 && call.result?.text ? { 'output.value': cap(call.result.text, chars) } : {}),
87
+ },
88
+ }));
89
+ }
90
+ return spans;
91
+ }
@@ -0,0 +1,51 @@
1
+ import type { HarnessSession, SessionError, SessionToolCall, TokenUsage } from '../schema.js';
2
+ /** One tool invocation in a turn, previewed for storage beside a post. */
3
+ export interface TurnToolCall {
4
+ name: string;
5
+ input_preview?: string;
6
+ file_path?: string;
7
+ result_preview?: string;
8
+ is_error?: boolean;
9
+ }
10
+ /** One turn of agent activity, the blog's trace unit (drewstone.github.io tools/harness/types.ts). */
11
+ export interface Turn {
12
+ role: 'user' | 'assistant' | 'system' | 'tool';
13
+ seq?: number;
14
+ text?: string;
15
+ text_summary?: string;
16
+ tool_calls?: number;
17
+ tool_names?: string[];
18
+ tool_call_details?: TurnToolCall[];
19
+ files_touched?: string[];
20
+ had_thinking?: boolean;
21
+ /** The model that answered this turn, as the session recorded it. */
22
+ model?: string;
23
+ usage?: TokenUsage;
24
+ /** The error the model call ended with, when it failed. */
25
+ error?: SessionError;
26
+ ts: string;
27
+ }
28
+ export interface TurnOptions {
29
+ /** Working directory to make touched paths relative to. Defaults to the session cwd. */
30
+ cwd?: string | null;
31
+ /** Characters kept in `text`. Default 8000. */
32
+ textChars?: number;
33
+ /** Characters kept in `text_summary`. Default 280. */
34
+ summaryChars?: number;
35
+ /** Characters kept in a tool input or result preview. Default 600. */
36
+ previewChars?: number;
37
+ /**
38
+ * The files a tool call touched. Defaults to the path of an edit or write tool and the files a
39
+ * patch names; a caller with its own notion (paths mentioned anywhere in the input) passes it.
40
+ */
41
+ filesOf?: (call: SessionToolCall) => string[];
42
+ }
43
+ export declare function summarizeText(text: string, n: number): string;
44
+ /**
45
+ * The session as the blog's turns: one turn per human message and per assistant message, with
46
+ * the tool calls an assistant message made, the files they touched, the model that answered,
47
+ * its usage and the error it ended with.
48
+ */
49
+ export declare function toTurns(session: HarnessSession, options?: TurnOptions): Turn[];
50
+ /** The model a session was answered by most often, or null when it recorded none. */
51
+ export declare function primaryModel(session: Pick<HarnessSession, 'modelCalls'>): string | null;
@@ -0,0 +1,136 @@
1
+ import { isRecord } from '../builder.js';
2
+ export function summarizeText(text, n) {
3
+ const t = text.replace(/\s+/gu, ' ').trim();
4
+ return t.length <= n ? t : `${t.slice(0, n - 1)}…`;
5
+ }
6
+ const EDIT_TOOLS = /^(?:Edit|Write|MultiEdit|NotebookEdit|edit|write|multiedit|patch|apply_patch|str_replace_editor|create_file)$/u;
7
+ function stringField(input, ...keys) {
8
+ if (!isRecord(input))
9
+ return undefined;
10
+ for (const key of keys)
11
+ if (typeof input[key] === 'string')
12
+ return input[key];
13
+ return undefined;
14
+ }
15
+ /** Paths a patch touches, from `*** Update File:` / `*** Add File:` / `*** Delete File:` headers. */
16
+ function patchPaths(text) {
17
+ return [...text.matchAll(/^\*\*\* (?:Update|Add|Delete) File: (.+)$/gmu)].map((m) => m[1].trim());
18
+ }
19
+ function touched(call) {
20
+ if (!EDIT_TOOLS.test(call.name))
21
+ return [];
22
+ const path = stringField(call.input, 'file_path', 'filePath', 'path', 'notebook_path');
23
+ if (path)
24
+ return [path];
25
+ const text = typeof call.input === 'string' ? call.input : stringField(call.input, 'patch', 'input', 'content') ?? call.inputText ?? '';
26
+ return patchPaths(text);
27
+ }
28
+ function inputPreview(call) {
29
+ const input = call.input;
30
+ const command = stringField(input, 'command', 'cmd');
31
+ if (command)
32
+ return command;
33
+ if (Array.isArray(isRecord(input) ? input.command : undefined))
34
+ return input.command.join(' ');
35
+ const oldString = stringField(input, 'old_string', 'oldString');
36
+ if (oldString !== undefined) {
37
+ const head = (s) => s.split('\n').slice(0, 3).join('\n');
38
+ return `- ${head(oldString)}\n+ ${head(stringField(input, 'new_string', 'newString') ?? '')}`;
39
+ }
40
+ const content = stringField(input, 'content');
41
+ if (content !== undefined && EDIT_TOOLS.test(call.name))
42
+ return content.split('\n').slice(0, 6).join('\n');
43
+ const pattern = stringField(input, 'pattern');
44
+ if (pattern) {
45
+ const path = stringField(input, 'path');
46
+ return path ? `${pattern} in ${path}` : pattern;
47
+ }
48
+ const path = stringField(input, 'file_path', 'filePath', 'path');
49
+ if (path)
50
+ return path;
51
+ if (typeof input === 'string')
52
+ return input;
53
+ try {
54
+ return JSON.stringify(input) ?? '';
55
+ }
56
+ catch {
57
+ return '';
58
+ }
59
+ }
60
+ function text(message, type) {
61
+ return message.parts.filter((p) => p.type === type).map((p) => p.text).join('\n');
62
+ }
63
+ /**
64
+ * The session as the blog's turns: one turn per human message and per assistant message, with
65
+ * the tool calls an assistant message made, the files they touched, the model that answered,
66
+ * its usage and the error it ended with.
67
+ */
68
+ export function toTurns(session, options = {}) {
69
+ const textChars = options.textChars ?? 8000;
70
+ const summaryChars = options.summaryChars ?? 280;
71
+ const previewChars = options.previewChars ?? 600;
72
+ const root = (options.cwd ?? session.cwd ?? '').replace(/\/$/u, '');
73
+ const relativize = (path) => (root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path);
74
+ const calls = new Map(session.toolCalls.map((c) => [c.id, c]));
75
+ const modelCalls = new Map(session.modelCalls.map((c) => [c.id, c]));
76
+ const turns = [];
77
+ let seq = 0;
78
+ for (const message of session.messages) {
79
+ const ts = message.at ?? '';
80
+ if (message.role === 'user') {
81
+ const body = text(message, 'text');
82
+ if (!body)
83
+ continue;
84
+ turns.push({ role: 'user', seq: seq++, text: body.length > textChars ? summarizeText(body, textChars) : body, ts });
85
+ continue;
86
+ }
87
+ if (message.role !== 'assistant')
88
+ continue;
89
+ const body = text(message, 'text');
90
+ const toolCalls = message.parts.filter((p) => p.type === 'tool-call').map((p) => calls.get(p.toolCallId)).filter((c) => c !== undefined);
91
+ const call = message.modelCallId ? modelCalls.get(message.modelCallId) : undefined;
92
+ if (!body && toolCalls.length === 0 && !call?.error)
93
+ continue;
94
+ const filesOf = options.filesOf ?? touched;
95
+ const files = toolCalls.flatMap(filesOf).map(relativize);
96
+ const details = toolCalls.map((c) => {
97
+ const preview = inputPreview(c);
98
+ const path = (options.filesOf ?? touched)(c)[0];
99
+ const result = c.result?.text ?? (typeof c.result?.output === 'string' ? c.result.output : undefined);
100
+ return {
101
+ name: c.name,
102
+ ...(preview ? { input_preview: summarizeText(preview, previewChars) } : {}),
103
+ ...(path ? { file_path: relativize(path) } : {}),
104
+ ...(result ? { result_preview: summarizeText(result, previewChars) } : {}),
105
+ ...(c.result?.isError ? { is_error: true } : {}),
106
+ };
107
+ });
108
+ const model = call?.servedModel ?? undefined;
109
+ turns.push({
110
+ seq: seq++,
111
+ role: 'assistant',
112
+ ...(body ? { text: body.length > textChars ? summarizeText(body, textChars) : body, text_summary: summarizeText(body, summaryChars) } : {}),
113
+ ...(toolCalls.length > 0 ? { tool_calls: toolCalls.length, tool_names: [...new Set(toolCalls.map((c) => c.name))].slice(0, 6), tool_call_details: details } : {}),
114
+ ...(files.length > 0 ? { files_touched: [...new Set(files)] } : {}),
115
+ ...(message.parts.some((p) => p.type === 'reasoning') ? { had_thinking: true } : {}),
116
+ ...(model ? { model } : {}),
117
+ ...(call?.usage ? { usage: call.usage } : {}),
118
+ ...(call?.error ? { error: call.error } : {}),
119
+ ts,
120
+ });
121
+ }
122
+ return turns;
123
+ }
124
+ /** The model a session was answered by most often, or null when it recorded none. */
125
+ export function primaryModel(session) {
126
+ const counts = new Map();
127
+ for (const call of session.modelCalls)
128
+ if (call.servedModel)
129
+ counts.set(call.servedModel, (counts.get(call.servedModel) ?? 0) + 1);
130
+ let best = null;
131
+ let bestCount = 0;
132
+ for (const [model, n] of counts)
133
+ if (n > bestCount)
134
+ [best, bestCount] = [model, n];
135
+ return best;
136
+ }
@@ -0,0 +1,11 @@
1
+ import type { HarnessSessionReader, SessionFormatId } from './schema.js';
2
+ /** Every reader this package ships. */
3
+ export declare function listReaders(): readonly HarnessSessionReader[];
4
+ /** Harness ids and aliases that resolve to a reader. */
5
+ export declare function knownHarnesses(): string[];
6
+ /** The reader for a harness id or alias. Throws for a harness with no reader. */
7
+ export declare function readerFor(harnessOrAlias: string): HarnessSessionReader;
8
+ /** The reader for a harness, or undefined when none exists. */
9
+ export declare function maybeReaderFor(harnessOrAlias: string): HarnessSessionReader | undefined;
10
+ /** The reader that owns a session format. */
11
+ export declare function readerForFormat(format: SessionFormatId): HarnessSessionReader;
@@ -0,0 +1,39 @@
1
+ import { claudeCodeReader } from './formats/claude-code.js';
2
+ import { codexReader } from './formats/codex.js';
3
+ import { kimiReader } from './formats/kimi.js';
4
+ import { factoryReader } from './formats/factory.js';
5
+ import { opencodeReader } from './formats/opencode.js';
6
+ import { piReader } from './formats/pi.js';
7
+ const READERS = [claudeCodeReader, codexReader, opencodeReader, piReader, kimiReader, factoryReader];
8
+ const BY_NAME = new Map();
9
+ for (const reader of READERS) {
10
+ BY_NAME.set(reader.harness, reader);
11
+ for (const alias of reader.aliases)
12
+ BY_NAME.set(alias, reader);
13
+ }
14
+ /** Every reader this package ships. */
15
+ export function listReaders() {
16
+ return READERS;
17
+ }
18
+ /** Harness ids and aliases that resolve to a reader. */
19
+ export function knownHarnesses() {
20
+ return [...BY_NAME.keys()].sort();
21
+ }
22
+ /** The reader for a harness id or alias. Throws for a harness with no reader. */
23
+ export function readerFor(harnessOrAlias) {
24
+ const reader = BY_NAME.get(harnessOrAlias) ?? BY_NAME.get(harnessOrAlias.toLowerCase());
25
+ if (!reader)
26
+ throw new Error(`no session reader for harness "${harnessOrAlias}"; known: ${knownHarnesses().join(', ')}`);
27
+ return reader;
28
+ }
29
+ /** The reader for a harness, or undefined when none exists. */
30
+ export function maybeReaderFor(harnessOrAlias) {
31
+ return BY_NAME.get(harnessOrAlias) ?? BY_NAME.get(harnessOrAlias.toLowerCase());
32
+ }
33
+ /** The reader that owns a session format. */
34
+ export function readerForFormat(format) {
35
+ const reader = READERS.find((r) => r.formats.includes(format));
36
+ if (!reader)
37
+ throw new Error(`no session reader for format "${format}"`);
38
+ return reader;
39
+ }