@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 +9 -5
- package/docs/contracts/acp-and-step-usage.md +2 -1
- package/host/sqlite/index.d.ts +3 -2
- package/host/sqlite/index.js +23 -10
- package/host/sqlite/rows.js +5 -0
- package/host/sqlite/schema.js +4 -2
- package/host/sqlite/usage-migration.js +47 -0
- package/host/title-generator.js +3 -1
- package/host/usage-delivery.js +59 -23
- package/package.json +4 -4
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.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
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
|
|
package/host/sqlite/index.d.ts
CHANGED
|
@@ -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>;
|
package/host/sqlite/index.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
244
|
-
|
|
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,
|
package/host/sqlite/rows.js
CHANGED
|
@@ -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
|
}
|
package/host/sqlite/schema.js
CHANGED
|
@@ -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
|
+
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 ===
|
|
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
|
+
}
|
package/host/title-generator.js
CHANGED
|
@@ -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:
|
|
54
|
+
assistantMessageId: randomUUID(),
|
|
53
55
|
agentId: 'amalgm-title-generator',
|
|
54
56
|
harness: 'title-generator',
|
|
55
57
|
authMethod: 'amalgm',
|
package/host/usage-delivery.js
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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 {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 = () => {
|
|
49
|
-
if (
|
|
50
|
-
reportedError = error
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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;
|
|
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.
|
|
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": "
|
|
122
|
+
"gitHead": "f825aef403a999349cd54940687f3fbcc3f8ca25",
|
|
123
123
|
"amalgmSource": {
|
|
124
124
|
"repository": "amalgm-inc/amalgm-chat",
|
|
125
125
|
"branch": "preview",
|
|
126
|
-
"commit": "
|
|
127
|
-
"occurrence": "
|
|
126
|
+
"commit": "f825aef403a999349cd54940687f3fbcc3f8ca25",
|
|
127
|
+
"occurrence": "36053697119"
|
|
128
128
|
}
|
|
129
129
|
}
|