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.
Files changed (107) hide show
  1. package/README.md +37 -1
  2. package/bin/amalgm.js +8 -2
  3. package/lib/auth-store.js +223 -0
  4. package/lib/cli.js +1000 -0
  5. package/lib/paths.js +30 -0
  6. package/lib/supervisor.js +467 -0
  7. package/lib/tunnel-chat.js +328 -0
  8. package/lib/tunnel-events.js +499 -0
  9. package/package.json +29 -3
  10. package/runtime/README.md +4 -0
  11. package/runtime/lib/chatInput.js +306 -0
  12. package/runtime/lib/harnesses.js +988 -0
  13. package/runtime/lib/local/amalgmStore.js +128 -0
  14. package/runtime/lib/local/credentialResolver.js +425 -0
  15. package/runtime/lib/mcpApps/registry.js +619 -0
  16. package/runtime/package.json +5 -0
  17. package/runtime/scripts/amalgm-mcp/agents/rest.js +165 -0
  18. package/runtime/scripts/amalgm-mcp/agents/store.js +153 -0
  19. package/runtime/scripts/amalgm-mcp/agents/talk.js +1156 -0
  20. package/runtime/scripts/amalgm-mcp/agents/tools.js +210 -0
  21. package/runtime/scripts/amalgm-mcp/artifacts/advertise.js +132 -0
  22. package/runtime/scripts/amalgm-mcp/artifacts/rest.js +103 -0
  23. package/runtime/scripts/amalgm-mcp/artifacts/store.js +141 -0
  24. package/runtime/scripts/amalgm-mcp/artifacts/supervisor.js +402 -0
  25. package/runtime/scripts/amalgm-mcp/artifacts/tools.js +176 -0
  26. package/runtime/scripts/amalgm-mcp/browser/page.js +637 -0
  27. package/runtime/scripts/amalgm-mcp/browser/tools.js +688 -0
  28. package/runtime/scripts/amalgm-mcp/config.js +138 -0
  29. package/runtime/scripts/amalgm-mcp/credentials/rest.js +45 -0
  30. package/runtime/scripts/amalgm-mcp/deps.js +40 -0
  31. package/runtime/scripts/amalgm-mcp/email/inbound.js +215 -0
  32. package/runtime/scripts/amalgm-mcp/events/executor.js +179 -0
  33. package/runtime/scripts/amalgm-mcp/events/ingress.js +113 -0
  34. package/runtime/scripts/amalgm-mcp/events/matcher.js +125 -0
  35. package/runtime/scripts/amalgm-mcp/events/rest.js +200 -0
  36. package/runtime/scripts/amalgm-mcp/events/ring-buffer.js +19 -0
  37. package/runtime/scripts/amalgm-mcp/events/store.js +98 -0
  38. package/runtime/scripts/amalgm-mcp/events/tools.js +306 -0
  39. package/runtime/scripts/amalgm-mcp/events/webhook-url.js +28 -0
  40. package/runtime/scripts/amalgm-mcp/fs/rest.js +293 -0
  41. package/runtime/scripts/amalgm-mcp/index.js +100 -0
  42. package/runtime/scripts/amalgm-mcp/lib/chat-runner.js +167 -0
  43. package/runtime/scripts/amalgm-mcp/lib/email-md.js +288 -0
  44. package/runtime/scripts/amalgm-mcp/lib/mcp-resolver.js +63 -0
  45. package/runtime/scripts/amalgm-mcp/lib/prefs.js +393 -0
  46. package/runtime/scripts/amalgm-mcp/lib/storage.js +92 -0
  47. package/runtime/scripts/amalgm-mcp/lib/supabase.js +118 -0
  48. package/runtime/scripts/amalgm-mcp/lib/tool-result.js +177 -0
  49. package/runtime/scripts/amalgm-mcp/local/rest.js +80 -0
  50. package/runtime/scripts/amalgm-mcp/mcp-connections/rest.js +151 -0
  51. package/runtime/scripts/amalgm-mcp/notify/index.js +107 -0
  52. package/runtime/scripts/amalgm-mcp/server/http.js +335 -0
  53. package/runtime/scripts/amalgm-mcp/server/mcp.js +116 -0
  54. package/runtime/scripts/amalgm-mcp/slack/inbound.js +200 -0
  55. package/runtime/scripts/amalgm-mcp/tasks/executor.js +225 -0
  56. package/runtime/scripts/amalgm-mcp/tasks/rest.js +110 -0
  57. package/runtime/scripts/amalgm-mcp/tasks/schedule-normalization.js +85 -0
  58. package/runtime/scripts/amalgm-mcp/tasks/scheduler.js +105 -0
  59. package/runtime/scripts/amalgm-mcp/tasks/store.js +139 -0
  60. package/runtime/scripts/amalgm-mcp/tasks/tools.js +391 -0
  61. package/runtime/scripts/amalgm-mcp/user-api-keys/rest.js +105 -0
  62. package/runtime/scripts/amalgm-mcp/workspace/rest.js +389 -0
  63. package/runtime/scripts/chat-core/adapters/claude.js +163 -0
  64. package/runtime/scripts/chat-core/adapters/codex.js +313 -0
  65. package/runtime/scripts/chat-core/adapters/opencode.js +412 -0
  66. package/runtime/scripts/chat-core/auth.js +177 -0
  67. package/runtime/scripts/chat-core/contract.js +326 -0
  68. package/runtime/scripts/chat-core/credentials/store.js +212 -0
  69. package/runtime/scripts/chat-core/egress.js +87 -0
  70. package/runtime/scripts/chat-core/engine.js +195 -0
  71. package/runtime/scripts/chat-core/event-schema.js +231 -0
  72. package/runtime/scripts/chat-core/events.js +190 -0
  73. package/runtime/scripts/chat-core/index.js +11 -0
  74. package/runtime/scripts/chat-core/input.js +50 -0
  75. package/runtime/scripts/chat-core/normalizers/claude.js +450 -0
  76. package/runtime/scripts/chat-core/normalizers/codex.js +380 -0
  77. package/runtime/scripts/chat-core/normalizers/normalizer_spec.md +259 -0
  78. package/runtime/scripts/chat-core/normalizers/opencode.js +552 -0
  79. package/runtime/scripts/chat-core/normalizers/tool_contract.md +123 -0
  80. package/runtime/scripts/chat-core/normalizers/usage_contract.md +304 -0
  81. package/runtime/scripts/chat-core/parts.js +253 -0
  82. package/runtime/scripts/chat-core/recorder.js +65 -0
  83. package/runtime/scripts/chat-core/runtime.js +86 -0
  84. package/runtime/scripts/chat-core/server.js +163 -0
  85. package/runtime/scripts/chat-core/sse.js +196 -0
  86. package/runtime/scripts/chat-core/stores.js +100 -0
  87. package/runtime/scripts/chat-core/tool-display.js +149 -0
  88. package/runtime/scripts/chat-core/tool-shape.js +143 -0
  89. package/runtime/scripts/chat-core/tooling/mcp-bundle.js +161 -0
  90. package/runtime/scripts/chat-core/tooling/mcp-relay.js +97 -0
  91. package/runtime/scripts/chat-core/tooling/native-binaries.js +608 -0
  92. package/runtime/scripts/chat-core/tooling/system-prompt.js +20 -0
  93. package/runtime/scripts/chat-core/usage.js +343 -0
  94. package/runtime/scripts/chat-server/config.js +110 -0
  95. package/runtime/scripts/chat-server/db.js +529 -0
  96. package/runtime/scripts/chat-server/index.js +33 -0
  97. package/runtime/scripts/chat-server/model-catalog.js +327 -0
  98. package/runtime/scripts/chat-server.js +75 -0
  99. package/runtime/scripts/credential-adapter.js +129 -0
  100. package/runtime/scripts/fs-watcher.js +888 -0
  101. package/runtime/scripts/local-gateway.js +852 -0
  102. package/runtime/scripts/platform-context.txt +246 -0
  103. package/runtime/scripts/port-monitor.js +175 -0
  104. package/runtime/scripts/proxy-token-store.js +162 -0
  105. package/runtime/scripts/runtime-auth.js +163 -0
  106. package/runtime/scripts/test-claude-code-models.js +87 -0
  107. 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
+ };