@1agents/session-reader 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.
- package/README.md +347 -0
- package/dist/bin/1session.d.ts +2 -0
- package/dist/bin/1session.js +408 -0
- package/dist/src/aggregator.d.ts +12 -0
- package/dist/src/aggregator.js +136 -0
- package/dist/src/classify.d.ts +2 -0
- package/dist/src/classify.js +11 -0
- package/dist/src/distiller.d.ts +7 -0
- package/dist/src/distiller.js +122 -0
- package/dist/src/index.d.ts +19 -0
- package/dist/src/index.js +17 -0
- package/dist/src/ledger.d.ts +19 -0
- package/dist/src/ledger.js +192 -0
- package/dist/src/overview.d.ts +6 -0
- package/dist/src/overview.js +324 -0
- package/dist/src/parsers/antigravity.d.ts +4 -0
- package/dist/src/parsers/antigravity.js +297 -0
- package/dist/src/parsers/claude.d.ts +2 -0
- package/dist/src/parsers/claude.js +214 -0
- package/dist/src/parsers/codex.d.ts +2 -0
- package/dist/src/parsers/codex.js +316 -0
- package/dist/src/parsers/provider.d.ts +24 -0
- package/dist/src/parsers/provider.js +5 -0
- package/dist/src/resolver.d.ts +42 -0
- package/dist/src/resolver.js +145 -0
- package/dist/src/search.d.ts +59 -0
- package/dist/src/search.js +257 -0
- package/dist/src/store/db.d.ts +10 -0
- package/dist/src/store/db.js +89 -0
- package/dist/src/store/edges.d.ts +46 -0
- package/dist/src/store/edges.js +153 -0
- package/dist/src/store/facts.d.ts +8 -0
- package/dist/src/store/facts.js +39 -0
- package/dist/src/store/indexer.d.ts +40 -0
- package/dist/src/store/indexer.js +62 -0
- package/dist/src/store/read.d.ts +29 -0
- package/dist/src/store/read.js +70 -0
- package/dist/src/store/rows.d.ts +35 -0
- package/dist/src/store/rows.js +55 -0
- package/dist/src/store/schema.d.ts +19 -0
- package/dist/src/store/schema.js +142 -0
- package/dist/src/store/write.d.ts +22 -0
- package/dist/src/store/write.js +72 -0
- package/dist/src/turns.d.ts +14 -0
- package/dist/src/turns.js +156 -0
- package/dist/src/types.d.ts +294 -0
- package/dist/src/types.js +13 -0
- package/dist/src/util/jsonl.d.ts +6 -0
- package/dist/src/util/jsonl.js +32 -0
- package/dist/src/util/paths.d.ts +15 -0
- package/dist/src/util/paths.js +69 -0
- package/dist/src/util/text.d.ts +7 -0
- package/dist/src/util/text.js +48 -0
- package/dist/src/writes.d.ts +35 -0
- package/dist/src/writes.js +204 -0
- package/package.json +58 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import type { AgentProvider, NormalizedSession } from '../types.js';
|
|
3
|
+
import type { SessionCandidate } from '../parsers/provider.js';
|
|
4
|
+
/** Store key. `SessionRef.id` stays the provider-native id, untouched. */
|
|
5
|
+
export declare function canonicalId(provider: AgentProvider, nativeId: string): string;
|
|
6
|
+
export interface Fingerprint {
|
|
7
|
+
sourceSize: number;
|
|
8
|
+
sourceMtimeMs: number;
|
|
9
|
+
headHash: string;
|
|
10
|
+
auxFingerprint?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Identity of the bytes we parsed. JSONL is append-only in practice, so a
|
|
14
|
+
* matching head hash rules out the one case size+mtime cannot: a rewrite that
|
|
15
|
+
* happens to land on the same length.
|
|
16
|
+
*/
|
|
17
|
+
export declare function fingerprintOf(candidate: SessionCandidate, aux?: string): Fingerprint;
|
|
18
|
+
/**
|
|
19
|
+
* Replaces the L1 rows of one session. Everything derived from it (L2, L3) is
|
|
20
|
+
* dropped in the same transaction, so the database is never half-new.
|
|
21
|
+
*/
|
|
22
|
+
export declare function writeSession(db: DatabaseSync, session: NormalizedSession, fingerprint: Fingerprint, turnCount: number): string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { PARSER_VERSION } from './schema.js';
|
|
4
|
+
/** Store key. `SessionRef.id` stays the provider-native id, untouched. */
|
|
5
|
+
export function canonicalId(provider, nativeId) {
|
|
6
|
+
return `${provider}:${nativeId}`;
|
|
7
|
+
}
|
|
8
|
+
/** Bytes of the file head that go into the fingerprint. */
|
|
9
|
+
const HEAD_BYTES = 64 * 1024;
|
|
10
|
+
/**
|
|
11
|
+
* Identity of the bytes we parsed. JSONL is append-only in practice, so a
|
|
12
|
+
* matching head hash rules out the one case size+mtime cannot: a rewrite that
|
|
13
|
+
* happens to land on the same length.
|
|
14
|
+
*/
|
|
15
|
+
export function fingerprintOf(candidate, aux) {
|
|
16
|
+
let headHash = '';
|
|
17
|
+
try {
|
|
18
|
+
const fd = fs.openSync(candidate.path, 'r');
|
|
19
|
+
try {
|
|
20
|
+
const buffer = Buffer.alloc(Math.min(HEAD_BYTES, candidate.sizeBytes || HEAD_BYTES));
|
|
21
|
+
const read = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
22
|
+
headHash = crypto.createHash('sha256').update(buffer.subarray(0, read)).digest('hex');
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
fs.closeSync(fd);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* unreadable head — size+mtime still gate re-indexing */
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
sourceSize: candidate.sizeBytes,
|
|
33
|
+
sourceMtimeMs: Math.round(candidate.mtimeMs),
|
|
34
|
+
headHash,
|
|
35
|
+
...(aux ? { auxFingerprint: aux } : {}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Replaces the L1 rows of one session. Everything derived from it (L2, L3) is
|
|
40
|
+
* dropped in the same transaction, so the database is never half-new.
|
|
41
|
+
*/
|
|
42
|
+
export function writeSession(db, session, fingerprint, turnCount) {
|
|
43
|
+
const id = canonicalId(session.ref.provider, session.ref.id);
|
|
44
|
+
db.exec('BEGIN IMMEDIATE');
|
|
45
|
+
try {
|
|
46
|
+
for (const table of ['events', 'file_ops', 'commands', 'jobs']) {
|
|
47
|
+
db.prepare(`DELETE FROM ${table} WHERE session_id = ?`).run(id);
|
|
48
|
+
}
|
|
49
|
+
db.prepare('DELETE FROM sessions WHERE id = ?').run(id);
|
|
50
|
+
db.prepare(`INSERT INTO sessions (
|
|
51
|
+
id, provider, native_id, source_path, workspace, title, started_at, ended_at,
|
|
52
|
+
event_count, turn_count, source_size, source_mtime_ms, head_hash, aux_fingerprint,
|
|
53
|
+
parser_version, extractor_version, edge_version, indexed_at, artifacts_json, stats_json
|
|
54
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)`).run(id, session.ref.provider, session.ref.id, session.ref.path, session.ref.workspace ?? null, session.ref.title ?? null, session.ref.createdAt ?? null, session.ref.updatedAt ?? null, session.turns.length, turnCount, fingerprint.sourceSize, fingerprint.sourceMtimeMs, fingerprint.headHash, fingerprint.auxFingerprint ?? null, PARSER_VERSION, new Date().toISOString(), JSON.stringify(session.artifacts), JSON.stringify(session.stats));
|
|
55
|
+
const insert = db.prepare(`INSERT INTO events (
|
|
56
|
+
session_id, idx, kind, text, tool_name, tool_args_json, tool_result, is_error,
|
|
57
|
+
ts, source_index, exit_code, pid, duration_ms, provider_truncated
|
|
58
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
59
|
+
// Event text is stored whole. Capping it at 128KB shrank the index by
|
|
60
|
+
// 0.08% and silently dropped search hits from long build logs — exactly
|
|
61
|
+
// the kind of "looks complete, isn't" answer this store exists to remove.
|
|
62
|
+
for (const event of session.turns) {
|
|
63
|
+
insert.run(id, event.index, event.kind, event.text ?? null, event.toolName ?? null, event.toolArgs ? JSON.stringify(event.toolArgs) : null, event.toolResult ?? null, event.isError === undefined ? null : event.isError ? 1 : 0, event.timestamp ?? null, event.sourceIndex ?? null, event.exitCode ?? null, event.processId ?? null, event.durationMs ?? null, event.truncated === undefined ? null : event.truncated ? 1 : 0);
|
|
64
|
+
}
|
|
65
|
+
db.exec('COMMIT');
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
db.exec('ROLLBACK');
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
return id;
|
|
72
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { NormalizedSession, TurnDetail, TurnEvent, TurnSummary } from './types.js';
|
|
2
|
+
export declare function summarizeTurns(session: NormalizedSession): TurnSummary[];
|
|
3
|
+
export declare function turnDetail(session: NormalizedSession, turnNo: number): TurnDetail;
|
|
4
|
+
export interface EventDetail extends TurnEvent {
|
|
5
|
+
/** Full text, recovered from the provider's side files when truncated. */
|
|
6
|
+
fullText?: string;
|
|
7
|
+
/** Set when the transcript is short and the full copy could not be found. */
|
|
8
|
+
truncationNote?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Tier three: one event with its untruncated payload. Antigravity shortens long
|
|
12
|
+
* step output in the transcript and keeps the full copy under `steps/<n>/`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function eventDetail(session: NormalizedSession, eventIndex: number): Promise<EventDetail>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { readFullStepOutput } from './parsers/antigravity.js';
|
|
2
|
+
import { oneLine } from './util/text.js';
|
|
3
|
+
import { fileWrites, rawCommand, resolveWritePath } from './writes.js';
|
|
4
|
+
import { classifyUserTurn } from './classify.js';
|
|
5
|
+
/**
|
|
6
|
+
* Whether a turn wrapped up — never a claim about what it achieved.
|
|
7
|
+
* Every non-`completed` verdict must name the signal that produced it.
|
|
8
|
+
*/
|
|
9
|
+
function assessTurn(events, native, nextPrompt, nudgeCount) {
|
|
10
|
+
const evidence = [];
|
|
11
|
+
let status = 'completed';
|
|
12
|
+
const assistantTail = [...events].reverse().find((event) => event.kind === 'assistant' && event.text?.trim());
|
|
13
|
+
const nonUser = events.filter((event) => event.kind !== 'user');
|
|
14
|
+
const openCalls = events.filter((event) => event.kind === 'tool_call' &&
|
|
15
|
+
!events.some((other) => other.kind === 'tool_result' && other.index > event.index));
|
|
16
|
+
const last = events.at(-1);
|
|
17
|
+
if (!nonUser.length) {
|
|
18
|
+
status = 'no_response';
|
|
19
|
+
evidence.push('该轮除用户消息外没有任何助理事件');
|
|
20
|
+
}
|
|
21
|
+
if (native && !native.completed) {
|
|
22
|
+
status = 'unfinished';
|
|
23
|
+
evidence.push(`provider 记录了 task_started 但没有配对的 task_complete${native.id ? `(turn ${native.id.slice(0, 13)})` : ''}`);
|
|
24
|
+
}
|
|
25
|
+
if (openCalls.length) {
|
|
26
|
+
if (status === 'completed')
|
|
27
|
+
status = 'interrupted';
|
|
28
|
+
evidence.push(`事件 #${openCalls[0].index} 的工具调用没有对应结果`);
|
|
29
|
+
}
|
|
30
|
+
if (last?.kind === 'tool_result' && (last.exitCode ?? 0) !== 0 && !assistantTail) {
|
|
31
|
+
if (status === 'completed')
|
|
32
|
+
status = 'failed_tail';
|
|
33
|
+
evidence.push(`以 exit ${last.exitCode} 的结果收尾,其后没有助理回复`);
|
|
34
|
+
}
|
|
35
|
+
if (nudgeCount > 0) {
|
|
36
|
+
if (status === 'completed')
|
|
37
|
+
status = 'nudged';
|
|
38
|
+
evidence.push(`下一轮是纯推进指令${nudgeCount > 1 ? `,连续 ${nudgeCount} 次` : ''}("${nextPrompt?.text?.trim().slice(0, 20) ?? ''}")`);
|
|
39
|
+
}
|
|
40
|
+
return { status, evidence };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Turn boundaries: codex records them natively, the others start a new turn on
|
|
44
|
+
* every user message.
|
|
45
|
+
*/
|
|
46
|
+
function boundaries(session) {
|
|
47
|
+
const starts = session.turns
|
|
48
|
+
.filter((turn) => turn.kind === 'user' && turn.text?.trim())
|
|
49
|
+
.map((turn) => turn.index);
|
|
50
|
+
if (!starts.length && session.turns.length)
|
|
51
|
+
return [0];
|
|
52
|
+
// Anything before the first user message belongs to turn 1.
|
|
53
|
+
if (starts[0] !== 0 && session.turns.length)
|
|
54
|
+
starts[0] = 0;
|
|
55
|
+
return starts;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Providers fire `task_started` a few seconds *before* the first event of the
|
|
59
|
+
* turn it opens, so a boundary belongs to the next turn that starts, not to
|
|
60
|
+
* the turn whose window happens to contain it.
|
|
61
|
+
*/
|
|
62
|
+
function boundariesByTurn(declared, turnStarts) {
|
|
63
|
+
const byTurn = new Map();
|
|
64
|
+
for (const boundary of declared) {
|
|
65
|
+
if (!boundary.startedAt)
|
|
66
|
+
continue;
|
|
67
|
+
const at = Date.parse(boundary.startedAt);
|
|
68
|
+
let owner = turnStarts.findIndex((start) => start !== undefined && Date.parse(start) >= at);
|
|
69
|
+
if (owner === -1)
|
|
70
|
+
owner = turnStarts.length - 1;
|
|
71
|
+
const list = byTurn.get(owner) ?? [];
|
|
72
|
+
list.push(boundary);
|
|
73
|
+
byTurn.set(owner, list);
|
|
74
|
+
}
|
|
75
|
+
return byTurn;
|
|
76
|
+
}
|
|
77
|
+
export function summarizeTurns(session) {
|
|
78
|
+
const starts = boundaries(session);
|
|
79
|
+
const workspace = session.ref.workspace;
|
|
80
|
+
const declared = session.stats.turnBoundaries;
|
|
81
|
+
const nativeByTurn = boundariesByTurn(declared, starts.map((start) => session.turns[start]?.timestamp));
|
|
82
|
+
return starts.map((start, i) => {
|
|
83
|
+
const end = (starts[i + 1] ?? session.turns.length) - 1;
|
|
84
|
+
const events = session.turns.slice(start, end + 1);
|
|
85
|
+
const files = new Set();
|
|
86
|
+
let commands = 0;
|
|
87
|
+
let errors = 0;
|
|
88
|
+
for (const event of events) {
|
|
89
|
+
for (const write of fileWrites(event))
|
|
90
|
+
files.add(resolveWritePath(write, workspace));
|
|
91
|
+
if (rawCommand(event))
|
|
92
|
+
commands++;
|
|
93
|
+
if (event.kind === 'tool_result' && event.isError)
|
|
94
|
+
errors++;
|
|
95
|
+
}
|
|
96
|
+
const prompt = events.find((event) => event.kind === 'user' && event.text?.trim());
|
|
97
|
+
// Count the run of pure "keep going" messages that follows this turn.
|
|
98
|
+
let nudgeCount = 0;
|
|
99
|
+
for (let k = i + 1; k < starts.length; k++) {
|
|
100
|
+
const next = session.turns[starts[k]];
|
|
101
|
+
if (next?.kind === 'user' && next.text && classifyUserTurn(next.text) === 'nudge')
|
|
102
|
+
nudgeCount++;
|
|
103
|
+
else
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
const nextPrompt = session.turns[starts[i + 1] ?? -1];
|
|
107
|
+
const outcome = [...events].reverse().find((event) => event.kind === 'assistant' && event.text?.trim());
|
|
108
|
+
const startedAt = events[0]?.timestamp;
|
|
109
|
+
const endedAt = events.at(-1)?.timestamp;
|
|
110
|
+
const owned = nativeByTurn.get(i) ?? [];
|
|
111
|
+
// A turn is only as finished as its least finished declared boundary.
|
|
112
|
+
const native = owned.find((boundary) => !boundary.completed) ?? owned[0];
|
|
113
|
+
const durationMs = (owned.length === 1 ? native?.durationMs : undefined) ??
|
|
114
|
+
(startedAt && endedAt ? Math.max(0, Date.parse(endedAt) - Date.parse(startedAt)) || undefined : undefined);
|
|
115
|
+
const { status, evidence } = assessTurn(events, native, nextPrompt, nudgeCount);
|
|
116
|
+
return {
|
|
117
|
+
no: i + 1,
|
|
118
|
+
status,
|
|
119
|
+
evidence,
|
|
120
|
+
nudgeCount,
|
|
121
|
+
startedAt,
|
|
122
|
+
endedAt,
|
|
123
|
+
...(durationMs ? { durationMs } : {}),
|
|
124
|
+
prompt: oneLine(prompt?.text, 120) || '(无用户消息)',
|
|
125
|
+
events: [start, end],
|
|
126
|
+
eventCount: events.length,
|
|
127
|
+
files: [...files],
|
|
128
|
+
commands,
|
|
129
|
+
errors,
|
|
130
|
+
outcome: oneLine(outcome?.text ?? native?.lastMessage, 120),
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
export function turnDetail(session, turnNo) {
|
|
135
|
+
const summaries = summarizeTurns(session);
|
|
136
|
+
const summary = summaries[turnNo - 1];
|
|
137
|
+
if (!summary)
|
|
138
|
+
throw new Error(`no turn ${turnNo} (session has ${summaries.length})`);
|
|
139
|
+
return { summary, events: session.turns.slice(summary.events[0], summary.events[1] + 1) };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Tier three: one event with its untruncated payload. Antigravity shortens long
|
|
143
|
+
* step output in the transcript and keeps the full copy under `steps/<n>/`.
|
|
144
|
+
*/
|
|
145
|
+
export async function eventDetail(session, eventIndex) {
|
|
146
|
+
const event = session.turns[eventIndex];
|
|
147
|
+
if (!event)
|
|
148
|
+
throw new Error(`no event ${eventIndex} (session has ${session.turns.length})`);
|
|
149
|
+
if (!event.truncated || session.ref.provider !== 'antigravity' || event.sourceIndex === undefined) {
|
|
150
|
+
return { ...event };
|
|
151
|
+
}
|
|
152
|
+
const full = await readFullStepOutput(session.ref.path, event.sourceIndex);
|
|
153
|
+
return full
|
|
154
|
+
? { ...event, fullText: full }
|
|
155
|
+
: { ...event, truncationNote: `transcript 已截断,steps/${event.sourceIndex}/output.txt 不存在` };
|
|
156
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/** Canonical domain types shared by every provider parser. */
|
|
2
|
+
export type AgentProvider = 'antigravity' | 'claude' | 'codex' | 'cursor' | 'unknown';
|
|
3
|
+
/** Cheap metadata about a discovered session, obtained without a full parse. */
|
|
4
|
+
export interface SessionRef {
|
|
5
|
+
id: string;
|
|
6
|
+
provider: AgentProvider;
|
|
7
|
+
/** Native file that holds the transcript. */
|
|
8
|
+
path: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
/** Canonical absolute working directory the session ran in. */
|
|
11
|
+
workspace?: string;
|
|
12
|
+
createdAt?: string;
|
|
13
|
+
updatedAt?: string;
|
|
14
|
+
sizeBytes?: number;
|
|
15
|
+
}
|
|
16
|
+
export type TurnKind = 'user' | 'assistant' | 'thinking' | 'tool_call' | 'tool_result';
|
|
17
|
+
export interface TurnEvent {
|
|
18
|
+
id: string;
|
|
19
|
+
index: number;
|
|
20
|
+
kind: TurnKind;
|
|
21
|
+
text?: string;
|
|
22
|
+
toolName?: string;
|
|
23
|
+
toolArgs?: Record<string, unknown>;
|
|
24
|
+
toolResult?: string;
|
|
25
|
+
isError?: boolean;
|
|
26
|
+
timestamp?: string;
|
|
27
|
+
/** Provider-native index of the record this event came from. */
|
|
28
|
+
sourceIndex?: number;
|
|
29
|
+
/** The provider stored a shortened copy; the full text lives elsewhere. */
|
|
30
|
+
truncated?: boolean;
|
|
31
|
+
/** OS process id, when the provider records one for a command. */
|
|
32
|
+
processId?: string;
|
|
33
|
+
/** Shell exit status, when the provider records or prints one. */
|
|
34
|
+
exitCode?: number;
|
|
35
|
+
durationMs?: number;
|
|
36
|
+
}
|
|
37
|
+
/** A file the agent produced alongside the session (plans, reports, images). */
|
|
38
|
+
export interface SessionArtifact {
|
|
39
|
+
name: string;
|
|
40
|
+
path: string;
|
|
41
|
+
kind: 'markdown' | 'image' | 'other';
|
|
42
|
+
/** Only read for text artifacts. */
|
|
43
|
+
content?: string;
|
|
44
|
+
/** The agent's own one-line description, when it recorded one. */
|
|
45
|
+
summary?: string;
|
|
46
|
+
sizeBytes?: number;
|
|
47
|
+
updatedAt?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* How much a fact is worth trusting.
|
|
51
|
+
* - `observed` — the provider recorded it as a structured field of its own.
|
|
52
|
+
* - `derived` — a deterministic rule over an action that provably ran.
|
|
53
|
+
* - `candidate` — merely mentioned in text; nobody was seen acting on it.
|
|
54
|
+
*/
|
|
55
|
+
export type Provenance = 'observed' | 'derived' | 'candidate';
|
|
56
|
+
/** Answers "why do you believe this?" for a single extracted fact. */
|
|
57
|
+
export interface FactSource {
|
|
58
|
+
provenance: Provenance;
|
|
59
|
+
/** The rule that produced it, e.g. `tool:Write`, `shell:redirect`. */
|
|
60
|
+
extractor: string;
|
|
61
|
+
/** Event index it was extracted from. */
|
|
62
|
+
event?: number;
|
|
63
|
+
turn?: number;
|
|
64
|
+
}
|
|
65
|
+
export interface FileChange {
|
|
66
|
+
path: string;
|
|
67
|
+
change: 'add' | 'update' | 'delete';
|
|
68
|
+
sizeBytes?: number;
|
|
69
|
+
}
|
|
70
|
+
export interface BackgroundTask {
|
|
71
|
+
id: string;
|
|
72
|
+
title?: string;
|
|
73
|
+
log?: string;
|
|
74
|
+
/** Only trustworthy when the provider records completion notices. */
|
|
75
|
+
finished: boolean;
|
|
76
|
+
}
|
|
77
|
+
export interface TokenUsage {
|
|
78
|
+
input: number;
|
|
79
|
+
output: number;
|
|
80
|
+
total: number;
|
|
81
|
+
/** Prefix replays served from cache — reported separately, never as input. */
|
|
82
|
+
cacheRead?: number;
|
|
83
|
+
}
|
|
84
|
+
export interface TurnBoundary {
|
|
85
|
+
/** Provider turn id — boundaries must be paired by id, never by position. */
|
|
86
|
+
id?: string;
|
|
87
|
+
startedAt?: string;
|
|
88
|
+
endedAt?: string;
|
|
89
|
+
durationMs?: number;
|
|
90
|
+
lastMessage?: string;
|
|
91
|
+
/** A start with no matching completion means the turn never wrapped up. */
|
|
92
|
+
completed: boolean;
|
|
93
|
+
}
|
|
94
|
+
/** One shell command with whatever the provider actually recorded about it. */
|
|
95
|
+
export interface CommandRecord {
|
|
96
|
+
eventIndex: number;
|
|
97
|
+
turn: number;
|
|
98
|
+
command: string;
|
|
99
|
+
host?: string;
|
|
100
|
+
cwd?: string;
|
|
101
|
+
exitCode?: number;
|
|
102
|
+
durationMs?: number;
|
|
103
|
+
pid?: string;
|
|
104
|
+
stderr?: string;
|
|
105
|
+
timestamp?: string;
|
|
106
|
+
provenance: Provenance;
|
|
107
|
+
extractor: string;
|
|
108
|
+
/** Errors only: a later command with the same prefix exited zero. */
|
|
109
|
+
laterSucceeded?: boolean;
|
|
110
|
+
}
|
|
111
|
+
export type JobStatus = 'completed' | 'failed' | 'running' | 'unknown';
|
|
112
|
+
export interface AsyncJob {
|
|
113
|
+
id: string;
|
|
114
|
+
command?: string;
|
|
115
|
+
pid?: string;
|
|
116
|
+
host?: string;
|
|
117
|
+
log?: string;
|
|
118
|
+
startedAt?: string;
|
|
119
|
+
discoveredFrom?: number;
|
|
120
|
+
provenance: Provenance;
|
|
121
|
+
extractor: string;
|
|
122
|
+
status: JobStatus;
|
|
123
|
+
/** Why we claim that status. Never empty for anything but `unknown`. */
|
|
124
|
+
evidence: string[];
|
|
125
|
+
}
|
|
126
|
+
export type FileGroup = 'project' | 'runtime' | 'log';
|
|
127
|
+
export interface FileRecord {
|
|
128
|
+
path: string;
|
|
129
|
+
host?: string;
|
|
130
|
+
operation: string;
|
|
131
|
+
turn: number;
|
|
132
|
+
eventIndex: number;
|
|
133
|
+
timestamp?: string;
|
|
134
|
+
provenance: Provenance;
|
|
135
|
+
extractor: string;
|
|
136
|
+
group: FileGroup;
|
|
137
|
+
}
|
|
138
|
+
/** Structured facts a provider records natively — never inferred by us. */
|
|
139
|
+
export interface ProviderStats {
|
|
140
|
+
tokens?: TokenUsage;
|
|
141
|
+
models: string[];
|
|
142
|
+
branches: string[];
|
|
143
|
+
/** Authoritative file changes, when the provider tracks them itself. */
|
|
144
|
+
fileChanges: FileChange[];
|
|
145
|
+
commandExecutions?: number;
|
|
146
|
+
uploads: {
|
|
147
|
+
name: string;
|
|
148
|
+
path: string;
|
|
149
|
+
sizeBytes?: number;
|
|
150
|
+
}[];
|
|
151
|
+
backgroundTasks: BackgroundTask[];
|
|
152
|
+
/** Provider-declared turn boundaries (codex task_started/task_complete). */
|
|
153
|
+
turnBoundaries: TurnBoundary[];
|
|
154
|
+
/** Command ledger, when the provider records commands itself. */
|
|
155
|
+
commands: CommandRecord[];
|
|
156
|
+
/** Counts of anything else worth surfacing: images viewed, compactions… */
|
|
157
|
+
extras: Record<string, number>;
|
|
158
|
+
}
|
|
159
|
+
export declare function emptyProviderStats(): ProviderStats;
|
|
160
|
+
export interface NormalizedSession {
|
|
161
|
+
ref: SessionRef;
|
|
162
|
+
turns: TurnEvent[];
|
|
163
|
+
artifacts: SessionArtifact[];
|
|
164
|
+
stats: ProviderStats;
|
|
165
|
+
}
|
|
166
|
+
export type DigestFocus = 'marketing' | 'review' | 'full';
|
|
167
|
+
export interface TurningPoint {
|
|
168
|
+
timestamp?: string;
|
|
169
|
+
kind: 'request' | 'redirect' | 'failure' | 'outcome';
|
|
170
|
+
text: string;
|
|
171
|
+
}
|
|
172
|
+
export interface SessionDigest {
|
|
173
|
+
session: SessionRef;
|
|
174
|
+
goal: string;
|
|
175
|
+
touchedFiles: string[];
|
|
176
|
+
commands: string[];
|
|
177
|
+
turningPoints: TurningPoint[];
|
|
178
|
+
artifacts: SessionArtifact[];
|
|
179
|
+
markdown: string;
|
|
180
|
+
}
|
|
181
|
+
export interface UnifiedTimelineEntry {
|
|
182
|
+
timestamp?: string;
|
|
183
|
+
provider: AgentProvider;
|
|
184
|
+
sessionId: string;
|
|
185
|
+
kind: TurnKind;
|
|
186
|
+
summary: string;
|
|
187
|
+
}
|
|
188
|
+
export interface FileTouch {
|
|
189
|
+
provider: AgentProvider;
|
|
190
|
+
sessionId: string;
|
|
191
|
+
timestamp?: string;
|
|
192
|
+
toolName?: string;
|
|
193
|
+
provenance?: Provenance;
|
|
194
|
+
/** Remote host, when the file was written over ssh/scp. */
|
|
195
|
+
host?: string;
|
|
196
|
+
}
|
|
197
|
+
export interface WorkspaceDigest {
|
|
198
|
+
workspace: string;
|
|
199
|
+
sessions: SessionRef[];
|
|
200
|
+
collaboratingAgents: AgentProvider[];
|
|
201
|
+
unifiedTimeline: UnifiedTimelineEntry[];
|
|
202
|
+
fileAttribution: Record<string, FileTouch[]>;
|
|
203
|
+
markdown: string;
|
|
204
|
+
}
|
|
205
|
+
export interface GitCommit {
|
|
206
|
+
sha?: string;
|
|
207
|
+
message: string;
|
|
208
|
+
timestamp?: string;
|
|
209
|
+
}
|
|
210
|
+
/** Session-level counters — the top tier of the three-level view. */
|
|
211
|
+
export interface SessionStats {
|
|
212
|
+
turns: number;
|
|
213
|
+
events: Record<TurnKind, number>;
|
|
214
|
+
/** Distinct files touched — always equals the file ledger's row count. */
|
|
215
|
+
filesChanged: number;
|
|
216
|
+
/** Write actions observed — a file written three times counts three. */
|
|
217
|
+
fileChangeEvents: number;
|
|
218
|
+
/** How the ledger's rows break down — a single label would misdescribe a mix. */
|
|
219
|
+
filesByProvenance: Record<Provenance, number>;
|
|
220
|
+
commands: number;
|
|
221
|
+
errors: number;
|
|
222
|
+
commits: GitCommit[];
|
|
223
|
+
branches: string[];
|
|
224
|
+
models: string[];
|
|
225
|
+
tokens?: TokenUsage;
|
|
226
|
+
artifacts: SessionArtifact[];
|
|
227
|
+
uploads: {
|
|
228
|
+
name: string;
|
|
229
|
+
path: string;
|
|
230
|
+
sizeBytes?: number;
|
|
231
|
+
}[];
|
|
232
|
+
jobs: AsyncJob[];
|
|
233
|
+
jobCounts: Record<JobStatus, number>;
|
|
234
|
+
extras: Record<string, number>;
|
|
235
|
+
}
|
|
236
|
+
export type UserTurnKind = 'correction' | 'nudge' | 'paste';
|
|
237
|
+
export interface UserTurnNote {
|
|
238
|
+
timestamp?: string;
|
|
239
|
+
kind: UserTurnKind;
|
|
240
|
+
text: string;
|
|
241
|
+
}
|
|
242
|
+
export interface SessionOverview {
|
|
243
|
+
session: SessionRef;
|
|
244
|
+
stats: SessionStats;
|
|
245
|
+
goal: string;
|
|
246
|
+
corrections: UserTurnNote[];
|
|
247
|
+
nudges: number;
|
|
248
|
+
pastes: number;
|
|
249
|
+
anchors: {
|
|
250
|
+
hosts: string[];
|
|
251
|
+
/**
|
|
252
|
+
* Paths merely mentioned in text — always `candidate`, never proof that
|
|
253
|
+
* anything acted on them. `firstTurn`/`lastTurn` express recency only.
|
|
254
|
+
*/
|
|
255
|
+
paths: {
|
|
256
|
+
path: string;
|
|
257
|
+
hits: number;
|
|
258
|
+
kind: 'dir' | 'file';
|
|
259
|
+
firstTurn: number;
|
|
260
|
+
lastTurn: number;
|
|
261
|
+
provenance: Provenance;
|
|
262
|
+
}[];
|
|
263
|
+
};
|
|
264
|
+
/** The file ledger itself — the same rows `1session files` prints. */
|
|
265
|
+
writes: FileRecord[];
|
|
266
|
+
pitfalls: string[];
|
|
267
|
+
lastWord: string;
|
|
268
|
+
markdown: string;
|
|
269
|
+
}
|
|
270
|
+
export type TurnStatus = 'completed' | 'unfinished' | 'nudged' | 'interrupted' | 'no_response' | 'failed_tail';
|
|
271
|
+
export interface TurnSummary {
|
|
272
|
+
no: number;
|
|
273
|
+
/** Whether the turn wrapped up — never a claim about what it achieved. */
|
|
274
|
+
status: TurnStatus;
|
|
275
|
+
/** Every signal that fired. Empty only when the status is `completed`. */
|
|
276
|
+
evidence: string[];
|
|
277
|
+
/** How many pure "keep going" messages followed this turn. */
|
|
278
|
+
nudgeCount: number;
|
|
279
|
+
startedAt?: string;
|
|
280
|
+
endedAt?: string;
|
|
281
|
+
durationMs?: number;
|
|
282
|
+
prompt: string;
|
|
283
|
+
/** Inclusive event index range this turn spans. */
|
|
284
|
+
events: [number, number];
|
|
285
|
+
eventCount: number;
|
|
286
|
+
files: string[];
|
|
287
|
+
commands: number;
|
|
288
|
+
errors: number;
|
|
289
|
+
outcome: string;
|
|
290
|
+
}
|
|
291
|
+
export interface TurnDetail {
|
|
292
|
+
summary: TurnSummary;
|
|
293
|
+
events: TurnEvent[];
|
|
294
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Canonical domain types shared by every provider parser. */
|
|
2
|
+
export function emptyProviderStats() {
|
|
3
|
+
return {
|
|
4
|
+
models: [],
|
|
5
|
+
branches: [],
|
|
6
|
+
fileChanges: [],
|
|
7
|
+
uploads: [],
|
|
8
|
+
backgroundTasks: [],
|
|
9
|
+
turnBoundaries: [],
|
|
10
|
+
commands: [],
|
|
11
|
+
extras: {},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface JsonlOptions {
|
|
2
|
+
/** Stop after this many parsed objects. */
|
|
3
|
+
maxLines?: number;
|
|
4
|
+
}
|
|
5
|
+
/** Streams a `.jsonl` file, silently skipping blank or malformed lines. */
|
|
6
|
+
export declare function readJsonl(file: string, options?: JsonlOptions): AsyncGenerator<Record<string, unknown>>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
/** Streams a `.jsonl` file, silently skipping blank or malformed lines. */
|
|
4
|
+
export async function* readJsonl(file, options = {}) {
|
|
5
|
+
const stream = fs.createReadStream(file, { encoding: 'utf8' });
|
|
6
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
7
|
+
let emitted = 0;
|
|
8
|
+
try {
|
|
9
|
+
for await (const line of rl) {
|
|
10
|
+
const trimmed = line.trim();
|
|
11
|
+
if (!trimmed)
|
|
12
|
+
continue;
|
|
13
|
+
let parsed;
|
|
14
|
+
try {
|
|
15
|
+
parsed = JSON.parse(trimmed);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (!parsed || typeof parsed !== 'object')
|
|
21
|
+
continue;
|
|
22
|
+
yield parsed;
|
|
23
|
+
emitted++;
|
|
24
|
+
if (options.maxLines && emitted >= options.maxLines)
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
rl.close();
|
|
30
|
+
stream.destroy();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare function expandHome(p: string): string;
|
|
2
|
+
/**
|
|
3
|
+
* Normalizes anything a session file may contain into one comparable absolute
|
|
4
|
+
* path: `~`, relative paths, `file://` URIs, percent-encoding and symlinks.
|
|
5
|
+
*/
|
|
6
|
+
export declare function canonicalizePath(input: string): string;
|
|
7
|
+
/** True when `child` is `parent` itself or lives under it. */
|
|
8
|
+
export declare function isInside(parent: string, child: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Claude Code names its project directories by replacing every non-alphanumeric
|
|
11
|
+
* character of the cwd with `-` (lossy, so we only ever slugify forwards).
|
|
12
|
+
*/
|
|
13
|
+
export declare function slugifyWorkspace(p: string): string;
|
|
14
|
+
/** Walks up from a file to the closest directory holding `.git`. */
|
|
15
|
+
export declare function findRepoRoot(from: string): string | undefined;
|