waypoi 0.0.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.
- package/.github/instructions/ui.instructions.md +42 -0
- package/.github/workflows/ci.yml +35 -0
- package/.github/workflows/publish.yml +71 -0
- package/.github/workflows/release.yml +48 -0
- package/.playwright-mcp/console-2026-04-04T01-41-10-746Z.log +2 -0
- package/.playwright-mcp/console-2026-04-04T01-41-28-799Z.log +3 -0
- package/.playwright-mcp/console-2026-04-05T02-26-51-909Z.log +76 -0
- package/.playwright-mcp/page-2026-04-04T01-41-10-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-04T01-41-29-141Z.yml +77 -0
- package/.playwright-mcp/page-2026-04-04T01-41-42-633Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T01-42-03-929Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-12-54-813Z.yml +6 -0
- package/.playwright-mcp/page-2026-04-04T02-14-58-600Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-03-923Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-07-426Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-25-729Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-16-22-984Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-17-00-599Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-17-50-874Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-05T02-26-55-570Z.yml +6 -0
- package/AGENTS.md +48 -0
- package/CHANGELOG.md +131 -0
- package/README.md +552 -0
- package/assets/agent-mode.png +0 -0
- package/assets/categorize.png +0 -0
- package/assets/dashboard.png +0 -0
- package/assets/endpoint-proxy.png +0 -0
- package/assets/icon.png +0 -0
- package/assets/mcp-generate-image.png +0 -0
- package/assets/mcp-understand-image.png +0 -0
- package/assets/peek-token-flow.png +0 -0
- package/assets/playground.png +0 -0
- package/assets/sankey.png +0 -0
- package/cli/index.ts +2805 -0
- package/cli/legacyRewrite.ts +108 -0
- package/cli/modelRef.ts +24 -0
- package/dist/cli/index.js +2536 -0
- package/dist/cli/legacyRewrite.js +92 -0
- package/dist/cli/modelRef.js +20 -0
- package/dist/src/benchmark/artifacts.js +131 -0
- package/dist/src/benchmark/capabilityClassifier.js +81 -0
- package/dist/src/benchmark/capabilityStore.js +144 -0
- package/dist/src/benchmark/config.js +238 -0
- package/dist/src/benchmark/gates.js +118 -0
- package/dist/src/benchmark/jobs.js +252 -0
- package/dist/src/benchmark/runner.js +1847 -0
- package/dist/src/benchmark/schema.js +353 -0
- package/dist/src/benchmark/suites.js +314 -0
- package/dist/src/benchmark/tinyQaDataset.js +422 -0
- package/dist/src/benchmark/types.js +25 -0
- package/dist/src/config.js +47 -0
- package/dist/src/index.js +178 -0
- package/dist/src/mcp/client.js +215 -0
- package/dist/src/mcp/discovery.js +226 -0
- package/dist/src/mcp/policy.js +65 -0
- package/dist/src/mcp/registry.js +129 -0
- package/dist/src/mcp/service.js +460 -0
- package/dist/src/middleware/auth.js +179 -0
- package/dist/src/middleware/requestCapture.js +192 -0
- package/dist/src/middleware/requestStats.js +118 -0
- package/dist/src/pools/builder.js +132 -0
- package/dist/src/pools/repository.js +69 -0
- package/dist/src/pools/scheduler.js +360 -0
- package/dist/src/pools/types.js +2 -0
- package/dist/src/protocols/adapters/dashscope.js +267 -0
- package/dist/src/protocols/adapters/inferenceV2.js +346 -0
- package/dist/src/protocols/adapters/openai.js +27 -0
- package/dist/src/protocols/registry.js +99 -0
- package/dist/src/protocols/types.js +2 -0
- package/dist/src/providers/health.js +153 -0
- package/dist/src/providers/importer.js +289 -0
- package/dist/src/providers/modelRegistry.js +313 -0
- package/dist/src/providers/repository.js +361 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/routes/admin.js +531 -0
- package/dist/src/routes/audio.js +295 -0
- package/dist/src/routes/chat.js +240 -0
- package/dist/src/routes/embeddings.js +157 -0
- package/dist/src/routes/images.js +288 -0
- package/dist/src/routes/mcp.js +256 -0
- package/dist/src/routes/mcpService.js +100 -0
- package/dist/src/routes/models.js +48 -0
- package/dist/src/routes/responses.js +711 -0
- package/dist/src/routes/sessions.js +450 -0
- package/dist/src/routes/stats.js +270 -0
- package/dist/src/routes/ui.js +97 -0
- package/dist/src/routes/videos.js +107 -0
- package/dist/src/routing/router.js +338 -0
- package/dist/src/services/imageGeneration.js +280 -0
- package/dist/src/services/imageUnderstanding.js +352 -0
- package/dist/src/services/videoGeneration.js +79 -0
- package/dist/src/storage/captureRepository.js +1591 -0
- package/dist/src/storage/files.js +157 -0
- package/dist/src/storage/imageCache.js +346 -0
- package/dist/src/storage/repositories.js +388 -0
- package/dist/src/storage/sessionRepository.js +370 -0
- package/dist/src/storage/statsRepository.js +204 -0
- package/dist/src/transport/httpClient.js +126 -0
- package/dist/src/types.js +2 -0
- package/dist/src/utils/messageMedia.js +285 -0
- package/dist/src/utils/modelCapabilities.js +108 -0
- package/dist/src/utils/modelDiscovery.js +170 -0
- package/dist/src/version.js +5 -0
- package/dist/src/workers/captureRetention.js +25 -0
- package/dist/src/workers/configWatcher.js +91 -0
- package/dist/src/workers/healthChecker.js +21 -0
- package/dist/src/workers/statsRotation.js +41 -0
- package/docs/LLM/output_schema.md +312 -0
- package/docs/benchmark.md +208 -0
- package/docs/mcp-guidelines.md +125 -0
- package/docs/mcp-service.md +178 -0
- package/docs/opencode.md +86 -0
- package/docs/providers.md +79 -0
- package/examples/benchmark.config.yaml +28 -0
- package/examples/providers/alibaba-dashscope.yaml +88 -0
- package/examples/providers/alibaba-llm.yaml +64 -0
- package/examples/providers/alibaba-registry.yaml +7 -0
- package/examples/providers/inference-v2-ray.yaml +29 -0
- package/examples/scenarios/assets/omni-call-sample.wav +0 -0
- package/examples/scenarios/custom.jsonl +5 -0
- package/examples/scenarios/custom.yaml +40 -0
- package/model-form-v2.png +0 -0
- package/package.json +66 -0
- package/provider-form-v2.png +0 -0
- package/provider-form.png +0 -0
- package/scripts/manual-test.sh +11 -0
- package/scripts/version-from-git.js +23 -0
- package/src/benchmark/artifacts.ts +149 -0
- package/src/benchmark/capabilityClassifier.ts +99 -0
- package/src/benchmark/capabilityStore.ts +174 -0
- package/src/benchmark/config.ts +337 -0
- package/src/benchmark/gates.ts +164 -0
- package/src/benchmark/jobs.ts +312 -0
- package/src/benchmark/runner.ts +2519 -0
- package/src/benchmark/schema.ts +443 -0
- package/src/benchmark/suites.ts +323 -0
- package/src/benchmark/tinyQaDataset.ts +428 -0
- package/src/benchmark/types.ts +442 -0
- package/src/config.ts +44 -0
- package/src/index.ts +195 -0
- package/src/mcp/client.ts +305 -0
- package/src/mcp/discovery.ts +266 -0
- package/src/mcp/policy.ts +105 -0
- package/src/mcp/registry.ts +164 -0
- package/src/mcp/service.ts +611 -0
- package/src/middleware/auth.ts +251 -0
- package/src/middleware/requestCapture.ts +245 -0
- package/src/middleware/requestStats.ts +163 -0
- package/src/pools/builder.ts +159 -0
- package/src/pools/repository.ts +71 -0
- package/src/pools/scheduler.ts +425 -0
- package/src/pools/types.ts +117 -0
- package/src/protocols/adapters/dashscope.ts +335 -0
- package/src/protocols/adapters/inferenceV2.ts +428 -0
- package/src/protocols/adapters/openai.ts +32 -0
- package/src/protocols/registry.ts +117 -0
- package/src/protocols/types.ts +81 -0
- package/src/providers/health.ts +207 -0
- package/src/providers/importer.ts +402 -0
- package/src/providers/modelRegistry.ts +415 -0
- package/src/providers/repository.ts +439 -0
- package/src/providers/types.ts +113 -0
- package/src/routes/admin.ts +666 -0
- package/src/routes/audio.ts +372 -0
- package/src/routes/chat.ts +301 -0
- package/src/routes/embeddings.ts +197 -0
- package/src/routes/images.ts +356 -0
- package/src/routes/mcp.ts +320 -0
- package/src/routes/mcpService.ts +114 -0
- package/src/routes/models.ts +50 -0
- package/src/routes/responses.ts +872 -0
- package/src/routes/sessions.ts +558 -0
- package/src/routes/stats.ts +312 -0
- package/src/routes/ui.ts +96 -0
- package/src/routes/videos.ts +132 -0
- package/src/routing/router.ts +501 -0
- package/src/services/imageGeneration.ts +396 -0
- package/src/services/imageUnderstanding.ts +449 -0
- package/src/services/videoGeneration.ts +127 -0
- package/src/storage/captureRepository.ts +1835 -0
- package/src/storage/files.ts +178 -0
- package/src/storage/imageCache.ts +405 -0
- package/src/storage/repositories.ts +494 -0
- package/src/storage/sessionRepository.ts +419 -0
- package/src/storage/statsRepository.ts +238 -0
- package/src/transport/httpClient.ts +145 -0
- package/src/types.ts +322 -0
- package/src/utils/messageMedia.ts +293 -0
- package/src/utils/modelCapabilities.ts +161 -0
- package/src/utils/modelDiscovery.ts +203 -0
- package/src/workers/captureRetention.ts +25 -0
- package/src/workers/configWatcher.ts +115 -0
- package/src/workers/healthChecker.ts +22 -0
- package/src/workers/statsRotation.ts +49 -0
- package/tests/benchmarkAdminRoutes.test.ts +82 -0
- package/tests/benchmarkBasics.test.ts +116 -0
- package/tests/captureAdminRoutes.test.ts +420 -0
- package/tests/captureRepository.test.ts +797 -0
- package/tests/cliLegacyRewrite.test.ts +45 -0
- package/tests/imageGeneration.service.test.ts +107 -0
- package/tests/imageUnderstanding.service.test.ts +123 -0
- package/tests/mcpPolicy.test.ts +105 -0
- package/tests/mcpService.test.ts +1245 -0
- package/tests/modelRef.test.ts +23 -0
- package/tests/modelsRoutes.test.ts +154 -0
- package/tests/sessionMediaCache.test.ts +167 -0
- package/tests/statsRoutes.test.ts +323 -0
- package/tsconfig.json +15 -0
- package/ui/index.html +16 -0
- package/ui/package-lock.json +8521 -0
- package/ui/package.json +52 -0
- package/ui/postcss.config.js +6 -0
- package/ui/public/assets/apple-touch-icon.png +0 -0
- package/ui/public/assets/favicon-16.png +0 -0
- package/ui/public/assets/favicon-32.png +0 -0
- package/ui/public/assets/icon-192.png +0 -0
- package/ui/public/assets/icon-512.png +0 -0
- package/ui/src/App.tsx +27 -0
- package/ui/src/api/client.ts +1503 -0
- package/ui/src/components/EndpointUsageGuide.tsx +361 -0
- package/ui/src/components/Layout.tsx +124 -0
- package/ui/src/components/MessageContent.tsx +365 -0
- package/ui/src/components/ToolCallMessage.tsx +179 -0
- package/ui/src/components/ToolPicker.tsx +442 -0
- package/ui/src/components/messageContentParser.test.ts +41 -0
- package/ui/src/components/messageContentParser.ts +73 -0
- package/ui/src/components/thinkingPreview.test.ts +27 -0
- package/ui/src/components/thinkingPreview.ts +15 -0
- package/ui/src/components/toMermaidSankey.test.ts +78 -0
- package/ui/src/components/toMermaidSankey.ts +56 -0
- package/ui/src/components/ui/button.tsx +58 -0
- package/ui/src/components/ui/input.tsx +21 -0
- package/ui/src/components/ui/textarea.tsx +21 -0
- package/ui/src/lib/utils.ts +6 -0
- package/ui/src/main.tsx +9 -0
- package/ui/src/pages/AgentPlayground.tsx +2010 -0
- package/ui/src/pages/Benchmark.tsx +988 -0
- package/ui/src/pages/Dashboard.tsx +581 -0
- package/ui/src/pages/Peek.tsx +962 -0
- package/ui/src/pages/Settings.tsx +2013 -0
- package/ui/src/pages/agentPlaygroundPayload.test.ts +109 -0
- package/ui/src/pages/agentPlaygroundPayload.ts +97 -0
- package/ui/src/pages/agentThinkingContent.test.ts +50 -0
- package/ui/src/pages/agentThinkingContent.ts +57 -0
- package/ui/src/pages/dashboardTokenUsage.test.ts +66 -0
- package/ui/src/pages/dashboardTokenUsage.ts +36 -0
- package/ui/src/pages/imageUpload.test.ts +39 -0
- package/ui/src/pages/imageUpload.ts +71 -0
- package/ui/src/pages/peekFilters.test.ts +29 -0
- package/ui/src/pages/peekFilters.ts +13 -0
- package/ui/src/pages/peekMedia.test.ts +58 -0
- package/ui/src/pages/peekMedia.ts +148 -0
- package/ui/src/pages/sessionAutoTitle.test.ts +128 -0
- package/ui/src/pages/sessionAutoTitle.ts +106 -0
- package/ui/src/stores/settings.ts +58 -0
- package/ui/src/styles/globals.css +223 -0
- package/ui/src/vite-env.d.ts +8 -0
- package/ui/tailwind.config.js +106 -0
- package/ui/tsconfig.json +32 -0
- package/ui/vite.config.ts +37 -0
|
@@ -0,0 +1,1591 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ensureCaptureStore = ensureCaptureStore;
|
|
7
|
+
exports.getCaptureConfig = getCaptureConfig;
|
|
8
|
+
exports.updateCaptureConfig = updateCaptureConfig;
|
|
9
|
+
exports.isCaptureEnabled = isCaptureEnabled;
|
|
10
|
+
exports.persistCaptureRecord = persistCaptureRecord;
|
|
11
|
+
exports.runCaptureRetention = runCaptureRetention;
|
|
12
|
+
exports.listCaptureRecords = listCaptureRecords;
|
|
13
|
+
exports.getCaptureRecordById = getCaptureRecordById;
|
|
14
|
+
exports.getCaptureCalendarMonth = getCaptureCalendarMonth;
|
|
15
|
+
exports.findCaptureBlobPath = findCaptureBlobPath;
|
|
16
|
+
const crypto_1 = require("crypto");
|
|
17
|
+
const fs_1 = require("fs");
|
|
18
|
+
const path_1 = __importDefault(require("path"));
|
|
19
|
+
const DEFAULT_CAPTURE_CONFIG = {
|
|
20
|
+
enabled: false,
|
|
21
|
+
retentionDays: 30,
|
|
22
|
+
maxBytes: 20 * 1024 * 1024 * 1024,
|
|
23
|
+
};
|
|
24
|
+
const DATA_URL_RE = /^data:([^;]+);base64,(.+)$/i;
|
|
25
|
+
function captureDir(paths) {
|
|
26
|
+
return path_1.default.join(paths.baseDir, "capture");
|
|
27
|
+
}
|
|
28
|
+
function captureConfigPath(paths) {
|
|
29
|
+
return path_1.default.join(captureDir(paths), "config.json");
|
|
30
|
+
}
|
|
31
|
+
function captureIndexPath(paths) {
|
|
32
|
+
return path_1.default.join(captureDir(paths), "index.jsonl");
|
|
33
|
+
}
|
|
34
|
+
function captureRecordsDir(paths) {
|
|
35
|
+
return path_1.default.join(captureDir(paths), "records");
|
|
36
|
+
}
|
|
37
|
+
function captureBlobsDir(paths) {
|
|
38
|
+
return path_1.default.join(captureDir(paths), "blobs");
|
|
39
|
+
}
|
|
40
|
+
async function ensureCaptureStore(paths) {
|
|
41
|
+
await fs_1.promises.mkdir(captureDir(paths), { recursive: true });
|
|
42
|
+
await fs_1.promises.mkdir(captureRecordsDir(paths), { recursive: true });
|
|
43
|
+
await fs_1.promises.mkdir(captureBlobsDir(paths), { recursive: true });
|
|
44
|
+
const configPath = captureConfigPath(paths);
|
|
45
|
+
try {
|
|
46
|
+
await fs_1.promises.access(configPath);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
await fs_1.promises.writeFile(configPath, JSON.stringify(DEFAULT_CAPTURE_CONFIG, null, 2), "utf8");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function getCaptureConfig(paths) {
|
|
53
|
+
await ensureCaptureStore(paths);
|
|
54
|
+
try {
|
|
55
|
+
const raw = await fs_1.promises.readFile(captureConfigPath(paths), "utf8");
|
|
56
|
+
const parsed = JSON.parse(raw);
|
|
57
|
+
return normalizeCaptureConfig(parsed);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return { ...DEFAULT_CAPTURE_CONFIG };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function updateCaptureConfig(paths, patch) {
|
|
64
|
+
const current = await getCaptureConfig(paths);
|
|
65
|
+
const next = normalizeCaptureConfig({ ...current, ...patch });
|
|
66
|
+
await fs_1.promises.writeFile(captureConfigPath(paths), JSON.stringify(next, null, 2), "utf8");
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
69
|
+
async function isCaptureEnabled(paths) {
|
|
70
|
+
const config = await getCaptureConfig(paths);
|
|
71
|
+
return config.enabled;
|
|
72
|
+
}
|
|
73
|
+
async function persistCaptureRecord(paths, input) {
|
|
74
|
+
const config = await getCaptureConfig(paths);
|
|
75
|
+
if (!config.enabled) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const id = (0, crypto_1.randomUUID)();
|
|
79
|
+
const now = new Date();
|
|
80
|
+
const timestamp = now.toISOString();
|
|
81
|
+
const artifacts = [];
|
|
82
|
+
const requestBodyPreview = await buildPreviewBody(paths, input.requestBody, artifacts);
|
|
83
|
+
const responseBodyPreview = await buildPreviewBody(paths, input.responseBody, artifacts);
|
|
84
|
+
const record = {
|
|
85
|
+
id,
|
|
86
|
+
timestamp,
|
|
87
|
+
route: input.route,
|
|
88
|
+
method: input.method,
|
|
89
|
+
captureEnabledSnapshot: true,
|
|
90
|
+
statusCode: input.statusCode,
|
|
91
|
+
latencyMs: input.latencyMs,
|
|
92
|
+
request: {
|
|
93
|
+
headers: normalizeHeaderRecord(input.requestHeaders),
|
|
94
|
+
body: input.requestBody,
|
|
95
|
+
derived: input.derivedRequest,
|
|
96
|
+
},
|
|
97
|
+
response: {
|
|
98
|
+
headers: normalizeHeaderRecord(input.responseHeaders),
|
|
99
|
+
body: input.responseBody,
|
|
100
|
+
error: input.error,
|
|
101
|
+
},
|
|
102
|
+
routing: input.routing ?? {},
|
|
103
|
+
analysis: buildAnalysisProjection(input.route, input.requestBody, input.responseBody, input.derivedRequest),
|
|
104
|
+
artifacts,
|
|
105
|
+
};
|
|
106
|
+
// Attach preview representations in derived block for UI readability.
|
|
107
|
+
if (requestBodyPreview !== undefined || responseBodyPreview !== undefined) {
|
|
108
|
+
record.request.derived = {
|
|
109
|
+
...(record.request.derived ?? {}),
|
|
110
|
+
preview: {
|
|
111
|
+
request: requestBodyPreview,
|
|
112
|
+
response: responseBodyPreview,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const datePath = path_1.default.join(captureRecordsDir(paths), `${now.getUTCFullYear()}`, `${String(now.getUTCMonth() + 1).padStart(2, "0")}`, `${String(now.getUTCDate()).padStart(2, "0")}`);
|
|
117
|
+
await fs_1.promises.mkdir(datePath, { recursive: true });
|
|
118
|
+
const fileName = `${timestamp.replace(/[:.]/g, "-")}_${id}.json`;
|
|
119
|
+
const absoluteRecordPath = path_1.default.join(datePath, fileName);
|
|
120
|
+
await fs_1.promises.writeFile(absoluteRecordPath, JSON.stringify(record, null, 2), "utf8");
|
|
121
|
+
const relRecordPath = path_1.default.relative(captureDir(paths), absoluteRecordPath);
|
|
122
|
+
const entry = {
|
|
123
|
+
id,
|
|
124
|
+
timestamp,
|
|
125
|
+
route: input.route,
|
|
126
|
+
method: input.method,
|
|
127
|
+
statusCode: input.statusCode,
|
|
128
|
+
latencyMs: input.latencyMs,
|
|
129
|
+
model: input.routing?.publicModel,
|
|
130
|
+
file: relRecordPath,
|
|
131
|
+
};
|
|
132
|
+
await fs_1.promises.appendFile(captureIndexPath(paths), `${JSON.stringify(entry)}\n`, "utf8");
|
|
133
|
+
await applyCaptureRetention(paths, config);
|
|
134
|
+
return record;
|
|
135
|
+
}
|
|
136
|
+
async function runCaptureRetention(paths) {
|
|
137
|
+
const config = await getCaptureConfig(paths);
|
|
138
|
+
await applyCaptureRetention(paths, config);
|
|
139
|
+
}
|
|
140
|
+
async function listCaptureRecords(paths, options = 5) {
|
|
141
|
+
await ensureCaptureStore(paths);
|
|
142
|
+
const entries = await readCaptureIndex(paths, { pruneMissing: true });
|
|
143
|
+
const opts = typeof options === "number"
|
|
144
|
+
? { limit: options, offset: 0 }
|
|
145
|
+
: { limit: 5, offset: 0, ...options };
|
|
146
|
+
const timeZone = normalizeTimeZone(opts.timeZone);
|
|
147
|
+
const limit = Math.max(1, Math.min(200, Math.floor(opts.limit ?? 5)));
|
|
148
|
+
const offset = Math.max(0, Math.floor(opts.offset ?? 0));
|
|
149
|
+
const filtered = opts.date
|
|
150
|
+
? entries.filter((entry) => dateStringForTimeZone(entry.timestamp, timeZone) === opts.date)
|
|
151
|
+
: entries;
|
|
152
|
+
const newestFirst = [...filtered].reverse();
|
|
153
|
+
return {
|
|
154
|
+
data: newestFirst.slice(offset, offset + limit),
|
|
155
|
+
total: filtered.length,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
async function getCaptureRecordById(paths, id) {
|
|
159
|
+
const entries = await readCaptureIndex(paths, { pruneMissing: true });
|
|
160
|
+
const match = entries.find((entry) => entry.id === id);
|
|
161
|
+
if (!match) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const absolute = path_1.default.join(captureDir(paths), match.file);
|
|
165
|
+
try {
|
|
166
|
+
const raw = await fs_1.promises.readFile(absolute, "utf8");
|
|
167
|
+
return hydrateCaptureRecord(JSON.parse(raw));
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function getCaptureCalendarMonth(paths, month, timeZone = "UTC") {
|
|
174
|
+
await ensureCaptureStore(paths);
|
|
175
|
+
const entries = await readCaptureIndex(paths, { pruneMissing: true });
|
|
176
|
+
const normalizedTimeZone = normalizeTimeZone(timeZone);
|
|
177
|
+
const counts = new Map();
|
|
178
|
+
for (const entry of entries) {
|
|
179
|
+
const date = dateStringForTimeZone(entry.timestamp, normalizedTimeZone);
|
|
180
|
+
if (!date.startsWith(`${month}-`))
|
|
181
|
+
continue;
|
|
182
|
+
counts.set(date, (counts.get(date) ?? 0) + 1);
|
|
183
|
+
}
|
|
184
|
+
return Array.from(counts.entries())
|
|
185
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
186
|
+
.map(([date, count]) => ({ date, count }));
|
|
187
|
+
}
|
|
188
|
+
async function findCaptureBlobPath(paths, hash) {
|
|
189
|
+
await ensureCaptureStore(paths);
|
|
190
|
+
const blobDir = captureBlobsDir(paths);
|
|
191
|
+
let files;
|
|
192
|
+
try {
|
|
193
|
+
files = await fs_1.promises.readdir(blobDir);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const candidate = files.find((name) => name.startsWith(`${hash}.`));
|
|
199
|
+
if (!candidate)
|
|
200
|
+
return null;
|
|
201
|
+
const ext = candidate.split(".").pop()?.toLowerCase() ?? "bin";
|
|
202
|
+
return {
|
|
203
|
+
path: path_1.default.join(blobDir, candidate),
|
|
204
|
+
mime: extToMime(ext),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function normalizeCaptureConfig(input) {
|
|
208
|
+
const retentionDays = Number.isFinite(input.retentionDays) ? Number(input.retentionDays) : DEFAULT_CAPTURE_CONFIG.retentionDays;
|
|
209
|
+
const maxBytes = Number.isFinite(input.maxBytes) ? Number(input.maxBytes) : DEFAULT_CAPTURE_CONFIG.maxBytes;
|
|
210
|
+
return {
|
|
211
|
+
enabled: input.enabled === true,
|
|
212
|
+
retentionDays: Math.max(1, Math.min(365, Math.floor(retentionDays))),
|
|
213
|
+
maxBytes: Math.max(50 * 1024 * 1024, Math.floor(maxBytes)),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function normalizeHeaderRecord(headers) {
|
|
217
|
+
const out = {};
|
|
218
|
+
if (!headers)
|
|
219
|
+
return out;
|
|
220
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
221
|
+
if (!value)
|
|
222
|
+
continue;
|
|
223
|
+
out[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : String(value);
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
function buildAnalysisProjection(route, requestBody, responseBody, derived) {
|
|
228
|
+
const source = derived?.normalizedRequest ?? asRecord(requestBody);
|
|
229
|
+
const messages = Array.isArray(source?.messages) ? source.messages : [];
|
|
230
|
+
const toolsRaw = Array.isArray(source?.tools) ? source.tools : [];
|
|
231
|
+
const systemMessages = [];
|
|
232
|
+
const userMessages = [];
|
|
233
|
+
const assistantMessages = [];
|
|
234
|
+
const toolMessages = [];
|
|
235
|
+
const tools = [];
|
|
236
|
+
const mcpToolDescriptions = [];
|
|
237
|
+
const hints = new Set();
|
|
238
|
+
const rawSections = [];
|
|
239
|
+
const requestTimeline = buildRequestTimeline(source, rawSections);
|
|
240
|
+
const responseTimeline = buildResponseTimeline(responseBody);
|
|
241
|
+
for (const message of messages) {
|
|
242
|
+
const m = asRecord(message);
|
|
243
|
+
if (!m)
|
|
244
|
+
continue;
|
|
245
|
+
const role = typeof m.role === "string" ? m.role : "unknown";
|
|
246
|
+
const content = extractTextContent(m.content);
|
|
247
|
+
const reasoningContent = typeof m.reasoning_content === "string" ? m.reasoning_content : undefined;
|
|
248
|
+
const combinedText = [content, reasoningContent].filter(Boolean).join("\n\n").trim();
|
|
249
|
+
if (combinedText && /(agents\.md|guardrail|mcp|tool|policy)/i.test(combinedText)) {
|
|
250
|
+
hints.add(combinedText);
|
|
251
|
+
}
|
|
252
|
+
if (role === "system" && content) {
|
|
253
|
+
systemMessages.push({ content });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (role === "user" && content) {
|
|
257
|
+
userMessages.push({ content });
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (role === "assistant") {
|
|
261
|
+
const assistantMessage = {
|
|
262
|
+
content,
|
|
263
|
+
};
|
|
264
|
+
if (reasoningContent)
|
|
265
|
+
assistantMessage.reasoningContent = reasoningContent;
|
|
266
|
+
const toolCalls = extractToolCalls(m.tool_calls);
|
|
267
|
+
if (toolCalls.length > 0)
|
|
268
|
+
assistantMessage.toolCalls = toolCalls;
|
|
269
|
+
if (looksLikeClarification(content))
|
|
270
|
+
assistantMessage.asksForClarification = true;
|
|
271
|
+
if (assistantMessage.content ||
|
|
272
|
+
assistantMessage.reasoningContent ||
|
|
273
|
+
(assistantMessage.toolCalls && assistantMessage.toolCalls.length > 0)) {
|
|
274
|
+
assistantMessages.push(assistantMessage);
|
|
275
|
+
}
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (role === "tool") {
|
|
279
|
+
const toolMessage = {
|
|
280
|
+
content,
|
|
281
|
+
};
|
|
282
|
+
if (typeof m.tool_call_id === "string")
|
|
283
|
+
toolMessage.toolCallId = m.tool_call_id;
|
|
284
|
+
if (toolMessage.content || toolMessage.toolCallId) {
|
|
285
|
+
toolMessages.push(toolMessage);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (const tool of toolsRaw) {
|
|
290
|
+
const t = asRecord(tool);
|
|
291
|
+
if (!t)
|
|
292
|
+
continue;
|
|
293
|
+
const fn = asRecord(t.function);
|
|
294
|
+
const name = typeof fn?.name === "string" ? fn.name : undefined;
|
|
295
|
+
const description = typeof fn?.description === "string" ? fn.description : undefined;
|
|
296
|
+
if (!name)
|
|
297
|
+
continue;
|
|
298
|
+
tools.push({ name, description });
|
|
299
|
+
if (description) {
|
|
300
|
+
mcpToolDescriptions.push(`${name}: ${description}`);
|
|
301
|
+
if (/(agents\.md|guardrail|mcp|policy)/i.test(description)) {
|
|
302
|
+
hints.add(description);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
systemMessages,
|
|
308
|
+
userMessages,
|
|
309
|
+
assistantMessages,
|
|
310
|
+
toolMessages,
|
|
311
|
+
requestTimeline,
|
|
312
|
+
responseTimeline,
|
|
313
|
+
tools,
|
|
314
|
+
mcpToolDescriptions,
|
|
315
|
+
agentsMdHints: Array.from(hints),
|
|
316
|
+
rawSections,
|
|
317
|
+
tokenFlow: buildTokenFlowProjection(route, source, responseBody),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function buildRequestTimeline(source, rawSections) {
|
|
321
|
+
const timeline = [];
|
|
322
|
+
if (!source)
|
|
323
|
+
return timeline;
|
|
324
|
+
const push = createTimelinePusher("request", timeline);
|
|
325
|
+
if (typeof source.instructions === "string") {
|
|
326
|
+
rawSections.push("request.body.instructions");
|
|
327
|
+
push({
|
|
328
|
+
kind: "instructions",
|
|
329
|
+
role: "system",
|
|
330
|
+
sourcePath: "request.body.instructions",
|
|
331
|
+
content: source.instructions,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
if (Array.isArray(source.messages)) {
|
|
335
|
+
rawSections.push("request.body.messages");
|
|
336
|
+
source.messages.forEach((message, idx) => pushMessageEntries(push, message, `request.body.messages[${idx}]`));
|
|
337
|
+
}
|
|
338
|
+
if (Array.isArray(source.input)) {
|
|
339
|
+
rawSections.push("request.body.input");
|
|
340
|
+
source.input.forEach((item, idx) => pushInputOutputEntry(push, item, `request.body.input[${idx}]`, "request"));
|
|
341
|
+
}
|
|
342
|
+
if (Array.isArray(source.tools)) {
|
|
343
|
+
rawSections.push("request.body.tools");
|
|
344
|
+
source.tools.forEach((tool, idx) => {
|
|
345
|
+
const t = asRecord(tool);
|
|
346
|
+
const fn = asRecord(t?.function);
|
|
347
|
+
const name = typeof fn?.name === "string" ? fn.name : undefined;
|
|
348
|
+
const description = typeof fn?.description === "string" ? fn.description : undefined;
|
|
349
|
+
push({
|
|
350
|
+
kind: "tool_definition",
|
|
351
|
+
sourcePath: `request.body.tools[${idx}]`,
|
|
352
|
+
name,
|
|
353
|
+
content: description,
|
|
354
|
+
metadata: t ?? undefined,
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
return timeline;
|
|
359
|
+
}
|
|
360
|
+
function buildResponseTimeline(responseBody) {
|
|
361
|
+
const timeline = [];
|
|
362
|
+
const push = createTimelinePusher("response", timeline);
|
|
363
|
+
const body = asRecord(responseBody);
|
|
364
|
+
if (!body) {
|
|
365
|
+
if (typeof responseBody === "string" && responseBody) {
|
|
366
|
+
push({ kind: "message", sourcePath: "response.body", content: responseBody });
|
|
367
|
+
}
|
|
368
|
+
return timeline;
|
|
369
|
+
}
|
|
370
|
+
if (asRecord(body.error)) {
|
|
371
|
+
const error = asRecord(body.error);
|
|
372
|
+
push({
|
|
373
|
+
kind: "error",
|
|
374
|
+
sourcePath: "response.body.error",
|
|
375
|
+
content: typeof error.message === "string" ? error.message : JSON.stringify(error, null, 2),
|
|
376
|
+
metadata: error,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
if (body.$type === "stream") {
|
|
380
|
+
const text = typeof body.text === "string" ? body.text : undefined;
|
|
381
|
+
if (text) {
|
|
382
|
+
const merged = buildMergedSsePreview(text);
|
|
383
|
+
if (merged) {
|
|
384
|
+
push({
|
|
385
|
+
kind: "stream_preview",
|
|
386
|
+
sourcePath: "response.body.stream",
|
|
387
|
+
content: merged.content,
|
|
388
|
+
metadata: merged.metadata,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
const streamedToolCalls = extractStreamToolCalls(text);
|
|
392
|
+
streamedToolCalls.forEach((toolCall, idx) => push({
|
|
393
|
+
kind: "tool_call",
|
|
394
|
+
role: "assistant",
|
|
395
|
+
sourcePath: `response.body.stream.tool_calls[${idx}]`,
|
|
396
|
+
name: toolCall.function?.name,
|
|
397
|
+
arguments: toolCall.function?.arguments,
|
|
398
|
+
toolCallId: toolCall.id,
|
|
399
|
+
metadata: toolCall.type ? { type: toolCall.type } : undefined,
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
push({
|
|
404
|
+
kind: "stream_preview",
|
|
405
|
+
sourcePath: "response.body",
|
|
406
|
+
content: typeof body.note === "string" ? body.note : "Stream response metadata only",
|
|
407
|
+
metadata: body,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
return timeline;
|
|
411
|
+
}
|
|
412
|
+
if (Array.isArray(body.output)) {
|
|
413
|
+
body.output.forEach((item, idx) => pushInputOutputEntry(push, item, `response.body.output[${idx}]`, "response"));
|
|
414
|
+
}
|
|
415
|
+
if (Array.isArray(body.choices)) {
|
|
416
|
+
body.choices.forEach((choice, idx) => {
|
|
417
|
+
const choiceRecord = asRecord(choice);
|
|
418
|
+
const message = asRecord(choiceRecord?.message);
|
|
419
|
+
if (message) {
|
|
420
|
+
pushMessageEntries(push, message, `response.body.choices[${idx}].message`);
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
if (timeline.length === 0) {
|
|
425
|
+
push({
|
|
426
|
+
kind: "message",
|
|
427
|
+
sourcePath: "response.body",
|
|
428
|
+
content: JSON.stringify(responseBody, null, 2),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return timeline;
|
|
432
|
+
}
|
|
433
|
+
function extractTextContent(content) {
|
|
434
|
+
if (typeof content === "string") {
|
|
435
|
+
return content;
|
|
436
|
+
}
|
|
437
|
+
if (!Array.isArray(content)) {
|
|
438
|
+
return "";
|
|
439
|
+
}
|
|
440
|
+
const chunks = [];
|
|
441
|
+
for (const part of content) {
|
|
442
|
+
const p = asRecord(part);
|
|
443
|
+
if (!p)
|
|
444
|
+
continue;
|
|
445
|
+
if (p.type === "text" && typeof p.text === "string") {
|
|
446
|
+
chunks.push(p.text);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return chunks.join(" ");
|
|
450
|
+
}
|
|
451
|
+
function extractToolCalls(value) {
|
|
452
|
+
if (!Array.isArray(value))
|
|
453
|
+
return [];
|
|
454
|
+
const calls = [];
|
|
455
|
+
for (const item of value) {
|
|
456
|
+
const record = asRecord(item);
|
|
457
|
+
if (!record)
|
|
458
|
+
continue;
|
|
459
|
+
const call = {};
|
|
460
|
+
if (typeof record.id === "string")
|
|
461
|
+
call.id = record.id;
|
|
462
|
+
if (typeof record.type === "string")
|
|
463
|
+
call.type = record.type;
|
|
464
|
+
const fn = asRecord(record.function);
|
|
465
|
+
if (fn) {
|
|
466
|
+
call.function = {};
|
|
467
|
+
if (typeof fn.name === "string")
|
|
468
|
+
call.function.name = fn.name;
|
|
469
|
+
if (typeof fn.arguments === "string")
|
|
470
|
+
call.function.arguments = fn.arguments;
|
|
471
|
+
if (!call.function.name && !call.function.arguments)
|
|
472
|
+
delete call.function;
|
|
473
|
+
}
|
|
474
|
+
calls.push(call);
|
|
475
|
+
}
|
|
476
|
+
return calls;
|
|
477
|
+
}
|
|
478
|
+
function pushMessageEntries(push, message, sourcePath) {
|
|
479
|
+
const m = asRecord(message);
|
|
480
|
+
if (!m)
|
|
481
|
+
return;
|
|
482
|
+
const role = normalizeRole(m.role);
|
|
483
|
+
const content = extractTextContent(m.content);
|
|
484
|
+
if (content) {
|
|
485
|
+
push({
|
|
486
|
+
kind: role === "tool" ? "tool_result" : "message",
|
|
487
|
+
role,
|
|
488
|
+
sourcePath,
|
|
489
|
+
content,
|
|
490
|
+
toolCallId: typeof m.tool_call_id === "string" ? m.tool_call_id : undefined,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
if (typeof m.reasoning_content === "string") {
|
|
494
|
+
push({
|
|
495
|
+
kind: "reasoning",
|
|
496
|
+
role: role ?? "assistant",
|
|
497
|
+
sourcePath: `${sourcePath}.reasoning_content`,
|
|
498
|
+
content: m.reasoning_content,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
const toolCalls = extractToolCalls(m.tool_calls);
|
|
502
|
+
toolCalls.forEach((toolCall, idx) => push({
|
|
503
|
+
kind: "tool_call",
|
|
504
|
+
role: "assistant",
|
|
505
|
+
sourcePath: `${sourcePath}.tool_calls[${idx}]`,
|
|
506
|
+
name: toolCall.function?.name,
|
|
507
|
+
arguments: toolCall.function?.arguments,
|
|
508
|
+
toolCallId: toolCall.id,
|
|
509
|
+
metadata: toolCall.type ? { type: toolCall.type } : undefined,
|
|
510
|
+
}));
|
|
511
|
+
}
|
|
512
|
+
function pushInputOutputEntry(push, item, sourcePath, direction) {
|
|
513
|
+
const record = asRecord(item);
|
|
514
|
+
if (!record) {
|
|
515
|
+
if (typeof item === "string") {
|
|
516
|
+
push({ kind: "message", sourcePath, content: item });
|
|
517
|
+
}
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const type = typeof record.type === "string" ? record.type : undefined;
|
|
521
|
+
if (type === "message") {
|
|
522
|
+
const role = normalizeRole(record.role);
|
|
523
|
+
const content = extractTextContentFromResponseItem(record);
|
|
524
|
+
if (content) {
|
|
525
|
+
push({ kind: "message", role, sourcePath, content });
|
|
526
|
+
}
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (type === "function_call") {
|
|
530
|
+
push({
|
|
531
|
+
kind: "tool_call",
|
|
532
|
+
role: direction === "response" ? "assistant" : undefined,
|
|
533
|
+
sourcePath,
|
|
534
|
+
name: typeof record.name === "string" ? record.name : undefined,
|
|
535
|
+
arguments: typeof record.arguments === "string" ? record.arguments : undefined,
|
|
536
|
+
toolCallId: typeof record.call_id === "string" ? record.call_id : undefined,
|
|
537
|
+
});
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (type === "function_call_output") {
|
|
541
|
+
push({
|
|
542
|
+
kind: "tool_result",
|
|
543
|
+
role: "tool",
|
|
544
|
+
sourcePath,
|
|
545
|
+
content: typeof record.output === "string" ? record.output : stringifyMaybe(record.output),
|
|
546
|
+
toolCallId: typeof record.call_id === "string" ? record.call_id : undefined,
|
|
547
|
+
});
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (type === "reasoning") {
|
|
551
|
+
push({
|
|
552
|
+
kind: "reasoning",
|
|
553
|
+
role: "assistant",
|
|
554
|
+
sourcePath,
|
|
555
|
+
content: extractReasoningItemText(record),
|
|
556
|
+
});
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
push({
|
|
560
|
+
kind: "message",
|
|
561
|
+
sourcePath,
|
|
562
|
+
content: stringifyMaybe(record),
|
|
563
|
+
metadata: type ? { type } : undefined,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
function looksLikeClarification(content) {
|
|
567
|
+
const text = content.trim();
|
|
568
|
+
if (!text)
|
|
569
|
+
return false;
|
|
570
|
+
return /(?:^|\b)(could you|can you|would you|which|what|do you want|please clarify|clarify)\b/i.test(text)
|
|
571
|
+
|| text.includes("?");
|
|
572
|
+
}
|
|
573
|
+
function createTimelinePusher(direction, timeline) {
|
|
574
|
+
return (entry) => {
|
|
575
|
+
timeline.push({
|
|
576
|
+
direction,
|
|
577
|
+
index: timeline.length,
|
|
578
|
+
...entry,
|
|
579
|
+
});
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
function extractTextContentFromResponseItem(item) {
|
|
583
|
+
const content = item.content;
|
|
584
|
+
if (typeof content === "string")
|
|
585
|
+
return content;
|
|
586
|
+
if (!Array.isArray(content))
|
|
587
|
+
return "";
|
|
588
|
+
const chunks = [];
|
|
589
|
+
for (const part of content) {
|
|
590
|
+
const record = asRecord(part);
|
|
591
|
+
if (!record)
|
|
592
|
+
continue;
|
|
593
|
+
if (typeof record.text === "string")
|
|
594
|
+
chunks.push(record.text);
|
|
595
|
+
else if (typeof record.content === "string")
|
|
596
|
+
chunks.push(record.content);
|
|
597
|
+
}
|
|
598
|
+
return chunks.join("\n\n");
|
|
599
|
+
}
|
|
600
|
+
function extractReasoningItemText(item) {
|
|
601
|
+
if (typeof item.summary === "string")
|
|
602
|
+
return item.summary;
|
|
603
|
+
if (Array.isArray(item.summary)) {
|
|
604
|
+
return item.summary
|
|
605
|
+
.map((part) => {
|
|
606
|
+
const record = asRecord(part);
|
|
607
|
+
if (!record)
|
|
608
|
+
return "";
|
|
609
|
+
if (typeof record.text === "string")
|
|
610
|
+
return record.text;
|
|
611
|
+
return "";
|
|
612
|
+
})
|
|
613
|
+
.filter(Boolean)
|
|
614
|
+
.join("\n");
|
|
615
|
+
}
|
|
616
|
+
if (typeof item.content === "string")
|
|
617
|
+
return item.content;
|
|
618
|
+
return stringifyMaybe(item);
|
|
619
|
+
}
|
|
620
|
+
function parseSsePreview(text) {
|
|
621
|
+
const entries = [];
|
|
622
|
+
const blocks = text.split(/\n\n+/).map((part) => part.trim()).filter(Boolean);
|
|
623
|
+
for (const block of blocks) {
|
|
624
|
+
const dataLines = block
|
|
625
|
+
.split("\n")
|
|
626
|
+
.filter((line) => line.startsWith("data:"))
|
|
627
|
+
.map((line) => line.slice(5).trim());
|
|
628
|
+
if (dataLines.length === 0)
|
|
629
|
+
continue;
|
|
630
|
+
const joined = dataLines.join("\n");
|
|
631
|
+
try {
|
|
632
|
+
const parsed = JSON.parse(joined);
|
|
633
|
+
entries.push({
|
|
634
|
+
content: stringifyMaybe(parsed),
|
|
635
|
+
metadata: parsed,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
entries.push({ content: joined });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return entries;
|
|
643
|
+
}
|
|
644
|
+
function buildMergedSsePreview(text) {
|
|
645
|
+
const entries = parseSsePreview(text);
|
|
646
|
+
if (entries.length === 0)
|
|
647
|
+
return null;
|
|
648
|
+
const mergedText = mergeUsefulSseText(entries);
|
|
649
|
+
if (mergedText.trim()) {
|
|
650
|
+
return {
|
|
651
|
+
content: mergedText,
|
|
652
|
+
metadata: { events: entries.length, mode: "merged_text" },
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
return {
|
|
656
|
+
content: entries.map((entry) => entry.content).join("\n\n"),
|
|
657
|
+
metadata: { events: entries.length, mode: "merged_events" },
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
function extractMergedUsefulSseText(text) {
|
|
661
|
+
return mergeUsefulSseText(parseSsePreview(text));
|
|
662
|
+
}
|
|
663
|
+
function mergeUsefulSseText(entries) {
|
|
664
|
+
const textChunks = [];
|
|
665
|
+
for (const entry of entries) {
|
|
666
|
+
const extracted = extractStreamText(entry.metadata);
|
|
667
|
+
if (extracted) {
|
|
668
|
+
textChunks.push(extracted);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return textChunks.join("");
|
|
672
|
+
}
|
|
673
|
+
function mergeSseEventPayloadText(text) {
|
|
674
|
+
const entries = parseSsePreview(text);
|
|
675
|
+
if (entries.length === 0)
|
|
676
|
+
return "";
|
|
677
|
+
return entries.map((entry) => entry.content).join("\n\n");
|
|
678
|
+
}
|
|
679
|
+
function extractStreamText(value) {
|
|
680
|
+
const record = asRecord(value);
|
|
681
|
+
if (!record)
|
|
682
|
+
return "";
|
|
683
|
+
if (typeof record.delta === "string") {
|
|
684
|
+
return record.delta;
|
|
685
|
+
}
|
|
686
|
+
if (typeof record.text === "string") {
|
|
687
|
+
return record.text;
|
|
688
|
+
}
|
|
689
|
+
const choices = Array.isArray(record.choices) ? record.choices : [];
|
|
690
|
+
const choiceChunks = [];
|
|
691
|
+
for (const choice of choices) {
|
|
692
|
+
const choiceRecord = asRecord(choice);
|
|
693
|
+
if (!choiceRecord)
|
|
694
|
+
continue;
|
|
695
|
+
const delta = asRecord(choiceRecord.delta);
|
|
696
|
+
if (typeof delta?.content === "string")
|
|
697
|
+
choiceChunks.push(delta.content);
|
|
698
|
+
if (typeof delta?.reasoning_content === "string")
|
|
699
|
+
choiceChunks.push(delta.reasoning_content);
|
|
700
|
+
const message = asRecord(choiceRecord.message);
|
|
701
|
+
if (typeof message?.content === "string")
|
|
702
|
+
choiceChunks.push(message.content);
|
|
703
|
+
}
|
|
704
|
+
if (choiceChunks.length > 0) {
|
|
705
|
+
return choiceChunks.join("");
|
|
706
|
+
}
|
|
707
|
+
if (typeof record.type === "string") {
|
|
708
|
+
if (record.type === "response.output_text.delta" ||
|
|
709
|
+
record.type === "response.reasoning.delta" ||
|
|
710
|
+
record.type === "response.output_text") {
|
|
711
|
+
if (typeof record.delta === "string")
|
|
712
|
+
return record.delta;
|
|
713
|
+
if (typeof record.text === "string")
|
|
714
|
+
return record.text;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
const output = Array.isArray(record.output) ? record.output : [];
|
|
718
|
+
const outputChunks = [];
|
|
719
|
+
for (const item of output) {
|
|
720
|
+
const itemRecord = asRecord(item);
|
|
721
|
+
if (!itemRecord)
|
|
722
|
+
continue;
|
|
723
|
+
const content = itemRecord.content;
|
|
724
|
+
if (typeof content === "string") {
|
|
725
|
+
outputChunks.push(content);
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
if (Array.isArray(content)) {
|
|
729
|
+
for (const part of content) {
|
|
730
|
+
const partRecord = asRecord(part);
|
|
731
|
+
if (!partRecord)
|
|
732
|
+
continue;
|
|
733
|
+
if (typeof partRecord.text === "string")
|
|
734
|
+
outputChunks.push(partRecord.text);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return outputChunks.join("");
|
|
739
|
+
}
|
|
740
|
+
function extractStreamToolCalls(text) {
|
|
741
|
+
const entries = parseSsePreview(text);
|
|
742
|
+
const byIndex = new Map();
|
|
743
|
+
const order = [];
|
|
744
|
+
const ensureBuilder = (index) => {
|
|
745
|
+
const existing = byIndex.get(index);
|
|
746
|
+
if (existing)
|
|
747
|
+
return existing;
|
|
748
|
+
const created = { functionName: "", functionArguments: "" };
|
|
749
|
+
byIndex.set(index, created);
|
|
750
|
+
order.push(index);
|
|
751
|
+
return created;
|
|
752
|
+
};
|
|
753
|
+
for (const entry of entries) {
|
|
754
|
+
const payload = asRecord(entry.metadata);
|
|
755
|
+
if (!payload)
|
|
756
|
+
continue;
|
|
757
|
+
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
|
758
|
+
for (const choice of choices) {
|
|
759
|
+
const choiceRecord = asRecord(choice);
|
|
760
|
+
if (!choiceRecord)
|
|
761
|
+
continue;
|
|
762
|
+
const delta = asRecord(choiceRecord.delta);
|
|
763
|
+
if (!delta)
|
|
764
|
+
continue;
|
|
765
|
+
const deltaToolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
|
|
766
|
+
for (const item of deltaToolCalls) {
|
|
767
|
+
const callRecord = asRecord(item);
|
|
768
|
+
if (!callRecord)
|
|
769
|
+
continue;
|
|
770
|
+
const index = typeof callRecord.index === "number" ? Math.max(0, Math.floor(callRecord.index)) : 0;
|
|
771
|
+
const builder = ensureBuilder(index);
|
|
772
|
+
if (typeof callRecord.id === "string")
|
|
773
|
+
builder.id = callRecord.id;
|
|
774
|
+
if (typeof callRecord.type === "string")
|
|
775
|
+
builder.type = callRecord.type;
|
|
776
|
+
const fn = asRecord(callRecord.function);
|
|
777
|
+
if (fn) {
|
|
778
|
+
if (typeof fn.name === "string")
|
|
779
|
+
builder.functionName += fn.name;
|
|
780
|
+
if (typeof fn.arguments === "string")
|
|
781
|
+
builder.functionArguments += fn.arguments;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const legacyFunctionCall = asRecord(delta.function_call);
|
|
785
|
+
if (legacyFunctionCall) {
|
|
786
|
+
const builder = ensureBuilder(0);
|
|
787
|
+
builder.type = builder.type ?? "function";
|
|
788
|
+
if (typeof legacyFunctionCall.name === "string")
|
|
789
|
+
builder.functionName += legacyFunctionCall.name;
|
|
790
|
+
if (typeof legacyFunctionCall.arguments === "string")
|
|
791
|
+
builder.functionArguments += legacyFunctionCall.arguments;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
const calls = [];
|
|
796
|
+
for (const index of order) {
|
|
797
|
+
const built = byIndex.get(index);
|
|
798
|
+
if (!built)
|
|
799
|
+
continue;
|
|
800
|
+
const call = {};
|
|
801
|
+
if (built.id)
|
|
802
|
+
call.id = built.id;
|
|
803
|
+
if (built.type)
|
|
804
|
+
call.type = built.type;
|
|
805
|
+
const hasName = built.functionName.trim().length > 0;
|
|
806
|
+
const hasArguments = built.functionArguments.length > 0;
|
|
807
|
+
if (hasName || hasArguments) {
|
|
808
|
+
call.function = {};
|
|
809
|
+
if (hasName)
|
|
810
|
+
call.function.name = built.functionName;
|
|
811
|
+
if (hasArguments)
|
|
812
|
+
call.function.arguments = built.functionArguments;
|
|
813
|
+
}
|
|
814
|
+
if (call.id || call.type || call.function) {
|
|
815
|
+
calls.push(call);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return calls;
|
|
819
|
+
}
|
|
820
|
+
const INPUT_TOKEN_CATEGORIES = [
|
|
821
|
+
{ key: "instructions", label: "Instructions" },
|
|
822
|
+
{ key: "system", label: "System" },
|
|
823
|
+
{ key: "developer", label: "Developer" },
|
|
824
|
+
{ key: "user", label: "User" },
|
|
825
|
+
{ key: "assistant_history", label: "Assistant History" },
|
|
826
|
+
{ key: "input_media", label: "Input Media" },
|
|
827
|
+
{ key: "tool_results", label: "Tool Results" },
|
|
828
|
+
{ key: "tool_definitions", label: "Tool Definitions" },
|
|
829
|
+
{ key: "unattributed_input", label: "Unattributed Input" },
|
|
830
|
+
];
|
|
831
|
+
const OUTPUT_TOKEN_CATEGORIES = [
|
|
832
|
+
{ key: "assistant_text", label: "Assistant Text" },
|
|
833
|
+
{ key: "reasoning", label: "Reasoning" },
|
|
834
|
+
{ key: "tool_calls", label: "Tool Calls" },
|
|
835
|
+
{ key: "tool_results", label: "Tool Results" },
|
|
836
|
+
{ key: "errors", label: "Errors" },
|
|
837
|
+
{ key: "unattributed_output", label: "Unattributed Output" },
|
|
838
|
+
];
|
|
839
|
+
function buildTokenFlowProjection(route, source, responseBody) {
|
|
840
|
+
if (!route.startsWith("/v1/chat/completions")) {
|
|
841
|
+
return {
|
|
842
|
+
eligible: false,
|
|
843
|
+
reason: "Token flow is available only for /v1/chat/completions captures.",
|
|
844
|
+
method: "unavailable",
|
|
845
|
+
totals: { inputTokens: null, outputTokens: null, totalTokens: null },
|
|
846
|
+
input: INPUT_TOKEN_CATEGORIES.map((category) => ({ ...category, tokens: 0 })),
|
|
847
|
+
output: OUTPUT_TOKEN_CATEGORIES.map((category) => ({ ...category, tokens: 0 })),
|
|
848
|
+
notes: ["Route is not eligible for token flow analysis."],
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
const usage = extractTokenUsageTotals(responseBody);
|
|
852
|
+
const estimatedInput = estimateInputCategoryTokens(source);
|
|
853
|
+
const estimatedOutput = estimateOutputCategoryTokens(responseBody);
|
|
854
|
+
const streamSource = estimatedOutput.streamSource;
|
|
855
|
+
const estimatedOutputTokens = estimatedOutput.tokens;
|
|
856
|
+
const hasExactSides = usage.inputTokens !== null && usage.outputTokens !== null;
|
|
857
|
+
if (hasExactSides) {
|
|
858
|
+
const inputExact = usage.inputTokens;
|
|
859
|
+
const outputExact = usage.outputTokens;
|
|
860
|
+
const inputBuckets = scaleCategoryTokens(INPUT_TOKEN_CATEGORIES, estimatedInput, inputExact);
|
|
861
|
+
const outputBuckets = scaleCategoryTokens(OUTPUT_TOKEN_CATEGORIES, estimatedOutputTokens, outputExact);
|
|
862
|
+
const totalTokens = usage.totalTokens ?? inputExact + outputExact;
|
|
863
|
+
const notes = [
|
|
864
|
+
"Input/output totals are exact from response usage.",
|
|
865
|
+
"Category slices are estimated from captured content structure.",
|
|
866
|
+
];
|
|
867
|
+
if (streamSource === "timeline_text_with_tool_calls") {
|
|
868
|
+
notes.push("For streamed responses, output categories are estimated from extracted timeline-equivalent SSE text and reconstructed streamed tool-call deltas.");
|
|
869
|
+
}
|
|
870
|
+
else if (streamSource === "timeline_text") {
|
|
871
|
+
notes.push("For streamed responses, output categories are estimated from extracted timeline-equivalent SSE text.");
|
|
872
|
+
}
|
|
873
|
+
else if (streamSource === "timeline_tool_calls_only") {
|
|
874
|
+
notes.push("For streamed responses, output categories are estimated from reconstructed streamed tool-call deltas.");
|
|
875
|
+
}
|
|
876
|
+
else if (streamSource === "fallback_events") {
|
|
877
|
+
notes.push("For streamed responses, no useful SSE text was extracted; output fallback uses merged SSE event payload text (lower confidence).");
|
|
878
|
+
}
|
|
879
|
+
return {
|
|
880
|
+
eligible: true,
|
|
881
|
+
method: "exact_totals_estimated_categories",
|
|
882
|
+
totals: {
|
|
883
|
+
inputTokens: inputExact,
|
|
884
|
+
outputTokens: outputExact,
|
|
885
|
+
totalTokens,
|
|
886
|
+
},
|
|
887
|
+
input: inputBuckets,
|
|
888
|
+
output: outputBuckets,
|
|
889
|
+
notes,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
const rawInputTotal = sumTokenMapValues(estimatedInput);
|
|
893
|
+
const rawOutputTotal = sumTokenMapValues(estimatedOutputTokens);
|
|
894
|
+
let estimatedInputTotal = rawInputTotal;
|
|
895
|
+
let estimatedOutputTotal = rawOutputTotal;
|
|
896
|
+
if (usage.totalTokens !== null && usage.totalTokens > 0 && rawInputTotal + rawOutputTotal > 0) {
|
|
897
|
+
const split = splitTotalByWeights(usage.totalTokens, [rawInputTotal, rawOutputTotal]);
|
|
898
|
+
estimatedInputTotal = split[0];
|
|
899
|
+
estimatedOutputTotal = split[1];
|
|
900
|
+
}
|
|
901
|
+
const inputBuckets = estimatedInputTotal !== rawInputTotal
|
|
902
|
+
? scaleCategoryTokens(INPUT_TOKEN_CATEGORIES, estimatedInput, estimatedInputTotal)
|
|
903
|
+
: mapTokensToBuckets(INPUT_TOKEN_CATEGORIES, estimatedInput);
|
|
904
|
+
const outputBuckets = estimatedOutputTotal !== rawOutputTotal
|
|
905
|
+
? scaleCategoryTokens(OUTPUT_TOKEN_CATEGORIES, estimatedOutputTokens, estimatedOutputTotal)
|
|
906
|
+
: mapTokensToBuckets(OUTPUT_TOKEN_CATEGORIES, estimatedOutputTokens);
|
|
907
|
+
const notes = [
|
|
908
|
+
"Input/output totals are estimated from captured content.",
|
|
909
|
+
"Category slices are estimated from captured content structure.",
|
|
910
|
+
];
|
|
911
|
+
if (streamSource === "timeline_text_with_tool_calls") {
|
|
912
|
+
notes.push("For streamed responses, output categories are estimated from extracted timeline-equivalent SSE text and reconstructed streamed tool-call deltas.");
|
|
913
|
+
}
|
|
914
|
+
else if (streamSource === "timeline_text") {
|
|
915
|
+
notes.push("For streamed responses, output categories are estimated from extracted timeline-equivalent SSE text.");
|
|
916
|
+
}
|
|
917
|
+
else if (streamSource === "timeline_tool_calls_only") {
|
|
918
|
+
notes.push("For streamed responses, output categories are estimated from reconstructed streamed tool-call deltas.");
|
|
919
|
+
}
|
|
920
|
+
else if (streamSource === "fallback_events") {
|
|
921
|
+
notes.push("For streamed responses, no useful SSE text was extracted; output fallback uses merged SSE event payload text (lower confidence).");
|
|
922
|
+
}
|
|
923
|
+
return {
|
|
924
|
+
eligible: true,
|
|
925
|
+
method: "estimated_only",
|
|
926
|
+
totals: {
|
|
927
|
+
inputTokens: estimatedInputTotal,
|
|
928
|
+
outputTokens: estimatedOutputTotal,
|
|
929
|
+
totalTokens: usage.totalTokens ?? estimatedInputTotal + estimatedOutputTotal,
|
|
930
|
+
},
|
|
931
|
+
input: inputBuckets,
|
|
932
|
+
output: outputBuckets,
|
|
933
|
+
notes,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
function extractTokenUsageTotals(responseBody) {
|
|
937
|
+
const body = asRecord(responseBody);
|
|
938
|
+
const usage = asRecord(body?.usage);
|
|
939
|
+
if (!usage) {
|
|
940
|
+
return { inputTokens: null, outputTokens: null, totalTokens: null };
|
|
941
|
+
}
|
|
942
|
+
const promptTokens = toNullableInt(usage.prompt_tokens);
|
|
943
|
+
const completionTokens = toNullableInt(usage.completion_tokens);
|
|
944
|
+
const inputTokens = toNullableInt(usage.input_tokens);
|
|
945
|
+
const outputTokens = toNullableInt(usage.output_tokens);
|
|
946
|
+
const totalTokens = toNullableInt(usage.total_tokens);
|
|
947
|
+
const normalizedInput = promptTokens ?? inputTokens;
|
|
948
|
+
const normalizedOutput = completionTokens ?? outputTokens;
|
|
949
|
+
const normalizedTotal = totalTokens ?? (normalizedInput !== null && normalizedOutput !== null ? normalizedInput + normalizedOutput : null);
|
|
950
|
+
return {
|
|
951
|
+
inputTokens: normalizedInput,
|
|
952
|
+
outputTokens: normalizedOutput,
|
|
953
|
+
totalTokens: normalizedTotal,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
function estimateInputCategoryTokens(source) {
|
|
957
|
+
const tokens = initCategoryTokenMap(INPUT_TOKEN_CATEGORIES);
|
|
958
|
+
if (!source) {
|
|
959
|
+
return tokens;
|
|
960
|
+
}
|
|
961
|
+
if (typeof source.instructions === "string") {
|
|
962
|
+
tokens.instructions += roughTokenEstimate(source.instructions);
|
|
963
|
+
}
|
|
964
|
+
if (Array.isArray(source.messages)) {
|
|
965
|
+
for (const message of source.messages) {
|
|
966
|
+
const record = asRecord(message);
|
|
967
|
+
if (!record)
|
|
968
|
+
continue;
|
|
969
|
+
const role = normalizeRole(record.role);
|
|
970
|
+
const text = extractTextContent(record.content);
|
|
971
|
+
tokens.input_media += estimateMediaTokensFromContent(record.content);
|
|
972
|
+
if (role === "system")
|
|
973
|
+
tokens.system += roughTokenEstimate(text);
|
|
974
|
+
else if (role === "developer")
|
|
975
|
+
tokens.developer += roughTokenEstimate(text);
|
|
976
|
+
else if (role === "user")
|
|
977
|
+
tokens.user += roughTokenEstimate(text);
|
|
978
|
+
else if (role === "assistant")
|
|
979
|
+
tokens.assistant_history += roughTokenEstimate(text);
|
|
980
|
+
else if (role === "tool")
|
|
981
|
+
tokens.tool_results += roughTokenEstimate(text);
|
|
982
|
+
else
|
|
983
|
+
tokens.unattributed_input += roughTokenEstimate(text);
|
|
984
|
+
if (role === "assistant" && typeof record.reasoning_content === "string") {
|
|
985
|
+
tokens.assistant_history += roughTokenEstimate(record.reasoning_content);
|
|
986
|
+
}
|
|
987
|
+
const toolCalls = extractToolCalls(record.tool_calls);
|
|
988
|
+
for (const toolCall of toolCalls) {
|
|
989
|
+
tokens.assistant_history += roughTokenEstimate(`${toolCall.function?.name ?? ""} ${toolCall.function?.arguments ?? ""}`.trim());
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
if (Array.isArray(source.input)) {
|
|
994
|
+
for (const item of source.input) {
|
|
995
|
+
const record = asRecord(item);
|
|
996
|
+
if (!record) {
|
|
997
|
+
if (typeof item === "string") {
|
|
998
|
+
tokens.user += roughTokenEstimate(item);
|
|
999
|
+
}
|
|
1000
|
+
else {
|
|
1001
|
+
tokens.unattributed_input += roughTokenEstimate(stringifyMaybe(item));
|
|
1002
|
+
}
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
const type = typeof record.type === "string" ? record.type : "";
|
|
1006
|
+
if (type === "message") {
|
|
1007
|
+
const role = normalizeRole(record.role);
|
|
1008
|
+
const text = extractTextContent(record.content);
|
|
1009
|
+
tokens.input_media += estimateMediaTokensFromContent(record.content);
|
|
1010
|
+
if (role === "system")
|
|
1011
|
+
tokens.system += roughTokenEstimate(text);
|
|
1012
|
+
else if (role === "developer")
|
|
1013
|
+
tokens.developer += roughTokenEstimate(text);
|
|
1014
|
+
else if (role === "user")
|
|
1015
|
+
tokens.user += roughTokenEstimate(text);
|
|
1016
|
+
else if (role === "assistant")
|
|
1017
|
+
tokens.assistant_history += roughTokenEstimate(text);
|
|
1018
|
+
else if (role === "tool")
|
|
1019
|
+
tokens.tool_results += roughTokenEstimate(text);
|
|
1020
|
+
else
|
|
1021
|
+
tokens.unattributed_input += roughTokenEstimate(text);
|
|
1022
|
+
}
|
|
1023
|
+
else if (isMediaInputItem(record)) {
|
|
1024
|
+
tokens.input_media += estimateMediaTokensFromItem(record);
|
|
1025
|
+
}
|
|
1026
|
+
else if (type === "function_call_output") {
|
|
1027
|
+
tokens.tool_results += roughTokenEstimate(stringifyMaybe(record.output));
|
|
1028
|
+
}
|
|
1029
|
+
else if (type === "function_call") {
|
|
1030
|
+
tokens.assistant_history += roughTokenEstimate(`${record.name ?? ""} ${record.arguments ?? ""}`.trim());
|
|
1031
|
+
}
|
|
1032
|
+
else {
|
|
1033
|
+
tokens.unattributed_input += roughTokenEstimate(stringifyMaybe(record));
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (Array.isArray(source.tools)) {
|
|
1038
|
+
for (const tool of source.tools) {
|
|
1039
|
+
const record = asRecord(tool);
|
|
1040
|
+
if (!record)
|
|
1041
|
+
continue;
|
|
1042
|
+
const fn = asRecord(record.function);
|
|
1043
|
+
const name = typeof fn?.name === "string" ? fn.name : "";
|
|
1044
|
+
const description = typeof fn?.description === "string" ? fn.description : "";
|
|
1045
|
+
const parameters = fn?.parameters ? stringifyMaybe(fn.parameters) : "";
|
|
1046
|
+
tokens.tool_definitions += roughTokenEstimate(`${name}\n${description}\n${parameters}`.trim());
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
return tokens;
|
|
1050
|
+
}
|
|
1051
|
+
function estimateMediaTokensFromContent(content) {
|
|
1052
|
+
if (!Array.isArray(content))
|
|
1053
|
+
return 0;
|
|
1054
|
+
let total = 0;
|
|
1055
|
+
for (const part of content) {
|
|
1056
|
+
const record = asRecord(part);
|
|
1057
|
+
if (!record)
|
|
1058
|
+
continue;
|
|
1059
|
+
if (isTextPart(record))
|
|
1060
|
+
continue;
|
|
1061
|
+
if (isMediaInputItem(record)) {
|
|
1062
|
+
total += estimateMediaTokensFromItem(record);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
return total;
|
|
1066
|
+
}
|
|
1067
|
+
function isTextPart(record) {
|
|
1068
|
+
const type = typeof record.type === "string" ? record.type : "";
|
|
1069
|
+
return type === "text" || type === "input_text" || type === "output_text";
|
|
1070
|
+
}
|
|
1071
|
+
function isMediaInputItem(record) {
|
|
1072
|
+
const type = typeof record.type === "string" ? record.type.toLowerCase() : "";
|
|
1073
|
+
if (type.includes("image") || type.includes("audio"))
|
|
1074
|
+
return true;
|
|
1075
|
+
return (record.image_url !== undefined ||
|
|
1076
|
+
record.input_image !== undefined ||
|
|
1077
|
+
record.image !== undefined ||
|
|
1078
|
+
record.input_audio !== undefined ||
|
|
1079
|
+
record.audio !== undefined);
|
|
1080
|
+
}
|
|
1081
|
+
function estimateMediaTokensFromItem(record) {
|
|
1082
|
+
const type = typeof record.type === "string" ? record.type.toLowerCase() : "";
|
|
1083
|
+
if (type.includes("image") || record.image_url !== undefined || record.input_image !== undefined || record.image !== undefined) {
|
|
1084
|
+
return estimateImageTokens(record);
|
|
1085
|
+
}
|
|
1086
|
+
if (type.includes("audio") || record.input_audio !== undefined || record.audio !== undefined) {
|
|
1087
|
+
return 256;
|
|
1088
|
+
}
|
|
1089
|
+
return 128;
|
|
1090
|
+
}
|
|
1091
|
+
function estimateImageTokens(record) {
|
|
1092
|
+
let detail = record.detail;
|
|
1093
|
+
const imageUrl = asRecord(record.image_url);
|
|
1094
|
+
if (detail === undefined && imageUrl)
|
|
1095
|
+
detail = imageUrl.detail;
|
|
1096
|
+
if (detail === "high")
|
|
1097
|
+
return 768;
|
|
1098
|
+
if (detail === "low")
|
|
1099
|
+
return 128;
|
|
1100
|
+
return 256;
|
|
1101
|
+
}
|
|
1102
|
+
function estimateOutputCategoryTokens(responseBody) {
|
|
1103
|
+
const tokens = initCategoryTokenMap(OUTPUT_TOKEN_CATEGORIES);
|
|
1104
|
+
const body = asRecord(responseBody);
|
|
1105
|
+
if (!body) {
|
|
1106
|
+
tokens.unattributed_output += roughTokenEstimate(stringifyMaybe(responseBody));
|
|
1107
|
+
return { tokens };
|
|
1108
|
+
}
|
|
1109
|
+
const error = asRecord(body.error);
|
|
1110
|
+
if (error) {
|
|
1111
|
+
tokens.errors += roughTokenEstimate(typeof error.message === "string" ? error.message : stringifyMaybe(error));
|
|
1112
|
+
}
|
|
1113
|
+
if (body.$type === "stream") {
|
|
1114
|
+
if (typeof body.text === "string") {
|
|
1115
|
+
const usefulText = extractMergedUsefulSseText(body.text);
|
|
1116
|
+
const streamedToolCalls = extractStreamToolCalls(body.text);
|
|
1117
|
+
const hasUsefulText = usefulText.trim().length > 0;
|
|
1118
|
+
const hasToolCalls = streamedToolCalls.length > 0;
|
|
1119
|
+
if (hasUsefulText) {
|
|
1120
|
+
tokens.assistant_text += roughTokenEstimate(usefulText);
|
|
1121
|
+
}
|
|
1122
|
+
if (hasToolCalls) {
|
|
1123
|
+
for (const toolCall of streamedToolCalls) {
|
|
1124
|
+
const callText = `${toolCall.function?.name ?? ""} ${toolCall.function?.arguments ?? ""}`.trim();
|
|
1125
|
+
tokens.tool_calls += roughTokenEstimate(callText);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
if (hasUsefulText && hasToolCalls) {
|
|
1129
|
+
return { tokens, streamSource: "timeline_text_with_tool_calls" };
|
|
1130
|
+
}
|
|
1131
|
+
if (hasUsefulText) {
|
|
1132
|
+
return { tokens, streamSource: "timeline_text" };
|
|
1133
|
+
}
|
|
1134
|
+
if (hasToolCalls) {
|
|
1135
|
+
return { tokens, streamSource: "timeline_tool_calls_only" };
|
|
1136
|
+
}
|
|
1137
|
+
const fallback = mergeSseEventPayloadText(body.text);
|
|
1138
|
+
if (fallback.trim()) {
|
|
1139
|
+
tokens.unattributed_output += roughTokenEstimate(fallback);
|
|
1140
|
+
return { tokens, streamSource: "fallback_events" };
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
else if (typeof body.note === "string") {
|
|
1144
|
+
tokens.unattributed_output += roughTokenEstimate(body.note);
|
|
1145
|
+
return { tokens, streamSource: "none" };
|
|
1146
|
+
}
|
|
1147
|
+
return { tokens, streamSource: "none" };
|
|
1148
|
+
}
|
|
1149
|
+
if (Array.isArray(body.output)) {
|
|
1150
|
+
for (const item of body.output) {
|
|
1151
|
+
const record = asRecord(item);
|
|
1152
|
+
if (!record)
|
|
1153
|
+
continue;
|
|
1154
|
+
const type = typeof record.type === "string" ? record.type : "";
|
|
1155
|
+
if (type === "message") {
|
|
1156
|
+
tokens.assistant_text += roughTokenEstimate(extractTextContentFromResponseItem(record));
|
|
1157
|
+
}
|
|
1158
|
+
else if (type === "reasoning") {
|
|
1159
|
+
tokens.reasoning += roughTokenEstimate(extractReasoningItemText(record));
|
|
1160
|
+
}
|
|
1161
|
+
else if (type === "function_call") {
|
|
1162
|
+
tokens.tool_calls += roughTokenEstimate(`${record.name ?? ""} ${record.arguments ?? ""}`.trim());
|
|
1163
|
+
}
|
|
1164
|
+
else if (type === "function_call_output") {
|
|
1165
|
+
tokens.tool_results += roughTokenEstimate(stringifyMaybe(record.output));
|
|
1166
|
+
}
|
|
1167
|
+
else {
|
|
1168
|
+
tokens.unattributed_output += roughTokenEstimate(stringifyMaybe(record));
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
return { tokens };
|
|
1172
|
+
}
|
|
1173
|
+
if (Array.isArray(body.choices)) {
|
|
1174
|
+
for (const choice of body.choices) {
|
|
1175
|
+
const choiceRecord = asRecord(choice);
|
|
1176
|
+
const message = asRecord(choiceRecord?.message);
|
|
1177
|
+
if (!message)
|
|
1178
|
+
continue;
|
|
1179
|
+
tokens.assistant_text += roughTokenEstimate(extractTextContent(message.content));
|
|
1180
|
+
if (typeof message.reasoning_content === "string") {
|
|
1181
|
+
tokens.reasoning += roughTokenEstimate(message.reasoning_content);
|
|
1182
|
+
}
|
|
1183
|
+
const toolCalls = extractToolCalls(message.tool_calls);
|
|
1184
|
+
for (const toolCall of toolCalls) {
|
|
1185
|
+
tokens.tool_calls += roughTokenEstimate(`${toolCall.function?.name ?? ""} ${toolCall.function?.arguments ?? ""}`.trim());
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return { tokens };
|
|
1189
|
+
}
|
|
1190
|
+
tokens.unattributed_output += roughTokenEstimate(stringifyMaybe(body));
|
|
1191
|
+
return { tokens };
|
|
1192
|
+
}
|
|
1193
|
+
function initCategoryTokenMap(categories) {
|
|
1194
|
+
const out = {};
|
|
1195
|
+
for (const category of categories) {
|
|
1196
|
+
out[category.key] = 0;
|
|
1197
|
+
}
|
|
1198
|
+
return out;
|
|
1199
|
+
}
|
|
1200
|
+
function mapTokensToBuckets(categories, values) {
|
|
1201
|
+
return categories.map((category) => ({
|
|
1202
|
+
key: category.key,
|
|
1203
|
+
label: category.label,
|
|
1204
|
+
tokens: Math.max(0, Math.round(values[category.key] ?? 0)),
|
|
1205
|
+
}));
|
|
1206
|
+
}
|
|
1207
|
+
function scaleCategoryTokens(categories, values, targetTotal) {
|
|
1208
|
+
const sanitizedTarget = Math.max(0, Math.round(targetTotal));
|
|
1209
|
+
if (sanitizedTarget === 0) {
|
|
1210
|
+
return categories.map((category) => ({ key: category.key, label: category.label, tokens: 0 }));
|
|
1211
|
+
}
|
|
1212
|
+
const weights = categories.map((category) => Math.max(0, values[category.key] ?? 0));
|
|
1213
|
+
const scaled = splitTotalByWeights(sanitizedTarget, weights);
|
|
1214
|
+
return categories.map((category, idx) => ({
|
|
1215
|
+
key: category.key,
|
|
1216
|
+
label: category.label,
|
|
1217
|
+
tokens: scaled[idx] ?? 0,
|
|
1218
|
+
}));
|
|
1219
|
+
}
|
|
1220
|
+
function splitTotalByWeights(total, weights) {
|
|
1221
|
+
const sanitizedTotal = Math.max(0, Math.round(total));
|
|
1222
|
+
const normalizedWeights = weights.map((value) => Math.max(0, value));
|
|
1223
|
+
const sum = normalizedWeights.reduce((acc, value) => acc + value, 0);
|
|
1224
|
+
if (sanitizedTotal === 0)
|
|
1225
|
+
return normalizedWeights.map(() => 0);
|
|
1226
|
+
if (sum <= 0) {
|
|
1227
|
+
const equal = Math.floor(sanitizedTotal / normalizedWeights.length);
|
|
1228
|
+
let remainder = sanitizedTotal - equal * normalizedWeights.length;
|
|
1229
|
+
return normalizedWeights.map((_value, idx) => equal + (remainder-- > 0 ? 1 : 0));
|
|
1230
|
+
}
|
|
1231
|
+
const rawShares = normalizedWeights.map((value) => (value / sum) * sanitizedTotal);
|
|
1232
|
+
const floors = rawShares.map((value) => Math.floor(value));
|
|
1233
|
+
let remainder = sanitizedTotal - floors.reduce((acc, value) => acc + value, 0);
|
|
1234
|
+
const order = rawShares
|
|
1235
|
+
.map((value, idx) => ({ idx, frac: value - floors[idx] }))
|
|
1236
|
+
.sort((a, b) => b.frac - a.frac);
|
|
1237
|
+
for (let i = 0; i < order.length && remainder > 0; i += 1) {
|
|
1238
|
+
floors[order[i].idx] += 1;
|
|
1239
|
+
remainder -= 1;
|
|
1240
|
+
}
|
|
1241
|
+
return floors;
|
|
1242
|
+
}
|
|
1243
|
+
function sumTokenMapValues(values) {
|
|
1244
|
+
return Object.values(values).reduce((acc, value) => acc + Math.max(0, Math.round(value)), 0);
|
|
1245
|
+
}
|
|
1246
|
+
function toNullableInt(value) {
|
|
1247
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
1248
|
+
return null;
|
|
1249
|
+
return Math.max(0, Math.round(value));
|
|
1250
|
+
}
|
|
1251
|
+
function roughTokenEstimate(value) {
|
|
1252
|
+
if (!value)
|
|
1253
|
+
return 0;
|
|
1254
|
+
return Math.ceil(Buffer.byteLength(value, "utf8") / 4);
|
|
1255
|
+
}
|
|
1256
|
+
function normalizeRole(value) {
|
|
1257
|
+
if (value === "system" ||
|
|
1258
|
+
value === "user" ||
|
|
1259
|
+
value === "assistant" ||
|
|
1260
|
+
value === "tool" ||
|
|
1261
|
+
value === "developer") {
|
|
1262
|
+
return value;
|
|
1263
|
+
}
|
|
1264
|
+
return undefined;
|
|
1265
|
+
}
|
|
1266
|
+
function stringifyMaybe(value) {
|
|
1267
|
+
if (typeof value === "string")
|
|
1268
|
+
return value;
|
|
1269
|
+
if (value === undefined)
|
|
1270
|
+
return "";
|
|
1271
|
+
try {
|
|
1272
|
+
return JSON.stringify(value, null, 2);
|
|
1273
|
+
}
|
|
1274
|
+
catch {
|
|
1275
|
+
return String(value);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
function dateStringForTimeZone(timestamp, timeZone) {
|
|
1279
|
+
const value = new Date(timestamp);
|
|
1280
|
+
if (!Number.isFinite(value.getTime())) {
|
|
1281
|
+
return timestamp.slice(0, 10);
|
|
1282
|
+
}
|
|
1283
|
+
const formatter = new Intl.DateTimeFormat("en-CA", {
|
|
1284
|
+
timeZone,
|
|
1285
|
+
year: "numeric",
|
|
1286
|
+
month: "2-digit",
|
|
1287
|
+
day: "2-digit",
|
|
1288
|
+
});
|
|
1289
|
+
const parts = formatter.formatToParts(value);
|
|
1290
|
+
const year = parts.find((part) => part.type === "year")?.value ?? "0000";
|
|
1291
|
+
const month = parts.find((part) => part.type === "month")?.value ?? "00";
|
|
1292
|
+
const day = parts.find((part) => part.type === "day")?.value ?? "00";
|
|
1293
|
+
return `${year}-${month}-${day}`;
|
|
1294
|
+
}
|
|
1295
|
+
function normalizeTimeZone(input) {
|
|
1296
|
+
if (!input)
|
|
1297
|
+
return "UTC";
|
|
1298
|
+
try {
|
|
1299
|
+
new Intl.DateTimeFormat("en-US", { timeZone: input });
|
|
1300
|
+
return input;
|
|
1301
|
+
}
|
|
1302
|
+
catch {
|
|
1303
|
+
return "UTC";
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
function hydrateCaptureRecord(record) {
|
|
1307
|
+
const analysis = buildAnalysisProjection(record.route, record.request.body, record.response.body, record.request.derived);
|
|
1308
|
+
return {
|
|
1309
|
+
...record,
|
|
1310
|
+
analysis: {
|
|
1311
|
+
...record.analysis,
|
|
1312
|
+
...analysis,
|
|
1313
|
+
tools: record.analysis?.tools?.length ? record.analysis.tools : analysis.tools,
|
|
1314
|
+
mcpToolDescriptions: record.analysis?.mcpToolDescriptions?.length
|
|
1315
|
+
? record.analysis.mcpToolDescriptions
|
|
1316
|
+
: analysis.mcpToolDescriptions,
|
|
1317
|
+
agentsMdHints: record.analysis?.agentsMdHints?.length ? record.analysis.agentsMdHints : analysis.agentsMdHints,
|
|
1318
|
+
rawSections: record.analysis?.rawSections?.length ? record.analysis.rawSections : analysis.rawSections,
|
|
1319
|
+
},
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
async function buildPreviewBody(paths, value, artifacts) {
|
|
1323
|
+
if (value === null || value === undefined)
|
|
1324
|
+
return value;
|
|
1325
|
+
if (typeof value === "string") {
|
|
1326
|
+
return previewString(value);
|
|
1327
|
+
}
|
|
1328
|
+
if (Array.isArray(value)) {
|
|
1329
|
+
const out = [];
|
|
1330
|
+
for (const item of value) {
|
|
1331
|
+
out.push(await buildPreviewBody(paths, item, artifacts));
|
|
1332
|
+
}
|
|
1333
|
+
return out;
|
|
1334
|
+
}
|
|
1335
|
+
if (typeof value === "object") {
|
|
1336
|
+
const out = {};
|
|
1337
|
+
for (const [key, v] of Object.entries(value)) {
|
|
1338
|
+
if (typeof v === "string") {
|
|
1339
|
+
const dataMatch = v.match(DATA_URL_RE);
|
|
1340
|
+
if (dataMatch) {
|
|
1341
|
+
out[key] = await storeDataUrlArtifact(paths, v, artifacts);
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
out[key] = await buildPreviewBody(paths, v, artifacts);
|
|
1346
|
+
}
|
|
1347
|
+
return out;
|
|
1348
|
+
}
|
|
1349
|
+
return value;
|
|
1350
|
+
}
|
|
1351
|
+
function previewString(input) {
|
|
1352
|
+
if (input.length <= 4000)
|
|
1353
|
+
return input;
|
|
1354
|
+
return {
|
|
1355
|
+
$type: "long_text",
|
|
1356
|
+
length: input.length,
|
|
1357
|
+
preview: `${input.slice(0, 320)}…`,
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
async function storeDataUrlArtifact(paths, value, artifacts) {
|
|
1361
|
+
const match = value.match(DATA_URL_RE);
|
|
1362
|
+
if (!match)
|
|
1363
|
+
return previewString(value);
|
|
1364
|
+
const mime = match[1].toLowerCase();
|
|
1365
|
+
let buffer;
|
|
1366
|
+
try {
|
|
1367
|
+
buffer = Buffer.from(match[2], "base64");
|
|
1368
|
+
}
|
|
1369
|
+
catch {
|
|
1370
|
+
return {
|
|
1371
|
+
$type: "data_url",
|
|
1372
|
+
mime,
|
|
1373
|
+
error: "invalid_base64",
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
const hash = (0, crypto_1.createHash)("sha256").update(buffer).digest("hex");
|
|
1377
|
+
const ext = mimeToExt(mime);
|
|
1378
|
+
const blobFile = `${hash}.${ext}`;
|
|
1379
|
+
const blobPath = path_1.default.join(captureBlobsDir(paths), blobFile);
|
|
1380
|
+
try {
|
|
1381
|
+
await fs_1.promises.access(blobPath);
|
|
1382
|
+
}
|
|
1383
|
+
catch {
|
|
1384
|
+
await fs_1.promises.writeFile(blobPath, buffer);
|
|
1385
|
+
}
|
|
1386
|
+
const artifact = {
|
|
1387
|
+
hash,
|
|
1388
|
+
mime,
|
|
1389
|
+
bytes: buffer.byteLength,
|
|
1390
|
+
blobRef: `/admin/capture/blobs/${hash}`,
|
|
1391
|
+
kind: mime.startsWith("image/")
|
|
1392
|
+
? "image"
|
|
1393
|
+
: mime.startsWith("audio/")
|
|
1394
|
+
? "audio"
|
|
1395
|
+
: "binary",
|
|
1396
|
+
};
|
|
1397
|
+
if (!artifacts.some((item) => item.hash === hash)) {
|
|
1398
|
+
artifacts.push(artifact);
|
|
1399
|
+
}
|
|
1400
|
+
return {
|
|
1401
|
+
$type: "data_url_ref",
|
|
1402
|
+
mime,
|
|
1403
|
+
bytes: buffer.byteLength,
|
|
1404
|
+
blobRef: artifact.blobRef,
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
function mimeToExt(mime) {
|
|
1408
|
+
if (mime === "image/png")
|
|
1409
|
+
return "png";
|
|
1410
|
+
if (mime === "image/jpeg")
|
|
1411
|
+
return "jpg";
|
|
1412
|
+
if (mime === "image/webp")
|
|
1413
|
+
return "webp";
|
|
1414
|
+
if (mime === "image/gif")
|
|
1415
|
+
return "gif";
|
|
1416
|
+
if (mime === "audio/mpeg")
|
|
1417
|
+
return "mp3";
|
|
1418
|
+
if (mime === "audio/wav")
|
|
1419
|
+
return "wav";
|
|
1420
|
+
if (mime === "audio/ogg")
|
|
1421
|
+
return "ogg";
|
|
1422
|
+
if (mime === "audio/webm")
|
|
1423
|
+
return "webm";
|
|
1424
|
+
return "bin";
|
|
1425
|
+
}
|
|
1426
|
+
function extToMime(ext) {
|
|
1427
|
+
if (ext === "png")
|
|
1428
|
+
return "image/png";
|
|
1429
|
+
if (ext === "jpg" || ext === "jpeg")
|
|
1430
|
+
return "image/jpeg";
|
|
1431
|
+
if (ext === "webp")
|
|
1432
|
+
return "image/webp";
|
|
1433
|
+
if (ext === "gif")
|
|
1434
|
+
return "image/gif";
|
|
1435
|
+
if (ext === "mp3")
|
|
1436
|
+
return "audio/mpeg";
|
|
1437
|
+
if (ext === "wav")
|
|
1438
|
+
return "audio/wav";
|
|
1439
|
+
if (ext === "ogg")
|
|
1440
|
+
return "audio/ogg";
|
|
1441
|
+
if (ext === "webm")
|
|
1442
|
+
return "audio/webm";
|
|
1443
|
+
return "application/octet-stream";
|
|
1444
|
+
}
|
|
1445
|
+
async function readCaptureIndex(paths, options) {
|
|
1446
|
+
try {
|
|
1447
|
+
const raw = await fs_1.promises.readFile(captureIndexPath(paths), "utf8");
|
|
1448
|
+
const entries = raw
|
|
1449
|
+
.split("\n")
|
|
1450
|
+
.filter((line) => line.trim().length > 0)
|
|
1451
|
+
.map((line) => JSON.parse(line))
|
|
1452
|
+
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
1453
|
+
if (!options?.pruneMissing) {
|
|
1454
|
+
return entries;
|
|
1455
|
+
}
|
|
1456
|
+
const existing = [];
|
|
1457
|
+
let removed = false;
|
|
1458
|
+
for (const entry of entries) {
|
|
1459
|
+
try {
|
|
1460
|
+
await fs_1.promises.access(path_1.default.join(captureDir(paths), entry.file));
|
|
1461
|
+
existing.push(entry);
|
|
1462
|
+
}
|
|
1463
|
+
catch {
|
|
1464
|
+
removed = true;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
if (removed) {
|
|
1468
|
+
const content = existing.map((entry) => JSON.stringify(entry)).join("\n");
|
|
1469
|
+
await fs_1.promises.writeFile(captureIndexPath(paths), content ? `${content}\n` : "", "utf8");
|
|
1470
|
+
}
|
|
1471
|
+
return existing;
|
|
1472
|
+
}
|
|
1473
|
+
catch {
|
|
1474
|
+
return [];
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
async function applyCaptureRetention(paths, config) {
|
|
1478
|
+
const cutoff = Date.now() - config.retentionDays * 24 * 60 * 60 * 1000;
|
|
1479
|
+
let entries = await readCaptureIndex(paths);
|
|
1480
|
+
if (entries.length === 0)
|
|
1481
|
+
return;
|
|
1482
|
+
entries = entries.filter((entry) => {
|
|
1483
|
+
const ts = new Date(entry.timestamp).getTime();
|
|
1484
|
+
if (!Number.isFinite(ts) || ts < cutoff) {
|
|
1485
|
+
return false;
|
|
1486
|
+
}
|
|
1487
|
+
return true;
|
|
1488
|
+
});
|
|
1489
|
+
let total = await dirSize(captureDir(paths));
|
|
1490
|
+
if (total > config.maxBytes) {
|
|
1491
|
+
const sorted = [...entries].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
1492
|
+
while (total > config.maxBytes && sorted.length > 0) {
|
|
1493
|
+
const oldest = sorted.shift();
|
|
1494
|
+
if (!oldest)
|
|
1495
|
+
break;
|
|
1496
|
+
const recPath = path_1.default.join(captureDir(paths), oldest.file);
|
|
1497
|
+
try {
|
|
1498
|
+
await fs_1.promises.unlink(recPath);
|
|
1499
|
+
}
|
|
1500
|
+
catch {
|
|
1501
|
+
// noop
|
|
1502
|
+
}
|
|
1503
|
+
entries = entries.filter((entry) => entry.id !== oldest.id);
|
|
1504
|
+
total = await dirSize(captureDir(paths));
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
else {
|
|
1508
|
+
const existingIds = new Set(entries.map((entry) => entry.id));
|
|
1509
|
+
const allEntries = await readCaptureIndex(paths);
|
|
1510
|
+
for (const item of allEntries) {
|
|
1511
|
+
if (existingIds.has(item.id))
|
|
1512
|
+
continue;
|
|
1513
|
+
try {
|
|
1514
|
+
await fs_1.promises.unlink(path_1.default.join(captureDir(paths), item.file));
|
|
1515
|
+
}
|
|
1516
|
+
catch {
|
|
1517
|
+
// noop
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
await cleanupOrphanBlobs(paths, entries);
|
|
1522
|
+
const content = entries.map((entry) => JSON.stringify(entry)).join("\n");
|
|
1523
|
+
await fs_1.promises.writeFile(captureIndexPath(paths), content ? `${content}\n` : "", "utf8");
|
|
1524
|
+
}
|
|
1525
|
+
async function cleanupOrphanBlobs(paths, entries) {
|
|
1526
|
+
const referenced = new Set();
|
|
1527
|
+
for (const entry of entries) {
|
|
1528
|
+
try {
|
|
1529
|
+
const raw = await fs_1.promises.readFile(path_1.default.join(captureDir(paths), entry.file), "utf8");
|
|
1530
|
+
const record = JSON.parse(raw);
|
|
1531
|
+
for (const artifact of record.artifacts ?? []) {
|
|
1532
|
+
referenced.add(artifact.hash);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
catch {
|
|
1536
|
+
// noop
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
let files;
|
|
1540
|
+
try {
|
|
1541
|
+
files = await fs_1.promises.readdir(captureBlobsDir(paths));
|
|
1542
|
+
}
|
|
1543
|
+
catch {
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
for (const file of files) {
|
|
1547
|
+
const hash = file.split(".")[0];
|
|
1548
|
+
if (!referenced.has(hash)) {
|
|
1549
|
+
try {
|
|
1550
|
+
await fs_1.promises.unlink(path_1.default.join(captureBlobsDir(paths), file));
|
|
1551
|
+
}
|
|
1552
|
+
catch {
|
|
1553
|
+
// noop
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
async function dirSize(root) {
|
|
1559
|
+
let total = 0;
|
|
1560
|
+
async function walk(dir) {
|
|
1561
|
+
let entries;
|
|
1562
|
+
try {
|
|
1563
|
+
entries = await fs_1.promises.readdir(dir, { withFileTypes: true });
|
|
1564
|
+
}
|
|
1565
|
+
catch {
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
for (const entry of entries) {
|
|
1569
|
+
const full = path_1.default.join(dir, entry.name);
|
|
1570
|
+
if (entry.isDirectory()) {
|
|
1571
|
+
await walk(full);
|
|
1572
|
+
}
|
|
1573
|
+
else if (entry.isFile()) {
|
|
1574
|
+
try {
|
|
1575
|
+
const stat = await fs_1.promises.stat(full);
|
|
1576
|
+
total += stat.size;
|
|
1577
|
+
}
|
|
1578
|
+
catch {
|
|
1579
|
+
// noop
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
await walk(root);
|
|
1585
|
+
return total;
|
|
1586
|
+
}
|
|
1587
|
+
function asRecord(value) {
|
|
1588
|
+
if (!value || typeof value !== "object")
|
|
1589
|
+
return null;
|
|
1590
|
+
return value;
|
|
1591
|
+
}
|