@seanxdo/superview 0.1.13
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 +193 -0
- package/README.zh-CN.md +193 -0
- package/core/contextReplay.ts +388 -0
- package/core/cost.ts +125 -0
- package/core/hash.ts +5 -0
- package/core/history.ts +96 -0
- package/core/id.ts +6 -0
- package/core/normalizer.ts +720 -0
- package/core/parser.ts +53 -0
- package/core/redactor.ts +49 -0
- package/core/replay.ts +55 -0
- package/core/timeline.ts +350 -0
- package/core/types.ts +460 -0
- package/dist/ui/assets/index-BUbbOxsU.js +18 -0
- package/dist/ui/assets/index-DafedT5l.css +1 -0
- package/dist/ui/index.html +13 -0
- package/package.json +72 -0
- package/runtime-node/adapters/claude-code.ts +205 -0
- package/runtime-node/adapters/codex.ts +24 -0
- package/runtime-node/adapters/index.ts +18 -0
- package/runtime-node/adapters/opencode.ts +193 -0
- package/runtime-node/adapters/shared.ts +113 -0
- package/runtime-node/cli-ingest.ts +7 -0
- package/runtime-node/cli-start.js +15 -0
- package/runtime-node/cli-start.ts +9 -0
- package/runtime-node/dev-server.ts +6 -0
- package/runtime-node/git-provider.ts +102 -0
- package/runtime-node/history.ts +9 -0
- package/runtime-node/ingest-worker.ts +32 -0
- package/runtime-node/ingest.ts +362 -0
- package/runtime-node/prod-server.ts +24 -0
- package/runtime-node/scanner.ts +13 -0
- package/runtime-node/server.ts +183 -0
- package/storage/database.ts +1016 -0
- package/storage/paths.ts +20 -0
package/core/parser.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { ParsedCodexLine } from "./types";
|
|
3
|
+
import { redactValue } from "./redactor";
|
|
4
|
+
import { sha256 } from "./hash";
|
|
5
|
+
|
|
6
|
+
interface CodexJsonLine {
|
|
7
|
+
timestamp?: string;
|
|
8
|
+
type?: string;
|
|
9
|
+
payload?: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseCodexJsonlContent(content: string, sourcePath: string): ParsedCodexLine[] {
|
|
13
|
+
const lines = content.split(/\r?\n/);
|
|
14
|
+
const parsed: ParsedCodexLine[] = [];
|
|
15
|
+
|
|
16
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
17
|
+
const raw = lines[index];
|
|
18
|
+
const lineNo = index + 1;
|
|
19
|
+
if (!raw.trim()) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const json = JSON.parse(raw) as CodexJsonLine;
|
|
25
|
+
parsed.push({
|
|
26
|
+
sourcePath,
|
|
27
|
+
lineNo,
|
|
28
|
+
timestamp: json.timestamp ?? new Date(0).toISOString(),
|
|
29
|
+
type: json.type ?? "unknown",
|
|
30
|
+
payload: json.payload ?? null,
|
|
31
|
+
redactedPayload: redactValue(json.payload ?? null),
|
|
32
|
+
sha256: sha256(raw)
|
|
33
|
+
});
|
|
34
|
+
} catch (error) {
|
|
35
|
+
parsed.push({
|
|
36
|
+
sourcePath,
|
|
37
|
+
lineNo,
|
|
38
|
+
timestamp: new Date(0).toISOString(),
|
|
39
|
+
type: "parse_error",
|
|
40
|
+
payload: { error: error instanceof Error ? error.message : String(error), raw },
|
|
41
|
+
redactedPayload: { error: error instanceof Error ? error.message : String(error), raw: redactValue(raw) },
|
|
42
|
+
sha256: sha256(raw)
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function parseCodexJsonlFile(sourcePath: string): Promise<ParsedCodexLine[]> {
|
|
51
|
+
const content = await readFile(sourcePath, "utf8");
|
|
52
|
+
return parseCodexJsonlContent(content, sourcePath);
|
|
53
|
+
}
|
package/core/redactor.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const SECRET_KEY_PATTERN = /(api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|token|secret|password|passwd|authorization)(\s*[:=]\s*)(["']?)([^"',\s}]+)/gi;
|
|
2
|
+
const AUTHORIZATION_HEADER_PATTERN = /(authorization\s*:\s*)(bearer\s+)?[^\r\n]+/gi;
|
|
3
|
+
const BEARER_PATTERN = /(bearer\s+)[a-z0-9._~+/=-]+/gi;
|
|
4
|
+
const OPENAI_KEY_PATTERN = /\bsk-[A-Za-z0-9_-]{12,}\b/g;
|
|
5
|
+
const RESEND_KEY_PATTERN = /\bre_[A-Za-z0-9_-]{8,}\b/g;
|
|
6
|
+
const ENV_SECRET_LINE_PATTERN = /^([A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)[A-Z0-9_]*\s*=\s*).+$/gim;
|
|
7
|
+
|
|
8
|
+
export function redactString(value: string): string {
|
|
9
|
+
return value
|
|
10
|
+
.replace(ENV_SECRET_LINE_PATTERN, "$1[REDACTED]")
|
|
11
|
+
.replace(AUTHORIZATION_HEADER_PATTERN, "$1[REDACTED]")
|
|
12
|
+
.replace(SECRET_KEY_PATTERN, "$1$2$3[REDACTED]")
|
|
13
|
+
.replace(BEARER_PATTERN, "$1[REDACTED]")
|
|
14
|
+
.replace(OPENAI_KEY_PATTERN, "sk-[REDACTED]")
|
|
15
|
+
.replace(RESEND_KEY_PATTERN, "re_[REDACTED]");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function redactValue<T>(value: T): T {
|
|
19
|
+
if (typeof value === "string") {
|
|
20
|
+
return redactString(value) as T;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
return value.map((item) => redactValue(item)) as T;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (value && typeof value === "object") {
|
|
28
|
+
const redacted: Record<string, unknown> = {};
|
|
29
|
+
for (const [key, child] of Object.entries(value)) {
|
|
30
|
+
if (/api[_-]?key|token|secret|password|passwd|authorization/i.test(key)) {
|
|
31
|
+
redacted[key] = "[REDACTED]";
|
|
32
|
+
} else {
|
|
33
|
+
redacted[key] = redactValue(child);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return redacted as T;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function safeExcerpt(value: unknown, maxLength = 8000): string {
|
|
43
|
+
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
44
|
+
const redacted = redactString(text ?? "");
|
|
45
|
+
if (redacted.length <= maxLength) {
|
|
46
|
+
return redacted;
|
|
47
|
+
}
|
|
48
|
+
return `${redacted.slice(0, maxLength)}\n...[truncated ${redacted.length - maxLength} chars]`;
|
|
49
|
+
}
|
package/core/replay.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ReplayNode, ReplayNodeType, SessionRecord, TimelineEvent } from "./types";
|
|
2
|
+
|
|
3
|
+
export function buildReplayNodes(events: TimelineEvent[]): ReplayNode[] {
|
|
4
|
+
const runEvents = events
|
|
5
|
+
.filter((event) => event.kind !== "reasoning_marker")
|
|
6
|
+
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
7
|
+
|
|
8
|
+
return runEvents.map((event, index) => ({
|
|
9
|
+
id: `node-${event.id}`,
|
|
10
|
+
eventId: event.id,
|
|
11
|
+
type: nodeTypeForEvent(event),
|
|
12
|
+
label: labelForEvent(event),
|
|
13
|
+
timestamp: event.timestamp,
|
|
14
|
+
status: event.status,
|
|
15
|
+
lane: event.lane,
|
|
16
|
+
x: 80 + index * 120,
|
|
17
|
+
detail: event.detail
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildRunReplay(session: SessionRecord, events: TimelineEvent[]) {
|
|
22
|
+
return {
|
|
23
|
+
session,
|
|
24
|
+
events,
|
|
25
|
+
nodes: buildReplayNodes(events),
|
|
26
|
+
artifacts: []
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function nodeTypeForEvent(event: TimelineEvent): ReplayNodeType {
|
|
31
|
+
if (hasRetrySignal(event)) return "loop";
|
|
32
|
+
if (event.kind === "user_prompt") return "start";
|
|
33
|
+
if (event.kind === "file_change") return "powerup";
|
|
34
|
+
if (event.kind === "verification" && event.status === "success") return "finish";
|
|
35
|
+
if (event.status === "failed" || event.kind === "error") return "hazard";
|
|
36
|
+
if (event.kind === "verification") return "platform";
|
|
37
|
+
if (/read|search|rg|find|open/i.test(`${event.title} ${event.detail ?? ""}`)) return "context";
|
|
38
|
+
if (event.kind === "tool_call" || event.kind === "tool_result") return "platform";
|
|
39
|
+
return "message";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function labelForEvent(event: TimelineEvent): string {
|
|
43
|
+
if (hasRetrySignal(event)) return "Retry";
|
|
44
|
+
if (event.kind === "user_prompt") return "Start";
|
|
45
|
+
if (event.kind === "file_change") return "Patch";
|
|
46
|
+
if (event.kind === "verification" && event.status === "success") return "Flag";
|
|
47
|
+
if (event.kind === "verification") return "Check";
|
|
48
|
+
if (event.status === "failed" || event.kind === "error") return "Hazard";
|
|
49
|
+
if (event.kind === "tool_call") return event.toolName ?? "Tool";
|
|
50
|
+
return event.title.length > 18 ? `${event.title.slice(0, 15)}...` : event.title;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hasRetrySignal(event: TimelineEvent): boolean {
|
|
54
|
+
return /retry|re-?run|try again|again|repeat|loop|重试|再试/i.test(`${event.title} ${event.detail ?? ""}`);
|
|
55
|
+
}
|
package/core/timeline.ts
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { addMinutes, differenceInMinutes, isValid, parseISO } from "date-fns";
|
|
2
|
+
import { CausalConfidence, CausalEdge, CausalEdgeType, Episode, EventStatus, ProjectRecord, ProjectTimeline, SkillUsage, TaskJourney, TaskJourneyStage, TimelineEvent, TimelineLane, TokenUsage } from "./types";
|
|
3
|
+
import { stableId } from "./id";
|
|
4
|
+
|
|
5
|
+
const EPISODE_GAP_MINUTES = 90;
|
|
6
|
+
const LANE_ORDER: TimelineLane[] = ["Product", "Architecture", "Code", "Agent Runs", "Verification", "Risks"];
|
|
7
|
+
|
|
8
|
+
export function buildProjectTimeline(project: ProjectRecord, events: TimelineEvent[]): ProjectTimeline {
|
|
9
|
+
const sortedEvents = [...events].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
10
|
+
return {
|
|
11
|
+
project,
|
|
12
|
+
events: sortedEvents,
|
|
13
|
+
episodes: groupEpisodes(project.id, sortedEvents),
|
|
14
|
+
causalEdges: buildCausalEdges(project.id, sortedEvents),
|
|
15
|
+
taskJourneys: buildTaskJourneys(project.id, sortedEvents),
|
|
16
|
+
tokenUsage: aggregateTokenUsage(project.id, sortedEvents)
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function aggregateTokenUsage(projectId: string, events: TimelineEvent[]): TokenUsage {
|
|
21
|
+
return aggregateEventTokenUsage(events.filter((event) => event.projectId === projectId));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function aggregateEventTokenUsage(events: TimelineEvent[]): TokenUsage {
|
|
25
|
+
return events.reduce<TokenUsage>(
|
|
26
|
+
(total, event) => ({
|
|
27
|
+
input: total.input + (event.tokenUsage?.input ?? 0),
|
|
28
|
+
output: total.output + (event.tokenUsage?.output ?? 0),
|
|
29
|
+
reasoning: total.reasoning + (event.tokenUsage?.reasoning ?? 0),
|
|
30
|
+
cachedInput: total.cachedInput + (event.tokenUsage?.cachedInput ?? 0),
|
|
31
|
+
total: total.total + (event.tokenUsage?.total ?? 0)
|
|
32
|
+
}),
|
|
33
|
+
{ input: 0, output: 0, reasoning: 0, cachedInput: 0, total: 0 }
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveJourneyStatus(journeyEvents: TimelineEvent[]): EventStatus {
|
|
38
|
+
if (journeyEvents.length === 0) return "unknown";
|
|
39
|
+
|
|
40
|
+
// 1. If verification events exist, the last verification's status is the source of truth.
|
|
41
|
+
const verifications = journeyEvents.filter((event) => event.lane === "Verification");
|
|
42
|
+
const lastVerification = verifications.at(-1);
|
|
43
|
+
if (lastVerification) {
|
|
44
|
+
if (lastVerification.status === "success") return "success";
|
|
45
|
+
if (lastVerification.status === "failed") return "failed";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 2. Otherwise look at the final event of the journey.
|
|
49
|
+
// A trailing assistant response means the agent finished its turn — count as success.
|
|
50
|
+
// A trailing error / failed event means the journey ended in failure.
|
|
51
|
+
const last = journeyEvents.at(-1)!;
|
|
52
|
+
if (last.kind === "assistant_message") return "success";
|
|
53
|
+
if (last.kind === "error" || last.status === "failed") return "failed";
|
|
54
|
+
|
|
55
|
+
// 3. Fall back to unknown for truncated or otherwise indeterminate journeys.
|
|
56
|
+
return "unknown";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildTaskJourneys(projectId: string, events: TimelineEvent[]): TaskJourney[] {
|
|
60
|
+
const projectEvents = events.filter((event) => event.projectId === projectId);
|
|
61
|
+
const promptIndexes = projectEvents
|
|
62
|
+
.map((event, index) => ({ event, index }))
|
|
63
|
+
.filter(({ event }) => event.kind === "user_prompt");
|
|
64
|
+
|
|
65
|
+
return promptIndexes.map(({ event: prompt, index }, promptIndex) => {
|
|
66
|
+
const nextPromptIndex = promptIndexes[promptIndex + 1]?.index ?? projectEvents.length;
|
|
67
|
+
const nextPrompt = promptIndexes[promptIndex + 1]?.event;
|
|
68
|
+
const endIndex = nextPromptIndex;
|
|
69
|
+
const journeyEvents = projectEvents.slice(index, endIndex);
|
|
70
|
+
const end = journeyEvents.at(-1) ?? prompt;
|
|
71
|
+
const stages = buildTaskJourneyStages(journeyEvents);
|
|
72
|
+
const status: EventStatus = resolveJourneyStatus(journeyEvents);
|
|
73
|
+
const stageCounts = stages.reduce<Partial<Record<TimelineLane, number>>>((counts, stage) => {
|
|
74
|
+
counts[stage.lane] = stage.count;
|
|
75
|
+
return counts;
|
|
76
|
+
}, {});
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
id: stableId("task_journey", projectId, prompt.id, end.id),
|
|
80
|
+
projectId,
|
|
81
|
+
sessionId: prompt.sessionId,
|
|
82
|
+
promptEventId: prompt.id,
|
|
83
|
+
startedAt: prompt.timestamp,
|
|
84
|
+
endedAt: end.timestamp,
|
|
85
|
+
durationMs: durationBetween(prompt.timestamp, end.timestamp),
|
|
86
|
+
title: prompt.title,
|
|
87
|
+
summary: `From user input through ${journeyEvents.length} event(s), ${stages.length} stage(s), ending at ${nextPrompt ? "next user input" : "session end"}.`,
|
|
88
|
+
status,
|
|
89
|
+
exitType: nextPrompt ? "next_prompt" : "session_end",
|
|
90
|
+
eventIds: journeyEvents.map((event) => event.id),
|
|
91
|
+
tokenUsage: aggregateEventTokenUsage(journeyEvents),
|
|
92
|
+
skills: aggregateEventSkills(journeyEvents),
|
|
93
|
+
stageCounts,
|
|
94
|
+
stages
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function aggregateEventSkills(events: TimelineEvent[]): SkillUsage[] {
|
|
100
|
+
const byIdentity = new Map<string, SkillUsage>();
|
|
101
|
+
for (const event of events) {
|
|
102
|
+
for (const skill of event.skills ?? []) {
|
|
103
|
+
const key = `${skill.name}\0${skill.path ?? ""}\0${skill.source}`;
|
|
104
|
+
if (!byIdentity.has(key)) {
|
|
105
|
+
byIdentity.set(key, skill);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return [...byIdentity.values()].sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function durationBetween(start: string, end: string): number {
|
|
113
|
+
const startMs = Date.parse(start);
|
|
114
|
+
const endMs = Date.parse(end);
|
|
115
|
+
if (Number.isNaN(startMs) || Number.isNaN(endMs)) return 0;
|
|
116
|
+
return Math.max(0, endMs - startMs);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildTaskJourneyStages(events: TimelineEvent[]): TaskJourneyStage[] {
|
|
120
|
+
const byLane = new Map<TimelineLane, { lane: TimelineLane; eventIds: string[]; firstEventId: string; lastEventId: string; failures: number; successes: number }>();
|
|
121
|
+
for (const event of events) {
|
|
122
|
+
const current =
|
|
123
|
+
byLane.get(event.lane) ??
|
|
124
|
+
{
|
|
125
|
+
lane: event.lane,
|
|
126
|
+
eventIds: [],
|
|
127
|
+
firstEventId: event.id,
|
|
128
|
+
lastEventId: event.id,
|
|
129
|
+
failures: 0,
|
|
130
|
+
successes: 0
|
|
131
|
+
};
|
|
132
|
+
current.eventIds.push(event.id);
|
|
133
|
+
current.lastEventId = event.id;
|
|
134
|
+
if (event.status === "failed") current.failures += 1;
|
|
135
|
+
if (event.status === "success") current.successes += 1;
|
|
136
|
+
byLane.set(event.lane, current);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return LANE_ORDER.flatMap((lane) => {
|
|
140
|
+
const stage = byLane.get(lane);
|
|
141
|
+
if (!stage) return [];
|
|
142
|
+
const status: EventStatus = stage.failures > 0 ? "failed" : stage.successes > 0 ? "success" : "unknown";
|
|
143
|
+
return [{ lane, count: stage.eventIds.length, status, firstEventId: stage.firstEventId, lastEventId: stage.lastEventId, eventIds: stage.eventIds }];
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function buildCausalEdges(projectId: string, events: TimelineEvent[]): CausalEdge[] {
|
|
148
|
+
const sortedEvents = events.filter((event) => event.projectId === projectId);
|
|
149
|
+
const byId = new Map(sortedEvents.map((event) => [event.id, event]));
|
|
150
|
+
const edges = new Map<string, CausalEdge>();
|
|
151
|
+
|
|
152
|
+
const addEdge = (
|
|
153
|
+
from: TimelineEvent | undefined,
|
|
154
|
+
to: TimelineEvent | undefined,
|
|
155
|
+
type: CausalEdgeType,
|
|
156
|
+
confidence: CausalConfidence,
|
|
157
|
+
reason: string,
|
|
158
|
+
evidence: string | null = null
|
|
159
|
+
) => {
|
|
160
|
+
if (!from || !to || from.id === to.id) return;
|
|
161
|
+
const key = `${from.id}:${to.id}:${type}`;
|
|
162
|
+
if (edges.has(key)) return;
|
|
163
|
+
edges.set(key, {
|
|
164
|
+
id: stableId("causal", projectId, from.id, to.id, type),
|
|
165
|
+
projectId,
|
|
166
|
+
fromEventId: from.id,
|
|
167
|
+
toEventId: to.id,
|
|
168
|
+
type,
|
|
169
|
+
confidence,
|
|
170
|
+
reason,
|
|
171
|
+
evidence
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
for (let index = 1; index < sortedEvents.length; index += 1) {
|
|
176
|
+
const previous = sortedEvents[index - 1];
|
|
177
|
+
const current = sortedEvents[index];
|
|
178
|
+
if (previous.turnId && current.turnId === previous.turnId) {
|
|
179
|
+
addEdge(previous, current, "same_turn", "deterministic", `Same turn_id ${current.turnId}.`, current.turnId);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const event of sortedEvents) {
|
|
184
|
+
const output = event.outputEventId ? byId.get(event.outputEventId) : undefined;
|
|
185
|
+
if (output) {
|
|
186
|
+
addEdge(event, output, output.status === "failed" ? "failed_by" : "same_call", "deterministic", `Function call ${event.callId ?? event.id} produced this output.`, event.callId);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const sessions = groupBySession(sortedEvents);
|
|
191
|
+
for (const sessionEvents of sessions.values()) {
|
|
192
|
+
addPromptEdges(sessionEvents, addEdge);
|
|
193
|
+
addForwardCausalEdges(sessionEvents, addEdge);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return [...edges.values()].sort((a, b) => {
|
|
197
|
+
const fromDelta = (byId.get(a.fromEventId)?.timestamp ?? "").localeCompare(byId.get(b.fromEventId)?.timestamp ?? "");
|
|
198
|
+
return fromDelta || (byId.get(a.toEventId)?.timestamp ?? "").localeCompare(byId.get(b.toEventId)?.timestamp ?? "") || a.type.localeCompare(b.type);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function groupBySession(events: TimelineEvent[]): Map<string, TimelineEvent[]> {
|
|
203
|
+
const sessions = new Map<string, TimelineEvent[]>();
|
|
204
|
+
for (const event of events) {
|
|
205
|
+
const current = sessions.get(event.sessionId) ?? [];
|
|
206
|
+
current.push(event);
|
|
207
|
+
sessions.set(event.sessionId, current);
|
|
208
|
+
}
|
|
209
|
+
return sessions;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function addPromptEdges(
|
|
213
|
+
sessionEvents: TimelineEvent[],
|
|
214
|
+
addEdge: (from: TimelineEvent | undefined, to: TimelineEvent | undefined, type: CausalEdgeType, confidence: CausalConfidence, reason: string, evidence?: string | null) => void
|
|
215
|
+
) {
|
|
216
|
+
let pendingPrompts: TimelineEvent[] = [];
|
|
217
|
+
for (const event of sessionEvents) {
|
|
218
|
+
if (event.kind === "user_prompt") {
|
|
219
|
+
pendingPrompts.push(event);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (pendingPrompts.length === 0) continue;
|
|
224
|
+
if (event.lane === "Architecture" && (event.kind === "file_change" || event.kind === "tool_call")) {
|
|
225
|
+
for (const prompt of pendingPrompts) {
|
|
226
|
+
addEdge(prompt, event, "updates_design", "inferred", "First architecture or design artifact after this prompt in the same session.", prompt.title);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (event.lane === "Code" && (event.kind === "file_change" || event.toolName === "git")) {
|
|
230
|
+
for (const prompt of pendingPrompts) {
|
|
231
|
+
addEdge(prompt, event, "implements_prompt", "inferred", "First code change after this prompt in the same session.", prompt.title);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (event.lane === "Architecture" || event.lane === "Code") {
|
|
236
|
+
pendingPrompts = pendingPrompts.filter((prompt) => {
|
|
237
|
+
const hasArchitectureEdge = event.lane === "Architecture";
|
|
238
|
+
const hasCodeEdge = event.lane === "Code";
|
|
239
|
+
return !(hasArchitectureEdge || hasCodeEdge) || prompt.id === event.id;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function addForwardCausalEdges(
|
|
246
|
+
sessionEvents: TimelineEvent[],
|
|
247
|
+
addEdge: (from: TimelineEvent | undefined, to: TimelineEvent | undefined, type: CausalEdgeType, confidence: CausalConfidence, reason: string, evidence?: string | null) => void
|
|
248
|
+
) {
|
|
249
|
+
const pendingVerification: TimelineEvent[] = [];
|
|
250
|
+
const pendingCommit: TimelineEvent[] = [];
|
|
251
|
+
const pendingRetry: TimelineEvent[] = [];
|
|
252
|
+
const pendingFailure: TimelineEvent[] = [];
|
|
253
|
+
|
|
254
|
+
for (const event of sessionEvents) {
|
|
255
|
+
if (event.lane === "Verification" && event.status === "success") {
|
|
256
|
+
for (const source of pendingVerification.splice(0)) {
|
|
257
|
+
addEdge(source, event, "verified_by", "inferred", "Nearest successful verification after this change in the same session.");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (event.toolName === "git" || Boolean(event.commitHash)) {
|
|
262
|
+
for (const source of pendingCommit.splice(0)) {
|
|
263
|
+
addEdge(source, event, "committed_as", "deterministic", "Nearest git commit after this change in the same session.");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (isRetryEvent(event)) {
|
|
268
|
+
for (const source of pendingRetry.splice(0)) {
|
|
269
|
+
addEdge(source, event, "retried_by", "inferred", "A later agent message or command indicates the failed step was retried.");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (event.status === "failed" && (event.lane === "Risks" || event.lane === "Verification")) {
|
|
274
|
+
for (const source of pendingFailure.splice(0)) {
|
|
275
|
+
addEdge(source, event, "failed_by", "inferred", "Nearest failed verification or risk event after this step in the same session.");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if ((event.lane === "Code" || event.lane === "Architecture") && event.status !== "failed") {
|
|
280
|
+
pendingVerification.push(event);
|
|
281
|
+
pendingCommit.push(event);
|
|
282
|
+
}
|
|
283
|
+
if (event.status === "failed") {
|
|
284
|
+
pendingRetry.push(event);
|
|
285
|
+
} else {
|
|
286
|
+
pendingFailure.push(event);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function groupEpisodes(projectId: string, events: TimelineEvent[]): Episode[] {
|
|
292
|
+
const projectEvents = events.filter((event) => event.projectId === projectId);
|
|
293
|
+
if (projectEvents.length === 0) return [];
|
|
294
|
+
|
|
295
|
+
const groups: TimelineEvent[][] = [];
|
|
296
|
+
let current: TimelineEvent[] = [];
|
|
297
|
+
|
|
298
|
+
for (const event of projectEvents) {
|
|
299
|
+
const previous = current.at(-1);
|
|
300
|
+
if (!previous || previous.sessionId === event.sessionId || minutesBetween(previous.timestamp, event.timestamp) <= EPISODE_GAP_MINUTES) {
|
|
301
|
+
current.push(event);
|
|
302
|
+
} else {
|
|
303
|
+
groups.push(current);
|
|
304
|
+
current = [event];
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (current.length > 0) groups.push(current);
|
|
308
|
+
|
|
309
|
+
return groups.map((group, index) => {
|
|
310
|
+
const start = group[0];
|
|
311
|
+
const end = group.at(-1) ?? start;
|
|
312
|
+
const prompt = group.find((event) => event.kind === "user_prompt");
|
|
313
|
+
const failures = group.filter((event) => event.status === "failed").length;
|
|
314
|
+
const verifications = group.filter((event) => event.lane === "Verification" && event.status === "success").length;
|
|
315
|
+
const status: EventStatus = failures > 0 ? "failed" : verifications > 0 ? "success" : "unknown";
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
id: stableId("episode", projectId, start.timestamp, end.timestamp, index),
|
|
319
|
+
projectId,
|
|
320
|
+
startedAt: start.timestamp,
|
|
321
|
+
endedAt: end.timestamp,
|
|
322
|
+
title: prompt?.title ?? `Auto grouped episode ${index + 1}`,
|
|
323
|
+
summary: `Auto grouped ${group.length} events across ${new Set(group.map((event) => event.sessionId)).size} run(s).`,
|
|
324
|
+
status,
|
|
325
|
+
eventIds: group.map((event) => event.id)
|
|
326
|
+
};
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function minutesBetween(left: string, right: string): number {
|
|
331
|
+
const a = parseISO(left);
|
|
332
|
+
const b = parseISO(right);
|
|
333
|
+
if (!isValid(a) || !isValid(b)) {
|
|
334
|
+
return 0;
|
|
335
|
+
}
|
|
336
|
+
return Math.abs(differenceInMinutes(b, a));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function isAfter(candidate: TimelineEvent, base: TimelineEvent): boolean {
|
|
340
|
+
return candidate.timestamp > base.timestamp || (candidate.timestamp === base.timestamp && candidate.id > base.id);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function isRetryEvent(event: TimelineEvent): boolean {
|
|
344
|
+
return /\bretry\b|\bretrying\b|重试|再次尝试/i.test(`${event.title}\n${event.detail ?? ""}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function makePlaceholderDate(seed: string): string {
|
|
348
|
+
const date = parseISO(seed);
|
|
349
|
+
return isValid(date) ? date.toISOString() : addMinutes(new Date(0), 1).toISOString();
|
|
350
|
+
}
|