@amalgm/chat 0.2.9-preview.35480111675 → 0.2.9-preview.36053697119

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/PURPOSE.md CHANGED
@@ -231,11 +231,15 @@ execution before the first turn exists.
231
231
  invocation's initialized counters cannot make a turn look fully measured.
232
232
  Provider cancellation and its final usage settle before the turn seals.
233
233
  48. Non-platform step usage is delivered from the committed journal with a stable
234
- idempotency key and retried until acknowledged. Platform usage is metered and
235
- persisted by the API proxy for each upstream invocation; local observations
236
- cannot create a second charge. The authenticated host supplies ownership.
237
- Delivery starts only with a complete host-supplied platform capability;
238
- absent delivery configuration leaves measurements pending and local Chat usable.
234
+ idempotency key and retried until acknowledged. Each delivery commits its own
235
+ payload with the step, so finding due work never re-reads turn journals. An
236
+ unavailable destination backs off the whole queue; a record it refuses is
237
+ retried on its own lengthening schedule and never blocks the records behind
238
+ it. Platform usage is metered and persisted by the API proxy for each
239
+ upstream invocation; local observations cannot create a second charge. The
240
+ authenticated host supplies ownership. Delivery starts only with a complete
241
+ host-supplied platform capability; absent delivery configuration leaves
242
+ measurements pending and local Chat usable.
239
243
  49. Only commands with an explicit Amalgm normalization may be advertised or
240
244
  executed. Native discovery is evidence, never execution permission. The only
241
245
  enabled command is `/compact`, on adapters with a proven compaction mapping;
@@ -261,7 +261,8 @@ matching host and proxy implementation.
261
261
  Verification joins native invocation evidence to local steps, checks each
262
262
  bucket independently, then compares live/saved/replayed records and the
263
263
  owner-scoped Supabase rows. Inspect `usage_deliveries` for unacknowledged local
264
- records. Check `billing_transactions.usage_log_id` for platform settlement and
264
+ records; `attempts`, `due_at` and `last_error` show a record the destination
265
+ refused and when it is next retried. Check `billing_transactions.usage_log_id` for platform settlement and
265
266
  confirm BYOK/subscription records have no platform debit. Counts alone do not
266
267
  prove matching usage.
267
268
 
@@ -64,10 +64,11 @@ export class SqliteConversationStore implements ConversationStorePort {
64
64
  beginTurn(input: BeginConversationTurnInput): Promise<ConversationTurn>;
65
65
  appendTurnUsage(input: AppendTurnUsageInput): Promise<void>;
66
66
  pendingUsage(ownerUserId: string, limit?: number): Promise<readonly {
67
- conversationId: string; turnId: string; stepId: string;
68
- execution: ConversationTurn['execution']; observation: AppendTurnUsageInput['observation'];
67
+ conversationId: string; turnId: string; stepId: string; attempts: number;
68
+ execution: ConversationTurn['execution']; observation: Omit<AppendTurnUsageInput['observation'], 'raw'>;
69
69
  }[]>;
70
70
  acknowledgeUsage(ownerUserId: string, turnId: string, stepId: string): Promise<void>;
71
+ deferUsage(ownerUserId: string, turnId: string, stepId: string, retry: { dueAt: string; reason: string }): Promise<void>;
71
72
  appendTurnUpdate(input: AppendTurnUpdateInput): Promise<StoredTurnUpdate>;
72
73
  appendTurnUpdates(input: AppendTurnUpdatesInput): Promise<readonly StoredTurnUpdate[]>;
73
74
  sealTurn(input: SealConversationTurnInput): Promise<ConversationTurn>;
@@ -5,7 +5,7 @@ import { TurnAdmissions } from './admissions.js';
5
5
  import { openDatabase } from './database.js';
6
6
  import { migrate } from './schema.js';
7
7
  import { SqliteConversationStoreError, required } from './errors.js';
8
- import { parseJson, stringify, conversationFromRow, turnFromRow, updateFromRow, messageFromRow, checkpointFromRow } from './rows.js';
8
+ import { parseJson, stringify, deliveryObservation, conversationFromRow, turnFromRow, updateFromRow, messageFromRow, checkpointFromRow } from './rows.js';
9
9
 
10
10
  export { SqliteConversationStoreError } from './errors.js';
11
11
  export { createSqliteJournalBatchPolicy } from './batching.js';
@@ -227,24 +227,29 @@ export class SqliteConversationStore {
227
227
  const ledger = { ...createStepUsageLedger(turn.conversation_id, turn.id), steps: records.flatMap(record => record.step ? [record.step] : []) };
228
228
  if (recordStepUsage(ledger, step) === ledger) return;
229
229
  }
230
- records.push({ usage: normalizeUsage(input.observation.usage), raw: input.observation.raw ?? null, ...(step ? { step } : {}) });
230
+ const record = { usage: normalizeUsage(input.observation.usage), raw: input.observation.raw ?? null, ...(step ? { step } : {}) };
231
+ records.push(record);
231
232
  this.database.prepare('UPDATE turns SET usage_json = ? WHERE id = ?').run(stringify(records), turn.id);
232
233
  if (step && parseJson(turn.execution_json, {}).auth?.mode !== 'platform') {
233
- this.database.prepare('INSERT INTO usage_deliveries(turn_id, step_id) VALUES (?, ?)').run(turn.id, step.stepId);
234
+ this.database.prepare(`INSERT INTO usage_deliveries(turn_id, step_id, observation_json, due_at)
235
+ VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`).run(turn.id, step.stepId, deliveryObservation(record));
234
236
  }
235
237
  })();
