@pinecall/protocol 0.1.0
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/LICENSE +202 -0
- package/dist/fixtures/call-log-golden.json +127 -0
- package/dist/fixtures/call-log-golden.state.json +39 -0
- package/dist/generated/codec.d.ts +53 -0
- package/dist/generated/codec.js +67 -0
- package/dist/generated/commands.d.ts +423 -0
- package/dist/generated/commands.js +168 -0
- package/dist/generated/defs.d.ts +604 -0
- package/dist/generated/defs.js +328 -0
- package/dist/generated/envelope.d.ts +27 -0
- package/dist/generated/envelope.js +26 -0
- package/dist/generated/events.d.ts +1234 -0
- package/dist/generated/events.js +388 -0
- package/dist/generated/index.d.ts +12 -0
- package/dist/generated/index.js +13 -0
- package/dist/generated/metrics.d.ts +386 -0
- package/dist/generated/metrics.js +268 -0
- package/dist/generated/registry.d.ts +1591 -0
- package/dist/generated/registry.js +130 -0
- package/dist/generated/rest.d.ts +1031 -0
- package/dist/generated/rest.js +291 -0
- package/dist/generated/room.d.ts +111 -0
- package/dist/generated/room.js +72 -0
- package/dist/generated/state.d.ts +902 -0
- package/dist/generated/state.js +179 -0
- package/dist/generated/units.d.ts +2 -0
- package/dist/generated/units.js +80 -0
- package/dist/generated/verbs.d.ts +77 -0
- package/dist/generated/verbs.js +49 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/reduce.d.ts +10 -0
- package/dist/reduce.js +352 -0
- package/package.json +38 -0
package/dist/reduce.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// Folds a log into its State, one entry at a time. The Python reducer keeps the same rules.
|
|
2
|
+
import { eventOf } from "./generated/codec.js";
|
|
3
|
+
/** Fold every entry, in the order given, into the state an empty log starts from. */
|
|
4
|
+
export function reduce(entries) {
|
|
5
|
+
let state = initialState();
|
|
6
|
+
for (const entry of entries) {
|
|
7
|
+
state = apply(state, entry);
|
|
8
|
+
}
|
|
9
|
+
return state;
|
|
10
|
+
}
|
|
11
|
+
/** What an empty log is: nothing known, every list empty, status idle. */
|
|
12
|
+
export function initialState() {
|
|
13
|
+
return {
|
|
14
|
+
seq: 0,
|
|
15
|
+
agent: "",
|
|
16
|
+
call: null,
|
|
17
|
+
status: "idle",
|
|
18
|
+
channel: null,
|
|
19
|
+
direction: null,
|
|
20
|
+
from: null,
|
|
21
|
+
to: null,
|
|
22
|
+
caller: null,
|
|
23
|
+
room: null,
|
|
24
|
+
started_at: null,
|
|
25
|
+
ended_at: null,
|
|
26
|
+
end_reason: null,
|
|
27
|
+
outcome: null,
|
|
28
|
+
user_state: null,
|
|
29
|
+
agent_state: null,
|
|
30
|
+
live: { user: null, agent: null },
|
|
31
|
+
turns: [],
|
|
32
|
+
metrics: { llm: [], stt: [], tts: [], vad: [], eou: [], eot: [], interruption: [], realtime: [], avatar: [] },
|
|
33
|
+
tools: [],
|
|
34
|
+
app_state: {},
|
|
35
|
+
events: [],
|
|
36
|
+
prompt: {},
|
|
37
|
+
tools_visible: [],
|
|
38
|
+
confirms: [],
|
|
39
|
+
memory: [],
|
|
40
|
+
sources: [],
|
|
41
|
+
handoff: { active: false, by: null },
|
|
42
|
+
held: false,
|
|
43
|
+
muted: false,
|
|
44
|
+
transfer: null,
|
|
45
|
+
usage: [],
|
|
46
|
+
cost: null,
|
|
47
|
+
routes: [],
|
|
48
|
+
gaps: [],
|
|
49
|
+
errors: [],
|
|
50
|
+
custom: [],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** What a reader says about an entry it cannot read, in the log's own errors list. */
|
|
54
|
+
export const UNREADABLE = "unreadable";
|
|
55
|
+
// A log outlives the shape of the entries in it: a call recorded before a field was renamed is
|
|
56
|
+
// still in the table, and a reader that refuses it refuses the whole call with it. So an entry
|
|
57
|
+
// this reader cannot validate is one line of the errors list and nothing more — the fold goes on,
|
|
58
|
+
// and the state says out loud which seq it could not read. The Python and Ruby reducers do the
|
|
59
|
+
// same, because the three of them fold one golden log into one state.
|
|
60
|
+
function unreadable(state, entry, why) {
|
|
61
|
+
const said = why instanceof Error ? why.message : String(why);
|
|
62
|
+
state.errors.push({
|
|
63
|
+
seq: entry.seq,
|
|
64
|
+
code: UNREADABLE,
|
|
65
|
+
message: `${entry.type} at seq ${entry.seq} is not the shape this reader knows: ${said.split("\n")[0]?.trim() ?? said}`,
|
|
66
|
+
});
|
|
67
|
+
return state;
|
|
68
|
+
}
|
|
69
|
+
// A gap that carries a snapshot replaces the state outright: that is what the snapshot is for.
|
|
70
|
+
// Every other entry mutates in place. Either way the seq moves to the entry's.
|
|
71
|
+
/** One entry folded in. Returns the state to keep going with. */
|
|
72
|
+
export function apply(state, entry) {
|
|
73
|
+
let event;
|
|
74
|
+
try {
|
|
75
|
+
event = eventOf(entry);
|
|
76
|
+
}
|
|
77
|
+
catch (why) {
|
|
78
|
+
const said = unreadable(state, entry, why);
|
|
79
|
+
said.seq = entry.seq;
|
|
80
|
+
said.agent = entry.agent;
|
|
81
|
+
if (entry.call !== null) {
|
|
82
|
+
said.call = entry.call;
|
|
83
|
+
}
|
|
84
|
+
return said;
|
|
85
|
+
}
|
|
86
|
+
let next = state;
|
|
87
|
+
if (event.type === "log.gap") {
|
|
88
|
+
next = onLogGap(state, event);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
applyEvent(state, entry, event);
|
|
92
|
+
}
|
|
93
|
+
next.seq = entry.seq;
|
|
94
|
+
next.agent = entry.agent;
|
|
95
|
+
if (entry.call !== null) {
|
|
96
|
+
next.call = entry.call;
|
|
97
|
+
}
|
|
98
|
+
return next;
|
|
99
|
+
}
|
|
100
|
+
function applyEvent(state, entry, event) {
|
|
101
|
+
switch (event.type) {
|
|
102
|
+
// ── the call ──
|
|
103
|
+
case "call.ringing":
|
|
104
|
+
state.status = "ringing";
|
|
105
|
+
state.direction = "inbound";
|
|
106
|
+
rememberTheLine(state, event.data);
|
|
107
|
+
return;
|
|
108
|
+
case "call.dialing":
|
|
109
|
+
state.status = "dialing";
|
|
110
|
+
state.direction = "outbound";
|
|
111
|
+
rememberTheLine(state, event.data);
|
|
112
|
+
return;
|
|
113
|
+
case "call.started":
|
|
114
|
+
state.status = "active";
|
|
115
|
+
state.direction = event.data.direction;
|
|
116
|
+
state.started_at = event.data.started_at;
|
|
117
|
+
rememberTheLine(state, event.data);
|
|
118
|
+
return;
|
|
119
|
+
case "call.ended":
|
|
120
|
+
state.status = "ended";
|
|
121
|
+
state.ended_at = event.data.ended_at;
|
|
122
|
+
state.end_reason = event.data.reason;
|
|
123
|
+
state.live = { user: null, agent: null };
|
|
124
|
+
return;
|
|
125
|
+
case "call.transferred":
|
|
126
|
+
state.transfer = {
|
|
127
|
+
to: event.data.to,
|
|
128
|
+
mode: event.data.mode,
|
|
129
|
+
status: event.data.ok ? "done" : "failed",
|
|
130
|
+
by: state.transfer?.by ?? "agent",
|
|
131
|
+
};
|
|
132
|
+
return;
|
|
133
|
+
case "call.line":
|
|
134
|
+
state.held = event.data.held;
|
|
135
|
+
state.muted = event.data.muted;
|
|
136
|
+
return;
|
|
137
|
+
case "call.summary":
|
|
138
|
+
state.usage = [...event.data.usage];
|
|
139
|
+
state.cost = event.data.cost;
|
|
140
|
+
state.outcome = event.data.outcome;
|
|
141
|
+
state.end_reason ??= event.data.reason;
|
|
142
|
+
return;
|
|
143
|
+
// ── the conversation ──
|
|
144
|
+
case "user.state":
|
|
145
|
+
state.user_state = event.data.state;
|
|
146
|
+
return;
|
|
147
|
+
case "agent.state":
|
|
148
|
+
state.agent_state = event.data.state;
|
|
149
|
+
return;
|
|
150
|
+
case "user.transcript":
|
|
151
|
+
state.live.user = event.data.final ? null : event.data.text;
|
|
152
|
+
return;
|
|
153
|
+
case "agent.transcript":
|
|
154
|
+
state.live.agent = event.data.final ? null : event.data.text;
|
|
155
|
+
return;
|
|
156
|
+
case "turn.user":
|
|
157
|
+
state.turns.push({ role: "user", ...event.data });
|
|
158
|
+
state.live.user = null;
|
|
159
|
+
return;
|
|
160
|
+
case "turn.agent":
|
|
161
|
+
state.turns.push({ role: "agent", ...event.data });
|
|
162
|
+
state.live.agent = null;
|
|
163
|
+
return;
|
|
164
|
+
case "memory.ops":
|
|
165
|
+
state.memory.push(...event.data.ops);
|
|
166
|
+
return;
|
|
167
|
+
case "docs.sources":
|
|
168
|
+
state.sources = [...event.data.sources];
|
|
169
|
+
return;
|
|
170
|
+
// ── metrics: every block is kept, in order, by kind ──
|
|
171
|
+
case "metrics.llm":
|
|
172
|
+
state.metrics.llm.push(event.data);
|
|
173
|
+
return;
|
|
174
|
+
case "metrics.stt":
|
|
175
|
+
state.metrics.stt.push(event.data);
|
|
176
|
+
return;
|
|
177
|
+
case "metrics.tts":
|
|
178
|
+
state.metrics.tts.push(event.data);
|
|
179
|
+
return;
|
|
180
|
+
case "metrics.vad":
|
|
181
|
+
state.metrics.vad.push(event.data);
|
|
182
|
+
return;
|
|
183
|
+
case "metrics.eou":
|
|
184
|
+
state.metrics.eou.push(event.data);
|
|
185
|
+
return;
|
|
186
|
+
case "metrics.eot":
|
|
187
|
+
state.metrics.eot.push(event.data);
|
|
188
|
+
return;
|
|
189
|
+
case "metrics.interruption":
|
|
190
|
+
state.metrics.interruption.push(event.data);
|
|
191
|
+
return;
|
|
192
|
+
case "metrics.realtime":
|
|
193
|
+
state.metrics.realtime.push(event.data);
|
|
194
|
+
return;
|
|
195
|
+
case "metrics.avatar":
|
|
196
|
+
state.metrics.avatar.push(event.data);
|
|
197
|
+
return;
|
|
198
|
+
// ── tools, state, confirmation ──
|
|
199
|
+
case "tool.call":
|
|
200
|
+
state.tools.push({ ...event.data, status: "running", seq: entry.seq });
|
|
201
|
+
return;
|
|
202
|
+
case "tool.result":
|
|
203
|
+
onToolResult(state, event.data);
|
|
204
|
+
return;
|
|
205
|
+
case "state.changed":
|
|
206
|
+
state.app_state = { ...event.data.state };
|
|
207
|
+
return;
|
|
208
|
+
case "prompt.changed":
|
|
209
|
+
state.prompt[event.data.name] = { hash: event.data.hash, chars: event.data.chars, seq: entry.seq };
|
|
210
|
+
return;
|
|
211
|
+
case "tools.changed":
|
|
212
|
+
state.tools_visible = [...event.data.visible];
|
|
213
|
+
return;
|
|
214
|
+
case "confirm.request":
|
|
215
|
+
state.confirms.push({
|
|
216
|
+
tool: event.data.tool,
|
|
217
|
+
call_id: event.data.call_id,
|
|
218
|
+
audience: event.data.audience,
|
|
219
|
+
phrase: event.data.phrase,
|
|
220
|
+
status: "pending",
|
|
221
|
+
});
|
|
222
|
+
return;
|
|
223
|
+
case "confirm.granted":
|
|
224
|
+
settleConfirm(state, event.data.call_id, { status: "granted", said: event.data.said });
|
|
225
|
+
return;
|
|
226
|
+
case "confirm.declined":
|
|
227
|
+
settleConfirm(state, event.data.call_id, {
|
|
228
|
+
status: "declined",
|
|
229
|
+
reason: event.data.reason,
|
|
230
|
+
...(event.data.said !== undefined ? { said: event.data.said } : {}),
|
|
231
|
+
});
|
|
232
|
+
return;
|
|
233
|
+
// ── supervision, the agent, markers ──
|
|
234
|
+
case "supervisor.took_over":
|
|
235
|
+
state.handoff = { active: true, by: event.data.by };
|
|
236
|
+
return;
|
|
237
|
+
case "supervisor.released":
|
|
238
|
+
state.handoff = { active: false, by: null };
|
|
239
|
+
return;
|
|
240
|
+
case "supervisor.transferred":
|
|
241
|
+
state.transfer = { to: event.data.to, mode: event.data.mode, status: "requested", by: "supervisor" };
|
|
242
|
+
return;
|
|
243
|
+
case "agent.registered":
|
|
244
|
+
state.routes = [...event.data.routes];
|
|
245
|
+
return;
|
|
246
|
+
case "error":
|
|
247
|
+
state.errors.push({ seq: entry.seq, code: event.data.code, message: event.data.message });
|
|
248
|
+
return;
|
|
249
|
+
case "custom":
|
|
250
|
+
state.custom.push({ seq: entry.seq, name: event.data.name, data: { ...event.data.data } });
|
|
251
|
+
return;
|
|
252
|
+
// ── the room and the outside world ──
|
|
253
|
+
case "room.opened":
|
|
254
|
+
state.room = { name: event.data.name, sid: event.data.sid, participants: [], caller: null };
|
|
255
|
+
return;
|
|
256
|
+
case "participant.joined":
|
|
257
|
+
onParticipantJoined(state, entry, event.data);
|
|
258
|
+
return;
|
|
259
|
+
case "participant.left":
|
|
260
|
+
onParticipantLeft(state, event.data.identity);
|
|
261
|
+
return;
|
|
262
|
+
case "participant.speaking": {
|
|
263
|
+
const who = participantIn(state, event.data.identity);
|
|
264
|
+
if (who !== undefined) {
|
|
265
|
+
who.speaking = event.data.speaking;
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
// The fact is kept by name and origin; its data stays in the log at that seq.
|
|
270
|
+
case "event.received": {
|
|
271
|
+
const { data: _data, ...fact } = event.data;
|
|
272
|
+
state.events.push({ ...fact, seq: entry.seq });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// Nothing a reader keeps: supervisor.said and .whispered land as turns and prompt changes,
|
|
276
|
+
// supervisor.ended as call.ended; agent.configured, pong and log.caught_up say nothing about the
|
|
277
|
+
// call; track.published, track.unpublished and room.sent are facts the log keeps and the state does not.
|
|
278
|
+
case "track.published":
|
|
279
|
+
case "track.unpublished":
|
|
280
|
+
case "room.sent":
|
|
281
|
+
case "supervisor.said":
|
|
282
|
+
case "supervisor.whispered":
|
|
283
|
+
case "supervisor.ended":
|
|
284
|
+
case "agent.configured":
|
|
285
|
+
case "pong":
|
|
286
|
+
case "log.caught_up":
|
|
287
|
+
case "log.gap":
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function rememberTheLine(state, line) {
|
|
292
|
+
state.channel = line.channel;
|
|
293
|
+
state.from = line.from;
|
|
294
|
+
state.to = line.to;
|
|
295
|
+
state.caller = line.caller;
|
|
296
|
+
}
|
|
297
|
+
function onToolResult(state, result) {
|
|
298
|
+
const index = lastIndex(state.tools, (run) => run.call_id === result.call_id);
|
|
299
|
+
if (index === -1) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const { call_id: _callId, name: _name, ...outcome } = result;
|
|
303
|
+
const run = state.tools[index];
|
|
304
|
+
state.tools[index] = { ...run, ...outcome, status: outcome.error !== undefined ? "failed" : "done" };
|
|
305
|
+
}
|
|
306
|
+
function settleConfirm(state, callId, verdict) {
|
|
307
|
+
const index = lastIndex(state.confirms, (confirm) => confirm.call_id === callId);
|
|
308
|
+
if (index === -1) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const confirm = state.confirms[index];
|
|
312
|
+
state.confirms[index] = { ...confirm, ...verdict };
|
|
313
|
+
}
|
|
314
|
+
// joined_at is the entry's ts: the room said when, the event did not have to repeat it.
|
|
315
|
+
function onParticipantJoined(state, entry, joined) {
|
|
316
|
+
if (state.room === null) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
state.room.participants.push({ ...joined, joined_at: entry.ts, speaking: false });
|
|
320
|
+
if (joined.kind === "caller") {
|
|
321
|
+
state.room.caller = joined.identity;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// An identity is unique within a room: LiveKit disconnects the first of two that share one.
|
|
325
|
+
function onParticipantLeft(state, identity) {
|
|
326
|
+
if (state.room === null) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const seat = state.room.participants.findIndex((one) => one.identity === identity);
|
|
330
|
+
if (seat !== -1) {
|
|
331
|
+
state.room.participants.splice(seat, 1);
|
|
332
|
+
}
|
|
333
|
+
if (state.room.caller === identity) {
|
|
334
|
+
state.room.caller = null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function participantIn(state, identity) {
|
|
338
|
+
return state.room?.participants.find((one) => one.identity === identity);
|
|
339
|
+
}
|
|
340
|
+
function onLogGap(state, event) {
|
|
341
|
+
const next = event.data.snapshot !== null ? structuredClone(event.data.snapshot) : state;
|
|
342
|
+
next.gaps.push({ from_seq: event.data.from_seq, to_seq: event.data.to_seq });
|
|
343
|
+
return next;
|
|
344
|
+
}
|
|
345
|
+
function lastIndex(items, matches) {
|
|
346
|
+
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
347
|
+
if (matches(items[index])) {
|
|
348
|
+
return index;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return -1;
|
|
352
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pinecall/protocol",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The Pinecall wire: zod schemas and types generated from the protocol schema, and the log reducer",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Pinecall <hello@pinecall.io>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/pinecall/protocol.git",
|
|
10
|
+
"directory": "typescript"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./fixtures/call-log-golden.json": "./dist/fixtures/call-log-golden.json",
|
|
21
|
+
"./fixtures/call-log-golden.state.json": "./dist/fixtures/call-log-golden.state.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"zod": "^4.5.4"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^26.4.1",
|
|
31
|
+
"vitest": "^5.0.0"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json && mkdir -p dist/fixtures && cp ../python/pinecall_protocol/fixtures/*.json dist/fixtures/",
|
|
35
|
+
"lint": "tsc -p tsconfig.json --noEmit",
|
|
36
|
+
"test": "vitest run"
|
|
37
|
+
}
|
|
38
|
+
}
|