@inetafrica/open-claudia 3.1.12 → 3.1.14
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/.env.example +2 -0
- package/CHANGELOG.md +16 -0
- package/channels/kazee/adapter.js +4 -0
- package/channels/kazee/call/audio.js +133 -0
- package/channels/kazee/call/janus-client.js +136 -0
- package/channels/kazee/call/manager.js +112 -0
- package/channels/kazee/call/session.js +286 -0
- package/channels/kazee/call/speech-client.js +79 -0
- package/core/runner.js +3 -1
- package/package.json +15 -3
- package/test-kazee-call-audio.js +43 -0
- package/test-kazee-call-janus.js +54 -0
- package/test-kazee-call-manager.js +36 -0
- package/test-kazee-call-session.js +231 -0
- package/test-kazee-call-speech.js +36 -0
- package/test-kazee-call-webrtc.js +55 -0
- package/test-provider-language.js +9 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const assert = require("assert");
|
|
5
|
+
const { KazeeCallSession } = require("./channels/kazee/call/session");
|
|
6
|
+
const { KazeeCallManager } = require("./channels/kazee/call/manager");
|
|
7
|
+
const { OpusAudioCodec, WEBRTC_SAMPLES } = require("./channels/kazee/call/audio");
|
|
8
|
+
|
|
9
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
|
|
11
|
+
class FakeJanus {
|
|
12
|
+
constructor() { this.handles = 0; }
|
|
13
|
+
async connect() {}
|
|
14
|
+
async attach() { this.handles += 1; return this.handles; }
|
|
15
|
+
onHandle() {}
|
|
16
|
+
async message() { return {}; }
|
|
17
|
+
async detach() {}
|
|
18
|
+
async close() {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class FakePC {
|
|
22
|
+
constructor() { this.onTrack = { subscribe: () => {} }; }
|
|
23
|
+
addTrack() { return { codec: { payloadType: 111 } }; }
|
|
24
|
+
async createOffer() { return {}; }
|
|
25
|
+
async setLocalDescription() {}
|
|
26
|
+
async createAnswer() { return {}; }
|
|
27
|
+
async setRemoteDescription() {}
|
|
28
|
+
async close() {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class FakeSpeechStream {
|
|
32
|
+
constructor({ onTranscript } = {}) {
|
|
33
|
+
this.onTranscript = onTranscript;
|
|
34
|
+
this.audioFrames = [];
|
|
35
|
+
this.commits = 0;
|
|
36
|
+
this.closedStream = false;
|
|
37
|
+
}
|
|
38
|
+
async connect() {}
|
|
39
|
+
async audio(buffer) { this.audioFrames.push(buffer); }
|
|
40
|
+
async commit() { this.commits += 1; }
|
|
41
|
+
close() { this.closedStream = true; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeSession({ synthesize, maxCallMs, idleMs } = {}) {
|
|
45
|
+
const ended = [];
|
|
46
|
+
const transcripts = [];
|
|
47
|
+
const session = new KazeeCallSession({
|
|
48
|
+
roomId: 7, chatId: "chat", callerId: "owner", botUserId: "bot", janusUrl: "ws://test",
|
|
49
|
+
maxCallMs, idleMs,
|
|
50
|
+
synthesize: synthesize || (async () => ({ pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 2), sampleRate: 48000 })),
|
|
51
|
+
onTranscript: (text, meta) => transcripts.push({ text, meta }),
|
|
52
|
+
onEnded: (reason) => ended.push(reason),
|
|
53
|
+
JanusClientImpl: FakeJanus, PeerConnectionImpl: FakePC, SpeechStreamImpl: FakeSpeechStream,
|
|
54
|
+
});
|
|
55
|
+
return { session, ended, transcripts };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toneOpus(codec, amplitude) {
|
|
59
|
+
const pcm = Buffer.alloc(WEBRTC_SAMPLES * 2);
|
|
60
|
+
const view = new Int16Array(pcm.buffer, pcm.byteOffset, WEBRTC_SAMPLES);
|
|
61
|
+
for (let i = 0; i < view.length; i += 1) view[i] = Math.round(Math.sin(2 * Math.PI * 440 * i / 48000) * amplitude);
|
|
62
|
+
return codec.encode(pcm);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function testSpeakSerialization() {
|
|
66
|
+
const writes = [];
|
|
67
|
+
const synthCalls = [];
|
|
68
|
+
let current = null;
|
|
69
|
+
const { session } = makeSession({
|
|
70
|
+
synthesize: async (text) => { synthCalls.push(text); current = text; return { pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 3), sampleRate: 48000 }; },
|
|
71
|
+
});
|
|
72
|
+
session.publisher = { track: { writeRtp: () => writes.push(current) }, sender: { codec: { payloadType: 111 } } };
|
|
73
|
+
const [first, second] = await Promise.all([session.speak("one"), session.speak("two")]);
|
|
74
|
+
assert.strictEqual(first, true);
|
|
75
|
+
assert.strictEqual(second, true);
|
|
76
|
+
assert.deepStrictEqual(synthCalls, ["one", "two"], "synthesis is sequential");
|
|
77
|
+
assert.deepStrictEqual(writes, ["one", "one", "one", "two", "two", "two"], "utterances never interleave on the RTP stream");
|
|
78
|
+
await session.close();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function testSpeakCancellation() {
|
|
82
|
+
let frames = 0;
|
|
83
|
+
const synthCalls = [];
|
|
84
|
+
const { session } = makeSession({
|
|
85
|
+
synthesize: async (text) => { synthCalls.push(text); return { pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 50), sampleRate: 48000 }; },
|
|
86
|
+
});
|
|
87
|
+
session.publisher = { track: { writeRtp: () => { frames += 1; } }, sender: { codec: { payloadType: 111 } } };
|
|
88
|
+
const playing = session.speak("long");
|
|
89
|
+
const queued = session.speak("queued");
|
|
90
|
+
await wait(80);
|
|
91
|
+
session.cancelSpeech();
|
|
92
|
+
assert.strictEqual(await playing, false, "current utterance reports cancelled");
|
|
93
|
+
assert.strictEqual(await queued, false, "queued utterance cancelled");
|
|
94
|
+
assert(frames < 20, `playback stopped early (${frames} frames)`);
|
|
95
|
+
assert.deepStrictEqual(synthCalls, ["long"], "queued text never synthesized after cancel");
|
|
96
|
+
assert.strictEqual(session.speaking, false);
|
|
97
|
+
await session.close();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function testEchoGateAndBargeIn() {
|
|
101
|
+
const { session } = makeSession({
|
|
102
|
+
synthesize: async () => ({ pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 100), sampleRate: 48000 }),
|
|
103
|
+
});
|
|
104
|
+
await session._subscribe(101, "sumeet@kazee");
|
|
105
|
+
const sub = session.subscribers.get(101);
|
|
106
|
+
const gen = new OpusAudioCodec();
|
|
107
|
+
session.publisher = { track: { writeRtp: () => {} }, sender: { codec: { payloadType: 111 } } };
|
|
108
|
+
const playing = session.speak("long answer");
|
|
109
|
+
await wait(30);
|
|
110
|
+
assert.strictEqual(session.speaking, true);
|
|
111
|
+
for (let i = 0; i < 6; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 1500) }); // ~-30dB: echo territory
|
|
112
|
+
assert.strictEqual(session.speaking, true, "echo-level audio does not interrupt TTS");
|
|
113
|
+
assert.strictEqual(sub.stt.audioFrames.length, 0, "echo-level audio is not streamed to STT");
|
|
114
|
+
for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) }); // ~-12dB: real speech
|
|
115
|
+
assert.strictEqual(await playing, false, "barge-in cancels TTS");
|
|
116
|
+
assert(sub.stt.audioFrames.length >= 3, "barge-in speech reaches STT");
|
|
117
|
+
gen.close();
|
|
118
|
+
await session.close();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function testPrebufferAndCommit() {
|
|
122
|
+
const { session } = makeSession({});
|
|
123
|
+
await session._subscribe(101, "caller");
|
|
124
|
+
const sub = session.subscribers.get(101);
|
|
125
|
+
const gen = new OpusAudioCodec();
|
|
126
|
+
for (let i = 0; i < 5; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 30) }); // near-silence
|
|
127
|
+
assert.strictEqual(sub.stt.audioFrames.length, 0, "silence stays out of STT");
|
|
128
|
+
for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) });
|
|
129
|
+
assert.strictEqual(sub.stt.audioFrames.length, 8, "onset flushes the prebuffer (5 quiet + 2 onset + current)");
|
|
130
|
+
for (let i = 0; i < 36; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 30) });
|
|
131
|
+
assert.strictEqual(sub.stt.commits, 1, "turn commits after sustained silence");
|
|
132
|
+
gen.close();
|
|
133
|
+
await session.close();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function testTranscriptAttribution() {
|
|
137
|
+
const { session, transcripts } = makeSession({});
|
|
138
|
+
await session._subscribe(101, "alice@kazee");
|
|
139
|
+
await session._subscribe(102, "bob@kazee");
|
|
140
|
+
session.subscribers.get(101).stt.onTranscript("hello there");
|
|
141
|
+
assert.deepStrictEqual(transcripts[0], {
|
|
142
|
+
text: "hello there",
|
|
143
|
+
meta: { feedId: 101, display: "alice@kazee", participants: 2 },
|
|
144
|
+
});
|
|
145
|
+
await session.close();
|
|
146
|
+
|
|
147
|
+
class FakeSession {
|
|
148
|
+
constructor(options) { this.options = options; this.roomId = options.roomId; }
|
|
149
|
+
async start() {}
|
|
150
|
+
async speak() {}
|
|
151
|
+
async close(reason) { this.options.onEnded(reason); }
|
|
152
|
+
}
|
|
153
|
+
const inbound = [];
|
|
154
|
+
const adapter = { ownerUserId: "owner-1", botUserId: "bot-1", _socket: { on() {}, emit() {} }, _emit: (name, payload) => inbound.push(payload) };
|
|
155
|
+
const manager = new KazeeCallManager({ adapter, SessionImpl: FakeSession });
|
|
156
|
+
await manager.incoming({ fromUserId: "owner-1", chatId: "c", roomId: 5, callType: "audio" });
|
|
157
|
+
manager.session.options.onTranscript("hi", { feedId: 1, display: "alice@kazee", participants: 2 });
|
|
158
|
+
assert.strictEqual(inbound[0].text, "alice@kazee: hi", "group transcripts are speaker-prefixed");
|
|
159
|
+
assert.strictEqual(inbound[0].raw.speaker, "alice@kazee");
|
|
160
|
+
manager.session.options.onTranscript("solo", { feedId: 1, display: "alice@kazee", participants: 1 });
|
|
161
|
+
assert.strictEqual(inbound[1].text, "solo", "1:1 transcripts stay unprefixed");
|
|
162
|
+
await manager.stop();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function testIdleTimeout() {
|
|
166
|
+
const goodbyes = [];
|
|
167
|
+
const { session, ended } = makeSession({
|
|
168
|
+
idleMs: 50, maxCallMs: 60_000,
|
|
169
|
+
synthesize: async (text) => { goodbyes.push(text); return { pcm: Buffer.alloc(WEBRTC_SAMPLES * 2), sampleRate: 48000 }; },
|
|
170
|
+
});
|
|
171
|
+
session._startedAt = Date.now();
|
|
172
|
+
session._lastHeard = Date.now() - 1000;
|
|
173
|
+
session._lastSpokeAt = Date.now() - 1000;
|
|
174
|
+
await session._enforceLimits();
|
|
175
|
+
assert.strictEqual(ended[0], "idle-timeout");
|
|
176
|
+
assert.strictEqual(goodbyes.length, 1, "idle hangup says goodbye first");
|
|
177
|
+
assert(goodbyes[0].toLowerCase().includes("hang up"));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function testIdleBargeInKeepsCall() {
|
|
181
|
+
let sess = null;
|
|
182
|
+
const { session, ended } = makeSession({
|
|
183
|
+
idleMs: 50, maxCallMs: 60_000,
|
|
184
|
+
synthesize: async () => { sess._lastHeard = Date.now(); return { pcm: Buffer.alloc(WEBRTC_SAMPLES * 2), sampleRate: 48000 }; },
|
|
185
|
+
});
|
|
186
|
+
sess = session;
|
|
187
|
+
session._startedAt = Date.now();
|
|
188
|
+
session._lastHeard = Date.now() - 1000;
|
|
189
|
+
session._lastSpokeAt = Date.now() - 1000;
|
|
190
|
+
await session._enforceLimits();
|
|
191
|
+
assert.strictEqual(ended.length, 0, "call stays up when the caller speaks during the goodbye");
|
|
192
|
+
assert.strictEqual(session._ending, false);
|
|
193
|
+
await session.close();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function testMaxDuration() {
|
|
197
|
+
const { session, ended } = makeSession({ idleMs: 60_000, maxCallMs: 50 });
|
|
198
|
+
session._startedAt = Date.now() - 1000;
|
|
199
|
+
session._lastHeard = Date.now();
|
|
200
|
+
session._lastSpokeAt = Date.now();
|
|
201
|
+
await session._enforceLimits();
|
|
202
|
+
assert.strictEqual(ended[0], "max-duration", "cap closes the call even mid-conversation");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function testCloseResolvesSpeech() {
|
|
206
|
+
const { session } = makeSession({
|
|
207
|
+
synthesize: async () => ({ pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 100), sampleRate: 48000 }),
|
|
208
|
+
});
|
|
209
|
+
await session._subscribe(101, "x");
|
|
210
|
+
const sub = session.subscribers.get(101);
|
|
211
|
+
const playing = session.speak("very long");
|
|
212
|
+
await wait(40);
|
|
213
|
+
await session.close("ended");
|
|
214
|
+
assert.strictEqual(await playing, false, "in-flight speak resolves on close");
|
|
215
|
+
assert.strictEqual(sub.stt.closedStream, true, "per-feed STT closed with the call");
|
|
216
|
+
assert.strictEqual(session.subscribers.size, 0);
|
|
217
|
+
assert.strictEqual(await session.speak("after"), false, "speak after close is a no-op");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
(async () => {
|
|
221
|
+
await testSpeakSerialization();
|
|
222
|
+
await testSpeakCancellation();
|
|
223
|
+
await testEchoGateAndBargeIn();
|
|
224
|
+
await testPrebufferAndCommit();
|
|
225
|
+
await testTranscriptAttribution();
|
|
226
|
+
await testIdleTimeout();
|
|
227
|
+
await testIdleBargeInKeepsCall();
|
|
228
|
+
await testMaxDuration();
|
|
229
|
+
await testCloseResolvesSpeech();
|
|
230
|
+
console.log("kazee call session: ok");
|
|
231
|
+
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
const assert = require("assert");
|
|
4
|
+
const { EventEmitter } = require("events");
|
|
5
|
+
const { SpeechStream, synthesizePcm } = require("./channels/kazee/call/speech-client");
|
|
6
|
+
class FakeWebSocket extends EventEmitter {
|
|
7
|
+
static OPEN = 1;
|
|
8
|
+
constructor(url, options) { super(); this.url = url; this.options = options; this.readyState = 1; this.sent = []; queueMicrotask(() => this.emit("open")); }
|
|
9
|
+
send(value) { this.sent.push(value); }
|
|
10
|
+
close() { this.readyState = 3; this.emit("close"); }
|
|
11
|
+
}
|
|
12
|
+
(async () => {
|
|
13
|
+
let transcript;
|
|
14
|
+
const stream = new SpeechStream({ baseUrl: "https://speech.example", token: "secret", WebSocketImpl: FakeWebSocket, onTranscript: (t) => { transcript = t; } });
|
|
15
|
+
await stream.connect();
|
|
16
|
+
assert.strictEqual(stream.socket.url, "wss://speech.example/v1/audio/transcriptions/stream");
|
|
17
|
+
assert.deepStrictEqual(JSON.parse(stream.socket.sent[0]), { sample_rate: 16000, language: "en" });
|
|
18
|
+
await stream.audio(Buffer.from([1, 2]));
|
|
19
|
+
await stream.commit();
|
|
20
|
+
assert(Buffer.isBuffer(stream.socket.sent[1]));
|
|
21
|
+
stream.socket.emit("message", Buffer.from(JSON.stringify({ type: "final", text: "hello" })));
|
|
22
|
+
assert.strictEqual(transcript, "hello");
|
|
23
|
+
let request;
|
|
24
|
+
const pcm = await synthesizePcm("Hello", { baseUrl: "https://speech.example", token: "secret", fetchImpl: async (url, options) => {
|
|
25
|
+
request = { url, options }; return {
|
|
26
|
+
ok: true,
|
|
27
|
+
headers: { get: () => "audio/pcm;rate=24000;encoding=signed-integer;bits=16" },
|
|
28
|
+
arrayBuffer: async () => Uint8Array.from([1, 2, 3]).buffer,
|
|
29
|
+
};
|
|
30
|
+
} });
|
|
31
|
+
assert.strictEqual(JSON.parse(request.options.body).response_format, "pcm");
|
|
32
|
+
assert.strictEqual(pcm.sampleRate, 24000);
|
|
33
|
+
assert.deepStrictEqual([...pcm.pcm], [1, 2, 3]);
|
|
34
|
+
stream.close();
|
|
35
|
+
console.log("kazee call speech: ok");
|
|
36
|
+
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const assert = require("assert");
|
|
5
|
+
const { MediaStreamTrack, RTCPeerConnection, RtpHeader, RtpPacket } = require("werift");
|
|
6
|
+
const { OpusAudioCodec, WEBRTC_SAMPLES } = require("./channels/kazee/call/audio");
|
|
7
|
+
|
|
8
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
const publisher = new RTCPeerConnection();
|
|
12
|
+
const subscriber = new RTCPeerConnection();
|
|
13
|
+
const outgoing = new MediaStreamTrack({ kind: "audio" });
|
|
14
|
+
const sender = publisher.addTrack(outgoing);
|
|
15
|
+
let resolvePacket;
|
|
16
|
+
const received = new Promise((resolve) => { resolvePacket = resolve; });
|
|
17
|
+
subscriber.onTrack.subscribe((track) => track.onReceiveRtp.subscribe(resolvePacket));
|
|
18
|
+
|
|
19
|
+
const offer = await publisher.createOffer();
|
|
20
|
+
await publisher.setLocalDescription(offer);
|
|
21
|
+
await subscriber.setRemoteDescription(publisher.localDescription);
|
|
22
|
+
const answer = await subscriber.createAnswer();
|
|
23
|
+
await subscriber.setLocalDescription(answer);
|
|
24
|
+
await publisher.setRemoteDescription(subscriber.localDescription);
|
|
25
|
+
|
|
26
|
+
for (let attempt = 0; attempt < 100 && publisher.connectionState !== "connected"; attempt += 1) await wait(20);
|
|
27
|
+
assert.strictEqual(publisher.connectionState, "connected", "local WebRTC peers connect");
|
|
28
|
+
|
|
29
|
+
const pcm = Buffer.alloc(WEBRTC_SAMPLES * 2);
|
|
30
|
+
const samples = new Int16Array(pcm.buffer, pcm.byteOffset, WEBRTC_SAMPLES);
|
|
31
|
+
for (let i = 0; i < samples.length; i += 1) samples[i] = Math.round(Math.sin(2 * Math.PI * 440 * i / 48000) * 12000);
|
|
32
|
+
const codec = new OpusAudioCodec();
|
|
33
|
+
const opus = codec.encode(pcm);
|
|
34
|
+
outgoing.writeRtp(new RtpPacket(new RtpHeader({
|
|
35
|
+
payloadType: sender.codec?.payloadType || 111,
|
|
36
|
+
sequenceNumber: 1,
|
|
37
|
+
timestamp: WEBRTC_SAMPLES,
|
|
38
|
+
ssrc: 0x0c1a0d10,
|
|
39
|
+
}), opus));
|
|
40
|
+
|
|
41
|
+
const packet = await Promise.race([received, wait(3000).then(() => null)]);
|
|
42
|
+
assert(packet, "encrypted WebRTC path delivers an RTP packet");
|
|
43
|
+
assert.deepStrictEqual(packet.payload, opus, "Opus payload survives ICE/DTLS/SRTP transport");
|
|
44
|
+
assert.strictEqual(codec.decode(packet.payload).length, pcm.length, "received Opus decodes to a 20ms PCM frame");
|
|
45
|
+
|
|
46
|
+
codec.close();
|
|
47
|
+
await publisher.close();
|
|
48
|
+
await subscriber.close();
|
|
49
|
+
console.log("kazee call WebRTC loopback: ok");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
main().catch((error) => {
|
|
53
|
+
console.error(error);
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
});
|
|
@@ -204,7 +204,15 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
204
204
|
// Custom hf:// escape hatch) plus voice cloning — send a clip and it's transcoded
|
|
205
205
|
// (ogg->24kHz mono wav) and registered via the speech gateway /v1/voices as the
|
|
206
206
|
// active stored:<digest> voice. Plus this version-pin bump.
|
|
207
|
-
|
|
207
|
+
// 3.1.13 approved by Sumeet 2026-07-22 ("work through till all your plan is
|
|
208
|
+
// completed"): owner-only incoming Kazee audio calls bridge Janus VideoRoom
|
|
209
|
+
// RTP to hosted streaming STT, the normal agent turn, and streamed Pocket TTS.
|
|
210
|
+
// 3.1.14 approved by Sumeet 2026-07-22 (calls deep-dive: "you're welcome to go
|
|
211
|
+
// ahead and work through all the way until everything's deployed"): call bridge
|
|
212
|
+
// production hardening — serialized TTS with barge-in cancellation, echo gate,
|
|
213
|
+
// per-feed STT with group speaker attribution, duration/idle limits with a
|
|
214
|
+
// spoken goodbye, and the call tests wired into the main npm test chain.
|
|
215
|
+
assert.strictEqual(pkg.version, "3.1.14", "release version must remain unchanged without explicit approval");
|
|
208
216
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
209
217
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
210
218
|
}
|