236
238
  }
237
239
 
240
+ /**
241
+ * Deliveries that are due now, oldest first. CROSS JOIN keeps the due index as
242
+ * the driving table, so the read is bounded by due rows, never by history.
243
+ */
238
244
  async pendingUsage(ownerUserId, limit = 100) {
239
245
  return this.database.prepare(`
240
- SELECT d.turn_id, d.step_id, c.id AS conversation_id, c.owner_user_id, t.execution_json, j.value AS observation_json
241
- FROM usage_deliveries d JOIN turns t ON t.id = d.turn_id
242
- JOIN conversations c ON c.id = t.conversation_id
243
- JOIN json_each(t.usage_json) j ON json_extract(j.value, '$.step.stepId') = d.step_id
244
- WHERE c.owner_user_id = ? AND d.delivered_at IS NULL
245
- ORDER BY t.created_at, json_extract(j.value, '$.step.ordinal') LIMIT ?
246
+ SELECT d.turn_id, d.step_id, d.attempts, d.observation_json, t.conversation_id, t.execution_json
247
+ FROM usage_deliveries d CROSS JOIN turns t ON t.id = d.turn_id
248
+ CROSS JOIN conversations c ON c.id = t.conversation_id
249
+ WHERE d.delivered_at IS NULL AND d.due_at <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now') AND c.owner_user_id = ?
250
+ ORDER BY d.due_at LIMIT ?
246
251
  `).all(ownerUserId, limit).map(row => ({
247
- conversationId: row.conversation_id, turnId: row.turn_id, stepId: row.step_id,
252
+ conversationId: row.conversation_id, turnId: row.turn_id, stepId: row.step_id, attempts: row.attempts,
248
253
  execution: parseJson(row.execution_json, {}), observation: parseJson(row.observation_json, {}),
249
254
  }));
250
255
  }
