open-multi-agent-kit 0.98.2 → 0.98.4
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/CHANGELOG.md +45 -0
- package/README.md +11 -2
- package/dist/cli/args.d.ts +1 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +14 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +3 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/mcp-attach.d.ts +3 -3
- package/dist/cli/mcp-attach.d.ts.map +1 -1
- package/dist/cli/mcp-attach.js +5 -4
- package/dist/cli/mcp-attach.js.map +1 -1
- package/dist/cli/model-contract.d.ts +5 -0
- package/dist/cli/model-contract.d.ts.map +1 -0
- package/dist/cli/model-contract.js +45 -0
- package/dist/cli/model-contract.js.map +1 -0
- package/dist/commands/adaptorch-doctor-cli.d.ts.map +1 -1
- package/dist/commands/adaptorch-doctor-cli.js +32 -10
- package/dist/commands/adaptorch-doctor-cli.js.map +1 -1
- package/dist/commands/init-cli.d.ts +12 -0
- package/dist/commands/init-cli.d.ts.map +1 -0
- package/dist/commands/init-cli.js +76 -0
- package/dist/commands/init-cli.js.map +1 -0
- package/dist/commands/provider-sync-cli.d.ts +27 -0
- package/dist/commands/provider-sync-cli.d.ts.map +1 -0
- package/dist/commands/provider-sync-cli.js +177 -0
- package/dist/commands/provider-sync-cli.js.map +1 -0
- package/dist/commands/run-command.d.ts +8 -0
- package/dist/commands/run-command.d.ts.map +1 -0
- package/dist/commands/run-command.js +26 -0
- package/dist/commands/run-command.js.map +1 -0
- package/dist/core/active-skill-state.d.ts +7 -0
- package/dist/core/active-skill-state.d.ts.map +1 -0
- package/dist/core/active-skill-state.js +25 -0
- package/dist/core/active-skill-state.js.map +1 -0
- package/dist/core/advisory-judge-model.d.ts.map +1 -1
- package/dist/core/advisory-judge-model.js +8 -1
- package/dist/core/advisory-judge-model.js.map +1 -1
- package/dist/core/advisory-judge-types.d.ts +18 -1
- package/dist/core/advisory-judge-types.d.ts.map +1 -1
- package/dist/core/advisory-judge-types.js.map +1 -1
- package/dist/core/advisory-judge.d.ts.map +1 -1
- package/dist/core/advisory-judge.js +41 -6
- package/dist/core/advisory-judge.js.map +1 -1
- package/dist/core/agent-session-services.d.ts +2 -0
- package/dist/core/agent-session-services.d.ts.map +1 -1
- package/dist/core/agent-session-services.js +2 -0
- package/dist/core/agent-session-services.js.map +1 -1
- package/dist/core/agent-session.d.ts +0 -1
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +30 -85
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/codex-chatgpt-web-bridge.d.ts +99 -0
- package/dist/core/codex-chatgpt-web-bridge.d.ts.map +1 -0
- package/dist/core/codex-chatgpt-web-bridge.js +192 -0
- package/dist/core/codex-chatgpt-web-bridge.js.map +1 -0
- package/dist/core/codex-chatgpt-web-sync.d.ts +50 -0
- package/dist/core/codex-chatgpt-web-sync.d.ts.map +1 -0
- package/dist/core/codex-chatgpt-web-sync.js +119 -0
- package/dist/core/codex-chatgpt-web-sync.js.map +1 -0
- package/dist/core/compaction/compaction-headroom.d.ts +30 -0
- package/dist/core/compaction/compaction-headroom.d.ts.map +1 -0
- package/dist/core/compaction/compaction-headroom.js +48 -0
- package/dist/core/compaction/compaction-headroom.js.map +1 -0
- package/dist/core/compaction/compaction.d.ts +2 -29
- package/dist/core/compaction/compaction.d.ts.map +1 -1
- package/dist/core/compaction/compaction.js +19 -47
- package/dist/core/compaction/compaction.js.map +1 -1
- package/dist/core/compaction/overflow-retry-guard.d.ts +20 -0
- package/dist/core/compaction/overflow-retry-guard.d.ts.map +1 -0
- package/dist/core/compaction/overflow-retry-guard.js +38 -0
- package/dist/core/compaction/overflow-retry-guard.js.map +1 -0
- package/dist/core/context-budget-headroom-types.d.ts.map +1 -1
- package/dist/core/context-budget-headroom-types.js +1 -1
- package/dist/core/context-budget-headroom-types.js.map +1 -1
- package/dist/core/context-budget-token-counter-types.d.ts +27 -0
- package/dist/core/context-budget-token-counter-types.d.ts.map +1 -0
- package/dist/core/context-budget-token-counter-types.js +2 -0
- package/dist/core/context-budget-token-counter-types.js.map +1 -0
- package/dist/core/context-budget-token-counter.d.ts +2 -26
- package/dist/core/context-budget-token-counter.d.ts.map +1 -1
- package/dist/core/context-budget-token-counter.js +2 -0
- package/dist/core/context-budget-token-counter.js.map +1 -1
- package/dist/core/context-budget-v2-scoring.d.ts.map +1 -1
- package/dist/core/context-budget-v2-scoring.js +14 -10
- package/dist/core/context-budget-v2-scoring.js.map +1 -1
- package/dist/core/domain-router.d.ts.map +1 -1
- package/dist/core/domain-router.js +1 -1
- package/dist/core/domain-router.js.map +1 -1
- package/dist/core/grok-harness-dispatch.d.ts +4 -0
- package/dist/core/grok-harness-dispatch.d.ts.map +1 -1
- package/dist/core/grok-harness-dispatch.js +20 -3
- package/dist/core/grok-harness-dispatch.js.map +1 -1
- package/dist/core/grok-harness.d.ts +15 -9
- package/dist/core/grok-harness.d.ts.map +1 -1
- package/dist/core/grok-harness.js +34 -8
- package/dist/core/grok-harness.js.map +1 -1
- package/dist/core/mcp/tools.d.ts +2 -2
- package/dist/core/mcp/tools.d.ts.map +1 -1
- package/dist/core/mcp/tools.js +29 -3
- package/dist/core/mcp/tools.js.map +1 -1
- package/dist/core/model-registry-schema.d.ts +1081 -0
- package/dist/core/model-registry-schema.d.ts.map +1 -0
- package/dist/core/model-registry-schema.js +148 -0
- package/dist/core/model-registry-schema.js.map +1 -0
- package/dist/core/model-registry.d.ts.map +1 -1
- package/dist/core/model-registry.js +1 -146
- package/dist/core/model-registry.js.map +1 -1
- package/dist/core/model-resolver.d.ts +2 -1
- package/dist/core/model-resolver.d.ts.map +1 -1
- package/dist/core/model-resolver.js +2 -1
- package/dist/core/model-resolver.js.map +1 -1
- package/dist/core/prompt-preset.d.ts +1 -1
- package/dist/core/prompt-preset.d.ts.map +1 -1
- package/dist/core/prompt-preset.js +16 -1
- package/dist/core/prompt-preset.js.map +1 -1
- package/dist/core/provider-display-names.d.ts.map +1 -1
- package/dist/core/provider-display-names.js +1 -0
- package/dist/core/provider-display-names.js.map +1 -1
- package/dist/core/provider-resilience.d.ts +8 -0
- package/dist/core/provider-resilience.d.ts.map +1 -1
- package/dist/core/provider-resilience.js +20 -3
- package/dist/core/provider-resilience.js.map +1 -1
- package/dist/core/provider-usage-types.d.ts +42 -0
- package/dist/core/provider-usage-types.d.ts.map +1 -0
- package/dist/core/provider-usage-types.js +2 -0
- package/dist/core/provider-usage-types.js.map +1 -0
- package/dist/core/provider-usage.d.ts +2 -31
- package/dist/core/provider-usage.d.ts.map +1 -1
- package/dist/core/provider-usage.js +4 -0
- package/dist/core/provider-usage.js.map +1 -1
- package/dist/core/redaction.d.ts.map +1 -1
- package/dist/core/redaction.js +18 -2
- package/dist/core/redaction.js.map +1 -1
- package/dist/core/sandbox/policy-merge.d.ts +6 -0
- package/dist/core/sandbox/policy-merge.d.ts.map +1 -0
- package/dist/core/sandbox/policy-merge.js +74 -0
- package/dist/core/sandbox/policy-merge.js.map +1 -0
- package/dist/core/sandbox/policy-paths.d.ts +4 -0
- package/dist/core/sandbox/policy-paths.d.ts.map +1 -0
- package/dist/core/sandbox/policy-paths.js +21 -0
- package/dist/core/sandbox/policy-paths.js.map +1 -0
- package/dist/core/sandbox/policy-types.d.ts +75 -0
- package/dist/core/sandbox/policy-types.d.ts.map +1 -0
- package/dist/core/sandbox/policy-types.js +2 -0
- package/dist/core/sandbox/policy-types.js.map +1 -0
- package/dist/core/sandbox/policy.d.ts +3 -77
- package/dist/core/sandbox/policy.d.ts.map +1 -1
- package/dist/core/sandbox/policy.js +3 -98
- package/dist/core/sandbox/policy.js.map +1 -1
- package/dist/core/sdk-provider-stream.d.ts +14 -0
- package/dist/core/sdk-provider-stream.d.ts.map +1 -0
- package/dist/core/sdk-provider-stream.js +38 -0
- package/dist/core/sdk-provider-stream.js.map +1 -0
- package/dist/core/sdk.d.ts +5 -1
- package/dist/core/sdk.d.ts.map +1 -1
- package/dist/core/sdk.js +17 -33
- package/dist/core/sdk.js.map +1 -1
- package/dist/core/session-bash-runtime.d.ts +7 -0
- package/dist/core/session-bash-runtime.d.ts.map +1 -1
- package/dist/core/session-bash-runtime.js +10 -1
- package/dist/core/session-bash-runtime.js.map +1 -1
- package/dist/core/session-failure-cause.d.ts.map +1 -1
- package/dist/core/session-failure-cause.js +10 -3
- package/dist/core/session-failure-cause.js.map +1 -1
- package/dist/core/session-run-termination.d.ts +18 -0
- package/dist/core/session-run-termination.d.ts.map +1 -0
- package/dist/core/session-run-termination.js +61 -0
- package/dist/core/session-run-termination.js.map +1 -0
- package/dist/core/session-termination.d.ts.map +1 -1
- package/dist/core/session-termination.js +1 -1
- package/dist/core/session-termination.js.map +1 -1
- package/dist/core/skill-selector.d.ts +40 -0
- package/dist/core/skill-selector.d.ts.map +1 -0
- package/dist/core/skill-selector.js +102 -0
- package/dist/core/skill-selector.js.map +1 -0
- package/dist/core/tools/render-utils.d.ts.map +1 -1
- package/dist/core/tools/render-utils.js +2 -4
- package/dist/core/tools/render-utils.js.map +1 -1
- package/dist/core/turn-metrics-record.d.ts +55 -0
- package/dist/core/turn-metrics-record.d.ts.map +1 -0
- package/dist/core/turn-metrics-record.js +149 -0
- package/dist/core/turn-metrics-record.js.map +1 -0
- package/dist/core/turn-metrics.d.ts +4 -56
- package/dist/core/turn-metrics.d.ts.map +1 -1
- package/dist/core/turn-metrics.js +12 -60
- package/dist/core/turn-metrics.js.map +1 -1
- package/dist/core/verified-bash-runtime.d.ts +40 -0
- package/dist/core/verified-bash-runtime.d.ts.map +1 -1
- package/dist/core/verified-bash-runtime.js +60 -13
- package/dist/core/verified-bash-runtime.js.map +1 -1
- package/dist/guardrails/evidence-system.d.ts +1 -1
- package/dist/guardrails/evidence-system.d.ts.map +1 -1
- package/dist/guardrails/evidence-system.js +18 -29
- package/dist/guardrails/evidence-system.js.map +1 -1
- package/dist/guardrails/merge-gate-result.d.ts +4 -0
- package/dist/guardrails/merge-gate-result.d.ts.map +1 -0
- package/dist/guardrails/merge-gate-result.js +32 -0
- package/dist/guardrails/merge-gate-result.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +22 -22
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/interactive-login-options.d.ts +27 -0
- package/dist/modes/interactive/interactive-login-options.d.ts.map +1 -0
- package/dist/modes/interactive/interactive-login-options.js +54 -0
- package/dist/modes/interactive/interactive-login-options.js.map +1 -0
- package/dist/modes/interactive/interactive-mode.d.ts +7 -4
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +138 -140
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/interactive-resume-command.d.ts +3 -0
- package/dist/modes/interactive/interactive-resume-command.d.ts.map +1 -0
- package/dist/modes/interactive/interactive-resume-command.js +24 -0
- package/dist/modes/interactive/interactive-resume-command.js.map +1 -0
- package/dist/modes/interactive/interactive-tool-result.d.ts +32 -0
- package/dist/modes/interactive/interactive-tool-result.d.ts.map +1 -0
- package/dist/modes/interactive/interactive-tool-result.js +59 -0
- package/dist/modes/interactive/interactive-tool-result.js.map +1 -0
- package/dist/modes/print-mode.d.ts.map +1 -1
- package/dist/modes/print-mode.js +22 -0
- package/dist/modes/print-mode.js.map +1 -1
- package/dist/utils/clipboard-image.d.ts.map +1 -1
- package/dist/utils/clipboard-image.js +10 -60
- package/dist/utils/clipboard-image.js.map +1 -1
- package/dist/utils/terminal-links.d.ts +13 -0
- package/dist/utils/terminal-links.d.ts.map +1 -0
- package/dist/utils/terminal-links.js +68 -0
- package/dist/utils/terminal-links.js.map +1 -0
- package/dist/utils/windows-clipboard-image.d.ts +16 -0
- package/dist/utils/windows-clipboard-image.d.ts.map +1 -0
- package/dist/utils/windows-clipboard-image.js +87 -0
- package/dist/utils/windows-clipboard-image.js.map +1 -0
- package/docs/adaptorch-onboarding.md +80 -0
- package/docs/advisory-selection.md +112 -0
- package/docs/containerization.md +12 -0
- package/docs/context-files.md +97 -0
- package/docs/correctness-wall.md +8 -0
- package/docs/custom-provider.md +35 -0
- package/docs/development.md +18 -3
- package/docs/environment-variables.md +2 -0
- package/docs/grok-harness.md +4 -0
- package/docs/harness-boundaries.md +154 -0
- package/docs/harness-improvements.md +90 -0
- package/docs/json.md +19 -0
- package/docs/keybindings.md +1 -1
- package/docs/mcp.md +22 -2
- package/docs/metrics.md +66 -16
- package/docs/model-catalog-refresh.md +190 -0
- package/docs/model-contract.md +168 -0
- package/docs/models.md +21 -4
- package/docs/provider-resilience.md +5 -3
- package/docs/providers.md +68 -1
- package/docs/quickstart.md +4 -0
- package/docs/release-audit-0.98.3.md +97 -0
- package/docs/release-audit-0.98.4.md +112 -0
- package/docs/review-bundle-followup.md +195 -0
- package/docs/run-protocol.md +3 -1
- package/docs/runtime-algorithms.md +38 -2
- package/docs/sdk.md +35 -3
- package/docs/sessions.md +1 -1
- package/docs/settings.md +17 -1
- package/docs/skills.md +2 -0
- package/docs/tb21-audit.md +131 -0
- package/docs/terminal-setup.md +20 -0
- package/docs/usage.md +23 -1
- package/docs/windows.md +48 -0
- package/examples/README.md +4 -0
- package/examples/context/AGENTS.md +64 -0
- package/examples/context/CLAUDE.md +17 -0
- package/examples/context/INTERNET.md +42 -0
- package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/gondolin/package-lock.json +2 -2
- package/examples/extensions/gondolin/package.json +1 -1
- package/examples/extensions/sandbox/package-lock.json +2 -2
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package-lock.json +2 -2
- package/examples/extensions/with-deps/package.json +1 -1
- package/npm-shrinkwrap.json +18 -18
- package/package.json +6 -6
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { parseRuntimeProvenance } from "./runtime-provenance.js";
|
|
2
|
+
export const TURN_METRICS_SCHEMA_VERSION = "omk-turn-metrics-2";
|
|
3
|
+
/** Compatibility constant: maximum failure text inspected for classification, never retained. */
|
|
4
|
+
export const MAX_ERROR_CHARS = 200;
|
|
5
|
+
const ERROR_CLASSES = ["timeout", "aborted", "permission", "not_found", "invalid_input", "unknown"];
|
|
6
|
+
const STOP_REASONS = ["stop", "length", "toolUse", "error", "aborted"];
|
|
7
|
+
function isRecord(value) {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
function object(value) {
|
|
11
|
+
if (!isRecord(value))
|
|
12
|
+
throw new TypeError("Invalid metric object");
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
function finite(value) {
|
|
16
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
17
|
+
throw new TypeError("Invalid metric number");
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function quantity(value, integer = false) {
|
|
21
|
+
const number = finite(value);
|
|
22
|
+
if (number < 0 || (integer && !Number.isSafeInteger(number)))
|
|
23
|
+
throw new TypeError("Invalid metric quantity");
|
|
24
|
+
return number;
|
|
25
|
+
}
|
|
26
|
+
function identifier(value) {
|
|
27
|
+
if (typeof value !== "string" || !value.trim() || value.length > 512 || /[\u0000-\u001f\u007f]/u.test(value))
|
|
28
|
+
throw new TypeError("Invalid metric identifier");
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function boolean(value) {
|
|
32
|
+
if (typeof value !== "boolean")
|
|
33
|
+
throw new TypeError("Invalid metric boolean");
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function classifyError(value) {
|
|
37
|
+
if (value !== undefined && typeof value !== "string")
|
|
38
|
+
throw new TypeError("Invalid metric error text");
|
|
39
|
+
const text = value?.slice(0, MAX_ERROR_CHARS) ?? "";
|
|
40
|
+
if (/timed? out|timeout/iu.test(text))
|
|
41
|
+
return "timeout";
|
|
42
|
+
if (/abort|cancel/iu.test(text))
|
|
43
|
+
return "aborted";
|
|
44
|
+
if (/permission|eacces|eperm/iu.test(text))
|
|
45
|
+
return "permission";
|
|
46
|
+
if (/not found|enoent/iu.test(text))
|
|
47
|
+
return "not_found";
|
|
48
|
+
if (/invalid|validation/iu.test(text))
|
|
49
|
+
return "invalid_input";
|
|
50
|
+
return "unknown";
|
|
51
|
+
}
|
|
52
|
+
function parseErrorClass(value) {
|
|
53
|
+
if (value === undefined)
|
|
54
|
+
return undefined;
|
|
55
|
+
for (const code of ERROR_CLASSES)
|
|
56
|
+
if (value === code)
|
|
57
|
+
return code;
|
|
58
|
+
throw new TypeError("Invalid metric error class");
|
|
59
|
+
}
|
|
60
|
+
/** Explicit projection is shared by new input and legacy/current persisted records. */
|
|
61
|
+
function project(value, source) {
|
|
62
|
+
const input = object(value);
|
|
63
|
+
const rawTools = input.toolCalls === undefined ? [] : input.toolCalls;
|
|
64
|
+
if (!Array.isArray(rawTools) || rawTools.length > 10000)
|
|
65
|
+
throw new TypeError("Invalid metric tool list");
|
|
66
|
+
const toolCalls = Array.from(rawTools, (raw) => {
|
|
67
|
+
const call = object(raw);
|
|
68
|
+
const ok = boolean(call.ok);
|
|
69
|
+
if (source === "v2" && call.error !== undefined)
|
|
70
|
+
throw new TypeError("Raw errors are not v2 metrics");
|
|
71
|
+
const errorClass = source === "v2" ? parseErrorClass(call.errorClass) : classifyError(call.error);
|
|
72
|
+
return {
|
|
73
|
+
name: identifier(call.name),
|
|
74
|
+
durationMs: source === "input" ? Math.max(0, Math.round(finite(call.durationMs))) : quantity(call.durationMs),
|
|
75
|
+
ok,
|
|
76
|
+
...(!ok && errorClass ? { errorClass } : {}),
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
const usage = input.usage === undefined ? undefined : object(input.usage);
|
|
80
|
+
const contextCache = input.contextCache === undefined ? undefined : object(input.contextCache);
|
|
81
|
+
const runtimeProvenance = input.runtimeProvenance === undefined ? undefined : parseRuntimeProvenance(input.runtimeProvenance);
|
|
82
|
+
const startedAtEpochMs = quantity(input.startedAtEpochMs);
|
|
83
|
+
const endedAtEpochMs = quantity(input.endedAtEpochMs);
|
|
84
|
+
if (input.stopReason !== undefined &&
|
|
85
|
+
(typeof input.stopReason !== "string" || !STOP_REASONS.includes(input.stopReason)))
|
|
86
|
+
throw new TypeError("Invalid metric stop reason");
|
|
87
|
+
return {
|
|
88
|
+
schemaVersion: TURN_METRICS_SCHEMA_VERSION,
|
|
89
|
+
sessionId: identifier(input.sessionId),
|
|
90
|
+
turnIndex: quantity(input.turnIndex, true),
|
|
91
|
+
startedAtEpochMs,
|
|
92
|
+
endedAtEpochMs,
|
|
93
|
+
durationMs: Math.max(0, endedAtEpochMs - startedAtEpochMs),
|
|
94
|
+
toolCalls,
|
|
95
|
+
toolCallCount: toolCalls.length,
|
|
96
|
+
toolFailureCount: toolCalls.filter((call) => !call.ok).length,
|
|
97
|
+
...(input.provider === undefined ? {} : { provider: identifier(input.provider) }),
|
|
98
|
+
...(input.model === undefined ? {} : { model: identifier(input.model) }),
|
|
99
|
+
...(input.timeToFirstChunkMs === undefined ? {} : { timeToFirstChunkMs: quantity(input.timeToFirstChunkMs) }),
|
|
100
|
+
...(usage === undefined
|
|
101
|
+
? {}
|
|
102
|
+
: {
|
|
103
|
+
usage: {
|
|
104
|
+
input: quantity(usage.input, true),
|
|
105
|
+
output: quantity(usage.output, true),
|
|
106
|
+
cacheRead: quantity(usage.cacheRead, true),
|
|
107
|
+
cacheWrite: quantity(usage.cacheWrite, true),
|
|
108
|
+
costUsd: quantity(usage.costUsd),
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
...(input.stopReason === undefined ? {} : { stopReason: input.stopReason }),
|
|
112
|
+
...(input.compacted === undefined ? {} : { compacted: boolean(input.compacted) }),
|
|
113
|
+
...(input.failovers === undefined ? {} : { failovers: quantity(input.failovers, true) }),
|
|
114
|
+
...(contextCache === undefined
|
|
115
|
+
? {}
|
|
116
|
+
: {
|
|
117
|
+
contextCache: {
|
|
118
|
+
planHit: boolean(contextCache.planHit),
|
|
119
|
+
hits: quantity(contextCache.hits, true),
|
|
120
|
+
misses: quantity(contextCache.misses, true),
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
...(runtimeProvenance ? { runtimeProvenance } : {}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export function buildTurnMetricRecord(input) {
|
|
127
|
+
return project(input, "input");
|
|
128
|
+
}
|
|
129
|
+
/** Invalid records count as malformed; legacy records are projected, never rewritten on disk. */
|
|
130
|
+
export function parseTurnMetricRecord(line) {
|
|
131
|
+
try {
|
|
132
|
+
const value = object(JSON.parse(line));
|
|
133
|
+
const version = value.schemaVersion;
|
|
134
|
+
if (version !== "omk-turn-metrics-1" && version !== TURN_METRICS_SCHEMA_VERSION)
|
|
135
|
+
return undefined;
|
|
136
|
+
const projected = project(value, version === "omk-turn-metrics-1" ? "v1" : "v2");
|
|
137
|
+
if (quantity(value.durationMs) !== projected.durationMs ||
|
|
138
|
+
quantity(value.toolCallCount, true) !== projected.toolCallCount ||
|
|
139
|
+
quantity(value.toolFailureCount, true) !== projected.toolFailureCount)
|
|
140
|
+
return undefined;
|
|
141
|
+
return projected;
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (error instanceof SyntaxError || error instanceof TypeError)
|
|
145
|
+
return undefined;
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
//# sourceMappingURL=turn-metrics-record.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"turn-metrics-record.js","sourceRoot":"","sources":["../../src/core/turn-metrics-record.ts"],"names":[],"mappings":"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","sourcesContent":["import { parseRuntimeProvenance, type RuntimeProvenance } from \"./runtime-provenance.ts\";\n\nexport const TURN_METRICS_SCHEMA_VERSION = \"omk-turn-metrics-2\" as const;\n/** Compatibility constant: maximum failure text inspected for classification, never retained. */\nexport const MAX_ERROR_CHARS = 200;\nconst ERROR_CLASSES = [\"timeout\", \"aborted\", \"permission\", \"not_found\", \"invalid_input\", \"unknown\"] as const;\ntype ErrorClass = (typeof ERROR_CLASSES)[number];\nconst STOP_REASONS = [\"stop\", \"length\", \"toolUse\", \"error\", \"aborted\"];\n\nexport interface ToolCallMetric {\n\treadonly name: string;\n\treadonly durationMs: number;\n\treadonly ok: boolean;\n\t/** Input-only failure text. Never written to the metrics stream. */\n\treadonly error?: string;\n}\nexport interface ToolCallMetricRecord extends Omit<ToolCallMetric, \"error\"> {\n\treadonly errorClass?: ErrorClass;\n}\nexport interface TurnUsageMetric {\n\treadonly input: number;\n\treadonly output: number;\n\treadonly cacheRead: number;\n\treadonly cacheWrite: number;\n\treadonly costUsd: number;\n}\nexport interface TurnMetricInput {\n\treadonly sessionId: string;\n\treadonly turnIndex: number;\n\treadonly provider?: string;\n\treadonly model?: string;\n\treadonly startedAtEpochMs: number;\n\treadonly endedAtEpochMs: number;\n\treadonly timeToFirstChunkMs?: number;\n\treadonly usage?: TurnUsageMetric;\n\treadonly stopReason?: string;\n\treadonly toolCalls?: readonly ToolCallMetric[];\n\treadonly compacted?: boolean;\n\treadonly failovers?: number;\n\treadonly contextCache?: { readonly planHit: boolean; readonly hits: number; readonly misses: number };\n\treadonly runtimeProvenance?: RuntimeProvenance;\n}\nexport interface TurnMetricRecord extends Omit<TurnMetricInput, \"toolCalls\"> {\n\treadonly schemaVersion: typeof TURN_METRICS_SCHEMA_VERSION;\n\treadonly durationMs: number;\n\treadonly toolCalls: readonly ToolCallMetricRecord[];\n\treadonly toolCallCount: number;\n\treadonly toolFailureCount: number;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\nfunction object(value: unknown): Record<string, unknown> {\n\tif (!isRecord(value)) throw new TypeError(\"Invalid metric object\");\n\treturn value;\n}\nfunction finite(value: unknown): number {\n\tif (typeof value !== \"number\" || !Number.isFinite(value)) throw new TypeError(\"Invalid metric number\");\n\treturn value;\n}\nfunction quantity(value: unknown, integer = false): number {\n\tconst number = finite(value);\n\tif (number < 0 || (integer && !Number.isSafeInteger(number))) throw new TypeError(\"Invalid metric quantity\");\n\treturn number;\n}\nfunction identifier(value: unknown): string {\n\tif (typeof value !== \"string\" || !value.trim() || value.length > 512 || /[\\u0000-\\u001f\\u007f]/u.test(value))\n\t\tthrow new TypeError(\"Invalid metric identifier\");\n\treturn value;\n}\nfunction boolean(value: unknown): boolean {\n\tif (typeof value !== \"boolean\") throw new TypeError(\"Invalid metric boolean\");\n\treturn value;\n}\nfunction classifyError(value: unknown): ErrorClass {\n\tif (value !== undefined && typeof value !== \"string\") throw new TypeError(\"Invalid metric error text\");\n\tconst text = value?.slice(0, MAX_ERROR_CHARS) ?? \"\";\n\tif (/timed? out|timeout/iu.test(text)) return \"timeout\";\n\tif (/abort|cancel/iu.test(text)) return \"aborted\";\n\tif (/permission|eacces|eperm/iu.test(text)) return \"permission\";\n\tif (/not found|enoent/iu.test(text)) return \"not_found\";\n\tif (/invalid|validation/iu.test(text)) return \"invalid_input\";\n\treturn \"unknown\";\n}\nfunction parseErrorClass(value: unknown): ErrorClass | undefined {\n\tif (value === undefined) return undefined;\n\tfor (const code of ERROR_CLASSES) if (value === code) return code;\n\tthrow new TypeError(\"Invalid metric error class\");\n}\n\n/** Explicit projection is shared by new input and legacy/current persisted records. */\nfunction project(value: unknown, source: \"input\" | \"v1\" | \"v2\"): TurnMetricRecord {\n\tconst input = object(value);\n\tconst rawTools = input.toolCalls === undefined ? [] : input.toolCalls;\n\tif (!Array.isArray(rawTools) || rawTools.length > 10000) throw new TypeError(\"Invalid metric tool list\");\n\tconst toolCalls = Array.from(rawTools, (raw): ToolCallMetricRecord => {\n\t\tconst call = object(raw);\n\t\tconst ok = boolean(call.ok);\n\t\tif (source === \"v2\" && call.error !== undefined) throw new TypeError(\"Raw errors are not v2 metrics\");\n\t\tconst errorClass = source === \"v2\" ? parseErrorClass(call.errorClass) : classifyError(call.error);\n\t\treturn {\n\t\t\tname: identifier(call.name),\n\t\t\tdurationMs: source === \"input\" ? Math.max(0, Math.round(finite(call.durationMs))) : quantity(call.durationMs),\n\t\t\tok,\n\t\t\t...(!ok && errorClass ? { errorClass } : {}),\n\t\t};\n\t});\n\tconst usage = input.usage === undefined ? undefined : object(input.usage);\n\tconst contextCache = input.contextCache === undefined ? undefined : object(input.contextCache);\n\tconst runtimeProvenance =\n\t\tinput.runtimeProvenance === undefined ? undefined : parseRuntimeProvenance(input.runtimeProvenance);\n\tconst startedAtEpochMs = quantity(input.startedAtEpochMs);\n\tconst endedAtEpochMs = quantity(input.endedAtEpochMs);\n\tif (\n\t\tinput.stopReason !== undefined &&\n\t\t(typeof input.stopReason !== \"string\" || !STOP_REASONS.includes(input.stopReason))\n\t)\n\t\tthrow new TypeError(\"Invalid metric stop reason\");\n\treturn {\n\t\tschemaVersion: TURN_METRICS_SCHEMA_VERSION,\n\t\tsessionId: identifier(input.sessionId),\n\t\tturnIndex: quantity(input.turnIndex, true),\n\t\tstartedAtEpochMs,\n\t\tendedAtEpochMs,\n\t\tdurationMs: Math.max(0, endedAtEpochMs - startedAtEpochMs),\n\t\ttoolCalls,\n\t\ttoolCallCount: toolCalls.length,\n\t\ttoolFailureCount: toolCalls.filter((call) => !call.ok).length,\n\t\t...(input.provider === undefined ? {} : { provider: identifier(input.provider) }),\n\t\t...(input.model === undefined ? {} : { model: identifier(input.model) }),\n\t\t...(input.timeToFirstChunkMs === undefined ? {} : { timeToFirstChunkMs: quantity(input.timeToFirstChunkMs) }),\n\t\t...(usage === undefined\n\t\t\t? {}\n\t\t\t: {\n\t\t\t\t\tusage: {\n\t\t\t\t\t\tinput: quantity(usage.input, true),\n\t\t\t\t\t\toutput: quantity(usage.output, true),\n\t\t\t\t\t\tcacheRead: quantity(usage.cacheRead, true),\n\t\t\t\t\t\tcacheWrite: quantity(usage.cacheWrite, true),\n\t\t\t\t\t\tcostUsd: quantity(usage.costUsd),\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t...(input.stopReason === undefined ? {} : { stopReason: input.stopReason }),\n\t\t...(input.compacted === undefined ? {} : { compacted: boolean(input.compacted) }),\n\t\t...(input.failovers === undefined ? {} : { failovers: quantity(input.failovers, true) }),\n\t\t...(contextCache === undefined\n\t\t\t? {}\n\t\t\t: {\n\t\t\t\t\tcontextCache: {\n\t\t\t\t\t\tplanHit: boolean(contextCache.planHit),\n\t\t\t\t\t\thits: quantity(contextCache.hits, true),\n\t\t\t\t\t\tmisses: quantity(contextCache.misses, true),\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t...(runtimeProvenance ? { runtimeProvenance } : {}),\n\t};\n}\n\nexport function buildTurnMetricRecord(input: TurnMetricInput): TurnMetricRecord {\n\treturn project(input, \"input\");\n}\n\n/** Invalid records count as malformed; legacy records are projected, never rewritten on disk. */\nexport function parseTurnMetricRecord(line: string): TurnMetricRecord | undefined {\n\ttry {\n\t\tconst value = object(JSON.parse(line));\n\t\tconst version = value.schemaVersion;\n\t\tif (version !== \"omk-turn-metrics-1\" && version !== TURN_METRICS_SCHEMA_VERSION) return undefined;\n\t\tconst projected = project(value, version === \"omk-turn-metrics-1\" ? \"v1\" : \"v2\");\n\t\tif (\n\t\t\tquantity(value.durationMs) !== projected.durationMs ||\n\t\t\tquantity(value.toolCallCount, true) !== projected.toolCallCount ||\n\t\t\tquantity(value.toolFailureCount, true) !== projected.toolFailureCount\n\t\t)\n\t\t\treturn undefined;\n\t\treturn projected;\n\t} catch (error) {\n\t\tif (error instanceof SyntaxError || error instanceof TypeError) return undefined;\n\t\tthrow error;\n\t}\n}\n"]}
|
|
@@ -12,65 +12,13 @@
|
|
|
12
12
|
* a failed write is dropped, never surfaced as a turn error.
|
|
13
13
|
*
|
|
14
14
|
* Privacy: records counts, durations, ids, and error *classes*. It never
|
|
15
|
-
* records prompt text, tool arguments, tool output, or file contents.
|
|
16
|
-
*
|
|
15
|
+
* records prompt text, tool arguments, tool output, or file contents. New
|
|
16
|
+
* records retain bounded error classes only; raw failure text is never serialized.
|
|
17
17
|
*/
|
|
18
|
-
import { type
|
|
19
|
-
export
|
|
18
|
+
import { type TurnMetricInput, type TurnUsageMetric } from "./turn-metrics-record.ts";
|
|
19
|
+
export * from "./turn-metrics-record.ts";
|
|
20
20
|
/** Rotate once the active file passes this size. */
|
|
21
21
|
export declare const DEFAULT_MAX_METRICS_BYTES: number;
|
|
22
|
-
/** Cap on a retained error string. Enough to classify, too short to carry a payload. */
|
|
23
|
-
export declare const MAX_ERROR_CHARS = 200;
|
|
24
|
-
export interface ToolCallMetric {
|
|
25
|
-
readonly name: string;
|
|
26
|
-
readonly durationMs: number;
|
|
27
|
-
readonly ok: boolean;
|
|
28
|
-
/** Truncated failure text, present only when `ok` is false. */
|
|
29
|
-
readonly error?: string;
|
|
30
|
-
}
|
|
31
|
-
export interface TurnUsageMetric {
|
|
32
|
-
readonly input: number;
|
|
33
|
-
readonly output: number;
|
|
34
|
-
readonly cacheRead: number;
|
|
35
|
-
readonly cacheWrite: number;
|
|
36
|
-
readonly costUsd: number;
|
|
37
|
-
}
|
|
38
|
-
export interface TurnMetricInput {
|
|
39
|
-
readonly sessionId: string;
|
|
40
|
-
readonly turnIndex: number;
|
|
41
|
-
readonly provider?: string;
|
|
42
|
-
readonly model?: string;
|
|
43
|
-
readonly startedAtEpochMs: number;
|
|
44
|
-
readonly endedAtEpochMs: number;
|
|
45
|
-
/** Time to the first streamed assistant chunk, when observed. */
|
|
46
|
-
readonly timeToFirstChunkMs?: number;
|
|
47
|
-
readonly usage?: TurnUsageMetric;
|
|
48
|
-
readonly stopReason?: string;
|
|
49
|
-
readonly toolCalls?: readonly ToolCallMetric[];
|
|
50
|
-
/** Whether compaction ran during this turn. */
|
|
51
|
-
readonly compacted?: boolean;
|
|
52
|
-
/** Provider failover attempts made during this turn. */
|
|
53
|
-
readonly failovers?: number;
|
|
54
|
-
/** Context-budget cache outcome for the turn's prompt build. */
|
|
55
|
-
readonly contextCache?: {
|
|
56
|
-
readonly planHit: boolean;
|
|
57
|
-
readonly hits: number;
|
|
58
|
-
readonly misses: number;
|
|
59
|
-
};
|
|
60
|
-
/** Requested/selected/response model and thinking projection. Advisory metadata only. */
|
|
61
|
-
readonly runtimeProvenance?: RuntimeProvenance;
|
|
62
|
-
}
|
|
63
|
-
export interface TurnMetricRecord extends TurnMetricInput {
|
|
64
|
-
readonly schemaVersion: typeof TURN_METRICS_SCHEMA_VERSION;
|
|
65
|
-
readonly durationMs: number;
|
|
66
|
-
readonly toolCallCount: number;
|
|
67
|
-
readonly toolFailureCount: number;
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Build the record written for one turn. Pure: no clock, no filesystem, so the
|
|
71
|
-
* derived fields can be asserted directly.
|
|
72
|
-
*/
|
|
73
|
-
export declare function buildTurnMetricRecord(input: TurnMetricInput): TurnMetricRecord;
|
|
74
22
|
export interface TurnMetricsSinkOptions {
|
|
75
23
|
readonly dir: string;
|
|
76
24
|
readonly maxBytes?: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"turn-metrics.d.ts","sourceRoot":"","sources":["../../src/core/turn-metrics.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Per-turn runtime metrics.\n *\n * Before this, `telemetry.ts` covered install opt-in and nothing else, and\n * `run-journal.ts` recorded a hash-chained *integrity* log (run started /\n * finished / tool timeout). Neither could answer the questions that actually\n * drive harness work: which tools fail, how long they take, what a turn costs,\n * and whether the context cache is doing anything.\n *\n * Shape: one JSON object per line under `<cwd>/.omk/metrics/turns.jsonl`.\n * Append-only, bounded by size with a single rotation, and never load-bearing —\n * a failed write is dropped, never surfaced as a turn error.\n *\n * Privacy: records counts, durations, ids, and error *classes*. It never\n * records prompt text, tool arguments, tool output, or file contents. Tool\n * error strings are truncated and kept only to distinguish failure modes.\n */\n\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { parseRuntimeProvenance, type RuntimeProvenance } from \"./runtime-provenance.ts\";\n\nexport const TURN_METRICS_SCHEMA_VERSION = \"omk-turn-metrics-1\" as const;\n/** Rotate once the active file passes this size. */\nexport const DEFAULT_MAX_METRICS_BYTES = 8 * 1024 * 1024;\n/** Cap on a retained error string. Enough to classify, too short to carry a payload. */\nexport const MAX_ERROR_CHARS = 200;\n\nexport interface ToolCallMetric {\n\treadonly name: string;\n\treadonly durationMs: number;\n\treadonly ok: boolean;\n\t/** Truncated failure text, present only when `ok` is false. */\n\treadonly error?: string;\n}\n\nexport interface TurnUsageMetric {\n\treadonly input: number;\n\treadonly output: number;\n\treadonly cacheRead: number;\n\treadonly cacheWrite: number;\n\treadonly costUsd: number;\n}\n\nexport interface TurnMetricInput {\n\treadonly sessionId: string;\n\treadonly turnIndex: number;\n\treadonly provider?: string;\n\treadonly model?: string;\n\treadonly startedAtEpochMs: number;\n\treadonly endedAtEpochMs: number;\n\t/** Time to the first streamed assistant chunk, when observed. */\n\treadonly timeToFirstChunkMs?: number;\n\treadonly usage?: TurnUsageMetric;\n\treadonly stopReason?: string;\n\treadonly toolCalls?: readonly ToolCallMetric[];\n\t/** Whether compaction ran during this turn. */\n\treadonly compacted?: boolean;\n\t/** Provider failover attempts made during this turn. */\n\treadonly failovers?: number;\n\t/** Context-budget cache outcome for the turn's prompt build. */\n\treadonly contextCache?: { readonly planHit: boolean; readonly hits: number; readonly misses: number };\n\t/** Requested/selected/response model and thinking projection. Advisory metadata only. */\n\treadonly runtimeProvenance?: RuntimeProvenance;\n}\n\nexport interface TurnMetricRecord extends TurnMetricInput {\n\treadonly schemaVersion: typeof TURN_METRICS_SCHEMA_VERSION;\n\treadonly durationMs: number;\n\treadonly toolCallCount: number;\n\treadonly toolFailureCount: number;\n}\n\nfunction truncateError(value: string | undefined): string | undefined {\n\tif (value === undefined) return undefined;\n\tconst collapsed = value.replace(/\\s+/gu, \" \").trim();\n\tif (collapsed.length === 0) return undefined;\n\treturn collapsed.length > MAX_ERROR_CHARS ? `${collapsed.slice(0, MAX_ERROR_CHARS)}\\u2026` : collapsed;\n}\n\n/**\n * Build the record written for one turn. Pure: no clock, no filesystem, so the\n * derived fields can be asserted directly.\n */\nexport function buildTurnMetricRecord(input: TurnMetricInput): TurnMetricRecord {\n\tconst toolCalls = (input.toolCalls ?? []).map((call) => ({\n\t\tname: call.name,\n\t\tdurationMs: Math.max(0, Math.round(call.durationMs)),\n\t\tok: call.ok,\n\t\t...(call.ok ? {} : { error: truncateError(call.error) }),\n\t}));\n\tconst runtimeProvenance =\n\t\tinput.runtimeProvenance === undefined ? undefined : parseRuntimeProvenance(input.runtimeProvenance);\n\t// Spread the raw input minus its projection: a validated projection is re-added\n\t// below, an invalid one is dropped rather than persisted.\n\tconst { runtimeProvenance: _rawProvenance, ...rest } = input;\n\treturn {\n\t\t...rest,\n\t\tschemaVersion: TURN_METRICS_SCHEMA_VERSION,\n\t\tdurationMs: Math.max(0, input.endedAtEpochMs - input.startedAtEpochMs),\n\t\ttoolCalls,\n\t\ttoolCallCount: toolCalls.length,\n\t\ttoolFailureCount: toolCalls.filter((call) => !call.ok).length,\n\t\t...(runtimeProvenance === null || runtimeProvenance === undefined ? {} : { runtimeProvenance }),\n\t};\n}\n\nexport interface TurnMetricsSinkOptions {\n\treadonly dir: string;\n\treadonly maxBytes?: number;\n\treadonly fileName?: string;\n}\n\n/**\n * Append-only JSONL sink. Writes are synchronous and best-effort: metrics must\n * never be the reason a turn fails, so every filesystem error is swallowed and\n * counted instead of thrown.\n */\nexport class TurnMetricsSink {\n\tprivate readonly filePath: string;\n\tprivate readonly maxBytes: number;\n\tprivate dropped = 0;\n\tprivate written = 0;\n\n\tconstructor(options: TurnMetricsSinkOptions) {\n\t\tthis.filePath = path.join(options.dir, options.fileName ?? \"turns.jsonl\");\n\t\tthis.maxBytes = Math.max(1024, options.maxBytes ?? DEFAULT_MAX_METRICS_BYTES);\n\t}\n\n\tget path(): string {\n\t\treturn this.filePath;\n\t}\n\n\t/** Records written successfully this process. */\n\tget writtenCount(): number {\n\t\treturn this.written;\n\t}\n\n\t/** Records lost to a filesystem error this process. */\n\tget droppedCount(): number {\n\t\treturn this.dropped;\n\t}\n\n\t/** Append one turn. Returns `true` when the line reached disk. */\n\trecord(input: TurnMetricInput): boolean {\n\t\tlet line: string;\n\t\ttry {\n\t\t\tline = `${JSON.stringify(buildTurnMetricRecord(input))}\\n`;\n\t\t} catch {\n\t\t\tthis.dropped++;\n\t\t\treturn false;\n\t\t}\n\t\ttry {\n\t\t\tfs.mkdirSync(path.dirname(this.filePath), { recursive: true });\n\t\t\tthis.rotateIfNeeded(Buffer.byteLength(line, \"utf8\"));\n\t\t\tfs.appendFileSync(this.filePath, line, { encoding: \"utf8\", mode: 0o600 });\n\t\t\tthis.written++;\n\t\t\treturn true;\n\t\t} catch {\n\t\t\tthis.dropped++;\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate rotateIfNeeded(incomingBytes: number): void {\n\t\tlet size: number;\n\t\ttry {\n\t\t\tsize = fs.statSync(this.filePath).size;\n\t\t} catch {\n\t\t\treturn; // No file yet.\n\t\t}\n\t\tif (size + incomingBytes <= this.maxBytes) return;\n\t\ttry {\n\t\t\t// Single generation: the previous file is replaced, so disk use stays bounded at 2x.\n\t\t\tfs.renameSync(this.filePath, `${this.filePath}.1`);\n\t\t} catch {\n\t\t\t// Rotation failure is not fatal; the next append still succeeds.\n\t\t}\n\t}\n}\n\nexport interface ToolAggregate {\n\treadonly name: string;\n\treadonly calls: number;\n\treadonly failures: number;\n\treadonly failureRate: number;\n\treadonly p50Ms: number;\n\treadonly p95Ms: number;\n\treadonly totalMs: number;\n}\n\nexport interface TurnMetricsSummary {\n\treadonly turns: number;\n\treadonly sessions: number;\n\treadonly models: readonly string[];\n\treadonly totalCostUsd: number;\n\treadonly totals: TurnUsageMetric;\n\treadonly turnDurationP50Ms: number;\n\treadonly turnDurationP95Ms: number;\n\treadonly timeToFirstChunkP50Ms?: number;\n\treadonly cacheReadShare: number;\n\treadonly compactions: number;\n\treadonly failovers: number;\n\treadonly contextCachePlanHitRate?: number;\n\t/** Turns where a valid projection's response model differs from the selected model. */\n\treadonly provenanceMismatchTurns: number;\n\treadonly tools: readonly ToolAggregate[];\n\t/** Lines that could not be parsed as a metric record. */\n\treadonly malformedLines: number;\n}\n\nfunction percentile(sorted: readonly number[], fraction: number): number {\n\tif (sorted.length === 0) return 0;\n\tconst index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));\n\treturn sorted[index];\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction parseRecord(line: string): TurnMetricRecord | undefined {\n\tlet parsed: unknown;\n\ttry {\n\t\tparsed = JSON.parse(line);\n\t} catch {\n\t\treturn undefined;\n\t}\n\tif (!isRecord(parsed) || parsed.schemaVersion !== TURN_METRICS_SCHEMA_VERSION) return undefined;\n\tif (typeof parsed.turnIndex !== \"number\" || typeof parsed.durationMs !== \"number\") return undefined;\n\treturn parsed as unknown as TurnMetricRecord;\n}\n\n/**\n * Aggregate JSONL lines into a summary. Malformed lines are counted rather than\n * discarded silently, so a truncated tail is visible instead of pretending the\n * data is complete.\n */\nexport function summarizeTurnMetrics(lines: readonly string[]): TurnMetricsSummary {\n\tconst records: TurnMetricRecord[] = [];\n\tlet malformedLines = 0;\n\tfor (const line of lines) {\n\t\tconst trimmed = line.trim();\n\t\tif (trimmed.length === 0) continue;\n\t\tconst record = parseRecord(trimmed);\n\t\tif (record) records.push(record);\n\t\telse malformedLines++;\n\t}\n\n\tconst sessions = new Set<string>();\n\tconst models = new Set<string>();\n\tconst totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 };\n\tconst turnDurations: number[] = [];\n\tconst firstChunkTimes: number[] = [];\n\tconst toolStats = new Map<string, { durations: number[]; failures: number }>();\n\tlet compactions = 0;\n\tlet failovers = 0;\n\tlet planHits = 0;\n\tlet planObservations = 0;\n\tlet provenanceMismatchTurns = 0;\n\n\tfor (const record of records) {\n\t\tsessions.add(record.sessionId);\n\t\tif (record.model) models.add(record.provider ? `${record.provider}/${record.model}` : record.model);\n\t\tif (record.usage) {\n\t\t\ttotals.input += record.usage.input;\n\t\t\ttotals.output += record.usage.output;\n\t\t\ttotals.cacheRead += record.usage.cacheRead;\n\t\t\ttotals.cacheWrite += record.usage.cacheWrite;\n\t\t\ttotals.costUsd += record.usage.costUsd;\n\t\t}\n\t\tturnDurations.push(record.durationMs);\n\t\tif (typeof record.timeToFirstChunkMs === \"number\") firstChunkTimes.push(record.timeToFirstChunkMs);\n\t\tif (record.compacted) compactions++;\n\t\tfailovers += record.failovers ?? 0;\n\t\tif (record.contextCache) {\n\t\t\tplanObservations++;\n\t\t\tif (record.contextCache.planHit) planHits++;\n\t\t}\n\t\t// Re-validate provenance at read time: a tampered projection never counts.\n\t\tconst provenance =\n\t\t\trecord.runtimeProvenance === undefined ? undefined : parseRuntimeProvenance(record.runtimeProvenance);\n\t\tif (\n\t\t\tprovenance !== undefined &&\n\t\t\tprovenance !== null &&\n\t\t\tprovenance.responseModel !== null &&\n\t\t\tprovenance.responseModel !== provenance.selectedModel\n\t\t) {\n\t\t\tprovenanceMismatchTurns++;\n\t\t}\n\t\tfor (const call of record.toolCalls ?? []) {\n\t\t\tconst stats = toolStats.get(call.name) ?? { durations: [], failures: 0 };\n\t\t\tstats.durations.push(call.durationMs);\n\t\t\tif (!call.ok) stats.failures++;\n\t\t\ttoolStats.set(call.name, stats);\n\t\t}\n\t}\n\n\tturnDurations.sort((a, b) => a - b);\n\tfirstChunkTimes.sort((a, b) => a - b);\n\n\tconst tools: ToolAggregate[] = [...toolStats.entries()]\n\t\t.map(([name, stats]) => {\n\t\t\tconst sorted = [...stats.durations].sort((a, b) => a - b);\n\t\t\treturn {\n\t\t\t\tname,\n\t\t\t\tcalls: sorted.length,\n\t\t\t\tfailures: stats.failures,\n\t\t\t\tfailureRate: sorted.length === 0 ? 0 : stats.failures / sorted.length,\n\t\t\t\tp50Ms: percentile(sorted, 0.5),\n\t\t\t\tp95Ms: percentile(sorted, 0.95),\n\t\t\t\ttotalMs: sorted.reduce((sum, value) => sum + value, 0),\n\t\t\t};\n\t\t})\n\t\t.sort((a, b) => b.totalMs - a.totalMs || a.name.localeCompare(b.name));\n\n\tconst promptSideTotal = totals.input + totals.cacheRead;\n\treturn {\n\t\tturns: records.length,\n\t\tsessions: sessions.size,\n\t\tmodels: [...models].sort(),\n\t\ttotalCostUsd: totals.costUsd,\n\t\ttotals,\n\t\tturnDurationP50Ms: percentile(turnDurations, 0.5),\n\t\tturnDurationP95Ms: percentile(turnDurations, 0.95),\n\t\ttimeToFirstChunkP50Ms: firstChunkTimes.length > 0 ? percentile(firstChunkTimes, 0.5) : undefined,\n\t\tcacheReadShare: promptSideTotal === 0 ? 0 : totals.cacheRead / promptSideTotal,\n\t\tcompactions,\n\t\tfailovers,\n\t\tcontextCachePlanHitRate: planObservations === 0 ? undefined : planHits / planObservations,\n\t\tprovenanceMismatchTurns,\n\t\ttools,\n\t\tmalformedLines,\n\t};\n}\n\n/** Read and summarize a metrics file. A missing file summarizes as zero turns. */\nexport function summarizeTurnMetricsFile(filePath: string): TurnMetricsSummary {\n\tlet raw: string;\n\ttry {\n\t\traw = fs.readFileSync(filePath, \"utf8\");\n\t} catch {\n\t\treturn summarizeTurnMetrics([]);\n\t}\n\treturn summarizeTurnMetrics(raw.split(\"\\n\"));\n}\n"]}
|
|
1
|
+
{"version":3,"file":"turn-metrics.d.ts","sourceRoot":"","sources":["../../src/core/turn-metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAIH,OAAO,EAGN,KAAK,eAAe,EAEpB,KAAK,eAAe,EACpB,MAAM,0BAA0B,CAAC;AAElC,cAAc,0BAA0B,CAAC;AACzC,oDAAoD;AACpD,eAAO,MAAM,yBAAyB,QAAkB,CAAC;AACzD,MAAM,WAAW,sBAAsB;IACtC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;GAIG;AACH,qBAAa,eAAe;IAC3B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,OAAO,CAAK;IAEpB,YAAY,OAAO,EAAE,sBAAsB,EAG1C;IAED,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,iDAAiD;IACjD,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,uDAAuD;IACvD,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,kEAAkE;IAClE,MAAM,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAuBtC;IAED,OAAO,CAAC,cAAc;CAetB;AAED,MAAM,WAAW,aAAa;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,kBAAkB;IAClC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC;IACjC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IACxC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,uBAAuB,CAAC,EAAE,MAAM,CAAC;IAC1C,uFAAuF;IACvF,QAAQ,CAAC,uBAAuB,EAAE,MAAM,CAAC;IACzC,QAAQ,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,CAAC;IACzC,yDAAyD;IACzD,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;CAChC;AAQD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,kBAAkB,CA8FjF;AAED,kFAAkF;AAClF,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,kBAAkB,CAQ7E","sourcesContent":["/**\n * Per-turn runtime metrics.\n *\n * Before this, `telemetry.ts` covered install opt-in and nothing else, and\n * `run-journal.ts` recorded a hash-chained *integrity* log (run started /\n * finished / tool timeout). Neither could answer the questions that actually\n * drive harness work: which tools fail, how long they take, what a turn costs,\n * and whether the context cache is doing anything.\n *\n * Shape: one JSON object per line under `<cwd>/.omk/metrics/turns.jsonl`.\n * Append-only, bounded by size with a single rotation, and never load-bearing —\n * a failed write is dropped, never surfaced as a turn error.\n *\n * Privacy: records counts, durations, ids, and error *classes*. It never\n * records prompt text, tool arguments, tool output, or file contents. New\n * records retain bounded error classes only; raw failure text is never serialized.\n */\n\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport {\n\tbuildTurnMetricRecord,\n\tparseTurnMetricRecord,\n\ttype TurnMetricInput,\n\ttype TurnMetricRecord,\n\ttype TurnUsageMetric,\n} from \"./turn-metrics-record.ts\";\n\nexport * from \"./turn-metrics-record.ts\";\n/** Rotate once the active file passes this size. */\nexport const DEFAULT_MAX_METRICS_BYTES = 8 * 1024 * 1024;\nexport interface TurnMetricsSinkOptions {\n\treadonly dir: string;\n\treadonly maxBytes?: number;\n\treadonly fileName?: string;\n}\n\n/**\n * Append-only JSONL sink. Writes are synchronous and best-effort: metrics must\n * never be the reason a turn fails, so every filesystem error is swallowed and\n * counted instead of thrown.\n */\nexport class TurnMetricsSink {\n\tprivate readonly filePath: string;\n\tprivate readonly maxBytes: number;\n\tprivate dropped = 0;\n\tprivate written = 0;\n\n\tconstructor(options: TurnMetricsSinkOptions) {\n\t\tthis.filePath = path.join(options.dir, options.fileName ?? \"turns.jsonl\");\n\t\tthis.maxBytes = Math.max(1024, options.maxBytes ?? DEFAULT_MAX_METRICS_BYTES);\n\t}\n\n\tget path(): string {\n\t\treturn this.filePath;\n\t}\n\n\t/** Records written successfully this process. */\n\tget writtenCount(): number {\n\t\treturn this.written;\n\t}\n\n\t/** Records lost to a filesystem error this process. */\n\tget droppedCount(): number {\n\t\treturn this.dropped;\n\t}\n\n\t/** Append one turn. Returns `true` when the line reached disk. */\n\trecord(input: TurnMetricInput): boolean {\n\t\tlet line: string;\n\t\ttry {\n\t\t\tline = `${JSON.stringify(buildTurnMetricRecord(input))}\\n`;\n\t\t} catch {\n\t\t\tthis.dropped++;\n\t\t\treturn false;\n\t\t}\n\t\tconst bytes = Buffer.byteLength(line, \"utf8\");\n\t\tif (bytes > this.maxBytes) {\n\t\t\tthis.dropped++;\n\t\t\treturn false;\n\t\t}\n\t\ttry {\n\t\t\tfs.mkdirSync(path.dirname(this.filePath), { recursive: true });\n\t\t\tthis.rotateIfNeeded(bytes);\n\t\t\tfs.appendFileSync(this.filePath, line, { encoding: \"utf8\", mode: 0o600 });\n\t\t\tthis.written++;\n\t\t\treturn true;\n\t\t} catch {\n\t\t\tthis.dropped++;\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate rotateIfNeeded(incomingBytes: number): void {\n\t\tlet size: number;\n\t\ttry {\n\t\t\tsize = fs.statSync(this.filePath).size;\n\t\t} catch {\n\t\t\treturn; // No file yet.\n\t\t}\n\t\tif (size + incomingBytes <= this.maxBytes) return;\n\t\ttry {\n\t\t\t// Single generation: the previous file is replaced, so disk use stays bounded at 2x.\n\t\t\tfs.renameSync(this.filePath, `${this.filePath}.1`);\n\t\t} catch {\n\t\t\t// Rotation failure is not fatal; the next append still succeeds.\n\t\t}\n\t}\n}\n\nexport interface ToolAggregate {\n\treadonly name: string;\n\treadonly calls: number;\n\treadonly failures: number;\n\treadonly failureRate: number;\n\treadonly p50Ms: number;\n\treadonly p95Ms: number;\n\treadonly totalMs: number;\n}\n\nexport interface TurnMetricsSummary {\n\treadonly turns: number;\n\treadonly sessions: number;\n\treadonly models: readonly string[];\n\treadonly totalCostUsd: number;\n\treadonly totals: TurnUsageMetric;\n\treadonly turnDurationP50Ms: number;\n\treadonly turnDurationP95Ms: number;\n\treadonly timeToFirstChunkP50Ms?: number;\n\treadonly cacheReadShare: number;\n\treadonly compactions: number;\n\treadonly failovers: number;\n\treadonly contextCachePlanHitRate?: number;\n\t/** Turns where a valid projection's response model differs from the selected model. */\n\treadonly provenanceMismatchTurns: number;\n\treadonly tools: readonly ToolAggregate[];\n\t/** Lines that could not be parsed as a metric record. */\n\treadonly malformedLines: number;\n}\n\nfunction percentile(sorted: readonly number[], fraction: number): number {\n\tif (sorted.length === 0) return 0;\n\tconst index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));\n\treturn sorted[index];\n}\n\n/**\n * Aggregate JSONL lines into a summary. Malformed lines are counted rather than\n * discarded silently, so a truncated tail is visible instead of pretending the\n * data is complete.\n */\nexport function summarizeTurnMetrics(lines: readonly string[]): TurnMetricsSummary {\n\tconst records: TurnMetricRecord[] = [];\n\tlet malformedLines = 0;\n\tfor (const line of lines) {\n\t\tconst trimmed = line.trim();\n\t\tif (trimmed.length === 0) continue;\n\t\tconst record = parseTurnMetricRecord(trimmed);\n\t\tif (record) records.push(record);\n\t\telse malformedLines++;\n\t}\n\n\tconst sessions = new Set<string>();\n\tconst models = new Set<string>();\n\tconst totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 };\n\tconst turnDurations: number[] = [];\n\tconst firstChunkTimes: number[] = [];\n\tconst toolStats = new Map<string, { durations: number[]; failures: number }>();\n\tlet compactions = 0;\n\tlet failovers = 0;\n\tlet planHits = 0;\n\tlet planObservations = 0;\n\tlet provenanceMismatchTurns = 0;\n\n\tfor (const record of records) {\n\t\tsessions.add(record.sessionId);\n\t\tif (record.model) models.add(record.provider ? `${record.provider}/${record.model}` : record.model);\n\t\tif (record.usage) {\n\t\t\ttotals.input += record.usage.input;\n\t\t\ttotals.output += record.usage.output;\n\t\t\ttotals.cacheRead += record.usage.cacheRead;\n\t\t\ttotals.cacheWrite += record.usage.cacheWrite;\n\t\t\ttotals.costUsd += record.usage.costUsd;\n\t\t}\n\t\tturnDurations.push(record.durationMs);\n\t\tif (typeof record.timeToFirstChunkMs === \"number\") firstChunkTimes.push(record.timeToFirstChunkMs);\n\t\tif (record.compacted) compactions++;\n\t\tfailovers += record.failovers ?? 0;\n\t\tif (record.contextCache) {\n\t\t\tplanObservations++;\n\t\t\tif (record.contextCache.planHit) planHits++;\n\t\t}\n\t\tconst provenance = record.runtimeProvenance;\n\t\tif (\n\t\t\tprovenance !== undefined &&\n\t\t\tprovenance !== null &&\n\t\t\tprovenance.responseModel !== null &&\n\t\t\tprovenance.responseModel !== provenance.selectedModel\n\t\t) {\n\t\t\tprovenanceMismatchTurns++;\n\t\t}\n\t\tfor (const call of record.toolCalls ?? []) {\n\t\t\tconst stats = toolStats.get(call.name) ?? { durations: [], failures: 0 };\n\t\t\tstats.durations.push(call.durationMs);\n\t\t\tif (!call.ok) stats.failures++;\n\t\t\ttoolStats.set(call.name, stats);\n\t\t}\n\t}\n\n\tturnDurations.sort((a, b) => a - b);\n\tfirstChunkTimes.sort((a, b) => a - b);\n\n\tconst tools: ToolAggregate[] = [...toolStats.entries()]\n\t\t.map(([name, stats]) => {\n\t\t\tconst sorted = [...stats.durations].sort((a, b) => a - b);\n\t\t\treturn {\n\t\t\t\tname,\n\t\t\t\tcalls: sorted.length,\n\t\t\t\tfailures: stats.failures,\n\t\t\t\tfailureRate: sorted.length === 0 ? 0 : stats.failures / sorted.length,\n\t\t\t\tp50Ms: percentile(sorted, 0.5),\n\t\t\t\tp95Ms: percentile(sorted, 0.95),\n\t\t\t\ttotalMs: sorted.reduce((sum, value) => sum + value, 0),\n\t\t\t};\n\t\t})\n\t\t.sort((a, b) => b.totalMs - a.totalMs || a.name.localeCompare(b.name));\n\n\tconst promptSideTotal = totals.input + totals.cacheRead;\n\treturn {\n\t\tturns: records.length,\n\t\tsessions: sessions.size,\n\t\tmodels: [...models].sort(),\n\t\ttotalCostUsd: totals.costUsd,\n\t\ttotals,\n\t\tturnDurationP50Ms: percentile(turnDurations, 0.5),\n\t\tturnDurationP95Ms: percentile(turnDurations, 0.95),\n\t\ttimeToFirstChunkP50Ms: firstChunkTimes.length > 0 ? percentile(firstChunkTimes, 0.5) : undefined,\n\t\tcacheReadShare: promptSideTotal === 0 ? 0 : totals.cacheRead / promptSideTotal,\n\t\tcompactions,\n\t\tfailovers,\n\t\tcontextCachePlanHitRate: planObservations === 0 ? undefined : planHits / planObservations,\n\t\tprovenanceMismatchTurns,\n\t\ttools,\n\t\tmalformedLines,\n\t};\n}\n\n/** Read and summarize a metrics file. A missing file summarizes as zero turns. */\nexport function summarizeTurnMetricsFile(filePath: string): TurnMetricsSummary {\n\tlet raw: string;\n\ttry {\n\t\traw = fs.readFileSync(filePath, \"utf8\");\n\t} catch {\n\t\treturn summarizeTurnMetrics([]);\n\t}\n\treturn summarizeTurnMetrics(raw.split(\"\\n\"));\n}\n"]}
|
|
@@ -12,50 +12,15 @@
|
|
|
12
12
|
* a failed write is dropped, never surfaced as a turn error.
|
|
13
13
|
*
|
|
14
14
|
* Privacy: records counts, durations, ids, and error *classes*. It never
|
|
15
|
-
* records prompt text, tool arguments, tool output, or file contents.
|
|
16
|
-
*
|
|
15
|
+
* records prompt text, tool arguments, tool output, or file contents. New
|
|
16
|
+
* records retain bounded error classes only; raw failure text is never serialized.
|
|
17
17
|
*/
|
|
18
18
|
import fs from "node:fs";
|
|
19
19
|
import path from "node:path";
|
|
20
|
-
import {
|
|
21
|
-
export
|
|
20
|
+
import { buildTurnMetricRecord, parseTurnMetricRecord, } from "./turn-metrics-record.js";
|
|
21
|
+
export * from "./turn-metrics-record.js";
|
|
22
22
|
/** Rotate once the active file passes this size. */
|
|
23
23
|
export const DEFAULT_MAX_METRICS_BYTES = 8 * 1024 * 1024;
|
|
24
|
-
/** Cap on a retained error string. Enough to classify, too short to carry a payload. */
|
|
25
|
-
export const MAX_ERROR_CHARS = 200;
|
|
26
|
-
function truncateError(value) {
|
|
27
|
-
if (value === undefined)
|
|
28
|
-
return undefined;
|
|
29
|
-
const collapsed = value.replace(/\s+/gu, " ").trim();
|
|
30
|
-
if (collapsed.length === 0)
|
|
31
|
-
return undefined;
|
|
32
|
-
return collapsed.length > MAX_ERROR_CHARS ? `${collapsed.slice(0, MAX_ERROR_CHARS)}\u2026` : collapsed;
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* Build the record written for one turn. Pure: no clock, no filesystem, so the
|
|
36
|
-
* derived fields can be asserted directly.
|
|
37
|
-
*/
|
|
38
|
-
export function buildTurnMetricRecord(input) {
|
|
39
|
-
const toolCalls = (input.toolCalls ?? []).map((call) => ({
|
|
40
|
-
name: call.name,
|
|
41
|
-
durationMs: Math.max(0, Math.round(call.durationMs)),
|
|
42
|
-
ok: call.ok,
|
|
43
|
-
...(call.ok ? {} : { error: truncateError(call.error) }),
|
|
44
|
-
}));
|
|
45
|
-
const runtimeProvenance = input.runtimeProvenance === undefined ? undefined : parseRuntimeProvenance(input.runtimeProvenance);
|
|
46
|
-
// Spread the raw input minus its projection: a validated projection is re-added
|
|
47
|
-
// below, an invalid one is dropped rather than persisted.
|
|
48
|
-
const { runtimeProvenance: _rawProvenance, ...rest } = input;
|
|
49
|
-
return {
|
|
50
|
-
...rest,
|
|
51
|
-
schemaVersion: TURN_METRICS_SCHEMA_VERSION,
|
|
52
|
-
durationMs: Math.max(0, input.endedAtEpochMs - input.startedAtEpochMs),
|
|
53
|
-
toolCalls,
|
|
54
|
-
toolCallCount: toolCalls.length,
|
|
55
|
-
toolFailureCount: toolCalls.filter((call) => !call.ok).length,
|
|
56
|
-
...(runtimeProvenance === null || runtimeProvenance === undefined ? {} : { runtimeProvenance }),
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
24
|
/**
|
|
60
25
|
* Append-only JSONL sink. Writes are synchronous and best-effort: metrics must
|
|
61
26
|
* never be the reason a turn fails, so every filesystem error is swallowed and
|
|
@@ -91,9 +56,14 @@ export class TurnMetricsSink {
|
|
|
91
56
|
this.dropped++;
|
|
92
57
|
return false;
|
|
93
58
|
}
|
|
59
|
+
const bytes = Buffer.byteLength(line, "utf8");
|
|
60
|
+
if (bytes > this.maxBytes) {
|
|
61
|
+
this.dropped++;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
94
64
|
try {
|
|
95
65
|
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
96
|
-
this.rotateIfNeeded(
|
|
66
|
+
this.rotateIfNeeded(bytes);
|
|
97
67
|
fs.appendFileSync(this.filePath, line, { encoding: "utf8", mode: 0o600 });
|
|
98
68
|
this.written++;
|
|
99
69
|
return true;
|
|
@@ -128,23 +98,6 @@ function percentile(sorted, fraction) {
|
|
|
128
98
|
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));
|
|
129
99
|
return sorted[index];
|
|
130
100
|
}
|
|
131
|
-
function isRecord(value) {
|
|
132
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
133
|
-
}
|
|
134
|
-
function parseRecord(line) {
|
|
135
|
-
let parsed;
|
|
136
|
-
try {
|
|
137
|
-
parsed = JSON.parse(line);
|
|
138
|
-
}
|
|
139
|
-
catch {
|
|
140
|
-
return undefined;
|
|
141
|
-
}
|
|
142
|
-
if (!isRecord(parsed) || parsed.schemaVersion !== TURN_METRICS_SCHEMA_VERSION)
|
|
143
|
-
return undefined;
|
|
144
|
-
if (typeof parsed.turnIndex !== "number" || typeof parsed.durationMs !== "number")
|
|
145
|
-
return undefined;
|
|
146
|
-
return parsed;
|
|
147
|
-
}
|
|
148
101
|
/**
|
|
149
102
|
* Aggregate JSONL lines into a summary. Malformed lines are counted rather than
|
|
150
103
|
* discarded silently, so a truncated tail is visible instead of pretending the
|
|
@@ -157,7 +110,7 @@ export function summarizeTurnMetrics(lines) {
|
|
|
157
110
|
const trimmed = line.trim();
|
|
158
111
|
if (trimmed.length === 0)
|
|
159
112
|
continue;
|
|
160
|
-
const record =
|
|
113
|
+
const record = parseTurnMetricRecord(trimmed);
|
|
161
114
|
if (record)
|
|
162
115
|
records.push(record);
|
|
163
116
|
else
|
|
@@ -196,8 +149,7 @@ export function summarizeTurnMetrics(lines) {
|
|
|
196
149
|
if (record.contextCache.planHit)
|
|
197
150
|
planHits++;
|
|
198
151
|
}
|
|
199
|
-
|
|
200
|
-
const provenance = record.runtimeProvenance === undefined ? undefined : parseRuntimeProvenance(record.runtimeProvenance);
|
|
152
|
+
const provenance = record.runtimeProvenance;
|
|
201
153
|
if (provenance !== undefined &&
|
|
202
154
|
provenance !== null &&
|
|
203
155
|
provenance.responseModel !== null &&
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"turn-metrics.js","sourceRoot":"","sources":["../../src/core/turn-metrics.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Per-turn runtime metrics.\n *\n * Before this, `telemetry.ts` covered install opt-in and nothing else, and\n * `run-journal.ts` recorded a hash-chained *integrity* log (run started /\n * finished / tool timeout). Neither could answer the questions that actually\n * drive harness work: which tools fail, how long they take, what a turn costs,\n * and whether the context cache is doing anything.\n *\n * Shape: one JSON object per line under `<cwd>/.omk/metrics/turns.jsonl`.\n * Append-only, bounded by size with a single rotation, and never load-bearing —\n * a failed write is dropped, never surfaced as a turn error.\n *\n * Privacy: records counts, durations, ids, and error *classes*. It never\n * records prompt text, tool arguments, tool output, or file contents. Tool\n * error strings are truncated and kept only to distinguish failure modes.\n */\n\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { parseRuntimeProvenance, type RuntimeProvenance } from \"./runtime-provenance.ts\";\n\nexport const TURN_METRICS_SCHEMA_VERSION = \"omk-turn-metrics-1\" as const;\n/** Rotate once the active file passes this size. */\nexport const DEFAULT_MAX_METRICS_BYTES = 8 * 1024 * 1024;\n/** Cap on a retained error string. Enough to classify, too short to carry a payload. */\nexport const MAX_ERROR_CHARS = 200;\n\nexport interface ToolCallMetric {\n\treadonly name: string;\n\treadonly durationMs: number;\n\treadonly ok: boolean;\n\t/** Truncated failure text, present only when `ok` is false. */\n\treadonly error?: string;\n}\n\nexport interface TurnUsageMetric {\n\treadonly input: number;\n\treadonly output: number;\n\treadonly cacheRead: number;\n\treadonly cacheWrite: number;\n\treadonly costUsd: number;\n}\n\nexport interface TurnMetricInput {\n\treadonly sessionId: string;\n\treadonly turnIndex: number;\n\treadonly provider?: string;\n\treadonly model?: string;\n\treadonly startedAtEpochMs: number;\n\treadonly endedAtEpochMs: number;\n\t/** Time to the first streamed assistant chunk, when observed. */\n\treadonly timeToFirstChunkMs?: number;\n\treadonly usage?: TurnUsageMetric;\n\treadonly stopReason?: string;\n\treadonly toolCalls?: readonly ToolCallMetric[];\n\t/** Whether compaction ran during this turn. */\n\treadonly compacted?: boolean;\n\t/** Provider failover attempts made during this turn. */\n\treadonly failovers?: number;\n\t/** Context-budget cache outcome for the turn's prompt build. */\n\treadonly contextCache?: { readonly planHit: boolean; readonly hits: number; readonly misses: number };\n\t/** Requested/selected/response model and thinking projection. Advisory metadata only. */\n\treadonly runtimeProvenance?: RuntimeProvenance;\n}\n\nexport interface TurnMetricRecord extends TurnMetricInput {\n\treadonly schemaVersion: typeof TURN_METRICS_SCHEMA_VERSION;\n\treadonly durationMs: number;\n\treadonly toolCallCount: number;\n\treadonly toolFailureCount: number;\n}\n\nfunction truncateError(value: string | undefined): string | undefined {\n\tif (value === undefined) return undefined;\n\tconst collapsed = value.replace(/\\s+/gu, \" \").trim();\n\tif (collapsed.length === 0) return undefined;\n\treturn collapsed.length > MAX_ERROR_CHARS ? `${collapsed.slice(0, MAX_ERROR_CHARS)}\\u2026` : collapsed;\n}\n\n/**\n * Build the record written for one turn. Pure: no clock, no filesystem, so the\n * derived fields can be asserted directly.\n */\nexport function buildTurnMetricRecord(input: TurnMetricInput): TurnMetricRecord {\n\tconst toolCalls = (input.toolCalls ?? []).map((call) => ({\n\t\tname: call.name,\n\t\tdurationMs: Math.max(0, Math.round(call.durationMs)),\n\t\tok: call.ok,\n\t\t...(call.ok ? {} : { error: truncateError(call.error) }),\n\t}));\n\tconst runtimeProvenance =\n\t\tinput.runtimeProvenance === undefined ? undefined : parseRuntimeProvenance(input.runtimeProvenance);\n\t// Spread the raw input minus its projection: a validated projection is re-added\n\t// below, an invalid one is dropped rather than persisted.\n\tconst { runtimeProvenance: _rawProvenance, ...rest } = input;\n\treturn {\n\t\t...rest,\n\t\tschemaVersion: TURN_METRICS_SCHEMA_VERSION,\n\t\tdurationMs: Math.max(0, input.endedAtEpochMs - input.startedAtEpochMs),\n\t\ttoolCalls,\n\t\ttoolCallCount: toolCalls.length,\n\t\ttoolFailureCount: toolCalls.filter((call) => !call.ok).length,\n\t\t...(runtimeProvenance === null || runtimeProvenance === undefined ? {} : { runtimeProvenance }),\n\t};\n}\n\nexport interface TurnMetricsSinkOptions {\n\treadonly dir: string;\n\treadonly maxBytes?: number;\n\treadonly fileName?: string;\n}\n\n/**\n * Append-only JSONL sink. Writes are synchronous and best-effort: metrics must\n * never be the reason a turn fails, so every filesystem error is swallowed and\n * counted instead of thrown.\n */\nexport class TurnMetricsSink {\n\tprivate readonly filePath: string;\n\tprivate readonly maxBytes: number;\n\tprivate dropped = 0;\n\tprivate written = 0;\n\n\tconstructor(options: TurnMetricsSinkOptions) {\n\t\tthis.filePath = path.join(options.dir, options.fileName ?? \"turns.jsonl\");\n\t\tthis.maxBytes = Math.max(1024, options.maxBytes ?? DEFAULT_MAX_METRICS_BYTES);\n\t}\n\n\tget path(): string {\n\t\treturn this.filePath;\n\t}\n\n\t/** Records written successfully this process. */\n\tget writtenCount(): number {\n\t\treturn this.written;\n\t}\n\n\t/** Records lost to a filesystem error this process. */\n\tget droppedCount(): number {\n\t\treturn this.dropped;\n\t}\n\n\t/** Append one turn. Returns `true` when the line reached disk. */\n\trecord(input: TurnMetricInput): boolean {\n\t\tlet line: string;\n\t\ttry {\n\t\t\tline = `${JSON.stringify(buildTurnMetricRecord(input))}\\n`;\n\t\t} catch {\n\t\t\tthis.dropped++;\n\t\t\treturn false;\n\t\t}\n\t\ttry {\n\t\t\tfs.mkdirSync(path.dirname(this.filePath), { recursive: true });\n\t\t\tthis.rotateIfNeeded(Buffer.byteLength(line, \"utf8\"));\n\t\t\tfs.appendFileSync(this.filePath, line, { encoding: \"utf8\", mode: 0o600 });\n\t\t\tthis.written++;\n\t\t\treturn true;\n\t\t} catch {\n\t\t\tthis.dropped++;\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate rotateIfNeeded(incomingBytes: number): void {\n\t\tlet size: number;\n\t\ttry {\n\t\t\tsize = fs.statSync(this.filePath).size;\n\t\t} catch {\n\t\t\treturn; // No file yet.\n\t\t}\n\t\tif (size + incomingBytes <= this.maxBytes) return;\n\t\ttry {\n\t\t\t// Single generation: the previous file is replaced, so disk use stays bounded at 2x.\n\t\t\tfs.renameSync(this.filePath, `${this.filePath}.1`);\n\t\t} catch {\n\t\t\t// Rotation failure is not fatal; the next append still succeeds.\n\t\t}\n\t}\n}\n\nexport interface ToolAggregate {\n\treadonly name: string;\n\treadonly calls: number;\n\treadonly failures: number;\n\treadonly failureRate: number;\n\treadonly p50Ms: number;\n\treadonly p95Ms: number;\n\treadonly totalMs: number;\n}\n\nexport interface TurnMetricsSummary {\n\treadonly turns: number;\n\treadonly sessions: number;\n\treadonly models: readonly string[];\n\treadonly totalCostUsd: number;\n\treadonly totals: TurnUsageMetric;\n\treadonly turnDurationP50Ms: number;\n\treadonly turnDurationP95Ms: number;\n\treadonly timeToFirstChunkP50Ms?: number;\n\treadonly cacheReadShare: number;\n\treadonly compactions: number;\n\treadonly failovers: number;\n\treadonly contextCachePlanHitRate?: number;\n\t/** Turns where a valid projection's response model differs from the selected model. */\n\treadonly provenanceMismatchTurns: number;\n\treadonly tools: readonly ToolAggregate[];\n\t/** Lines that could not be parsed as a metric record. */\n\treadonly malformedLines: number;\n}\n\nfunction percentile(sorted: readonly number[], fraction: number): number {\n\tif (sorted.length === 0) return 0;\n\tconst index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));\n\treturn sorted[index];\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction parseRecord(line: string): TurnMetricRecord | undefined {\n\tlet parsed: unknown;\n\ttry {\n\t\tparsed = JSON.parse(line);\n\t} catch {\n\t\treturn undefined;\n\t}\n\tif (!isRecord(parsed) || parsed.schemaVersion !== TURN_METRICS_SCHEMA_VERSION) return undefined;\n\tif (typeof parsed.turnIndex !== \"number\" || typeof parsed.durationMs !== \"number\") return undefined;\n\treturn parsed as unknown as TurnMetricRecord;\n}\n\n/**\n * Aggregate JSONL lines into a summary. Malformed lines are counted rather than\n * discarded silently, so a truncated tail is visible instead of pretending the\n * data is complete.\n */\nexport function summarizeTurnMetrics(lines: readonly string[]): TurnMetricsSummary {\n\tconst records: TurnMetricRecord[] = [];\n\tlet malformedLines = 0;\n\tfor (const line of lines) {\n\t\tconst trimmed = line.trim();\n\t\tif (trimmed.length === 0) continue;\n\t\tconst record = parseRecord(trimmed);\n\t\tif (record) records.push(record);\n\t\telse malformedLines++;\n\t}\n\n\tconst sessions = new Set<string>();\n\tconst models = new Set<string>();\n\tconst totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 };\n\tconst turnDurations: number[] = [];\n\tconst firstChunkTimes: number[] = [];\n\tconst toolStats = new Map<string, { durations: number[]; failures: number }>();\n\tlet compactions = 0;\n\tlet failovers = 0;\n\tlet planHits = 0;\n\tlet planObservations = 0;\n\tlet provenanceMismatchTurns = 0;\n\n\tfor (const record of records) {\n\t\tsessions.add(record.sessionId);\n\t\tif (record.model) models.add(record.provider ? `${record.provider}/${record.model}` : record.model);\n\t\tif (record.usage) {\n\t\t\ttotals.input += record.usage.input;\n\t\t\ttotals.output += record.usage.output;\n\t\t\ttotals.cacheRead += record.usage.cacheRead;\n\t\t\ttotals.cacheWrite += record.usage.cacheWrite;\n\t\t\ttotals.costUsd += record.usage.costUsd;\n\t\t}\n\t\tturnDurations.push(record.durationMs);\n\t\tif (typeof record.timeToFirstChunkMs === \"number\") firstChunkTimes.push(record.timeToFirstChunkMs);\n\t\tif (record.compacted) compactions++;\n\t\tfailovers += record.failovers ?? 0;\n\t\tif (record.contextCache) {\n\t\t\tplanObservations++;\n\t\t\tif (record.contextCache.planHit) planHits++;\n\t\t}\n\t\t// Re-validate provenance at read time: a tampered projection never counts.\n\t\tconst provenance =\n\t\t\trecord.runtimeProvenance === undefined ? undefined : parseRuntimeProvenance(record.runtimeProvenance);\n\t\tif (\n\t\t\tprovenance !== undefined &&\n\t\t\tprovenance !== null &&\n\t\t\tprovenance.responseModel !== null &&\n\t\t\tprovenance.responseModel !== provenance.selectedModel\n\t\t) {\n\t\t\tprovenanceMismatchTurns++;\n\t\t}\n\t\tfor (const call of record.toolCalls ?? []) {\n\t\t\tconst stats = toolStats.get(call.name) ?? { durations: [], failures: 0 };\n\t\t\tstats.durations.push(call.durationMs);\n\t\t\tif (!call.ok) stats.failures++;\n\t\t\ttoolStats.set(call.name, stats);\n\t\t}\n\t}\n\n\tturnDurations.sort((a, b) => a - b);\n\tfirstChunkTimes.sort((a, b) => a - b);\n\n\tconst tools: ToolAggregate[] = [...toolStats.entries()]\n\t\t.map(([name, stats]) => {\n\t\t\tconst sorted = [...stats.durations].sort((a, b) => a - b);\n\t\t\treturn {\n\t\t\t\tname,\n\t\t\t\tcalls: sorted.length,\n\t\t\t\tfailures: stats.failures,\n\t\t\t\tfailureRate: sorted.length === 0 ? 0 : stats.failures / sorted.length,\n\t\t\t\tp50Ms: percentile(sorted, 0.5),\n\t\t\t\tp95Ms: percentile(sorted, 0.95),\n\t\t\t\ttotalMs: sorted.reduce((sum, value) => sum + value, 0),\n\t\t\t};\n\t\t})\n\t\t.sort((a, b) => b.totalMs - a.totalMs || a.name.localeCompare(b.name));\n\n\tconst promptSideTotal = totals.input + totals.cacheRead;\n\treturn {\n\t\tturns: records.length,\n\t\tsessions: sessions.size,\n\t\tmodels: [...models].sort(),\n\t\ttotalCostUsd: totals.costUsd,\n\t\ttotals,\n\t\tturnDurationP50Ms: percentile(turnDurations, 0.5),\n\t\tturnDurationP95Ms: percentile(turnDurations, 0.95),\n\t\ttimeToFirstChunkP50Ms: firstChunkTimes.length > 0 ? percentile(firstChunkTimes, 0.5) : undefined,\n\t\tcacheReadShare: promptSideTotal === 0 ? 0 : totals.cacheRead / promptSideTotal,\n\t\tcompactions,\n\t\tfailovers,\n\t\tcontextCachePlanHitRate: planObservations === 0 ? undefined : planHits / planObservations,\n\t\tprovenanceMismatchTurns,\n\t\ttools,\n\t\tmalformedLines,\n\t};\n}\n\n/** Read and summarize a metrics file. A missing file summarizes as zero turns. */\nexport function summarizeTurnMetricsFile(filePath: string): TurnMetricsSummary {\n\tlet raw: string;\n\ttry {\n\t\traw = fs.readFileSync(filePath, \"utf8\");\n\t} catch {\n\t\treturn summarizeTurnMetrics([]);\n\t}\n\treturn summarizeTurnMetrics(raw.split(\"\\n\"));\n}\n"]}
|
|
1
|
+
{"version":3,"file":"turn-metrics.js","sourceRoot":"","sources":["../../src/core/turn-metrics.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Per-turn runtime metrics.\n *\n * Before this, `telemetry.ts` covered install opt-in and nothing else, and\n * `run-journal.ts` recorded a hash-chained *integrity* log (run started /\n * finished / tool timeout). Neither could answer the questions that actually\n * drive harness work: which tools fail, how long they take, what a turn costs,\n * and whether the context cache is doing anything.\n *\n * Shape: one JSON object per line under `<cwd>/.omk/metrics/turns.jsonl`.\n * Append-only, bounded by size with a single rotation, and never load-bearing —\n * a failed write is dropped, never surfaced as a turn error.\n *\n * Privacy: records counts, durations, ids, and error *classes*. It never\n * records prompt text, tool arguments, tool output, or file contents. New\n * records retain bounded error classes only; raw failure text is never serialized.\n */\n\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport {\n\tbuildTurnMetricRecord,\n\tparseTurnMetricRecord,\n\ttype TurnMetricInput,\n\ttype TurnMetricRecord,\n\ttype TurnUsageMetric,\n} from \"./turn-metrics-record.ts\";\n\nexport * from \"./turn-metrics-record.ts\";\n/** Rotate once the active file passes this size. */\nexport const DEFAULT_MAX_METRICS_BYTES = 8 * 1024 * 1024;\nexport interface TurnMetricsSinkOptions {\n\treadonly dir: string;\n\treadonly maxBytes?: number;\n\treadonly fileName?: string;\n}\n\n/**\n * Append-only JSONL sink. Writes are synchronous and best-effort: metrics must\n * never be the reason a turn fails, so every filesystem error is swallowed and\n * counted instead of thrown.\n */\nexport class TurnMetricsSink {\n\tprivate readonly filePath: string;\n\tprivate readonly maxBytes: number;\n\tprivate dropped = 0;\n\tprivate written = 0;\n\n\tconstructor(options: TurnMetricsSinkOptions) {\n\t\tthis.filePath = path.join(options.dir, options.fileName ?? \"turns.jsonl\");\n\t\tthis.maxBytes = Math.max(1024, options.maxBytes ?? DEFAULT_MAX_METRICS_BYTES);\n\t}\n\n\tget path(): string {\n\t\treturn this.filePath;\n\t}\n\n\t/** Records written successfully this process. */\n\tget writtenCount(): number {\n\t\treturn this.written;\n\t}\n\n\t/** Records lost to a filesystem error this process. */\n\tget droppedCount(): number {\n\t\treturn this.dropped;\n\t}\n\n\t/** Append one turn. Returns `true` when the line reached disk. */\n\trecord(input: TurnMetricInput): boolean {\n\t\tlet line: string;\n\t\ttry {\n\t\t\tline = `${JSON.stringify(buildTurnMetricRecord(input))}\\n`;\n\t\t} catch {\n\t\t\tthis.dropped++;\n\t\t\treturn false;\n\t\t}\n\t\tconst bytes = Buffer.byteLength(line, \"utf8\");\n\t\tif (bytes > this.maxBytes) {\n\t\t\tthis.dropped++;\n\t\t\treturn false;\n\t\t}\n\t\ttry {\n\t\t\tfs.mkdirSync(path.dirname(this.filePath), { recursive: true });\n\t\t\tthis.rotateIfNeeded(bytes);\n\t\t\tfs.appendFileSync(this.filePath, line, { encoding: \"utf8\", mode: 0o600 });\n\t\t\tthis.written++;\n\t\t\treturn true;\n\t\t} catch {\n\t\t\tthis.dropped++;\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate rotateIfNeeded(incomingBytes: number): void {\n\t\tlet size: number;\n\t\ttry {\n\t\t\tsize = fs.statSync(this.filePath).size;\n\t\t} catch {\n\t\t\treturn; // No file yet.\n\t\t}\n\t\tif (size + incomingBytes <= this.maxBytes) return;\n\t\ttry {\n\t\t\t// Single generation: the previous file is replaced, so disk use stays bounded at 2x.\n\t\t\tfs.renameSync(this.filePath, `${this.filePath}.1`);\n\t\t} catch {\n\t\t\t// Rotation failure is not fatal; the next append still succeeds.\n\t\t}\n\t}\n}\n\nexport interface ToolAggregate {\n\treadonly name: string;\n\treadonly calls: number;\n\treadonly failures: number;\n\treadonly failureRate: number;\n\treadonly p50Ms: number;\n\treadonly p95Ms: number;\n\treadonly totalMs: number;\n}\n\nexport interface TurnMetricsSummary {\n\treadonly turns: number;\n\treadonly sessions: number;\n\treadonly models: readonly string[];\n\treadonly totalCostUsd: number;\n\treadonly totals: TurnUsageMetric;\n\treadonly turnDurationP50Ms: number;\n\treadonly turnDurationP95Ms: number;\n\treadonly timeToFirstChunkP50Ms?: number;\n\treadonly cacheReadShare: number;\n\treadonly compactions: number;\n\treadonly failovers: number;\n\treadonly contextCachePlanHitRate?: number;\n\t/** Turns where a valid projection's response model differs from the selected model. */\n\treadonly provenanceMismatchTurns: number;\n\treadonly tools: readonly ToolAggregate[];\n\t/** Lines that could not be parsed as a metric record. */\n\treadonly malformedLines: number;\n}\n\nfunction percentile(sorted: readonly number[], fraction: number): number {\n\tif (sorted.length === 0) return 0;\n\tconst index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));\n\treturn sorted[index];\n}\n\n/**\n * Aggregate JSONL lines into a summary. Malformed lines are counted rather than\n * discarded silently, so a truncated tail is visible instead of pretending the\n * data is complete.\n */\nexport function summarizeTurnMetrics(lines: readonly string[]): TurnMetricsSummary {\n\tconst records: TurnMetricRecord[] = [];\n\tlet malformedLines = 0;\n\tfor (const line of lines) {\n\t\tconst trimmed = line.trim();\n\t\tif (trimmed.length === 0) continue;\n\t\tconst record = parseTurnMetricRecord(trimmed);\n\t\tif (record) records.push(record);\n\t\telse malformedLines++;\n\t}\n\n\tconst sessions = new Set<string>();\n\tconst models = new Set<string>();\n\tconst totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 };\n\tconst turnDurations: number[] = [];\n\tconst firstChunkTimes: number[] = [];\n\tconst toolStats = new Map<string, { durations: number[]; failures: number }>();\n\tlet compactions = 0;\n\tlet failovers = 0;\n\tlet planHits = 0;\n\tlet planObservations = 0;\n\tlet provenanceMismatchTurns = 0;\n\n\tfor (const record of records) {\n\t\tsessions.add(record.sessionId);\n\t\tif (record.model) models.add(record.provider ? `${record.provider}/${record.model}` : record.model);\n\t\tif (record.usage) {\n\t\t\ttotals.input += record.usage.input;\n\t\t\ttotals.output += record.usage.output;\n\t\t\ttotals.cacheRead += record.usage.cacheRead;\n\t\t\ttotals.cacheWrite += record.usage.cacheWrite;\n\t\t\ttotals.costUsd += record.usage.costUsd;\n\t\t}\n\t\tturnDurations.push(record.durationMs);\n\t\tif (typeof record.timeToFirstChunkMs === \"number\") firstChunkTimes.push(record.timeToFirstChunkMs);\n\t\tif (record.compacted) compactions++;\n\t\tfailovers += record.failovers ?? 0;\n\t\tif (record.contextCache) {\n\t\t\tplanObservations++;\n\t\t\tif (record.contextCache.planHit) planHits++;\n\t\t}\n\t\tconst provenance = record.runtimeProvenance;\n\t\tif (\n\t\t\tprovenance !== undefined &&\n\t\t\tprovenance !== null &&\n\t\t\tprovenance.responseModel !== null &&\n\t\t\tprovenance.responseModel !== provenance.selectedModel\n\t\t) {\n\t\t\tprovenanceMismatchTurns++;\n\t\t}\n\t\tfor (const call of record.toolCalls ?? []) {\n\t\t\tconst stats = toolStats.get(call.name) ?? { durations: [], failures: 0 };\n\t\t\tstats.durations.push(call.durationMs);\n\t\t\tif (!call.ok) stats.failures++;\n\t\t\ttoolStats.set(call.name, stats);\n\t\t}\n\t}\n\n\tturnDurations.sort((a, b) => a - b);\n\tfirstChunkTimes.sort((a, b) => a - b);\n\n\tconst tools: ToolAggregate[] = [...toolStats.entries()]\n\t\t.map(([name, stats]) => {\n\t\t\tconst sorted = [...stats.durations].sort((a, b) => a - b);\n\t\t\treturn {\n\t\t\t\tname,\n\t\t\t\tcalls: sorted.length,\n\t\t\t\tfailures: stats.failures,\n\t\t\t\tfailureRate: sorted.length === 0 ? 0 : stats.failures / sorted.length,\n\t\t\t\tp50Ms: percentile(sorted, 0.5),\n\t\t\t\tp95Ms: percentile(sorted, 0.95),\n\t\t\t\ttotalMs: sorted.reduce((sum, value) => sum + value, 0),\n\t\t\t};\n\t\t})\n\t\t.sort((a, b) => b.totalMs - a.totalMs || a.name.localeCompare(b.name));\n\n\tconst promptSideTotal = totals.input + totals.cacheRead;\n\treturn {\n\t\tturns: records.length,\n\t\tsessions: sessions.size,\n\t\tmodels: [...models].sort(),\n\t\ttotalCostUsd: totals.costUsd,\n\t\ttotals,\n\t\tturnDurationP50Ms: percentile(turnDurations, 0.5),\n\t\tturnDurationP95Ms: percentile(turnDurations, 0.95),\n\t\ttimeToFirstChunkP50Ms: firstChunkTimes.length > 0 ? percentile(firstChunkTimes, 0.5) : undefined,\n\t\tcacheReadShare: promptSideTotal === 0 ? 0 : totals.cacheRead / promptSideTotal,\n\t\tcompactions,\n\t\tfailovers,\n\t\tcontextCachePlanHitRate: planObservations === 0 ? undefined : planHits / planObservations,\n\t\tprovenanceMismatchTurns,\n\t\ttools,\n\t\tmalformedLines,\n\t};\n}\n\n/** Read and summarize a metrics file. A missing file summarizes as zero turns. */\nexport function summarizeTurnMetricsFile(filePath: string): TurnMetricsSummary {\n\tlet raw: string;\n\ttry {\n\t\traw = fs.readFileSync(filePath, \"utf8\");\n\t} catch {\n\t\treturn summarizeTurnMetrics([]);\n\t}\n\treturn summarizeTurnMetrics(raw.split(\"\\n\"));\n}\n"]}
|
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
import type { WorkspaceScope } from "../types/evidence.ts";
|
|
2
2
|
export declare function isVerifiedBashEnabled(env?: Record<string, string | undefined>): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Why a session scope is not a complete view of the workspace dirty set.
|
|
5
|
+
*
|
|
6
|
+
* `unavailable` is not `complete`: outside a worktree, or when git cannot be
|
|
7
|
+
* read, nothing was enumerated, so an empty artifact set is an absence of
|
|
8
|
+
* evidence rather than evidence of a clean tree.
|
|
9
|
+
*/
|
|
10
|
+
export type SessionScopeCompleteness = "complete" | "partial_truncated" | "partial_excluded" | "unavailable";
|
|
11
|
+
/**
|
|
12
|
+
* A session scope plus what it could not bind.
|
|
13
|
+
*
|
|
14
|
+
* The scope drops dirty paths two ways — a hard cap for availability, and the
|
|
15
|
+
* normalized-path filter the receipt parser forces — and both were previously
|
|
16
|
+
* silent, so a receipt built on a partial view read exactly like one that saw
|
|
17
|
+
* the whole workspace. Dropping stays deliberate; hiding it was the defect.
|
|
18
|
+
*/
|
|
19
|
+
export interface SessionWorkspaceScopeReport {
|
|
20
|
+
readonly scope: WorkspaceScope;
|
|
21
|
+
/** Unique dirty entries git reported, before the cap and the path filter. */
|
|
22
|
+
readonly totalDirtyPathCount: number;
|
|
23
|
+
/** Entries the scope binds: always `scope.artifactPaths.length`. */
|
|
24
|
+
readonly selectedPathCount: number;
|
|
25
|
+
/** Unique dirty entries no receipt can bind, such as a name carrying a backslash. */
|
|
26
|
+
readonly excludedPathCount: number;
|
|
27
|
+
/** True when the cap, not the filter, kept an eligible path out of the scope. */
|
|
28
|
+
readonly truncated: boolean;
|
|
29
|
+
readonly completeness: SessionScopeCompleteness;
|
|
30
|
+
/** Digest of the sorted excluded set, so two different losses are distinguishable. Absent when nothing was excluded. */
|
|
31
|
+
readonly excludedPathSetSha256?: string;
|
|
32
|
+
}
|
|
3
33
|
/**
|
|
4
34
|
* Workspace scope for session bash receipts. Inside a git worktree the scope
|
|
5
35
|
* root is the toplevel and `artifactPaths` is the current dirty set (staged,
|
|
@@ -10,4 +40,14 @@ export declare function isVerifiedBashEnabled(env?: Record<string, string | unde
|
|
|
10
40
|
export declare function resolveSessionWorkspaceScope(cwd: string, options?: {
|
|
11
41
|
maxPaths?: number;
|
|
12
42
|
}): WorkspaceScope;
|
|
43
|
+
/**
|
|
44
|
+
* The scope a receipt would bind, together with the completeness facts that say
|
|
45
|
+
* whether binding it proves anything about the whole workspace.
|
|
46
|
+
*
|
|
47
|
+
* Cached per `(cwd, maxPaths)` for {@link SCOPE_TTL_MS}: keying the cap in too
|
|
48
|
+
* keeps a capped probe from serving a later full request a truncated answer.
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolveSessionWorkspaceScopeReport(cwd: string, options?: {
|
|
51
|
+
maxPaths?: number;
|
|
52
|
+
}): SessionWorkspaceScopeReport;
|
|
13
53
|
//# sourceMappingURL=verified-bash-runtime.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"verified-bash-runtime.d.ts","sourceRoot":"","sources":["../../src/core/verified-bash-runtime.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"verified-bash-runtime.d.ts","sourceRoot":"","sources":["../../src/core/verified-bash-runtime.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,wBAAgB,qBAAqB,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,OAAO,CAGvF;AAeD;;;;;;GAMG;AACH,MAAM,MAAM,wBAAwB,GAAG,UAAU,GAAG,mBAAmB,GAAG,kBAAkB,GAAG,aAAa,CAAC;AAE7G;;;;;;;GAOG;AACH,MAAM,WAAW,2BAA2B;IAC3C,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,6EAA6E;IAC7E,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;IACrC,oEAAoE;IACpE,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,qFAAqF;IACrF,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,iFAAiF;IACjF,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,wBAAwB,CAAC;IAChD,wHAAwH;IACxH,QAAQ,CAAC,qBAAqB,CAAC,EAAE,MAAM,CAAC;CACxC;AAED;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,cAAc,CAEzG;AAED;;;;;;GAMG;AACH,wBAAgB,kCAAkC,CACjD,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAC7B,2BAA2B,CAY7B","sourcesContent":["/**\n * Default-on gate for the verified bash adapter (opt-out via OMK_VERIFIED_BASH=0).\n *\n * Mirrors ADR-OMP-009: the receipt-bound path is the default experience; the\n * exact legacy unverified path remains available as a byte-identical rollback.\n */\nimport { spawnSync } from \"node:child_process\";\nimport { createHash } from \"node:crypto\";\nimport { isNormalizedArtifactPath } from \"../guardrails/workspace-fingerprint.ts\";\nimport type { WorkspaceScope } from \"../types/evidence.ts\";\n\nexport function isVerifiedBashEnabled(env?: Record<string, string | undefined>): boolean {\n\tconst source: Record<string, string | undefined> = env ?? process.env;\n\treturn source.OMK_VERIFIED_BASH !== \"0\";\n}\n\n// ---------------------------------------------------------------------------\n// Git-aware session workspace scope (bounded, TTL-cached)\n// ---------------------------------------------------------------------------\n\nconst SCOPE_TTL_MS = 1_000;\nconst SCOPE_CACHE_MAX = 8;\nconst SCOPE_MAX_PATHS = 32;\nconst GIT_TIMEOUT_MS = 1_500;\n/** Domain separator so an excluded-set digest can never collide with another digest. */\nconst EXCLUDED_SET_DOMAIN = \"omk:evidence:session-scope-excluded:v1\\0\";\n\nconst scopeCache = new Map<string, { at: number; report: SessionWorkspaceScopeReport }>();\n\n/**\n * Why a session scope is not a complete view of the workspace dirty set.\n *\n * `unavailable` is not `complete`: outside a worktree, or when git cannot be\n * read, nothing was enumerated, so an empty artifact set is an absence of\n * evidence rather than evidence of a clean tree.\n */\nexport type SessionScopeCompleteness = \"complete\" | \"partial_truncated\" | \"partial_excluded\" | \"unavailable\";\n\n/**\n * A session scope plus what it could not bind.\n *\n * The scope drops dirty paths two ways — a hard cap for availability, and the\n * normalized-path filter the receipt parser forces — and both were previously\n * silent, so a receipt built on a partial view read exactly like one that saw\n * the whole workspace. Dropping stays deliberate; hiding it was the defect.\n */\nexport interface SessionWorkspaceScopeReport {\n\treadonly scope: WorkspaceScope;\n\t/** Unique dirty entries git reported, before the cap and the path filter. */\n\treadonly totalDirtyPathCount: number;\n\t/** Entries the scope binds: always `scope.artifactPaths.length`. */\n\treadonly selectedPathCount: number;\n\t/** Unique dirty entries no receipt can bind, such as a name carrying a backslash. */\n\treadonly excludedPathCount: number;\n\t/** True when the cap, not the filter, kept an eligible path out of the scope. */\n\treadonly truncated: boolean;\n\treadonly completeness: SessionScopeCompleteness;\n\t/** Digest of the sorted excluded set, so two different losses are distinguishable. Absent when nothing was excluded. */\n\treadonly excludedPathSetSha256?: string;\n}\n\n/**\n * Workspace scope for session bash receipts. Inside a git worktree the scope\n * root is the toplevel and `artifactPaths` is the current dirty set (staged,\n * modified, untracked; capped and sorted), which lets `captureWorkspaceFingerprint`\n * bind HEAD plus a scope-limited dirty digest. Outside git, falls back to an\n * empty artifact set under `cwd`. Never throws.\n */\nexport function resolveSessionWorkspaceScope(cwd: string, options?: { maxPaths?: number }): WorkspaceScope {\n\treturn resolveSessionWorkspaceScopeReport(cwd, options).scope;\n}\n\n/**\n * The scope a receipt would bind, together with the completeness facts that say\n * whether binding it proves anything about the whole workspace.\n *\n * Cached per `(cwd, maxPaths)` for {@link SCOPE_TTL_MS}: keying the cap in too\n * keeps a capped probe from serving a later full request a truncated answer.\n */\nexport function resolveSessionWorkspaceScopeReport(\n\tcwd: string,\n\toptions?: { maxPaths?: number },\n): SessionWorkspaceScopeReport {\n\tconst maxPaths = options?.maxPaths ?? SCOPE_MAX_PATHS;\n\tconst key = `${maxPaths}\\0${cwd}`;\n\tconst cached = scopeCache.get(key);\n\tif (cached && Date.now() - cached.at < SCOPE_TTL_MS) return cached.report;\n\tconst report = computeSessionWorkspaceScope(cwd, maxPaths);\n\tif (scopeCache.size >= SCOPE_CACHE_MAX) {\n\t\tconst oldest = scopeCache.keys().next().value;\n\t\tif (oldest !== undefined) scopeCache.delete(oldest);\n\t}\n\tscopeCache.set(key, { at: Date.now(), report });\n\treturn report;\n}\n\n/** Nothing was enumerated, so the empty artifact set proves nothing about the tree. */\nfunction unavailableScope(root: string): SessionWorkspaceScopeReport {\n\treturn {\n\t\tscope: { root, artifactPaths: [] },\n\t\ttotalDirtyPathCount: 0,\n\t\tselectedPathCount: 0,\n\t\texcludedPathCount: 0,\n\t\ttruncated: false,\n\t\tcompleteness: \"unavailable\",\n\t};\n}\n\nfunction computeSessionWorkspaceScope(cwd: string, maxPaths: number): SessionWorkspaceScopeReport {\n\ttry {\n\t\tconst top = spawnSync(\"git\", [\"-C\", cwd, \"rev-parse\", \"--show-toplevel\"], {\n\t\t\tencoding: \"utf8\",\n\t\t\ttimeout: GIT_TIMEOUT_MS,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t\tif (top.status !== 0 || typeof top.stdout !== \"string\") return unavailableScope(cwd);\n\t\tconst root = top.stdout.trim();\n\t\tif (root.length === 0) return unavailableScope(cwd);\n\t\t// File-granular untracked listing: -unormal emits untracked directories as\n\t\t// `dir/`, whose trailing slash fails assertNormalizedArtifactPath downstream\n\t\t// and kills every verified bash call in the session.\n\t\tconst status = spawnSync(\"git\", [\"-C\", cwd, \"status\", \"--porcelain=v1\", \"-z\", \"--untracked-files=all\"], {\n\t\t\ttimeout: GIT_TIMEOUT_MS,\n\t\t\tmaxBuffer: 4 * 1024 * 1024,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t\tif (status.status !== 0 || !status.stdout) return unavailableScope(root);\n\t\tconst fields = status.stdout.toString(\"utf8\").split(\"\\0\");\n\t\tconst paths = new Set<string>();\n\t\tconst excluded = new Set<string>();\n\t\tfor (let index = 0; index < fields.length; index++) {\n\t\t\tconst field = fields[index];\n\t\t\tif (field.length < 4) continue;\n\t\t\tconst xy = field.slice(0, 2);\n\t\t\tif (xy === \"!!\") continue;\n\t\t\tif (xy.includes(\"R\") || xy.includes(\"C\")) index++; // rename/copy: consume the source path field too\n\t\t\tconst artifact = field.slice(3);\n\t\t\t// Keep only entries the fingerprint can bind. This drops trailing-slash\n\t\t\t// survivors (untracked nested repositories, whose contents are outside\n\t\t\t// the parent's dirty digest) and names the receipt parser rejects, such\n\t\t\t// as a mangled `\\\\wsl.localhost\\...` directory containing a backslash.\n\t\t\tif (isNormalizedArtifactPath(artifact)) paths.add(artifact);\n\t\t\telse excluded.add(artifact);\n\t\t}\n\t\tconst eligible = [...paths].sort();\n\t\tconst artifactPaths = eligible.slice(0, maxPaths);\n\t\tconst truncated = artifactPaths.length < eligible.length;\n\t\treturn {\n\t\t\tscope: { root, artifactPaths },\n\t\t\ttotalDirtyPathCount: eligible.length + excluded.size,\n\t\t\tselectedPathCount: artifactPaths.length,\n\t\t\texcludedPathCount: excluded.size,\n\t\t\ttruncated,\n\t\t\t// Truncation outranks exclusion: an excluded path is named by the digest\n\t\t\t// below, a capped one is an unbounded unknown.\n\t\t\tcompleteness: truncated ? \"partial_truncated\" : excluded.size > 0 ? \"partial_excluded\" : \"complete\",\n\t\t\t...(excluded.size === 0\n\t\t\t\t? {}\n\t\t\t\t: {\n\t\t\t\t\t\texcludedPathSetSha256: createHash(\"sha256\")\n\t\t\t\t\t\t\t.update(`${EXCLUDED_SET_DOMAIN}${JSON.stringify([...excluded].sort())}`)\n\t\t\t\t\t\t\t.digest(\"hex\"),\n\t\t\t\t\t}),\n\t\t};\n\t} catch {\n\t\treturn unavailableScope(cwd);\n\t}\n}\n"]}
|