viber-channel 0.8.5 → 0.8.6
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/package.json +1 -1
- package/viber-codex-bridge.ts +107 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viber-channel",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.6",
|
|
4
4
|
"description": "Voice + text MCP channel between a Claude Code session and the Viber UI (https://viber.dgypx.dev). Push transcripts to Claude; send_message tool delivers text back to the UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/viber-codex-bridge.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* Codex's final assistant reply back into Viber.
|
|
8
8
|
*/
|
|
9
9
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
10
11
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
12
|
import { dirname, join } from "node:path";
|
|
12
13
|
import { authFilePath, loadAuth } from "./lib/auth.ts";
|
|
@@ -242,33 +243,81 @@ function buildReply(receivedContent: string, msg: ConversationMessage | undefine
|
|
|
242
243
|
// the shared runConversationStream now owns token refresh, so the bridge no
|
|
243
244
|
// longer wires it directly.
|
|
244
245
|
|
|
245
|
-
// #
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
|
|
246
|
+
// #409: monotonic (perf) timing for diagnosing codex spawn / first-response
|
|
247
|
+
// latency. Structured, greppable `timing:` lines — the user's "codex is slow" is
|
|
248
|
+
// largely INHERENT (app-server initialize + first model turn), so we MEASURE where
|
|
249
|
+
// the time goes rather than guess. NOT a behaviour change.
|
|
250
|
+
const nowMs = (): number => performance.now();
|
|
251
|
+
// #409 (Codex): classify a conversation's turn as first vs subsequent and RESERVE
|
|
252
|
+
// the "first" label atomically (before any await), so two near-simultaneous
|
|
253
|
+
// messages can't both be logged `which=first`. Pure/testable.
|
|
254
|
+
export function classifyTurn(
|
|
255
|
+
seen: Set<string>,
|
|
256
|
+
convId: string,
|
|
257
|
+
): "first" | "subsequent" {
|
|
258
|
+
if (seen.has(convId)) return "subsequent";
|
|
259
|
+
seen.add(convId);
|
|
260
|
+
return "first";
|
|
261
|
+
}
|
|
262
|
+
function logTiming(phase: string, ms: number, extra = ""): void {
|
|
263
|
+
process.stderr.write(
|
|
264
|
+
`[viber-codex-bridge] timing: ${phase} ms=${Math.round(ms)}${extra ? ` ${extra}` : ""}\n`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// #414: the persistent thread identity. #280 keyed the thread by the server-issued
|
|
269
|
+
// instanceId so multiple agents sharing one conversation each get their own Codex
|
|
270
|
+
// thread — but the instanceId is FRESH on every register (#269), so a same-name
|
|
271
|
+
// re-spawn never found its predecessor's thread (thread/start instead of resume,
|
|
272
|
+
// runtime memory lost). Fix: when an EXPLICIT VIBER_CODEX_BRIDGE_LABEL is set (the
|
|
273
|
+
// stable logical name — vibe-master always sets it to the agent id), key by
|
|
274
|
+
// sha256(label) so a re-spawn under the same name resumes; distinct labels stay
|
|
275
|
+
// isolated (#280 preserved). No explicit label → fall back to the instanceId (no
|
|
276
|
+
// resume, but #280 isolation kept — the DEFAULT `Codex bridge • <cwd>` label is NOT
|
|
277
|
+
// a unique identity, and an empty label must never collapse everyone onto
|
|
278
|
+
// hash("")). The hash is filename-safe (hex) and doesn't leak the label.
|
|
279
|
+
export function threadIdentity(
|
|
280
|
+
instanceId: string,
|
|
281
|
+
label: string | undefined,
|
|
282
|
+
): { key: string; fileSuffix: string } {
|
|
283
|
+
const trimmed = label?.trim();
|
|
284
|
+
if (trimmed) {
|
|
285
|
+
const h = createHash("sha256").update(trimmed, "utf8").digest("hex");
|
|
286
|
+
return { key: `label:${h}`, fileSuffix: `label-${h}` };
|
|
287
|
+
}
|
|
288
|
+
return { key: `instance:${instanceId}`, fileSuffix: `instance-${instanceId}` };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// #280 step-12 / #414: the state file is per AGENT IDENTITY (stable label when set,
|
|
292
|
+
// else instanceId). It lives in the workspace's `.viber` dir (per-folder), so two
|
|
293
|
+
// folders — even a cross-project #307 pair using the same explicit label — write
|
|
294
|
+
// DIFFERENT files; the fingerprint in stateKey is defence-in-depth. Concurrent RMW
|
|
295
|
+
// of one file across agents can't drop another's entry (distinct identities →
|
|
296
|
+
// distinct files). An explicit VIBER_CODEX_BRIDGE_STATE override still wins.
|
|
297
|
+
function stateFilePath(
|
|
298
|
+
instanceId: string,
|
|
299
|
+
label: string | undefined,
|
|
300
|
+
cwd: string = process.cwd(),
|
|
301
|
+
): string {
|
|
250
302
|
const override = process.env.VIBER_CODEX_BRIDGE_STATE;
|
|
251
303
|
if (override !== undefined && override.trim() !== "") return override;
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const name =
|
|
255
|
-
instanceId && instanceId.trim() !== ""
|
|
256
|
-
? `codex-bridge-threads-${instanceId}.json`
|
|
257
|
-
: "codex-bridge-threads.json";
|
|
258
|
-
return join(dirname(authFilePath(cwd)), name);
|
|
304
|
+
const { fileSuffix } = threadIdentity(instanceId, label);
|
|
305
|
+
return join(dirname(authFilePath(cwd)), `codex-bridge-threads-${fileSuffix}.json`);
|
|
259
306
|
}
|
|
260
307
|
|
|
261
|
-
// #280
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
308
|
+
// #280/#414: the key is per (project, folder, conversation, AGENT IDENTITY). The
|
|
309
|
+
// conversationId is KEPT (a same agent on TWO conversations must not share one
|
|
310
|
+
// runtime thread — cross-conversation contamination); the fingerprint scopes it
|
|
311
|
+
// per folder; the identity is the stable label (or instanceId fallback).
|
|
265
312
|
export function stateKey(
|
|
266
313
|
projectId: number,
|
|
267
314
|
fingerprint: string,
|
|
268
315
|
conversationId: string,
|
|
269
316
|
instanceId: string,
|
|
317
|
+
label: string | undefined,
|
|
270
318
|
): string {
|
|
271
|
-
|
|
319
|
+
const { key } = threadIdentity(instanceId, label);
|
|
320
|
+
return `${projectId}:${fingerprint}:${conversationId}:${key}`;
|
|
272
321
|
}
|
|
273
322
|
|
|
274
323
|
function readThreadState(path: string): ThreadState {
|
|
@@ -584,11 +633,13 @@ class CodexAppServer {
|
|
|
584
633
|
}
|
|
585
634
|
|
|
586
635
|
async initialize(): Promise<void> {
|
|
636
|
+
const t0 = nowMs(); // #409: measure the app-server init (a big share of spawn latency)
|
|
587
637
|
await this.request("initialize", {
|
|
588
638
|
clientInfo: { name: "viber-codex-bridge", title: "Viber Codex Bridge", version: "0.1.0" },
|
|
589
639
|
capabilities: { experimentalApi: true },
|
|
590
640
|
});
|
|
591
641
|
this.notify("initialized");
|
|
642
|
+
logTiming("codex.initialize", nowMs() - t0);
|
|
592
643
|
}
|
|
593
644
|
|
|
594
645
|
async ensureThread(options: {
|
|
@@ -598,18 +649,25 @@ class CodexAppServer {
|
|
|
598
649
|
instanceKey: string;
|
|
599
650
|
newThread: boolean;
|
|
600
651
|
}): Promise<string> {
|
|
601
|
-
|
|
652
|
+
// #414: prefer the STABLE explicit label over the fresh instanceId so a
|
|
653
|
+
// same-name re-spawn resumes its own thread (see threadIdentity).
|
|
654
|
+
const label = process.env.VIBER_CODEX_BRIDGE_LABEL;
|
|
655
|
+
const path = stateFilePath(options.instanceKey, label);
|
|
602
656
|
const state = readThreadState(path);
|
|
603
657
|
const key = stateKey(
|
|
604
658
|
options.auth.project_id,
|
|
605
659
|
options.fingerprint,
|
|
606
660
|
options.conversationId,
|
|
607
661
|
options.instanceKey,
|
|
662
|
+
label,
|
|
608
663
|
);
|
|
609
664
|
const existing = state.threads[key]?.thread_id;
|
|
665
|
+
const t0 = nowMs(); // #409: measure thread resume/start (the first-DM cost)
|
|
666
|
+
let resumeAttempted = false;
|
|
610
667
|
|
|
611
668
|
if (existing && !options.newThread) {
|
|
612
669
|
try {
|
|
670
|
+
resumeAttempted = true;
|
|
613
671
|
process.stderr.write(`[viber-codex-bridge] codex: resuming persisted thread ${existing}\n`);
|
|
614
672
|
const resumed = await this.request("thread/resume", {
|
|
615
673
|
threadId: existing,
|
|
@@ -620,7 +678,11 @@ class CodexAppServer {
|
|
|
620
678
|
developerInstructions: AGENT_INSTRUCTIONS,
|
|
621
679
|
excludeTurns: true,
|
|
622
680
|
});
|
|
623
|
-
|
|
681
|
+
// #409 (Codex): validate/extract the id BEFORE logging success — a malformed
|
|
682
|
+
// response must NOT be logged as resume then again as resume-failed→start.
|
|
683
|
+
const resumedId = extractThreadId(resumed);
|
|
684
|
+
logTiming("ensureThread", nowMs() - t0, "outcome=resume");
|
|
685
|
+
return resumedId;
|
|
624
686
|
} catch (err) {
|
|
625
687
|
process.stderr.write(
|
|
626
688
|
`[viber-codex-bridge] codex: resume failed (${safeErrorMessage(err)}), starting a fresh thread\n`,
|
|
@@ -651,6 +713,11 @@ class CodexAppServer {
|
|
|
651
713
|
};
|
|
652
714
|
writeThreadState(path, state);
|
|
653
715
|
process.stderr.write(`[viber-codex-bridge] codex: persisted thread ${threadId} in ${path}\n`);
|
|
716
|
+
logTiming(
|
|
717
|
+
"ensureThread",
|
|
718
|
+
nowMs() - t0,
|
|
719
|
+
`outcome=${resumeAttempted ? "resume-failed→start" : "start"}`,
|
|
720
|
+
);
|
|
654
721
|
return threadId;
|
|
655
722
|
}
|
|
656
723
|
|
|
@@ -1166,9 +1233,15 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
1166
1233
|
|
|
1167
1234
|
// codex's per-conversation adapter for the shared runConversationStream:
|
|
1168
1235
|
// ensureThread (keyed by conversation+agent) then the tracked, queued codex turn.
|
|
1236
|
+
// #409: which conversations have already run their FIRST model turn, so we can
|
|
1237
|
+
// report the (expensive) first turn distinctly from the fast subsequent ones.
|
|
1238
|
+
const firstTurnDone = new Set<string>();
|
|
1169
1239
|
const makeCodexAdapter = (codex: CodexAppServer, instanceKey: string): MakeRunTurn =>
|
|
1170
1240
|
async (convId, runtime) => {
|
|
1241
|
+
// #409: time join → thread ready (the eager ensureThread on a fresh join).
|
|
1242
|
+
const tJoin = nowMs();
|
|
1171
1243
|
let threadPromise = threadCache.get(convId);
|
|
1244
|
+
const cached = threadPromise !== undefined;
|
|
1172
1245
|
if (!threadPromise) {
|
|
1173
1246
|
threadPromise = codex
|
|
1174
1247
|
.ensureThread({ auth, fingerprint, conversationId: convId, instanceKey, newThread: options.newThread })
|
|
@@ -1179,8 +1252,22 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
1179
1252
|
threadCache.set(convId, threadPromise);
|
|
1180
1253
|
}
|
|
1181
1254
|
const codexThreadId = await threadPromise;
|
|
1255
|
+
if (!cached) logTiming("join→thread-ready", nowMs() - tJoin, `conv=${convId.slice(0, 8)}`);
|
|
1182
1256
|
process.stderr.write(`${LOG_PREFIX} codex thread ready for ${convId} (thread ${codexThreadId})\n`);
|
|
1183
|
-
|
|
1257
|
+
const runTurn = makeCodexRunTurn(codex, codexThreadId, runtime, options, queue, tracker);
|
|
1258
|
+
// #409: wrap to measure the FIRST model turn (cold: the biggest perceived
|
|
1259
|
+
// latency) apart from subsequent turns. Pure instrumentation, same behaviour.
|
|
1260
|
+
return async (content, msg, signal) => {
|
|
1261
|
+
// #409 (Codex): reserve first/subsequent BEFORE the await so concurrent
|
|
1262
|
+
// turns can't both be classified `first`.
|
|
1263
|
+
const which = classifyTurn(firstTurnDone, convId);
|
|
1264
|
+
const tTurn = nowMs();
|
|
1265
|
+
try {
|
|
1266
|
+
return await runTurn(content, msg, signal);
|
|
1267
|
+
} finally {
|
|
1268
|
+
logTiming("turn", nowMs() - tTurn, `which=${which} conv=${convId.slice(0, 8)}`);
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1184
1271
|
};
|
|
1185
1272
|
|
|
1186
1273
|
if (options.awaitInvite) {
|