reprobe 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.
@@ -0,0 +1,3 @@
1
+ export declare function redactText(s: string): string;
2
+ export declare function redactValue<T>(v: T): T;
3
+ export declare function initials(name: string | null | undefined): string;
package/dist/redact.js ADDED
@@ -0,0 +1,98 @@
1
+ // Redaction helpers for bug-repro output. Replacement order in redactText matters.
2
+ const SENSITIVE_KEYS = new Set([
3
+ 'phone',
4
+ 'phonenumber',
5
+ 'email',
6
+ 'token',
7
+ 'firebaseuid',
8
+ 'password',
9
+ 'accesstoken',
10
+ 'authorization',
11
+ 'clientsecret',
12
+ 'idtoken',
13
+ 'refreshtoken',
14
+ 'upiid',
15
+ 'apikey',
16
+ 'awsaccesskeyid',
17
+ 'googleaccessid',
18
+ 'secret',
19
+ ]);
20
+ const POSTGRES_PASSWORD = /(\bpostgres(?:ql)?:\/\/[^\s:/@]+):[^\s@]+@/g;
21
+ const URL_PASSWORD = /(\bhttps?:\/\/[^\s:/@]+):[^\s/@]+@/gi;
22
+ const JWT = /\beyJ[A-Za-z0-9_-]{7,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g;
23
+ const BEARER = /\bBearer\s+[\w~+\/=\[\]-]+(?:\.[\w~+\/=\[\]-]+)*/g;
24
+ const SECRET_ASSIGNMENT = /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|api[_-]?key|client[_-]?secret|password|secret)["']?\s*[:=]\s*)("(?:\\.|[^"\\])*(?:"|$)|'(?:\\.|[^'\\])*(?:'|$)|[^&,\s;"'<>]+)/gi;
25
+ const BASIC_AUTH = /(["']?Authorization["']?\s*:\s*["']?Basic\s+)[A-Za-z0-9+/=]+/gi;
26
+ const SECRET_KEY = /(?<![A-Za-z0-9_-])(?:sk-lf-|pk-lf-|sk-|sntryu_|lsv2_|ghp_|gho_|xox)[A-Za-z0-9_-]{8,}/g;
27
+ const QUERY_PARAM = /([?&](?:access_token|token|key|sig|signature|x-amz-(?:credential|signature|security-token)|x-goog-(?:credential|signature)|awsaccesskeyid|googleaccessid|key-pair-id|policy)=)[^&#\s"'<>]+/gi;
28
+ const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
29
+ const PHONE_PLUS91 = /\+91(?:[ -]?\d){10}(?!\d)/g;
30
+ const PHONE_PLUS_LONG = /\+\d{11,13}(?!\d)/g;
31
+ const PHONE_PLUS_FORMATTED = /\+(?:\d[ .()-]?){8,15}(?!\d)/g;
32
+ const PHONE_US_FORMATTED = /(?<!\d)(?:\+?1[ .-]?)?\(?[2-9]\d{2}\)?[ .-][2-9]\d{2}[ .-]\d{4}(?!\d)/g;
33
+ const PHONE_LOCAL = /(?<![\w.-])[6-9]\d{9}(?![\w-]|\.\d)/g;
34
+ export function redactText(s) {
35
+ return s
36
+ .replace(POSTGRES_PASSWORD, '$1:[redacted]@')
37
+ .replace(URL_PASSWORD, '$1:[redacted]@')
38
+ .replace(JWT, '[jwt]')
39
+ .replace(BEARER, 'Bearer [redacted]')
40
+ .replace(BASIC_AUTH, '$1[redacted]')
41
+ .replace(SECRET_ASSIGNMENT, (_match, key, value) => {
42
+ if (value.startsWith('"'))
43
+ return `${key}"[redacted]${value.endsWith('"') ? '"' : ''}`;
44
+ if (value.startsWith("'"))
45
+ return `${key}'[redacted]${value.endsWith("'") ? "'" : ''}`;
46
+ return `${key}[redacted]`;
47
+ })
48
+ .replace(SECRET_KEY, '[secret]')
49
+ .replace(QUERY_PARAM, '$1[redacted]')
50
+ .replace(EMAIL, '[email]')
51
+ .replace(PHONE_PLUS91, '[phone]')
52
+ .replace(PHONE_PLUS_LONG, '[phone]')
53
+ .replace(PHONE_PLUS_FORMATTED, '[phone]')
54
+ .replace(PHONE_US_FORMATTED, '[phone]')
55
+ .replace(PHONE_LOCAL, '[phone]');
56
+ }
57
+ export function redactValue(v) {
58
+ return walk(v, 0);
59
+ }
60
+ function walk(v, depth) {
61
+ if (typeof v === 'string') {
62
+ const trimmed = v.trim();
63
+ if (depth < 3 && (trimmed.startsWith('{') || trimmed.startsWith('[') || trimmed.startsWith('"{') || trimmed.startsWith('"['))) {
64
+ try {
65
+ const parsed = JSON.parse(v);
66
+ return JSON.stringify(walk(parsed, depth + 1));
67
+ }
68
+ catch {
69
+ // Truncated JSON is still handled by the text patterns below.
70
+ }
71
+ }
72
+ return redactText(v);
73
+ }
74
+ if (Array.isArray(v))
75
+ return v.map((item) => walk(item, depth));
76
+ if (isPlainObject(v)) {
77
+ const out = {};
78
+ for (const [k, val] of Object.entries(v)) {
79
+ const normalizedKey = k.toLowerCase().replace(/[^a-z0-9]/g, '');
80
+ const sensitive = SENSITIVE_KEYS.has(normalizedKey) && val !== null && val !== undefined;
81
+ out[redactText(k)] = sensitive ? '[redacted]' : walk(val, depth);
82
+ }
83
+ return out;
84
+ }
85
+ return v;
86
+ }
87
+ function isPlainObject(v) {
88
+ if (typeof v !== 'object' || v === null)
89
+ return false;
90
+ const proto = Object.getPrototypeOf(v);
91
+ return proto === Object.prototype || proto === null;
92
+ }
93
+ export function initials(name) {
94
+ const words = (name ?? '').trim().split(/\s+/).filter(Boolean).slice(0, 3);
95
+ if (words.length === 0)
96
+ return '?';
97
+ return words.map((w) => `${w.charAt(0).toUpperCase()}.`).join('');
98
+ }
@@ -0,0 +1,7 @@
1
+ import type { Diagnosis } from './diagnosis.ts';
2
+ import type { Narrative } from './llm.ts';
3
+ import type { Finding, Gathered } from './types.ts';
4
+ import type { VerifyResult } from './verify.ts';
5
+ export declare function renderMarkdown(caseId: string, g: Gathered, findings: Finding[], narrative: Narrative | {
6
+ error: string;
7
+ } | null, verify?: VerifyResult[], diagnosis?: Diagnosis | null): string;
package/dist/report.js ADDED
@@ -0,0 +1,145 @@
1
+ import { redactText } from "./redact.js";
2
+ const inline = (s) => redactText(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/[\\`*_{}\[\]()#+!|~]/g, '\\$&').replace(/[\r\n]/g, ' ');
3
+ const cell = inline;
4
+ const code = (s) => {
5
+ const safe = redactText(s).replace(/[\r\n]/g, ' ');
6
+ const ticks = '`'.repeat(Math.max(1, ...[...safe.matchAll(/`+/g)].map((m) => m[0].length + 1)));
7
+ const padded = safe.startsWith('`') || safe.endsWith('`') || (safe.startsWith(' ') && safe.endsWith(' ')) ? ` ${safe} ` : safe;
8
+ return `${ticks}${padded}${ticks}`;
9
+ };
10
+ function cite(id, byId) {
11
+ const e = byId.get(id);
12
+ if (!e)
13
+ return '[evidence unavailable]';
14
+ const displayId = inline(id);
15
+ let href = '';
16
+ if (e.link && !/[\u0000-\u001f\u007f]/.test(e.link)) {
17
+ try {
18
+ const url = new URL(redactText(e.link));
19
+ if (/^https?:$/.test(url.protocol) && !url.username && !url.password)
20
+ href = url.href.replace(/[()]/g, (c) => c === '(' ? '%28' : '%29');
21
+ }
22
+ catch {
23
+ href = '';
24
+ }
25
+ }
26
+ const ref = href ? `[${displayId}](${href})` : code(id);
27
+ return `${ref} ${e.at ? `(${inline(e.at)}) ` : ''}${cell(e.summary.slice(0, 200))}`;
28
+ }
29
+ /** The report keeps every cited row, every non-log row, and only the warnings/failures among log lines. */
30
+ const codeCell = (s) => code(s).replace(/\|/g, '\\|');
31
+ function timelineRows(g, findings, narrative, diagnosis) {
32
+ const diagnosisCitations = diagnosis ? [...(diagnosis.rootCause?.evidence ?? []), ...diagnosis.summary.flatMap((c) => c.evidence), ...diagnosis.hypotheses.flatMap((c) => c.evidence)] : [];
33
+ const narrativeCitations = narrative && !('error' in narrative) ? [...narrative.summary.flatMap((c) => c.evidence), ...narrative.hypotheses.flatMap((c) => c.evidence)] : [];
34
+ const cited = new Set([...findings.flatMap((f) => f.evidence), ...diagnosisCitations, ...narrativeCitations]);
35
+ return g.evidence.filter((e) => cited.has(e.id) || !['logs', 'archive'].includes(e.source) || ['http.5xx', 'http.4xx', 'log.error', 'log.warn'].includes(e.kind)).sort((a, b) => {
36
+ const normalize = (at) => at && /^\d{4}-\d\d-\d\dT\d\d:\d\d(?::\d\d(?:\.\d+)?)?$/.test(at) ? `${at}Z` : at;
37
+ const atA = a.at ? Date.parse(normalize(a.at)) : NaN;
38
+ const atB = b.at ? Date.parse(normalize(b.at)) : NaN;
39
+ const time = Number.isFinite(atA) && Number.isFinite(atB) ? atA - atB : (a.at ?? '') < (b.at ?? '') ? -1 : (a.at ?? '') > (b.at ?? '') ? 1 : 0;
40
+ return time || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
41
+ });
42
+ }
43
+ export function renderMarkdown(caseId, g, findings, narrative, verify = [], diagnosis = null) {
44
+ const byId = new Map(g.evidence.map((e) => [e.id, e]));
45
+ const out = [];
46
+ const i = g.input;
47
+ out.push(`# reprobe ${inline(caseId)}`, '');
48
+ out.push(`> ${inline(i.complaint).replace(/\n/g, '\n> ')}`, '');
49
+ const given = [i.trace && `trace ${code(i.trace)}`, i.user && `user ${code(i.user)}`, i.chat && `chat ${code(i.chat)}`, i.sentry && `sentry ${code(i.sentry)}`].filter(Boolean);
50
+ out.push(`- **Input:** ${given.length ? given.join(', ') : 'complaint only'}`);
51
+ out.push(`- **Window:** ${inline(g.window.from)} to ${inline(g.window.to)}`);
52
+ const subject = g.subject.label ?? (g.subject.userId ? `user ${g.subject.userId}` : g.subject.sessionIds[0] ? `session ${g.subject.sessionIds[0]}` : 'not resolved');
53
+ out.push(`- **Subject:** ${inline(subject)}${g.subject.how ? ` (${inline(g.subject.how)})` : ''}`);
54
+ out.push(`- **Evidence:** ${g.evidence.length} items from ${[...new Set(g.evidence.map((e) => inline(e.source)))].sort().join(', ') || 'no source'}`, '');
55
+ if (diagnosis) {
56
+ const ids = (c) => c.evidence.map((id) => (byId.has(id) ? code(id) : '[evidence unavailable]')).join(' ');
57
+ out.push('## Diagnosis', '', `_By ${cell(diagnosis.agent)}. Every claim below cites evidence ids that exist in evidence.json; ${diagnosis.dropped.length} claim(s) that did not were dropped._`, '');
58
+ if (diagnosis.rootCause) {
59
+ out.push(`**Root cause [${diagnosis.rootCause.confidence.toUpperCase()}]:** ${inline(diagnosis.rootCause.text)}`, '', 'Evidence:');
60
+ for (const id of diagnosis.rootCause.evidence)
61
+ out.push(`- ${cite(id, byId)}`);
62
+ out.push('');
63
+ }
64
+ else
65
+ out.push('No root cause survived citation checks.', '');
66
+ for (const c of diagnosis.summary)
67
+ out.push(`- ${inline(c.text)} ${ids(c)}`);
68
+ if (diagnosis.hypotheses.length) {
69
+ out.push('', 'Other hypotheses:');
70
+ for (const c of diagnosis.hypotheses)
71
+ out.push(`- ${inline(c.text)} ${ids(c)}`);
72
+ }
73
+ if (diagnosis.unknowns.length) {
74
+ out.push('', 'Not known:');
75
+ for (const u of diagnosis.unknowns)
76
+ out.push(`- ${inline(u)}`);
77
+ }
78
+ if (diagnosis.fix)
79
+ out.push('', `**Fix:** ${inline(diagnosis.fix.text)}${diagnosis.fix.commit ? ` (commit ${code(diagnosis.fix.commit)})` : ''}`);
80
+ if (diagnosis.dropped.length) {
81
+ out.push('', 'Dropped (no valid citation):');
82
+ for (const t of diagnosis.dropped)
83
+ out.push(`- ~~${cell(t)}~~`);
84
+ }
85
+ out.push('');
86
+ }
87
+ out.push(diagnosis ? '## Detector findings' : '## Likely root cause', '');
88
+ if (!findings.length)
89
+ out.push('No failure signature matched the evidence. Read the timeline and the source table: missing sources are the first thing to fix.', '');
90
+ findings.forEach((f, n) => {
91
+ out.push(`### ${n + 1}. [${f.confidence.toUpperCase()}] ${inline(f.title)}`, '');
92
+ out.push(inline(f.rootCause), '');
93
+ out.push('Evidence:');
94
+ for (const id of f.evidence)
95
+ out.push(`- ${cite(id, byId)}`);
96
+ if (f.unknowns.length) {
97
+ out.push('', 'Not known from the evidence:');
98
+ for (const u of f.unknowns)
99
+ out.push(`- ${inline(u)}`);
100
+ }
101
+ if (f.replay) {
102
+ out.push('', `Replay: ${inline(f.replay.description)}.`);
103
+ for (const file of f.replay.files)
104
+ out.push(`- ${code(`replay/${file.path}`)} (${file.mode === 'append' ? 'appended to the existing file' : 'new file'})`);
105
+ out.push(`- Run at any commit: ${code(`reprobe verify ${caseId} <commit>`)} (runs ${code(f.replay.command.join(' '))} in a detached worktree)`);
106
+ }
107
+ out.push('');
108
+ });
109
+ if (verify.length) {
110
+ out.push('## Replay results', '', '| commit | sha | outcome | detail |', '|---|---|---|---|');
111
+ for (const v of verify)
112
+ out.push(`| ${cell(v.commit)} | ${cell(v.sha.slice(0, 10))} | **${cell(v.outcome.toUpperCase())}** | ${cell(v.detail)} |`);
113
+ out.push('');
114
+ }
115
+ if (narrative || !diagnosis)
116
+ out.push('## Summary', '');
117
+ if (!narrative) {
118
+ if (!diagnosis)
119
+ out.push('No summary yet: run `reprobe agent <case>` to investigate with Claude Code or Codex, or pass `--summary` for a short cited LLM summary.');
120
+ }
121
+ else if ('error' in narrative)
122
+ out.push(`LLM summary unavailable: ${inline(narrative.error)}. The findings above are deterministic and complete without it.`);
123
+ else {
124
+ out.push(`_Written by ${inline(narrative.model)}. Citation IDs are checked against the evidence list; claim accuracy is not independently verified. ${narrative.dropped.length} uncited claim(s) were dropped._`, '');
125
+ for (const c of narrative.summary)
126
+ out.push(`- ${inline(c.text)} ${c.evidence.map((id) => byId.has(id) ? code(id) : '[evidence unavailable]').join(' ')}`);
127
+ if (narrative.hypotheses.length) {
128
+ out.push('', 'Other hypotheses:');
129
+ for (const c of narrative.hypotheses)
130
+ out.push(`- ${inline(c.text)} ${c.evidence.map((id) => byId.has(id) ? code(id) : '[evidence unavailable]').join(' ')}`);
131
+ }
132
+ }
133
+ out.push('');
134
+ out.push('## Sources', '', '| source | state | detail |', '|---|---|---|');
135
+ for (const s of [...g.sources].sort((a, b) => a.source < b.source ? -1 : a.source > b.source ? 1 : a.state < b.state ? -1 : a.state > b.state ? 1 : 0))
136
+ out.push(`| ${cell(s.source)} | ${s.state === 'ok' ? 'ok' : `**${s.state}**`} | ${cell(s.detail)} |`);
137
+ out.push('');
138
+ const rows = timelineRows(g, findings, narrative, diagnosis);
139
+ out.push('## Timeline', '', `${rows.length} of ${g.evidence.length} items (all cited items, all DB/trace rows, and log warnings/failures). Everything is in \`evidence.json\`.`, '');
140
+ out.push('| time (UTC) | source | what | id |', '|---|---|---|---|');
141
+ for (const e of rows)
142
+ out.push(`| ${e.at ? cell(e.at) : '-'} | ${cell(e.source)} | ${cell(e.summary.slice(0, 220))} | ${codeCell(e.id)} |`);
143
+ out.push('');
144
+ return out.join('\n');
145
+ }
@@ -0,0 +1,36 @@
1
+ import type { Evidence, SourceStatus, Window } from '../types.ts';
2
+ export declare function parseLangfuseTrace(input: string): string | null;
3
+ export interface LangfuseClient {
4
+ base: string;
5
+ get(path: string): Promise<{
6
+ status: number;
7
+ body: any;
8
+ }>;
9
+ }
10
+ export declare function langfuseClient(fetchImpl?: typeof fetch): Promise<LangfuseClient | null>;
11
+ /**
12
+ * Product-specific facts read off one observation (a pack knows its agent's output shapes).
13
+ * `flags` lands in the evidence data for detectors; `bits` are appended to the summary.
14
+ */
15
+ export type ObservationAnnotator = (o: {
16
+ type: string;
17
+ name: string;
18
+ input: unknown;
19
+ output: unknown;
20
+ metadata: unknown;
21
+ }) => {
22
+ flags: Record<string, unknown>;
23
+ bits: string[];
24
+ };
25
+ /**
26
+ * v2 observations is the supported read path, but it does not serve older traces; the v1 trace
27
+ * endpoint does until Langfuse removes it (announced for 2026-11-16). Try v2, fall back to v1, and say which.
28
+ */
29
+ export declare function fetchTrace(client: LangfuseClient, traceId: string, annotate?: ObservationAnnotator): Promise<{
30
+ status: SourceStatus;
31
+ evidence: Evidence[];
32
+ userId?: string;
33
+ sessionId?: string;
34
+ at?: string;
35
+ }>;
36
+ export declare function findTraceIds(client: LangfuseClient, w: Window, userId?: string, sessionIds?: string[]): Promise<string[]>;
@@ -0,0 +1,147 @@
1
+ import { config, resolveSecret } from "../config.js";
2
+ import { retryingFetch } from "../exec.js";
3
+ import { redactText, redactValue } from "../redact.js";
4
+ export function parseLangfuseTrace(input) {
5
+ const m = input.match(/traces\/([0-9a-f]{32})/i) ?? input.match(/traceId=([0-9a-f]{32})/i) ?? input.trim().match(/^([0-9a-f]{32})$/i);
6
+ return m ? m[1].toLowerCase() : null;
7
+ }
8
+ export async function langfuseClient(fetchImpl = retryingFetch()) {
9
+ const lf = config.sources.langfuse;
10
+ if (!lf)
11
+ return null;
12
+ const [pk, sk, rawBase] = (await Promise.all([resolveSecret(lf.publicKey), resolveSecret(lf.secretKey), resolveSecret(lf.baseUrl)])).map((r) => r.value);
13
+ if (!pk || !sk || !rawBase)
14
+ return null;
15
+ const base = rawBase.replace(/\/+$/, '');
16
+ const auth = `Basic ${Buffer.from(`${pk}:${sk}`).toString('base64')}`;
17
+ return {
18
+ base,
19
+ async get(path) {
20
+ let res;
21
+ try {
22
+ res = await fetchImpl(`${base}${path}`, { headers: { authorization: auth }, signal: AbortSignal.timeout(120_000) });
23
+ }
24
+ catch (e) {
25
+ throw new Error(redactText(e instanceof Error ? e.message : String(e)));
26
+ }
27
+ const text = await res.text();
28
+ let body = null;
29
+ try {
30
+ body = JSON.parse(text);
31
+ }
32
+ catch {
33
+ body = text;
34
+ }
35
+ return { status: res.status, body };
36
+ },
37
+ };
38
+ }
39
+ function parse(v) {
40
+ if (typeof v !== 'string')
41
+ return v;
42
+ try {
43
+ return JSON.parse(v);
44
+ }
45
+ catch {
46
+ return v;
47
+ }
48
+ }
49
+ function toEvidence(o, link, annotate) {
50
+ const meta = (parse(o.metadata) ?? {});
51
+ const extra = annotate?.({ type: o.type, name: o.name, input: o.input ?? null, output: o.output ?? null, metadata: o.metadata ?? null }) ?? { flags: {}, bits: [] };
52
+ const flags = extra.flags;
53
+ const bits = [`${o.type.toLowerCase()} ${o.name}`];
54
+ if (o.level && o.level !== 'DEFAULT')
55
+ bits.push(`level ${o.level}`);
56
+ if (o.statusMessage)
57
+ bits.push(`status "${String(o.statusMessage).slice(0, 160)}"`);
58
+ bits.push(...extra.bits);
59
+ return {
60
+ id: `langfuse:obs:${o.id}`,
61
+ source: 'langfuse',
62
+ at: o.startTime,
63
+ kind: `langfuse.${o.type.toLowerCase()}`,
64
+ summary: bits.join(' · '),
65
+ data: {
66
+ traceId: o.traceId,
67
+ id: o.id,
68
+ parent: o.parentObservationId ?? null,
69
+ type: o.type,
70
+ name: o.name,
71
+ level: o.level ?? null,
72
+ startTime: o.startTime,
73
+ endTime: o.endTime ?? null,
74
+ runId: meta.runId ?? null,
75
+ stepIndex: meta.stepIndex ?? null,
76
+ agentName: meta.agentName ?? null,
77
+ turnId: meta.turnId ?? null,
78
+ flags,
79
+ inputPreview: JSON.stringify(redactValue(o.input ?? null)).slice(0, 800),
80
+ outputPreview: JSON.stringify(redactValue(o.output ?? null)).slice(0, 2000),
81
+ },
82
+ link,
83
+ };
84
+ }
85
+ /**
86
+ * v2 observations is the supported read path, but it does not serve older traces; the v1 trace
87
+ * endpoint does until Langfuse removes it (announced for 2026-11-16). Try v2, fall back to v1, and say which.
88
+ */
89
+ export async function fetchTrace(client, traceId, annotate) {
90
+ const v2 = await client.get(`/api/public/v2/observations?traceId=${traceId}&fields=core,basic,io,metadata&limit=200`);
91
+ let observations = v2.status === 200 && Array.isArray(v2.body?.data) ? v2.body.data : [];
92
+ let via = 'v2 observations';
93
+ let trace = null;
94
+ if (!observations.length) {
95
+ const v1 = await client.get(`/api/public/traces/${traceId}`);
96
+ if (v1.status === 404) {
97
+ return { status: { source: 'langfuse', state: 'not-found', detail: `trace ${traceId} not found (deleted or past retention)` }, evidence: [] };
98
+ }
99
+ if (v1.status !== 200)
100
+ return { status: { source: 'langfuse', state: 'error', detail: `trace ${traceId}: HTTP ${v1.status}` }, evidence: [] };
101
+ trace = v1.body;
102
+ observations = (trace.observations ?? []).map((o) => ({ ...o, traceId }));
103
+ via = 'deprecated v1 trace endpoint (snapshot kept in the case dir)';
104
+ }
105
+ const projectId = trace?.projectId ?? observations[0]?.projectId ?? '';
106
+ const link = `${client.base}/project/${projectId}/traces/${traceId}`;
107
+ const evidence = [];
108
+ const userId = trace?.userId ?? observations.find((o) => o.userId)?.userId ?? undefined;
109
+ const sessionId = trace?.sessionId ?? observations.find((o) => o.sessionId)?.sessionId ?? undefined;
110
+ const at = trace?.timestamp ?? observations.map((o) => o.startTime).sort()[0];
111
+ evidence.push({
112
+ id: `langfuse:trace:${traceId}`,
113
+ source: 'langfuse',
114
+ at: at ?? null,
115
+ kind: 'langfuse.trace',
116
+ summary: `trace ${trace?.name ?? observations.find((o) => !o.parentObservationId)?.name ?? '?'} with ${observations.length} observations${trace?.release ? `, server release ${trace.release}` : ''}`,
117
+ data: { traceId, release: trace?.release ?? null, userId: userId ?? null, sessionId: sessionId ?? null, tags: trace?.tags ?? [], output: trace?.output ?? null },
118
+ link,
119
+ });
120
+ for (const o of [...observations].sort((a, b) => a.startTime.localeCompare(b.startTime) || a.id.localeCompare(b.id))) {
121
+ evidence.push(toEvidence(o, link, annotate));
122
+ }
123
+ return { status: { source: 'langfuse', state: 'ok', detail: `trace ${traceId}: ${observations.length} observations via ${via}` }, evidence, userId, sessionId, at };
124
+ }
125
+ export async function findTraceIds(client, w, userId, sessionIds = []) {
126
+ const ids = new Set();
127
+ if (userId) {
128
+ const query = new URLSearchParams({ userId, fromStartTime: w.from, toStartTime: w.to, fields: 'core', limit: '200' });
129
+ const r = await client.get(`/api/public/v2/observations?${query}`);
130
+ for (const o of r.status === 200 && Array.isArray(r.body?.data) ? r.body.data : []) {
131
+ if (typeof o?.traceId === 'string')
132
+ ids.add(o.traceId);
133
+ }
134
+ }
135
+ if (!ids.size) {
136
+ const identities = [...(userId ? [{ userId }] : []), ...sessionIds.map((sessionId) => ({ sessionId }))];
137
+ for (const identity of identities) {
138
+ const query = new URLSearchParams({ ...identity, fromTimestamp: w.from, toTimestamp: w.to, limit: '50' });
139
+ const r = await client.get(`/api/public/traces?${query}`);
140
+ for (const t of r.status === 200 && Array.isArray(r.body?.data) ? r.body.data : []) {
141
+ if (typeof t?.id === 'string')
142
+ ids.add(t.id);
143
+ }
144
+ }
145
+ }
146
+ return [...ids].sort();
147
+ }
@@ -0,0 +1,9 @@
1
+ import type { Evidence, SourceStatus } from '../types.ts';
2
+ interface Result {
3
+ status: SourceStatus;
4
+ evidence: Evidence[];
5
+ }
6
+ /** Accepts a share URL or a bare share UUID; returns the share UUID, or null. */
7
+ export declare function parseLangsmithShare(input: string): string | null;
8
+ export declare function gatherLangsmith(share: string, fetchImpl?: typeof fetch): Promise<Result>;
9
+ export {};
@@ -0,0 +1,130 @@
1
+ import { redactText, redactValue } from "../redact.js";
2
+ const API = 'https://api.smith.langchain.com/api/v1/public';
3
+ const WEB = 'https://smith.langchain.com/public';
4
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
+ const TIMEOUT_MS = 20_000;
6
+ /** Accepts a share URL or a bare share UUID; returns the share UUID, or null. */
7
+ export function parseLangsmithShare(input) {
8
+ const s = input.trim();
9
+ if (UUID.test(s))
10
+ return s;
11
+ let url;
12
+ try {
13
+ url = new URL(s);
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ const id = /\/public\/([^/]+)/.exec(url.pathname)?.[1];
19
+ return id !== undefined && UUID.test(id) ? id : null;
20
+ }
21
+ export async function gatherLangsmith(share, fetchImpl = fetch) {
22
+ const none = (state, detail) => ({
23
+ status: { source: 'langsmith', state, detail },
24
+ evidence: [],
25
+ });
26
+ let res;
27
+ try {
28
+ res = await fetchImpl(`${API}/${share}/run`, { method: 'GET', signal: AbortSignal.timeout(TIMEOUT_MS) });
29
+ }
30
+ catch (e) {
31
+ return none('unreachable', errorMessage(e));
32
+ }
33
+ if (res.status === 404) {
34
+ return none('expired', `share token not found (trace deleted, unshared, or past LangSmith retention): ${share}`);
35
+ }
36
+ if (!res.ok)
37
+ return none('error', `HTTP ${res.status}`);
38
+ let top;
39
+ try {
40
+ top = (await res.json());
41
+ }
42
+ catch (e) {
43
+ return none('error', `invalid JSON: ${errorMessage(e)}`);
44
+ }
45
+ if (!top || typeof top.id !== 'string')
46
+ return none('error', 'unexpected response: no run id');
47
+ // Best effort: if the list call fails, report the top run alone.
48
+ let listed = [];
49
+ try {
50
+ const q = await fetchImpl(`${API}/${share}/runs/query`, {
51
+ method: 'POST',
52
+ headers: { 'content-type': 'application/json' },
53
+ body: JSON.stringify({ limit: 100 }),
54
+ signal: AbortSignal.timeout(TIMEOUT_MS),
55
+ });
56
+ if (q.ok)
57
+ listed = arr((await q.json()).runs);
58
+ }
59
+ catch {
60
+ listed = [];
61
+ }
62
+ const rest = listed.filter((r) => typeof r?.id === 'string' && r.id !== top.id).sort(byStartThenId);
63
+ const runs = [top, ...rest];
64
+ return {
65
+ status: { source: 'langsmith', state: 'ok', detail: `${runs.length} runs` },
66
+ evidence: runs.map((r) => toEvidence(r, share)),
67
+ };
68
+ }
69
+ function toEvidence(run, share) {
70
+ const tools = toolNames(run.outputs);
71
+ const error = run.error ? ` ERROR: ${errorText(run.error).slice(0, 200)}` : '';
72
+ const toolPart = tools.length > 0 ? ` tools=[${tools.join(',')}]` : '';
73
+ return {
74
+ id: `langsmith:run:${run.id}`,
75
+ source: 'langsmith',
76
+ at: withZone(run.start_time),
77
+ kind: 'langsmith.run',
78
+ summary: `${run.run_type ?? ''} ${run.name ?? ''}`.trim() + error + toolPart,
79
+ data: {
80
+ id: run.id,
81
+ name: run.name ?? null,
82
+ run_type: run.run_type ?? null,
83
+ status: run.status ?? null,
84
+ error: run.error ?? null,
85
+ start_time: run.start_time ?? null,
86
+ end_time: run.end_time ?? null,
87
+ parent_run_id: run.parent_run_id ?? null,
88
+ inputs_preview: preview(run.inputs),
89
+ outputs_preview: preview(run.outputs),
90
+ },
91
+ link: `${WEB}/${share}/r`,
92
+ };
93
+ }
94
+ function toolNames(outputs) {
95
+ const names = [];
96
+ const steps = arr(outputs?.output);
97
+ for (const step of steps) {
98
+ for (const msg of arr(step?.update?.messages)) {
99
+ for (const call of arr(msg?.tool_calls)) {
100
+ if (typeof call?.name === 'string')
101
+ names.push(call.name);
102
+ }
103
+ }
104
+ }
105
+ return names;
106
+ }
107
+ function byStartThenId(a, b) {
108
+ const sa = a.start_time ?? '';
109
+ const sb = b.start_time ?? '';
110
+ if (sa !== sb)
111
+ return sa < sb ? -1 : 1;
112
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
113
+ }
114
+ function withZone(t) {
115
+ if (!t)
116
+ return null;
117
+ return /(?:Z|[+-]\d{2}:?\d{2})$/i.test(t) ? t : `${t}Z`;
118
+ }
119
+ function preview(v) {
120
+ return JSON.stringify(redactValue(v ?? null)).slice(0, 800);
121
+ }
122
+ function errorText(e) {
123
+ return redactText(typeof e === 'string' ? e : JSON.stringify(redactValue(e)) ?? '');
124
+ }
125
+ function arr(x) {
126
+ return Array.isArray(x) ? x : [];
127
+ }
128
+ function errorMessage(e) {
129
+ return redactText(e instanceof Error ? e.message : String(e));
130
+ }
@@ -0,0 +1,40 @@
1
+ import { type Runner } from '../exec.ts';
2
+ import type { Evidence, SourceStatus, Window } from '../types.ts';
3
+ export interface LogLine {
4
+ at: string;
5
+ text: string;
6
+ level: string;
7
+ context: string | null;
8
+ method: string | null;
9
+ path: string | null;
10
+ status: number | null;
11
+ userId: string | null;
12
+ clientVersion: string | null;
13
+ }
14
+ /** Normalises the three shapes Railway logs come in: `--json` objects, `<ts> [level] <nest line>`, and `<ts> [LEVEL] key=value`. */
15
+ export declare function parseLogLine(raw: string): LogLine | null;
16
+ export declare function inWindow(at: string, w: Window): boolean;
17
+ /** Lines worth keeping: anything about the people we care about, every warning/error, every failed request. */
18
+ export declare function relevant(l: LogLine, ids: string[]): boolean;
19
+ /**
20
+ * Railway emits a multi-line message (an error object, a stack trace) as one line per row, all with
21
+ * the same timestamp. Fold those rows back into the line that started them, in source order.
22
+ */
23
+ export declare function mergeContinuations(lines: LogLine[]): LogLine[];
24
+ export declare function dedupe(lines: LogLine[]): LogLine[];
25
+ type Result = {
26
+ status: SourceStatus;
27
+ evidence: Evidence[];
28
+ };
29
+ /** Every configured log source, filtered to lines that mention the ids or look like failures. */
30
+ export declare function gatherLogs(w: Window, ids: string[], runner?: Runner): Promise<Result[]>;
31
+ export declare const globRegex: (g: string) => RegExp;
32
+ /**
33
+ * Log-shaped tool output from a saved Claude Code transcript. Assistant messages are excluded, but
34
+ * the transcript does not prove which command produced the output or whether it was edited later.
35
+ */
36
+ export declare function gatherArchive(w: Window, ids: string[], runner?: Runner): Promise<{
37
+ status: SourceStatus;
38
+ evidence: Evidence[];
39
+ }>;
40
+ export {};