@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.
@@ -0,0 +1,388 @@
1
+ import type {
2
+ CodexHistoryPrompt,
3
+ ContextBlock,
4
+ ContextBlockState,
5
+ ContextBlockType,
6
+ ContextReplayResponse,
7
+ ContextSnapshot,
8
+ ContextSnapshotPhase,
9
+ ContextWarning,
10
+ EventEvidence,
11
+ TimelineEvent,
12
+ TaskJourneyDetail
13
+ } from "./types";
14
+
15
+ export function buildContextReplay({
16
+ detail,
17
+ evidenceByEventId,
18
+ historyPrompts = []
19
+ }: {
20
+ detail: TaskJourneyDetail;
21
+ evidenceByEventId?: Record<string, EventEvidence>;
22
+ historyPrompts?: CodexHistoryPrompt[];
23
+ }): ContextReplayResponse {
24
+ const evidence = evidenceByEventId ?? {};
25
+ const events = orderedJourneyEvents(detail);
26
+ const activeBlocks = new Map<string, ContextBlock>();
27
+ const finalBlocks = new Map<string, ContextBlock>();
28
+ const snapshots: ContextSnapshot[] = [];
29
+ const warnings: ContextWarning[] = [];
30
+
31
+ for (const [index, event] of events.entries()) {
32
+ if (index === 1 && historyPrompts.length > 0) {
33
+ const historyBlocks = historyPrompts.map((prompt, promptIndex) => historyPromptBlock(prompt, promptIndex, detail.journey.promptEventId));
34
+ addSnapshot({
35
+ snapshots,
36
+ activeBlocks,
37
+ finalBlocks,
38
+ event: events[0],
39
+ phase: "history",
40
+ title: "History context",
41
+ addedBlocks: historyBlocks,
42
+ warnings: warningsForSnapshot(warnings, historyBlocks, events[0].id)
43
+ });
44
+ }
45
+
46
+ const addedBlocks = blocksForEvent(event, evidence[event.id]);
47
+ const snapshotWarnings: ContextWarning[] = [];
48
+ const isFinalResponse = event.kind === "assistant_message" && index === events.length - 1;
49
+ if (isFinalResponse && !hasSuccessfulVerificationBefore(events, index)) {
50
+ snapshotWarnings.push({
51
+ id: "warning-unverified-final",
52
+ severity: "high",
53
+ title: "Unverified final response",
54
+ detail: "The final assistant response is observable, but no verification event appears before it in this task journey.",
55
+ blockIds: addedBlocks.map((block) => block.id),
56
+ eventIds: [event.id]
57
+ });
58
+ }
59
+
60
+ addSnapshot({
61
+ snapshots,
62
+ activeBlocks,
63
+ finalBlocks,
64
+ event,
65
+ phase: phaseForEvent(event),
66
+ title: event.title,
67
+ addedBlocks,
68
+ warnings: snapshotWarnings
69
+ });
70
+ pushUniqueWarnings(warnings, snapshotWarnings);
71
+ }
72
+
73
+ const finalEvent = events.at(-1);
74
+ if (finalEvent) {
75
+ const staleHistoryBlocks = Array.from(activeBlocks.values()).filter((block) => block.type === "history_prompt" && block.state !== "cited");
76
+ if (staleHistoryBlocks.length > 0) {
77
+ const staleWarning: ContextWarning = {
78
+ id: "warning-stale-history",
79
+ severity: "medium",
80
+ title: "Stale history context",
81
+ detail: "A history prompt entered the observable context but was not cited by later tool, file, verification, or response events.",
82
+ blockIds: staleHistoryBlocks.map((block) => block.id),
83
+ eventIds: [finalEvent.id]
84
+ };
85
+ pushUniqueWarnings(warnings, [staleWarning]);
86
+ const lastSnapshot = snapshots.at(-1);
87
+ if (lastSnapshot) {
88
+ const updatedBlocks = lastSnapshot.blocks.map((block) =>
89
+ staleHistoryBlocks.some((stale) => stale.id === block.id)
90
+ ? {
91
+ ...block,
92
+ state: "stale" as ContextBlockState,
93
+ confidence: "inferred" as const,
94
+ reason: "Potentially stale: history prompt is not cited by later observable events."
95
+ }
96
+ : block
97
+ );
98
+ lastSnapshot.blocks = updatedBlocks;
99
+ lastSnapshot.warnings = [...lastSnapshot.warnings, staleWarning];
100
+ for (const block of updatedBlocks) finalBlocks.set(block.id, block);
101
+ }
102
+ }
103
+ }
104
+
105
+ return {
106
+ journey: detail.journey,
107
+ snapshots,
108
+ blocks: Array.from(finalBlocks.values()),
109
+ evidenceByEventId: evidence,
110
+ warnings
111
+ };
112
+ }
113
+
114
+ function orderedJourneyEvents(detail: TaskJourneyDetail) {
115
+ const byId = new Map(detail.events.map((event) => [event.id, event]));
116
+ const ordered = detail.journey.eventIds.map((id) => byId.get(id)).filter((event): event is TimelineEvent => Boolean(event));
117
+ return ordered.length > 0 ? ordered : [...detail.events].sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
118
+ }
119
+
120
+ function addSnapshot({
121
+ snapshots,
122
+ activeBlocks,
123
+ finalBlocks,
124
+ event,
125
+ phase,
126
+ title,
127
+ addedBlocks,
128
+ warnings
129
+ }: {
130
+ snapshots: ContextSnapshot[];
131
+ activeBlocks: Map<string, ContextBlock>;
132
+ finalBlocks: Map<string, ContextBlock>;
133
+ event: TimelineEvent;
134
+ phase: ContextSnapshotPhase;
135
+ title: string;
136
+ addedBlocks: ContextBlock[];
137
+ warnings: ContextWarning[];
138
+ }) {
139
+ const addedBlockIds = addedBlocks.map((block) => block.id);
140
+ const retainedBlockIds: string[] = [];
141
+ const changedBlockIds: string[] = [];
142
+ const droppedBlockIds: string[] = [];
143
+
144
+ for (const [id, block] of activeBlocks.entries()) {
145
+ const next = nextBlockState(block, event, phase);
146
+ if (next.state === "cited" || next.state === "changed") changedBlockIds.push(id);
147
+ if (next.state === "dropped" || next.state === "stale" || next.state === "contradicted") droppedBlockIds.push(id);
148
+ if (next.state === "retained") retainedBlockIds.push(id);
149
+ activeBlocks.set(id, next);
150
+ }
151
+
152
+ for (const block of addedBlocks) {
153
+ activeBlocks.set(block.id, block);
154
+ }
155
+
156
+ const blocks = Array.from(activeBlocks.values()).map((block) => ({ ...block }));
157
+ for (const block of blocks) finalBlocks.set(block.id, block);
158
+
159
+ snapshots.push({
160
+ id: `context-snapshot-${event.id}-${phase}`,
161
+ phase,
162
+ timestamp: event.timestamp,
163
+ eventId: event.id,
164
+ title,
165
+ blocks,
166
+ addedBlockIds,
167
+ retainedBlockIds,
168
+ changedBlockIds,
169
+ droppedBlockIds,
170
+ warnings,
171
+ tokenUsage: event.tokenUsage ?? null
172
+ });
173
+ }
174
+
175
+ function nextBlockState(block: ContextBlock, event: TimelineEvent, phase: ContextSnapshotPhase): ContextBlock {
176
+ if (event.id === block.sourceEventId) return block;
177
+ if (referencesBlock(event, block)) {
178
+ return {
179
+ ...block,
180
+ state: "cited",
181
+ confidence: "inferred",
182
+ reason: `Cited by ${event.kind} event via observable text, file path, command, or phrase.`
183
+ };
184
+ }
185
+ if (phase === "response") {
186
+ if (block.type === "history_prompt") {
187
+ return {
188
+ ...block,
189
+ state: "stale",
190
+ confidence: "inferred",
191
+ reason: "Potentially stale: history prompt is not cited by the final response."
192
+ };
193
+ }
194
+ if (block.type !== "user_prompt" && block.type !== "verification_output") {
195
+ return {
196
+ ...block,
197
+ state: "dropped",
198
+ confidence: "inferred",
199
+ reason: `Dropped at final response: block content is not referenced by ${event.id}.`
200
+ };
201
+ }
202
+ }
203
+ if (block.state === "new") {
204
+ return {
205
+ ...block,
206
+ state: "retained",
207
+ reason: "Retained in the active observable journey window."
208
+ };
209
+ }
210
+ return block;
211
+ }
212
+
213
+ function blocksForEvent(event: TimelineEvent, evidence: EventEvidence | undefined): ContextBlock[] {
214
+ const blocks: ContextBlock[] = [];
215
+ const base = {
216
+ sourceEventId: event.id,
217
+ rawEventRefId: event.rawEventRefId,
218
+ timestamp: event.timestamp,
219
+ sourcePath: evidence?.rawEvent?.sourcePath ?? null,
220
+ lineNo: evidence?.rawEvent?.lineNo ?? null,
221
+ files: event.files,
222
+ skills: (event.skills ?? []).map((skill) => skill.name)
223
+ };
224
+
225
+ if (event.kind === "user_prompt") {
226
+ blocks.push(contextBlock(event, base, "user_prompt", event.title, event.detail ?? event.title, "User prompt directly entered the agent conversation."));
227
+ } else if (event.kind === "tool_call") {
228
+ blocks.push(contextBlock(event, base, "tool_input", event.toolName ?? event.title, event.detail ?? event.title, "Tool input was invoked by the agent."));
229
+ } else if (event.kind === "tool_result") {
230
+ const artifacts = evidence?.artifacts.length ? evidence.artifacts : [];
231
+ if (artifacts.length === 0) {
232
+ blocks.push(contextBlock(event, base, "tool_output", event.toolName ?? event.title, event.detail ?? event.title, "Tool output was returned to the agent."));
233
+ } else {
234
+ for (const [index, artifact] of artifacts.entries()) {
235
+ blocks.push(
236
+ contextBlock(
237
+ event,
238
+ { ...base, sourcePath: artifact.path ?? base.sourcePath, files: artifact.path ? [artifact.path, ...event.files] : event.files },
239
+ artifact.type === "file" ? "file_excerpt" : "tool_output",
240
+ artifact.path ?? `${event.title} artifact`,
241
+ artifact.excerpt,
242
+ "Artifact excerpt is stored as redacted observable evidence.",
243
+ index
244
+ )
245
+ );
246
+ }
247
+ }
248
+ } else if (event.kind === "file_change") {
249
+ for (const [index, file] of event.files.entries()) {
250
+ blocks.push(contextBlock(event, { ...base, sourcePath: file, files: [file] }, "file_reference", file, file, "File path changed during this task.", index));
251
+ }
252
+ if (event.detail) {
253
+ blocks.push(contextBlock(event, base, "file_excerpt", event.title, event.detail, "File change summary entered the observable context.", event.files.length));
254
+ }
255
+ } else if (event.kind === "verification") {
256
+ blocks.push(contextBlock(event, base, "verification_output", event.title, event.detail ?? event.title, "Verification output is observable evidence for the result."));
257
+ } else if (event.kind === "assistant_message") {
258
+ blocks.push(contextBlock(event, base, "final_response", event.title, event.detail ?? event.title, "Assistant response is the visible result of this task journey."));
259
+ } else if (event.kind === "reasoning_marker") {
260
+ blocks.push(contextBlock(event, base, "reasoning_summary", event.title, event.detail ?? event.title, "Reasoning summary marker was visible in the log."));
261
+ } else if (event.kind === "error") {
262
+ blocks.push(contextBlock(event, base, "error_output", event.title, event.detail ?? event.title, "Error output contradicted or interrupted the active path."));
263
+ }
264
+
265
+ for (const [index, skill] of (event.skills ?? []).entries()) {
266
+ blocks.push(
267
+ contextBlock(
268
+ event,
269
+ base,
270
+ "skill_instruction",
271
+ skill.name,
272
+ skill.excerpt || skill.command || skill.path || skill.name,
273
+ `Skill ${skill.name} was detected from ${skill.source}.`,
274
+ blocks.length + index
275
+ )
276
+ );
277
+ }
278
+
279
+ return blocks.filter((block) => block.excerpt.trim().length > 0);
280
+ }
281
+
282
+ function contextBlock(
283
+ event: TimelineEvent,
284
+ base: Pick<ContextBlock, "sourceEventId" | "rawEventRefId" | "timestamp" | "sourcePath" | "lineNo" | "files" | "skills">,
285
+ type: ContextBlockType,
286
+ title: string,
287
+ excerpt: string,
288
+ reason: string,
289
+ index = 0
290
+ ): ContextBlock {
291
+ return {
292
+ id: `context-block-${event.id}-${type}-${index}`,
293
+ type,
294
+ state: "new",
295
+ title,
296
+ excerpt: trimExcerpt(excerpt),
297
+ tokenEstimate: estimateTokens(excerpt),
298
+ confidence: "direct",
299
+ reason,
300
+ ...base
301
+ };
302
+ }
303
+
304
+ function historyPromptBlock(prompt: CodexHistoryPrompt, index: number, promptEventId: string): ContextBlock {
305
+ return {
306
+ id: `context-block-history-${index}`,
307
+ type: "history_prompt",
308
+ state: "new",
309
+ title: "History prompt",
310
+ excerpt: trimExcerpt(prompt.text),
311
+ sourceEventId: promptEventId,
312
+ rawEventRefId: null,
313
+ sourcePath: prompt.sourcePath,
314
+ lineNo: prompt.lineNo,
315
+ timestamp: prompt.ts,
316
+ tokenEstimate: estimateTokens(prompt.text),
317
+ confidence: "direct",
318
+ reason: "History prompt is observable from history.jsonl near this session.",
319
+ files: extractFilePaths(prompt.text),
320
+ skills: []
321
+ };
322
+ }
323
+
324
+ function phaseForEvent(event: TimelineEvent): ContextSnapshotPhase {
325
+ if (event.kind === "user_prompt") return "prompt";
326
+ if (event.kind === "tool_call") return "tool_call";
327
+ if (event.kind === "tool_result") return "tool_result";
328
+ if (event.kind === "file_change") return "file_change";
329
+ if (event.kind === "verification") return "verification";
330
+ if (event.kind === "assistant_message") return "response";
331
+ return "planning";
332
+ }
333
+
334
+ function referencesBlock(event: TimelineEvent, block: ContextBlock) {
335
+ const haystack = eventText(event);
336
+ if (!haystack) return false;
337
+ const needles = [
338
+ ...block.files,
339
+ block.sourcePath ?? "",
340
+ ...extractFilePaths(block.excerpt),
341
+ ...importantPhrases(block.excerpt),
342
+ block.title
343
+ ]
344
+ .map((item) => item.trim())
345
+ .filter((item) => item.length >= 4);
346
+ return needles.some((needle) => haystack.includes(needle.toLowerCase()));
347
+ }
348
+
349
+ function eventText(event: TimelineEvent) {
350
+ return [event.title, event.detail, event.toolName, event.callId, ...event.files].filter(Boolean).join("\n").toLowerCase();
351
+ }
352
+
353
+ function importantPhrases(text: string) {
354
+ const filePaths = extractFilePaths(text);
355
+ const compactNumbers = text.match(/\b\d+[-/]\d+\b/g) ?? [];
356
+ const distinctivePhrases = (text.match(/\b[A-Za-z0-9_.-]+(?:\s+[A-Za-z0-9_.-]+){2,5}\b/g) ?? [])
357
+ .map((phrase) => phrase.toLowerCase())
358
+ .filter((phrase) => phrase.length >= 24)
359
+ .slice(0, 4);
360
+ return [...filePaths, ...compactNumbers, ...distinctivePhrases];
361
+ }
362
+
363
+ function extractFilePaths(text: string) {
364
+ return text.match(/[A-Za-z0-9_.-]+\/[A-Za-z0-9_./-]+/g) ?? [];
365
+ }
366
+
367
+ function trimExcerpt(text: string) {
368
+ const normalized = text.replace(/\s+/g, " ").trim();
369
+ return normalized.length > 360 ? `${normalized.slice(0, 357)}...` : normalized;
370
+ }
371
+
372
+ function estimateTokens(text: string) {
373
+ return Math.max(1, Math.ceil(text.trim().length / 4));
374
+ }
375
+
376
+ function hasSuccessfulVerificationBefore(events: TimelineEvent[], finalIndex: number) {
377
+ return events.slice(0, finalIndex).some((event) => event.kind === "verification" && event.status !== "failed");
378
+ }
379
+
380
+ function warningsForSnapshot(warnings: ContextWarning[], blocks: ContextBlock[], eventId: string) {
381
+ return warnings.filter((warning) => warning.eventIds.includes(eventId) || warning.blockIds.some((blockId) => blocks.some((block) => block.id === blockId)));
382
+ }
383
+
384
+ function pushUniqueWarnings(target: ContextWarning[], warnings: ContextWarning[]) {
385
+ for (const warning of warnings) {
386
+ if (!target.some((item) => item.id === warning.id)) target.push(warning);
387
+ }
388
+ }
package/core/cost.ts ADDED
@@ -0,0 +1,125 @@
1
+ import type { SessionRecord, TaskJourney, TokenUsage } from "./types";
2
+
3
+ /** A single pricing tier matched by model name substring. */
4
+ export interface ModelPricing {
5
+ id: string;
6
+ provider: "Anthropic" | "OpenAI" | "Other";
7
+ label: string;
8
+ test: RegExp;
9
+ inRate: number; // $/1M input tokens
10
+ outRate: number; // $/1M output tokens
11
+ }
12
+
13
+ /** Default pricing table (USD per 1M tokens, standard API tiers, June 2026). */
14
+ export const DEFAULT_PRICING: ModelPricing[] = [
15
+ // Anthropic (Claude Code)
16
+ { id: "sonnet", provider: "Anthropic", label: "Sonnet", test: /sonnet/i, inRate: 3, outRate: 15 },
17
+ { id: "haiku45", provider: "Anthropic", label: "Haiku 4.5", test: /haiku-?4-?5/i, inRate: 1, outRate: 5 },
18
+ { id: "haiku", provider: "Anthropic", label: "Haiku (3.x)", test: /haiku/i, inRate: 0.8, outRate: 4 },
19
+ { id: "opus4x", provider: "Anthropic", label: "Opus 4.5–4.8", test: /opus-?4-?(5|6|7|8)/i, inRate: 5, outRate: 25 },
20
+ { id: "opus", provider: "Anthropic", label: "Opus", test: /opus/i, inRate: 5, outRate: 25 },
21
+
22
+ // OpenAI (Codex)
23
+ { id: "oMini", provider: "OpenAI", label: "GPT mini", test: /mini/i, inRate: 0.45, outRate: 3.6 },
24
+ { id: "oGpt5", provider: "OpenAI", label: "GPT-5", test: /(gpt-?5|o3|o4)/i, inRate: 2.5, outRate: 15 },
25
+ { id: "o55", provider: "OpenAI", label: "GPT-5.5", test: /gpt-?5\.?5/i, inRate: 5, outRate: 30 },
26
+
27
+ // Fallback
28
+ { id: "unknown", provider: "Other", label: "Unknown", test: /.*/, inRate: 3, outRate: 15 },
29
+ ];
30
+
31
+ export const CACHE_READ_MULT = 0.1;
32
+ export const CACHE_WRITE_MULT = 1.25;
33
+
34
+ /** Match a pricing tier from a model string. */
35
+ export function matchPricing(model: string | null | undefined, pricing = DEFAULT_PRICING): ModelPricing {
36
+ const m = model ?? "";
37
+ for (const p of pricing) {
38
+ if (p.test.test(m)) return p;
39
+ }
40
+ return pricing[pricing.length - 1];
41
+ }
42
+
43
+ /** Estimate the cost of a single TokenUsage record. */
44
+ export function estimateCost(
45
+ usage: TokenUsage | null | undefined,
46
+ model: string | null | undefined,
47
+ pricing = DEFAULT_PRICING
48
+ ): number {
49
+ if (!usage) return 0;
50
+ const p = matchPricing(model, pricing);
51
+ const inputCost = (usage.input ?? 0) * p.inRate / 1_000_000;
52
+ const outputCost = (usage.output ?? 0) * p.outRate / 1_000_000;
53
+ const cachedCost = (usage.cachedInput ?? 0) * p.inRate * CACHE_READ_MULT / 1_000_000;
54
+ return inputCost + outputCost + cachedCost;
55
+ }
56
+
57
+ /** Estimated total cost from a project's token usage. */
58
+ export function estimateProjectCost(usage: TokenUsage | null | undefined, model?: string, pricing?: ModelPricing[]): number {
59
+ return estimateCost(usage, model ?? "sonnet", pricing);
60
+ }
61
+
62
+ /** Per-model cost breakdown for a set of task journeys. */
63
+ export interface ModelCostBreakdown {
64
+ model: string;
65
+ label: string;
66
+ provider: string;
67
+ input: number;
68
+ output: number;
69
+ cachedInput: number;
70
+ messages: number;
71
+ cost: number;
72
+ }
73
+
74
+ /** Aggregate token usage and cost by model across journeys. */
75
+ export function aggregateCostByModel(
76
+ journeys: TaskJourney[],
77
+ sessionMap: Map<string, SessionRecord>,
78
+ pricing = DEFAULT_PRICING
79
+ ): ModelCostBreakdown[] {
80
+ const byModel = new Map<string, {
81
+ label: string;
82
+ provider: string;
83
+ input: number;
84
+ output: number;
85
+ cachedInput: number;
86
+ messages: number;
87
+ cost: number;
88
+ }>();
89
+
90
+ for (const journey of journeys) {
91
+ const session = sessionMap.get(journey.sessionId);
92
+ const model = session?.modelProvider ?? null;
93
+ const pricingTier = matchPricing(model, pricing);
94
+
95
+ const key = pricingTier.id;
96
+ const entry = byModel.get(key) ?? {
97
+ label: pricingTier.label,
98
+ provider: pricingTier.provider,
99
+ input: 0,
100
+ output: 0,
101
+ cachedInput: 0,
102
+ messages: 0,
103
+ cost: 0,
104
+ };
105
+
106
+ entry.input += journey.tokenUsage?.input ?? 0;
107
+ entry.output += journey.tokenUsage?.output ?? 0;
108
+ entry.cachedInput += journey.tokenUsage?.cachedInput ?? 0;
109
+ entry.messages += 1;
110
+ entry.cost += estimateCost(journey.tokenUsage, model, pricing);
111
+ byModel.set(key, entry);
112
+ }
113
+
114
+ return [...byModel.entries()]
115
+ .map(([model, data]) => ({ model, ...data }))
116
+ .sort((a, b) => b.cost - a.cost);
117
+ }
118
+
119
+ /** Format a USD cost for display. */
120
+ export function formatCost(value: number): string {
121
+ if (value >= 100) return `$${Math.round(value)}`;
122
+ if (value >= 1) return `$${value.toFixed(2)}`;
123
+ if (value >= 0.01) return `$${value.toFixed(4)}`;
124
+ return `$${value.toFixed(6)}`;
125
+ }
package/core/hash.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export function sha256(input: string): string {
4
+ return createHash("sha256").update(input).digest("hex");
5
+ }
@@ -0,0 +1,96 @@
1
+ import { redactString } from "./redactor";
2
+
3
+ export interface CodexHistoryRecord {
4
+ sessionId: string;
5
+ ts: string;
6
+ text: string;
7
+ sourcePath: string;
8
+ lineNo: number;
9
+ }
10
+
11
+ export interface CodexHistoryBadLine {
12
+ sourcePath: string;
13
+ lineNo: number;
14
+ error: string;
15
+ }
16
+
17
+ export interface CodexHistoryParseResult {
18
+ records: CodexHistoryRecord[];
19
+ bySessionId: Map<string, CodexHistoryRecord[]>;
20
+ badLines: CodexHistoryBadLine[];
21
+ }
22
+
23
+ interface RawHistoryLine {
24
+ session_id?: unknown;
25
+ sessionId?: unknown;
26
+ ts?: unknown;
27
+ timestamp?: unknown;
28
+ text?: unknown;
29
+ }
30
+
31
+ export function parseCodexHistoryJsonlContent(content: string, sourcePath = "history.jsonl"): CodexHistoryParseResult {
32
+ const records: CodexHistoryRecord[] = [];
33
+ const badLines: CodexHistoryBadLine[] = [];
34
+
35
+ const lines = content.split(/\r?\n/);
36
+ for (let index = 0; index < lines.length; index += 1) {
37
+ const raw = lines[index];
38
+ const lineNo = index + 1;
39
+ if (!raw.trim()) continue;
40
+
41
+ try {
42
+ const json = JSON.parse(raw) as RawHistoryLine;
43
+ const sessionId = typeof json.session_id === "string" ? json.session_id : typeof json.sessionId === "string" ? json.sessionId : null;
44
+ const ts = normalizeTimestamp(json.ts ?? json.timestamp);
45
+ const text = typeof json.text === "string" ? json.text : null;
46
+
47
+ if (!sessionId || !ts || text === null) {
48
+ badLines.push({ sourcePath, lineNo, error: "missing required history fields" });
49
+ continue;
50
+ }
51
+
52
+ records.push({
53
+ sessionId,
54
+ ts,
55
+ text: redactString(text),
56
+ sourcePath,
57
+ lineNo
58
+ });
59
+ } catch (error) {
60
+ badLines.push({
61
+ sourcePath,
62
+ lineNo,
63
+ error: error instanceof Error ? error.message : String(error)
64
+ });
65
+ }
66
+ }
67
+
68
+ return { records, bySessionId: groupBySessionId(records), badLines };
69
+ }
70
+
71
+ export function groupBySessionId(records: CodexHistoryRecord[]): Map<string, CodexHistoryRecord[]> {
72
+ const bySessionId = new Map<string, CodexHistoryRecord[]>();
73
+ for (const record of records) {
74
+ const existing = bySessionId.get(record.sessionId);
75
+ if (existing) {
76
+ existing.push(record);
77
+ } else {
78
+ bySessionId.set(record.sessionId, [record]);
79
+ }
80
+ }
81
+ return bySessionId;
82
+ }
83
+
84
+ function normalizeTimestamp(value: unknown): string | null {
85
+ if (typeof value === "string" && value.trim()) {
86
+ const date = new Date(value);
87
+ return Number.isNaN(date.getTime()) ? value : date.toISOString();
88
+ }
89
+
90
+ if (typeof value === "number" && Number.isFinite(value)) {
91
+ const millis = value > 10_000_000_000 ? value : value * 1000;
92
+ return new Date(millis).toISOString();
93
+ }
94
+
95
+ return null;
96
+ }
package/core/id.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { sha256 } from "./hash";
2
+
3
+ export function stableId(prefix: string, ...parts: Array<string | number | null | undefined>): string {
4
+ const joined = parts.map((part) => String(part ?? "")).join("\u001f");
5
+ return `${prefix}_${sha256(joined).slice(0, 20)}`;
6
+ }