amalgm 0.0.0 → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -1
- package/bin/amalgm.js +8 -2
- package/lib/auth-store.js +223 -0
- package/lib/cli.js +1000 -0
- package/lib/paths.js +30 -0
- package/lib/supervisor.js +467 -0
- package/lib/tunnel-chat.js +328 -0
- package/lib/tunnel-events.js +499 -0
- package/package.json +29 -3
- package/runtime/README.md +4 -0
- package/runtime/lib/chatInput.js +306 -0
- package/runtime/lib/harnesses.js +988 -0
- package/runtime/lib/local/amalgmStore.js +128 -0
- package/runtime/lib/local/credentialResolver.js +425 -0
- package/runtime/lib/mcpApps/registry.js +619 -0
- package/runtime/package.json +5 -0
- package/runtime/scripts/amalgm-mcp/agents/rest.js +165 -0
- package/runtime/scripts/amalgm-mcp/agents/store.js +153 -0
- package/runtime/scripts/amalgm-mcp/agents/talk.js +1156 -0
- package/runtime/scripts/amalgm-mcp/agents/tools.js +210 -0
- package/runtime/scripts/amalgm-mcp/artifacts/advertise.js +132 -0
- package/runtime/scripts/amalgm-mcp/artifacts/rest.js +103 -0
- package/runtime/scripts/amalgm-mcp/artifacts/store.js +141 -0
- package/runtime/scripts/amalgm-mcp/artifacts/supervisor.js +402 -0
- package/runtime/scripts/amalgm-mcp/artifacts/tools.js +176 -0
- package/runtime/scripts/amalgm-mcp/browser/page.js +637 -0
- package/runtime/scripts/amalgm-mcp/browser/tools.js +688 -0
- package/runtime/scripts/amalgm-mcp/config.js +138 -0
- package/runtime/scripts/amalgm-mcp/credentials/rest.js +45 -0
- package/runtime/scripts/amalgm-mcp/deps.js +40 -0
- package/runtime/scripts/amalgm-mcp/email/inbound.js +215 -0
- package/runtime/scripts/amalgm-mcp/events/executor.js +179 -0
- package/runtime/scripts/amalgm-mcp/events/ingress.js +113 -0
- package/runtime/scripts/amalgm-mcp/events/matcher.js +125 -0
- package/runtime/scripts/amalgm-mcp/events/rest.js +200 -0
- package/runtime/scripts/amalgm-mcp/events/ring-buffer.js +19 -0
- package/runtime/scripts/amalgm-mcp/events/store.js +98 -0
- package/runtime/scripts/amalgm-mcp/events/tools.js +306 -0
- package/runtime/scripts/amalgm-mcp/events/webhook-url.js +28 -0
- package/runtime/scripts/amalgm-mcp/fs/rest.js +293 -0
- package/runtime/scripts/amalgm-mcp/index.js +100 -0
- package/runtime/scripts/amalgm-mcp/lib/chat-runner.js +167 -0
- package/runtime/scripts/amalgm-mcp/lib/email-md.js +288 -0
- package/runtime/scripts/amalgm-mcp/lib/mcp-resolver.js +63 -0
- package/runtime/scripts/amalgm-mcp/lib/prefs.js +393 -0
- package/runtime/scripts/amalgm-mcp/lib/storage.js +92 -0
- package/runtime/scripts/amalgm-mcp/lib/supabase.js +118 -0
- package/runtime/scripts/amalgm-mcp/lib/tool-result.js +177 -0
- package/runtime/scripts/amalgm-mcp/local/rest.js +80 -0
- package/runtime/scripts/amalgm-mcp/mcp-connections/rest.js +151 -0
- package/runtime/scripts/amalgm-mcp/notify/index.js +107 -0
- package/runtime/scripts/amalgm-mcp/server/http.js +335 -0
- package/runtime/scripts/amalgm-mcp/server/mcp.js +116 -0
- package/runtime/scripts/amalgm-mcp/slack/inbound.js +200 -0
- package/runtime/scripts/amalgm-mcp/tasks/executor.js +225 -0
- package/runtime/scripts/amalgm-mcp/tasks/rest.js +110 -0
- package/runtime/scripts/amalgm-mcp/tasks/schedule-normalization.js +85 -0
- package/runtime/scripts/amalgm-mcp/tasks/scheduler.js +105 -0
- package/runtime/scripts/amalgm-mcp/tasks/store.js +139 -0
- package/runtime/scripts/amalgm-mcp/tasks/tools.js +391 -0
- package/runtime/scripts/amalgm-mcp/user-api-keys/rest.js +105 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +389 -0
- package/runtime/scripts/chat-core/adapters/claude.js +163 -0
- package/runtime/scripts/chat-core/adapters/codex.js +313 -0
- package/runtime/scripts/chat-core/adapters/opencode.js +412 -0
- package/runtime/scripts/chat-core/auth.js +177 -0
- package/runtime/scripts/chat-core/contract.js +326 -0
- package/runtime/scripts/chat-core/credentials/store.js +212 -0
- package/runtime/scripts/chat-core/egress.js +87 -0
- package/runtime/scripts/chat-core/engine.js +195 -0
- package/runtime/scripts/chat-core/event-schema.js +231 -0
- package/runtime/scripts/chat-core/events.js +190 -0
- package/runtime/scripts/chat-core/index.js +11 -0
- package/runtime/scripts/chat-core/input.js +50 -0
- package/runtime/scripts/chat-core/normalizers/claude.js +450 -0
- package/runtime/scripts/chat-core/normalizers/codex.js +380 -0
- package/runtime/scripts/chat-core/normalizers/normalizer_spec.md +259 -0
- package/runtime/scripts/chat-core/normalizers/opencode.js +552 -0
- package/runtime/scripts/chat-core/normalizers/tool_contract.md +123 -0
- package/runtime/scripts/chat-core/normalizers/usage_contract.md +304 -0
- package/runtime/scripts/chat-core/parts.js +253 -0
- package/runtime/scripts/chat-core/recorder.js +65 -0
- package/runtime/scripts/chat-core/runtime.js +86 -0
- package/runtime/scripts/chat-core/server.js +163 -0
- package/runtime/scripts/chat-core/sse.js +196 -0
- package/runtime/scripts/chat-core/stores.js +100 -0
- package/runtime/scripts/chat-core/tool-display.js +149 -0
- package/runtime/scripts/chat-core/tool-shape.js +143 -0
- package/runtime/scripts/chat-core/tooling/mcp-bundle.js +161 -0
- package/runtime/scripts/chat-core/tooling/mcp-relay.js +97 -0
- package/runtime/scripts/chat-core/tooling/native-binaries.js +608 -0
- package/runtime/scripts/chat-core/tooling/system-prompt.js +20 -0
- package/runtime/scripts/chat-core/usage.js +343 -0
- package/runtime/scripts/chat-server/config.js +110 -0
- package/runtime/scripts/chat-server/db.js +529 -0
- package/runtime/scripts/chat-server/index.js +33 -0
- package/runtime/scripts/chat-server/model-catalog.js +327 -0
- package/runtime/scripts/chat-server.js +75 -0
- package/runtime/scripts/credential-adapter.js +129 -0
- package/runtime/scripts/fs-watcher.js +888 -0
- package/runtime/scripts/local-gateway.js +852 -0
- package/runtime/scripts/platform-context.txt +246 -0
- package/runtime/scripts/port-monitor.js +175 -0
- package/runtime/scripts/proxy-token-store.js +162 -0
- package/runtime/scripts/runtime-auth.js +163 -0
- package/runtime/scripts/test-claude-code-models.js +87 -0
- package/runtime/tsconfig.json +15 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# Usage normalization contract
|
|
2
|
+
|
|
3
|
+
This contract is only for usage and context accounting. Text, reasoning, and
|
|
4
|
+
tool rendering are separate.
|
|
5
|
+
|
|
6
|
+
## Canonical shape
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
type NormalizedUsage = {
|
|
10
|
+
operation: 'chat_step' | 'chat_turn' | 'compaction' | 'title_generation' | 'tool_or_subagent_child';
|
|
11
|
+
cadence?: 'snapshot' | 'step_final' | 'turn_final';
|
|
12
|
+
|
|
13
|
+
modelId: string; // canonical billing model, resolved server-side
|
|
14
|
+
displayModelId?: string; // user-facing selection, optional
|
|
15
|
+
runtimeModelId?: string; // CLI/provider-facing name, optional
|
|
16
|
+
|
|
17
|
+
tokenList: {
|
|
18
|
+
input: number; // uncached input only
|
|
19
|
+
output: number; // includes reasoning/thought output
|
|
20
|
+
cacheRead: number;
|
|
21
|
+
cacheWrite: number;
|
|
22
|
+
thought: number; // breakdown of output, not added to total
|
|
23
|
+
total: number; // input + output + cacheRead + cacheWrite
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
inputTokens: number; // uncached input only
|
|
27
|
+
outputTokens: number;
|
|
28
|
+
cacheReadTokens: number;
|
|
29
|
+
cacheWriteTokens: number;
|
|
30
|
+
thoughtTokens: number;
|
|
31
|
+
|
|
32
|
+
totalInputTokens: number; // input + cacheRead + cacheWrite
|
|
33
|
+
totalTokens: number; // tokenList.total
|
|
34
|
+
inputContextSize: number; // current context footprint for the circle
|
|
35
|
+
|
|
36
|
+
contextUsedTokens: number | null; // legacy/wire alias for inputContextSize
|
|
37
|
+
contextWindow: number | null;
|
|
38
|
+
totalProcessedTokens?: number | null;
|
|
39
|
+
billable: boolean;
|
|
40
|
+
|
|
41
|
+
costUsd: {
|
|
42
|
+
input: number | null;
|
|
43
|
+
output: number | null;
|
|
44
|
+
cacheRead: number | null;
|
|
45
|
+
cacheWrite: number | null;
|
|
46
|
+
total: number | null;
|
|
47
|
+
source: 'proxy' | 'provider' | 'local_estimate' | 'unknown';
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
source: string; // provider/native source
|
|
51
|
+
exact: boolean; // safe for billing/logging
|
|
52
|
+
providerSessionId?: string | null;
|
|
53
|
+
parentTurnId?: string | null;
|
|
54
|
+
raw?: unknown; // audit only, not persisted by default
|
|
55
|
+
};
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Rules
|
|
59
|
+
|
|
60
|
+
1. `inputTokens` means uncached input only.
|
|
61
|
+
2. Cache reads and cache writes are always separate fields.
|
|
62
|
+
3. `totalInputTokens = inputTokens + cacheReadTokens + cacheWriteTokens`.
|
|
63
|
+
4. `tokenList.total = input + output + cacheRead + cacheWrite`.
|
|
64
|
+
5. `totalTokens = tokenList.total`.
|
|
65
|
+
6. `inputContextSize` is the current context footprint used by the circle. For
|
|
66
|
+
step-shaped events it usually equals `tokenList.total`. For aggregate
|
|
67
|
+
turn-final billing records it may differ because `tokenList` is billable
|
|
68
|
+
processed work while `inputContextSize` is the latest context footprint.
|
|
69
|
+
7. `thoughtTokens` is a breakdown of output. It is not added separately to
|
|
70
|
+
`inputContextSize`; normalize provider shapes so `outputTokens` already
|
|
71
|
+
includes reasoning/thought tokens.
|
|
72
|
+
8. Unknown cost is `null`, never zero.
|
|
73
|
+
9. Context window comes from the server-resolved gateway model table, not the CLI
|
|
74
|
+
display name and not the client.
|
|
75
|
+
10. Billing model identity is resolved server-side and must not be trusted from
|
|
76
|
+
the browser or from a user-editable CLI model string.
|
|
77
|
+
11. BYOK/provider-auth usage is logged from the machine when exact usage is
|
|
78
|
+
available. Amalgm-key usage is logged by the proxy.
|
|
79
|
+
12. Live context UI should update whenever a provider emits usage: per turn,
|
|
80
|
+
per step, or per progress event. Cadence may differ; shape should not.
|
|
81
|
+
13. Billing uses exact operation deltas, not cumulative thread totals.
|
|
82
|
+
14. If a provider emits a step-final usage event, treat that as the billable
|
|
83
|
+
operation unit. If only turn-final usage exists, the turn is the operation
|
|
84
|
+
unit.
|
|
85
|
+
15. Context UI may consume snapshots or final operation usage, but it should
|
|
86
|
+
only draw a percentage when `contextWindow` is known.
|
|
87
|
+
16. Compaction is a billable operation when provider usage is available. Do not
|
|
88
|
+
derive billing tokens from `preTokens` / `postTokens`; those are context
|
|
89
|
+
state metadata, not price input.
|
|
90
|
+
|
|
91
|
+
## Operation semantics
|
|
92
|
+
|
|
93
|
+
Usage has two consumers:
|
|
94
|
+
|
|
95
|
+
1. Billing / persistence wants exact operation deltas.
|
|
96
|
+
2. The context circle wants live snapshots of current context pressure.
|
|
97
|
+
|
|
98
|
+
The normalized event shape should support both without making the UI infer
|
|
99
|
+
provider-specific meaning.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
type UsageSnapshotEvent = {
|
|
103
|
+
type: 'usage.snapshot';
|
|
104
|
+
usage: NormalizedUsage; // cadence: snapshot
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
type UsageOperationEvent = {
|
|
108
|
+
type: 'usage.operation';
|
|
109
|
+
usage: NormalizedUsage; // cadence: step_final | turn_final
|
|
110
|
+
};
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Current wire compatibility may still use `usage.final` / `usage_update`, but the
|
|
114
|
+
semantic target is:
|
|
115
|
+
|
|
116
|
+
- `usage.snapshot`: frequent enough for context UI; not necessarily billable.
|
|
117
|
+
- `usage.operation`: exact provider/proxy delta; safe for usage logs.
|
|
118
|
+
- While wire compatibility uses `usage.final`, distinguish the two with
|
|
119
|
+
`billable` and `exact`: snapshots use `billable: false, exact: false`;
|
|
120
|
+
operation rows use `billable: true, exact: true`.
|
|
121
|
+
|
|
122
|
+
If a provider sends only one exact event at turn end, emit one
|
|
123
|
+
`usage.operation` with `operation: "chat_turn"` and `cadence: "turn_final"`.
|
|
124
|
+
|
|
125
|
+
If a provider sends step-final events, emit each as a `usage.operation` with
|
|
126
|
+
`cadence: "step_final"`. The chat turn can still complete separately, but billing
|
|
127
|
+
must not overwrite/squash earlier step operations with only the last step.
|
|
128
|
+
|
|
129
|
+
For cumulative providers, compute or select the per-operation delta:
|
|
130
|
+
|
|
131
|
+
- Prefer native `last` / step delta.
|
|
132
|
+
- Store cumulative totals separately only for audit and context.
|
|
133
|
+
- After compaction/reset boundaries, never compute a negative delta. Mark the
|
|
134
|
+
baseline reset.
|
|
135
|
+
|
|
136
|
+
## Context circle
|
|
137
|
+
|
|
138
|
+
The context circle should consume:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
{
|
|
142
|
+
inputContextSize,
|
|
143
|
+
contextWindow,
|
|
144
|
+
totalProcessedTokens?,
|
|
145
|
+
operation,
|
|
146
|
+
cadence,
|
|
147
|
+
updatedAt
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Rules:
|
|
152
|
+
|
|
153
|
+
- Use `inputContextSize / contextWindow` for the percentage.
|
|
154
|
+
- `contextUsedTokens` may stay on the wire for compatibility, but it should be
|
|
155
|
+
derived from the same computed `inputContextSize`.
|
|
156
|
+
- If `contextWindow` is null or zero, show unknown/disabled state rather than
|
|
157
|
+
`used == size`.
|
|
158
|
+
- Compute `inputContextSize` the same way for every provider. Do not use native
|
|
159
|
+
cumulative usage for one provider and local bucket math for another.
|
|
160
|
+
- For Codex, `last` is the current operation delta, while `total` is useful for
|
|
161
|
+
cumulative processed tokens and audit comparisons. `modelContextWindow` is
|
|
162
|
+
the trusted window when provided.
|
|
163
|
+
- For Claude and OpenCode, server-resolved model metadata provides the window.
|
|
164
|
+
- For Claude aggregate `chat_turn` records, use `result.modelUsage` for the
|
|
165
|
+
billable `tokenList` when present, and use the latest assistant snapshot or
|
|
166
|
+
`result.usage.iterations[]` total for `inputContextSize`.
|
|
167
|
+
- After compaction, context used should drop to the post-compaction working set
|
|
168
|
+
when the provider exposes it. If only operation usage exists, use the next
|
|
169
|
+
context-bearing usage event.
|
|
170
|
+
|
|
171
|
+
## Compaction billing
|
|
172
|
+
|
|
173
|
+
Compaction should be represented as both:
|
|
174
|
+
|
|
175
|
+
- a process/message part, so the user sees the boundary after reload
|
|
176
|
+
- a usage operation, when provider usage exists
|
|
177
|
+
|
|
178
|
+
Recommended metadata:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
type CompactionUsageMetadata = {
|
|
182
|
+
operation: 'compaction';
|
|
183
|
+
preCompactionContextTokens?: number | null;
|
|
184
|
+
postCompactionContextTokens?: number | null;
|
|
185
|
+
trigger?: 'auto' | 'manual' | 'model_triggered';
|
|
186
|
+
durationMs?: number | null;
|
|
187
|
+
providerSessionId?: string | null;
|
|
188
|
+
parentTurnId?: string | null;
|
|
189
|
+
};
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Billing rule:
|
|
193
|
+
|
|
194
|
+
```text
|
|
195
|
+
bill from provider/proxy usage tokens for the compaction operation
|
|
196
|
+
do not bill from preTokens/postTokens alone
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
If provider usage is missing, persist the compaction boundary but do not create a
|
|
200
|
+
billable usage row unless an exact delta can be proven.
|
|
201
|
+
|
|
202
|
+
## 2026-05-08 compaction stress audit
|
|
203
|
+
|
|
204
|
+
Run id:
|
|
205
|
+
|
|
206
|
+
```text
|
|
207
|
+
usage-compaction-20260508-143857
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Files:
|
|
211
|
+
|
|
212
|
+
```text
|
|
213
|
+
~/.amalgm/chat-core-recordings/usage-compaction-20260508-143857.ndjson
|
|
214
|
+
~/.amalgm/agent-normalization-audits/usage-compaction-20260508-143857/ui-events.ndjson
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Reports:
|
|
218
|
+
|
|
219
|
+
```text
|
|
220
|
+
~/.amalgm/agent-normalization-audits/usage-compaction-20260508-143857/reports/b1e015c7-994a-4ed1-be65-a2dd1d18d07d.json
|
|
221
|
+
~/.amalgm/agent-normalization-audits/usage-compaction-20260508-143857/reports/a8e18261-bf9d-4716-b84a-e2b1a194fff9.json
|
|
222
|
+
~/.amalgm/agent-normalization-audits/usage-compaction-20260508-143857/reports/d61a55bd-035f-4e76-a5d4-0ae985b3cb68.json
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Observed:
|
|
226
|
+
|
|
227
|
+
- Claude Haiku/provider-auth emitted three automatic compactions. Native
|
|
228
|
+
`compact_boundary` exposed `pre_tokens`, `post_tokens`, and `duration_ms`, but
|
|
229
|
+
exact usage/cost still arrived on the surrounding `result.usage` turn-final
|
|
230
|
+
events.
|
|
231
|
+
- Codex mini/provider-auth emitted `thread/tokenUsage/updated` with exact `last`
|
|
232
|
+
deltas and cumulative `total`, but no native cost. It did not compact in this
|
|
233
|
+
run; it hit the context limit and reported `last` as zero with `totalTokens`
|
|
234
|
+
equal to the window.
|
|
235
|
+
- OpenCode DeepSeek/Amalgm emitted build step usage and compaction-agent usage.
|
|
236
|
+
Native compaction messages had `mode: "compaction"` / `agent: "compaction"`
|
|
237
|
+
with exact tokens and provider-reported cost.
|
|
238
|
+
- Follow-up input-context verification showed the OpenCode normalizer must emit
|
|
239
|
+
hidden compaction and `compaction_continue` usage into the normalized stream,
|
|
240
|
+
not just rely on proxy persistence. Otherwise billing rows can exist while the
|
|
241
|
+
context circle does not drop until the next user turn.
|
|
242
|
+
- OpenCode's third compaction reached native, Amalgm, and UI, but the turn never
|
|
243
|
+
produced a final UI completion, so the compaction process part was not
|
|
244
|
+
persisted even though proxy usage rows were logged. This is a persistence edge
|
|
245
|
+
case to fix: compaction boundaries should not depend solely on a later turn
|
|
246
|
+
completion to survive reload.
|
|
247
|
+
- Claude and OpenCode provide provider-side cost. Codex does not.
|
|
248
|
+
- Codex runtime model ids with effort suffixes, such as
|
|
249
|
+
`openai/gpt-5.4-mini-thinking-low`, must resolve to a canonical billing model
|
|
250
|
+
before pricing. In this run, saved Codex market/cost rows were zero because
|
|
251
|
+
that alias was not found by the local catalog.
|
|
252
|
+
|
|
253
|
+
## Model names
|
|
254
|
+
|
|
255
|
+
We intentionally keep separate identities:
|
|
256
|
+
|
|
257
|
+
| Name | Purpose | Example |
|
|
258
|
+
| --- | --- | --- |
|
|
259
|
+
| Display model | What the user sees | `Opus 1M` |
|
|
260
|
+
| Runtime model | What the CLI needs | `opus[1m]` |
|
|
261
|
+
| Billing model | Tamper-proof cost identity | `anthropic/claude-opus-4.7` |
|
|
262
|
+
| Context profile | Window / max-output variant | `1m-context` |
|
|
263
|
+
|
|
264
|
+
`Opus` and `Opus 1M` can share the same billing model while using different
|
|
265
|
+
context profiles.
|
|
266
|
+
|
|
267
|
+
## Provider mapping targets
|
|
268
|
+
|
|
269
|
+
### Claude Code
|
|
270
|
+
|
|
271
|
+
- Final exact billing usage: `result.modelUsage` when present; fall back to
|
|
272
|
+
`result.usage` only if `modelUsage` is missing.
|
|
273
|
+
- Native cost: `result.total_cost_usd`
|
|
274
|
+
- Assistant message usage snapshots: non-billable context circle updates when
|
|
275
|
+
present.
|
|
276
|
+
- `result.usage.iterations[]` is useful for the latest context footprint, but it
|
|
277
|
+
can omit hidden/system/compaction billing work.
|
|
278
|
+
- Intermediate subagent progress: `system task_progress usage.total_tokens`
|
|
279
|
+
- Current gap: progress usage is useful for live context, but does not include
|
|
280
|
+
input/output/cache breakdown.
|
|
281
|
+
- Compaction boundary metadata exposes `pre_tokens`, `post_tokens`, and
|
|
282
|
+
`duration_ms`. Do not bill from those. In observed Electron fixtures, Claude
|
|
283
|
+
folds compaction billing into the surrounding exact `result.modelUsage`
|
|
284
|
+
aggregate rather than emitting a separate compaction tokenList.
|
|
285
|
+
|
|
286
|
+
### Codex
|
|
287
|
+
|
|
288
|
+
- Live usage: `thread/tokenUsage/updated`
|
|
289
|
+
- Fields: `inputTokens`, `outputTokens`, `cachedInputTokens`,
|
|
290
|
+
`reasoningOutputTokens`, `modelContextWindow`
|
|
291
|
+
- Current gap: cache-write semantics still need confirmation from fixtures.
|
|
292
|
+
|
|
293
|
+
### OpenCode
|
|
294
|
+
|
|
295
|
+
- Step usage: `step-finish` parts and final `message.tokens`
|
|
296
|
+
- Fields: `tokens.input`, `tokens.output`, `tokens.cache.read`,
|
|
297
|
+
`tokens.cache.write`, `tokens.reasoning`, `cost`
|
|
298
|
+
- Normalize `tokens.output + tokens.reasoning` into `outputTokens`, while
|
|
299
|
+
keeping `tokens.reasoning` as `thoughtTokens`.
|
|
300
|
+
- Emit usage for same-session hidden compaction operations (`mode:
|
|
301
|
+
"compaction"` / `agent: "compaction"`) and post-compaction continuation
|
|
302
|
+
messages so `inputContextSize` drops immediately after compaction.
|
|
303
|
+
- Current gap: child-session usage should be classified separately from parent
|
|
304
|
+
turn usage.
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function toIso(ts) {
|
|
4
|
+
return new Date(Number.isFinite(ts) ? ts : Date.now()).toISOString();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function suffix(name) {
|
|
8
|
+
return String(name || 'unknown').trim().replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'unknown';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function toToolPartType(name) {
|
|
12
|
+
const clean = suffix(name);
|
|
13
|
+
const aliases = {
|
|
14
|
+
bash: 'bash',
|
|
15
|
+
commandexecution: 'bash',
|
|
16
|
+
read: 'fileRead',
|
|
17
|
+
write: 'fileWrite',
|
|
18
|
+
edit: 'fileEdit',
|
|
19
|
+
filechange: 'fileEdit',
|
|
20
|
+
grep: 'codeSearch',
|
|
21
|
+
glob: 'fileSearch',
|
|
22
|
+
websearch: 'webSearch',
|
|
23
|
+
webfetch: 'webFetch',
|
|
24
|
+
agent: 'task',
|
|
25
|
+
task: 'task',
|
|
26
|
+
askuserquestion: 'question',
|
|
27
|
+
};
|
|
28
|
+
return `tool-${aliases[clean] || clean}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ensureTiming(part, ts, kind) {
|
|
32
|
+
part.providerMetadata = part.providerMetadata && typeof part.providerMetadata === 'object' ? part.providerMetadata : {};
|
|
33
|
+
const timing = part.providerMetadata.processTiming && typeof part.providerMetadata.processTiming === 'object'
|
|
34
|
+
? part.providerMetadata.processTiming
|
|
35
|
+
: {};
|
|
36
|
+
if (!timing.startedAt) timing.startedAt = toIso(ts);
|
|
37
|
+
if (kind && !timing.kind) timing.kind = kind;
|
|
38
|
+
part.providerMetadata.processTiming = timing;
|
|
39
|
+
return timing;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mergeToolShape(part, e) {
|
|
43
|
+
if (e.toolName) part.toolName = e.toolName;
|
|
44
|
+
if (e.kind) part.kind = e.kind;
|
|
45
|
+
if (e.title) part.title = e.title;
|
|
46
|
+
if (e.metadata && typeof e.metadata === 'object') part.metadata = { ...(part.metadata || {}), ...e.metadata };
|
|
47
|
+
if (e.timing && typeof e.timing === 'object') part.timing = { ...(part.timing || {}), ...e.timing };
|
|
48
|
+
if (e.error) part.error = e.error;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function finishTiming(part, ts, secondsKey, kind) {
|
|
52
|
+
const endMs = Number.isFinite(ts) ? ts : Date.now();
|
|
53
|
+
const timing = ensureTiming(part, endMs, kind);
|
|
54
|
+
timing.completedAt = toIso(endMs);
|
|
55
|
+
const startMs = timing.startedAt ? Date.parse(timing.startedAt) : NaN;
|
|
56
|
+
const elapsedMs = Number.isFinite(startMs) ? Math.max(0, endMs - startMs) : 0;
|
|
57
|
+
timing.elapsedMs = elapsedMs;
|
|
58
|
+
part.providerMetadata[secondsKey] = Math.max(1, Math.round((elapsedMs || 1000) / 1000));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function compactionIso(ts) {
|
|
62
|
+
return toIso(Number.isFinite(ts) ? ts : Date.now());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class PartAccumulator {
|
|
66
|
+
constructor() {
|
|
67
|
+
this.parts = [];
|
|
68
|
+
this.textBuffer = '';
|
|
69
|
+
this.reasoningBuffer = '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
apply(e) {
|
|
73
|
+
if (!e) return;
|
|
74
|
+
if (e.type === 'text.delta') {
|
|
75
|
+
this.finishReasoning(e.ts);
|
|
76
|
+
this.textBuffer += e.text || '';
|
|
77
|
+
const last = this.parts[this.parts.length - 1];
|
|
78
|
+
if (last?.type === 'text') last.text = this.textBuffer;
|
|
79
|
+
else this.parts.push({ type: 'text', text: this.textBuffer });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (e.type === 'reasoning.delta') {
|
|
83
|
+
this.reasoningBuffer += e.text || '';
|
|
84
|
+
const last = this.parts[this.parts.length - 1];
|
|
85
|
+
if (last?.type === 'reasoning' && last.state === 'streaming') {
|
|
86
|
+
last.text = this.reasoningBuffer;
|
|
87
|
+
ensureTiming(last, e.ts, 'thinking');
|
|
88
|
+
} else {
|
|
89
|
+
const part = { type: 'reasoning', text: this.reasoningBuffer, state: 'streaming' };
|
|
90
|
+
ensureTiming(part, e.ts, 'thinking');
|
|
91
|
+
this.parts.push(part);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (e.type === 'tool.started') {
|
|
96
|
+
this.finishReasoning(e.ts);
|
|
97
|
+
this.textBuffer = '';
|
|
98
|
+
const existing = this.findTool(e.toolCallId);
|
|
99
|
+
if (existing) {
|
|
100
|
+
existing.input = { ...existing.input, ...(e.input || {}) };
|
|
101
|
+
mergeToolShape(existing, e);
|
|
102
|
+
ensureTiming(existing, e.ts, 'tool');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const part = {
|
|
106
|
+
type: toToolPartType(e.toolName),
|
|
107
|
+
toolCallId: e.toolCallId,
|
|
108
|
+
toolName: e.toolName,
|
|
109
|
+
kind: e.kind,
|
|
110
|
+
input: e.input || {},
|
|
111
|
+
title: e.title || e.toolName,
|
|
112
|
+
metadata: e.metadata,
|
|
113
|
+
timing: e.timing,
|
|
114
|
+
error: e.error,
|
|
115
|
+
state: 'calling',
|
|
116
|
+
};
|
|
117
|
+
ensureTiming(part, e.ts, 'tool');
|
|
118
|
+
this.parts.push(part);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (e.type === 'tool.updated') {
|
|
122
|
+
const tool = this.findTool(e.toolCallId);
|
|
123
|
+
if (!tool) return;
|
|
124
|
+
if (e.inputDelta && typeof e.inputDelta === 'object') tool.input = { ...tool.input, ...e.inputDelta };
|
|
125
|
+
mergeToolShape(tool, e);
|
|
126
|
+
if (e.outputDelta !== undefined && e.outputDelta !== null) {
|
|
127
|
+
const delta = typeof e.outputDelta === 'string' ? e.outputDelta : JSON.stringify(e.outputDelta);
|
|
128
|
+
const previous = typeof tool.output?.result === 'string' ? tool.output.result : '';
|
|
129
|
+
tool.output = {
|
|
130
|
+
status: 'running',
|
|
131
|
+
result: `${previous}${delta}`,
|
|
132
|
+
output: `${previous}${delta}`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
ensureTiming(tool, e.ts, 'tool');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (e.type === 'tool.completed') {
|
|
139
|
+
const tool = this.findTool(e.toolCallId);
|
|
140
|
+
if (!tool) {
|
|
141
|
+
this.parts.push({
|
|
142
|
+
type: toToolPartType(e.toolName || 'unknown'),
|
|
143
|
+
toolCallId: e.toolCallId,
|
|
144
|
+
toolName: e.toolName || 'unknown',
|
|
145
|
+
kind: e.kind,
|
|
146
|
+
input: e.input || {},
|
|
147
|
+
title: e.title || e.toolName || 'unknown',
|
|
148
|
+
metadata: e.metadata,
|
|
149
|
+
timing: e.timing,
|
|
150
|
+
error: e.error,
|
|
151
|
+
state: 'calling',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const part = this.findTool(e.toolCallId);
|
|
155
|
+
mergeToolShape(part, e);
|
|
156
|
+
part.state = e.isError ? 'output-error' : 'output-available';
|
|
157
|
+
part.output = {
|
|
158
|
+
status: e.isError ? 'error' : 'success',
|
|
159
|
+
result: typeof e.result === 'string' ? e.result : String(e.output ?? ''),
|
|
160
|
+
output: e.output ?? e.result ?? '',
|
|
161
|
+
};
|
|
162
|
+
finishTiming(part, e.ts, 'executionSeconds', 'tool');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (e.type === 'compaction.started') {
|
|
166
|
+
this.finishReasoning(e.ts);
|
|
167
|
+
this.textBuffer = '';
|
|
168
|
+
const part = {
|
|
169
|
+
type: 'compaction',
|
|
170
|
+
phase: 'started',
|
|
171
|
+
trigger: e.trigger || 'auto',
|
|
172
|
+
preTokens: Number.isFinite(e.preTokens) ? e.preTokens : null,
|
|
173
|
+
postTokens: Number.isFinite(e.postTokens) ? e.postTokens : null,
|
|
174
|
+
summary: e.summary || undefined,
|
|
175
|
+
startedAt: compactionIso(e.ts),
|
|
176
|
+
finishedAt: undefined,
|
|
177
|
+
durationMs: null,
|
|
178
|
+
providerDurationMs: Number.isFinite(e.durationMs) ? e.durationMs : null,
|
|
179
|
+
};
|
|
180
|
+
this.parts.push(part);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (e.type === 'compaction.finished') {
|
|
184
|
+
this.finishReasoning(e.ts);
|
|
185
|
+
this.textBuffer = '';
|
|
186
|
+
const existing = this.findOpenCompaction();
|
|
187
|
+
const finishedAt = compactionIso(e.ts);
|
|
188
|
+
const startedAt = existing?.startedAt || undefined;
|
|
189
|
+
const startMs = startedAt ? Date.parse(startedAt) : NaN;
|
|
190
|
+
const elapsedMs = Number.isFinite(startMs) && Number.isFinite(e.ts)
|
|
191
|
+
? Math.max(0, e.ts - startMs)
|
|
192
|
+
: null;
|
|
193
|
+
const providerDurationMs = Number.isFinite(e.durationMs) ? e.durationMs : null;
|
|
194
|
+
const part = existing || {
|
|
195
|
+
type: 'compaction',
|
|
196
|
+
startedAt,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
part.phase = 'finished';
|
|
200
|
+
part.trigger = e.trigger || part.trigger || 'auto';
|
|
201
|
+
part.preTokens = Number.isFinite(e.preTokens) ? e.preTokens : (Number.isFinite(part.preTokens) ? part.preTokens : null);
|
|
202
|
+
part.postTokens = Number.isFinite(e.postTokens) ? e.postTokens : (Number.isFinite(part.postTokens) ? part.postTokens : null);
|
|
203
|
+
part.summary = e.summary || part.summary || undefined;
|
|
204
|
+
part.finishedAt = finishedAt;
|
|
205
|
+
part.durationMs = elapsedMs ?? providerDurationMs ?? null;
|
|
206
|
+
part.providerDurationMs = providerDurationMs;
|
|
207
|
+
|
|
208
|
+
if (!existing) this.parts.push(part);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
findTool(id) {
|
|
213
|
+
return this.parts.find((part) => typeof part?.type === 'string' && part.type.startsWith('tool-') && part.toolCallId === id);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
findOpenCompaction() {
|
|
217
|
+
for (let index = this.parts.length - 1; index >= 0; index -= 1) {
|
|
218
|
+
const part = this.parts[index];
|
|
219
|
+
if (part?.type === 'compaction' && part.phase === 'started') return part;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
finishReasoning(ts) {
|
|
225
|
+
const last = this.parts[this.parts.length - 1];
|
|
226
|
+
if (last?.type === 'reasoning' && last.state === 'streaming') {
|
|
227
|
+
last.state = 'done';
|
|
228
|
+
finishTiming(last, ts, 'thinkingSeconds', 'thinking');
|
|
229
|
+
}
|
|
230
|
+
this.reasoningBuffer = '';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
finalize() {
|
|
234
|
+
const ts = Date.now();
|
|
235
|
+
this.finishReasoning(ts);
|
|
236
|
+
for (const part of this.parts) {
|
|
237
|
+
if (typeof part?.type === 'string' && part.type.startsWith('tool-') && part.state === 'calling') {
|
|
238
|
+
part.state = 'output-available';
|
|
239
|
+
const timing = ensureTiming(part, ts, 'tool');
|
|
240
|
+
timing.inferredCompletion = true;
|
|
241
|
+
}
|
|
242
|
+
if (part?.type === 'compaction' && part.phase === 'started') {
|
|
243
|
+
part.phase = 'finished';
|
|
244
|
+
part.finishedAt = toIso(ts);
|
|
245
|
+
const startMs = part.startedAt ? Date.parse(part.startedAt) : NaN;
|
|
246
|
+
part.durationMs = Number.isFinite(startMs) ? Math.max(0, ts - startMs) : null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return this.parts;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = { PartAccumulator, toToolPartType };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const SENSITIVE_KEY = /(api[-_]?key|authorization|auth|bearer|cookie|password|secret|credential|access[-_]?token|refresh[-_]?token|session[-_]?token|id[-_]?token)/i;
|
|
8
|
+
|
|
9
|
+
function recordEnabled() {
|
|
10
|
+
return process.env.CHAT_CORE_RECORD === '1';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function recordPath() {
|
|
14
|
+
if (process.env.CHAT_CORE_RECORD_PATH) return process.env.CHAT_CORE_RECORD_PATH;
|
|
15
|
+
const root = process.env.AMALGM_DIR || path.join(os.homedir(), '.amalgm');
|
|
16
|
+
const session = process.env.CHAT_CORE_RECORD_SESSION || new Date().toISOString().slice(0, 10);
|
|
17
|
+
return path.join(root, 'chat-core-recordings', `${session}.ndjson`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function redactString(value) {
|
|
21
|
+
const home = os.homedir();
|
|
22
|
+
let next = String(value);
|
|
23
|
+
if (home) next = next.split(home).join('~');
|
|
24
|
+
next = next.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]');
|
|
25
|
+
next = next.replace(/\b(?:sk|sk-ant|v0)_[A-Za-z0-9._-]{12,}\b/g, '[redacted-key]');
|
|
26
|
+
return next;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function redact(value, depth = 0) {
|
|
30
|
+
if (depth > 8) return '[redacted-depth]';
|
|
31
|
+
if (value == null) return value;
|
|
32
|
+
if (typeof value === 'string') return redactString(value);
|
|
33
|
+
if (typeof value !== 'object') return value;
|
|
34
|
+
if (Array.isArray(value)) return value.map((entry) => redact(entry, depth + 1));
|
|
35
|
+
|
|
36
|
+
const output = {};
|
|
37
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
38
|
+
output[key] = SENSITIVE_KEY.test(key) ? '[redacted]' : redact(entry, depth + 1);
|
|
39
|
+
}
|
|
40
|
+
return output;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function recordNativeEvent(source, payload, metadata = {}) {
|
|
44
|
+
if (!recordEnabled()) return;
|
|
45
|
+
const file = recordPath();
|
|
46
|
+
const row = {
|
|
47
|
+
recordedAt: new Date().toISOString(),
|
|
48
|
+
source,
|
|
49
|
+
metadata: redact(metadata),
|
|
50
|
+
payload: redact(payload),
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
54
|
+
fs.appendFileSync(file, `${JSON.stringify(row)}\n`);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (process.env.CHAT_CORE_RECORD_DEBUG === '1') {
|
|
57
|
+
console.warn('[chat-core-recorder] failed:', err.message);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = {
|
|
63
|
+
recordNativeEvent,
|
|
64
|
+
redact,
|
|
65
|
+
};
|