@@ -256,6 +261,14 @@ export class SqliteConversationStore {
256
261
  `).run(turnId, stepId, ownerUserId);
257
262
  }
258
263
 
264
+ /** Record a refused attempt; the delivery stays pending and becomes due again at dueAt. */
265
+ async deferUsage(ownerUserId, turnId, stepId, { dueAt, reason }) {
266
+ this.database.prepare(`UPDATE usage_deliveries SET attempts = attempts + 1, due_at = ?, last_error = ?
267
+ WHERE turn_id = ? AND step_id = ? AND delivered_at IS NULL AND turn_id IN
268
+ (SELECT t.id FROM turns t JOIN conversations c ON c.id = t.conversation_id WHERE c.owner_user_id = ?)
269
+ `).run(required(dueAt, 'dueAt'), String(reason ?? ''), turnId, stepId, ownerUserId);
270
+ }
271
+
259
272
  async appendTurnUpdate(input) {
260
273
  const updates = await this.appendTurnUpdates({
261
274
  ...input,
@@ -9,6 +9,11 @@ export function stringify(value) {
9
9
  return JSON.stringify(value ?? null);
10
10
  }
11
11
 
12
+ /** The committed step as delivered: its measurement without the provider's raw evidence. */
13
+ export function deliveryObservation({ usage, step: { raw: _raw, ...step } }) {
14
+ return stringify({ usage, step });
15
+ }
16
+
12
17
  export function boolean(value) {
13
18
  return Number(value) === 1;
14
19
  }
@@ -1,7 +1,8 @@
1
1
  import { migrateTurnQueue } from './queue-migration.js';
2
+ import { migrateUsageDeliveries } from './usage-migration.js';
2
3
  import { SqliteConversationStoreError } from './errors.js';
3
4
 
4
- const SCHEMA_VERSION = 5;
5
+ const SCHEMA_VERSION = 6;
5
6
 
6
7
  export function migrate(database) {
7
8
  const version = Number(database.pragma('user_version', { simple: true }));
@@ -12,7 +13,8 @@ export function migrate(database) {
12
13
  );
13
14
  }
14
15
  if (version === SCHEMA_VERSION) return;
15
- if (version === 4) { migrateTurnQueue(database); return; }
16
+ if (version === 5) { migrateUsageDeliveries(database); return; }
17
+ if (version === 4) { migrateTurnQueue(database); migrate(database); return; }
16
18
  if (version === 3) {
17
19
  database.exec(`
18
20
  BEGIN IMMEDIATE;
@@ -0,0 +1,47 @@
1
+ import { deliveryObservation, parseJson } from './rows.js';
2
+
3
+ /**
4
+ * Give every delivery its own committed payload and retry schedule, so reading
5
+ * pending usage touches only pending rows and never re-reads turn journals.
6
+ * Each journal is parsed once here; a delivery with no journaled step has
7
+ * nothing to deliver and is not carried forward.
8
+ */
9
+ export function migrateUsageDeliveries(database) {
10
+ database.transaction(() => {
11
+ database.exec(`
12
+ ALTER TABLE usage_deliveries RENAME TO usage_deliveries_v5;
13
+ DROP INDEX usage_deliveries_pending_idx;
14
+ CREATE TABLE usage_deliveries (
15
+ turn_id TEXT NOT NULL REFERENCES turns(id) ON DELETE CASCADE,
16
+ step_id TEXT NOT NULL,
17
+ observation_json TEXT NOT NULL,
18
+ attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts >= 0),
19
+ due_at TEXT NOT NULL,
20
+ last_error TEXT,
21
+ delivered_at TEXT,
22
+ PRIMARY KEY(turn_id, step_id)
23
+ );
24
+ CREATE INDEX usage_deliveries_due_idx ON usage_deliveries(due_at) WHERE delivered_at IS NULL;
25
+ `);
26
+ const turns = database.prepare(`
27
+ SELECT id, usage_json, created_at FROM turns
28
+ WHERE id IN (SELECT turn_id FROM usage_deliveries_v5) ORDER BY created_at, rowid
29
+ `).all();
30
+ const deliveries = database.prepare('SELECT step_id, delivered_at FROM usage_deliveries_v5 WHERE turn_id = ?');
31
+ const insert = database.prepare(`
32
+ INSERT INTO usage_deliveries(turn_id, step_id, observation_json, due_at, delivered_at) VALUES (?, ?, ?, ?, ?)
33
+ `);
34
+ for (const turn of turns) {
35
+ const deliveredAt = new Map(deliveries.all(turn.id).map(row => [row.step_id, row.delivered_at]));
36
+ for (const record of parseJson(turn.usage_json, [])) {
37
+ if (!record.step || !deliveredAt.has(record.step.stepId)) continue;
38
+ insert.run(turn.id, record.step.stepId, deliveryObservation(record), turn.created_at, deliveredAt.get(record.step.stepId));
39
+ }
40
+ }
41
+ database.exec(`
42
+ DROP TABLE usage_deliveries_v5;
43
+ INSERT INTO schema_migrations(version, applied_at) VALUES (6, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
44
+ PRAGMA user_version = 6;
45
+ `);
46
+ })();
47
+ }
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
1
3
  import { fetchPlatformEgress, platformMetadataHeaders } from './platform-egress.js';
2
4
 
3
5
  export const TITLE_MODEL_ID = 'openai/gpt-5.6-luna';
@@ -49,7 +51,7 @@ export async function* generateConversationTitle({ conversationId, message, prox
49
51
  'content-type': 'application/json',
50
52
  ...platformMetadataHeaders({
51
53
  sessionId: conversationId,
52
- assistantMessageId: `title:${conversationId}`,
54
+ assistantMessageId: randomUUID(),
53
55
  agentId: 'amalgm-title-generator',
54
56
  harness: 'title-generator',
55
57
  authMethod: 'amalgm',
@@ -18,39 +18,75 @@ export async function beginPlatformTurn(proxy, contract) {
18
18
  if (!decision.skipped) await post(proxy, '/internal/turn-start', decision.body);
19
19
  }
20
20
 
21
- /** Deliver committed steps only. Acknowledgement is separate from the measurement. */
22
- export function createUsageDelivery({ store, ownerUserId, getProxy, intervalMs = 1000, onError = console.warn }) {
21
+ function usageBody({ execution, observation, conversationId, turnId }) {
22
+ const step = observation.step;
23
+ return {
24
+ sessionId: conversationId, messageId: turnId,
25
+ agentId: execution.agent.installationId, harnessId: execution.agent.adapterId,
26
+ authMethod: execution.auth.mode === 'subscription' ? 'provider_auth' : 'byok',
27
+ modelId: step.model.model, provider: step.model.provider,
28
+ stepId: step.stepId, ordinal: step.ordinal, operation: step.operation,
29
+ source: step.source, exactness: step.exactness, usage: {
30
+ inputTokens: step.inputTokens, outputTokens: step.outputTokens,
31
+ cacheReadTokens: step.cacheReadTokens, cacheWriteTokens: step.cacheWriteTokens,
32
+ }, costUsd: observation.usage.costUsd,
33
+ };
34
+ }
35
+
36
+ /**
37
+ * A 4xx that is about the record itself: sending the same record again now
38
+ * cannot succeed, but the records behind it can. Auth, timeout, and rate
39
+ * limits describe the destination instead and pause the whole queue.
40
+ */
41
+ function refusesRecord(error) {
42
+ return error.status >= 400 && error.status < 500 && ![401, 403, 408, 429].includes(error.status);
43
+ }
44
+
45
+ /**
46
+ * Deliver committed steps only. Acknowledgement is separate from the measurement.
47
+ * An unavailable destination backs the queue off; a refused record is retried on
48
+ * its own schedule and never blocks the records behind it.
49
+ */
50
+ export function createUsageDelivery({ store, ownerUserId, getProxy, intervalMs = 1000, maxBackoffMs = 300_000,
51
+ refusedRetryMs = 60_000, maxRefusedRetryMs = 21_600_000, now = Date.now, onError = console.warn }) {
23
52
  let active = null;
24
53
  let closed = false;
25
54
  const flush = () => {
26
55
  if (active) return active;
27
56
  active = (async () => {
57
+ const refused = [];
28
58
  for (const record of await store.pendingUsage(ownerUserId)) {
29
- const { execution, observation, conversationId, turnId } = record;
30
- const step = observation.step;
31
- await post(getProxy(), '/internal/usage', {
32
- sessionId: conversationId, messageId: turnId,
33
- agentId: execution.agent.installationId, harnessId: execution.agent.adapterId,
34
- authMethod: execution.auth.mode === 'subscription' ? 'provider_auth' : 'byok',
35
- modelId: step.model.model, provider: step.model.provider,
36
- stepId: step.stepId, ordinal: step.ordinal, operation: step.operation,
37
- source: step.source, exactness: step.exactness, usage: {
38
- inputTokens: step.inputTokens, outputTokens: step.outputTokens,
39
- cacheReadTokens: step.cacheReadTokens, cacheWriteTokens: step.cacheWriteTokens,
40
- }, costUsd: observation.usage.costUsd,
41
- });
42
- await store.acknowledgeUsage(ownerUserId, turnId, step.stepId);
59
+ const { turnId, stepId, attempts } = record;
60
+ try {
61
+ await post(getProxy(), '/internal/usage', usageBody(record));
62
+ } catch (error) {
63
+ if (!refusesRecord(error)) throw error;
64
+ const retryMs = Math.min(refusedRetryMs * 2 ** attempts, maxRefusedRetryMs);
65
+ await store.deferUsage(ownerUserId, turnId, stepId, { dueAt: new Date(now() + retryMs).toISOString(), reason: error.message });
66
+ refused.push(error.message);
67
+ continue;
68
+ }
69
+ await store.acknowledgeUsage(ownerUserId, turnId, stepId);
43
70
  }
71
+ if (refused.length) onError(`[Chat usage] Destination refused ${refused.length} step(s); retrying later: ${refused[0]}`);
44
72
  })().finally(() => { active = null; });
45
73
  return active;
46
74
  };
75
+ let timer = null;
76
+ let delayMs = intervalMs;
47
77
  let reportedError = null;
48
- const tick = () => { if (!closed) void flush().then(() => { reportedError = null; }, error => {
49
- if (reportedError !== error.message) onError(`[Chat usage] Delivery pending: ${error.message}`);
50
- reportedError = error.message;
51
- }); };
52
- const timer = setInterval(tick, intervalMs);
53
- timer.unref?.();
78
+ const tick = () => {
79
+ if (closed) return;
80
+ void flush().then(() => { delayMs = intervalMs; reportedError = null; }, error => {
81
+ delayMs = Math.min(delayMs * 2, maxBackoffMs);
82
+ if (reportedError !== error.message) onError(`[Chat usage] Delivery pending: ${error.message}`);
83
+ reportedError = error.message;
84
+ }).finally(() => {
85
+ if (closed) return;
86
+ timer = setTimeout(tick, delayMs);
87
+ timer.unref?.();
88
+ });
89
+ };
54
90
  tick();
55
- return { flush, async close() { closed = true; clearInterval(timer); await active?.catch(() => {}); } };
91
+ return { flush, async close() { closed = true; clearTimeout(timer); await active?.catch(() => {}); } };
56
92
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amalgm/chat",
3
- "version": "0.2.9-preview.35480111675",
3
+ "version": "0.2.9-preview.36053697119",
4
4
  "description": "A provider-agnostic agent chat SDK with ACP content, prepared execution, normalized streams, and usage.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -119,11 +119,11 @@
119
119
  "allowScripts": {
120
120
  "better-sqlite3@12.11.1": true
121
121
  },
122
- "gitHead": "399cea2ea226d47877e6686902a5321dea9645bd",
122
+ "gitHead": "f825aef403a999349cd54940687f3fbcc3f8ca25",
123
123
  "amalgmSource": {
124
124
  "repository": "amalgm-inc/amalgm-chat",
125
125
  "branch": "preview",
126
- "commit": "399cea2ea226d47877e6686902a5321dea9645bd",
127
- "occurrence": "35480111675"
126
+ "commit": "f825aef403a999349cd54940687f3fbcc3f8ca25",
127
+ "occurrence": "36053697119"
128
128
  }
129
129
  }