viber-channel 0.8.6 → 0.8.7
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/lib/dm_stream.ts +45 -0
- package/package.json +1 -1
- package/viber-channel.ts +22 -1
package/lib/dm_stream.ts
CHANGED
|
@@ -27,6 +27,21 @@ export interface DmMessage {
|
|
|
27
27
|
artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Persistent cross-run dedup watermark for ONE DM conversation (#427). Holds the
|
|
32
|
+
* highest message id already DELIVERED to the agent in a prior `runDmStream`.
|
|
33
|
+
* The caller keeps one of these per conversation and passes it back on every
|
|
34
|
+
* (re-)join, so it SURVIVES the abort+restart cycle that `startDmStream` does on
|
|
35
|
+
* a re-pushed join. Mutated in place. Without it every re-join allocates a fresh
|
|
36
|
+
* `handledIds`, re-fetches the full history, and replays every already-delivered
|
|
37
|
+
* message (the "pong loop" the agent saw) — the run-scoped `handledIds` cannot
|
|
38
|
+
* catch that because it is empty at the start of each run.
|
|
39
|
+
*/
|
|
40
|
+
export interface DmWatermark {
|
|
41
|
+
/** Highest delivered message id (0 = nothing delivered yet). */
|
|
42
|
+
lastId: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
/** Signature of the catch-up fetch (mirrors messages.ts `fetchMessages`). */
|
|
31
46
|
export type FetchMessagesFn = (
|
|
32
47
|
baseUrl: string,
|
|
@@ -66,6 +81,16 @@ export interface DmStreamOptions {
|
|
|
66
81
|
* forward anything published while we were disconnected (#291 step-03).
|
|
67
82
|
*/
|
|
68
83
|
fetchMessagesImpl?: FetchMessagesFn;
|
|
84
|
+
/**
|
|
85
|
+
* Cross-run dedup watermark (#427). The run-scoped `handledIds` only dedups
|
|
86
|
+
* WITHIN a single run; a re-join starts a fresh run with an empty set and the
|
|
87
|
+
* catch-up re-fetches the whole history. Supplying a caller-owned watermark
|
|
88
|
+
* (persisted across joins) suppresses ids already delivered in a prior run,
|
|
89
|
+
* while ids ABOVE the watermark still fire — so a message that arrived during
|
|
90
|
+
* detachment is preserved (#291 trigger-on-join). Advanced only after a
|
|
91
|
+
* successful delivery. Omitted → no cross-run dedup (legacy behaviour).
|
|
92
|
+
*/
|
|
93
|
+
watermark?: DmWatermark;
|
|
69
94
|
}
|
|
70
95
|
|
|
71
96
|
/** Resolve after `ms`, or early when `signal` aborts. */
|
|
@@ -106,6 +131,20 @@ async function forwardMessage(
|
|
|
106
131
|
? String(msg.id)
|
|
107
132
|
: undefined;
|
|
108
133
|
if (idStr && handledIds.has(idStr)) return; // already forwarded (live or catch-up)
|
|
134
|
+
// Cross-run dedup (#427): the message id is `INTEGER PRIMARY KEY AUTOINCREMENT`
|
|
135
|
+
// in D1 (migration 0009), so the server assigns a STRICTLY INCREASING, never-
|
|
136
|
+
// reused id at insert — a genuinely new message always outranks everything
|
|
137
|
+
// already delivered. Hence any id at or below the watermark was already
|
|
138
|
+
// DELIVERED in a prior run and a re-join's full-history re-fetch must not replay
|
|
139
|
+
// it; ids ABOVE the watermark are new (incl. a trigger that arrived while
|
|
140
|
+
// detached) and still fire (#291). The AUTOINCREMENT guarantee is why a
|
|
141
|
+
// watermark is sufficient and a per-id Set is not needed (review: Opus/Codex).
|
|
142
|
+
// Use isSafeInteger (not isFinite) so an id beyond 2^53 — where Number() loses
|
|
143
|
+
// precision and could collide — falls through to normal handling + the in-run
|
|
144
|
+
// handledIds filter rather than silently mis-gating. The watermark only ever
|
|
145
|
+
// advances AFTER a successful delivery, so a NaN/absent id is never watermarked.
|
|
146
|
+
const numId = idStr !== undefined ? Number(idStr) : Number.NaN;
|
|
147
|
+
if (opts.watermark && Number.isSafeInteger(numId) && numId <= opts.watermark.lastId) return;
|
|
109
148
|
// Content guard BEFORE consuming the id: a content-less payload must not burn
|
|
110
149
|
// its id in handledIds, or a later (correctly-populated) catch-up of the same
|
|
111
150
|
// id would be suppressed forever (review: Opus #4).
|
|
@@ -121,6 +160,12 @@ async function forwardMessage(
|
|
|
121
160
|
try {
|
|
122
161
|
await opts.onMessage(normalized);
|
|
123
162
|
if (idStr) handledIds.add(idStr);
|
|
163
|
+
// Advance the cross-run watermark ONLY after a successful delivery (#427,
|
|
164
|
+
// Opus guard): a crash mid-handling leaves the id un-watermarked so the next
|
|
165
|
+
// catch-up retries it — at-least-once over at-most-once, same as handledIds.
|
|
166
|
+
if (opts.watermark && Number.isSafeInteger(numId) && numId > opts.watermark.lastId) {
|
|
167
|
+
opts.watermark.lastId = numId;
|
|
168
|
+
}
|
|
124
169
|
} catch (handlerErr) {
|
|
125
170
|
opts.log?.(
|
|
126
171
|
`[dm-stream] onMessage handler error: ${handlerErr instanceof Error ? handlerErr.message : String(handlerErr)}\n`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viber-channel",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.7",
|
|
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-channel.ts
CHANGED
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
runPersistentControlStream,
|
|
48
48
|
sendInstanceHeartbeat,
|
|
49
49
|
} from "./lib/control_stream.ts";
|
|
50
|
-
import { runDmStream } from "./lib/dm_stream.ts";
|
|
50
|
+
import { type DmWatermark, runDmStream } from "./lib/dm_stream.ts";
|
|
51
51
|
import { isOwnMessage } from "./lib/self_echo.ts";
|
|
52
52
|
import {
|
|
53
53
|
createTokenRefreshScheduler,
|
|
@@ -654,6 +654,19 @@ let INSTANCE_TOKEN = "";
|
|
|
654
654
|
// starts a new one, so a stuck/stale stream can never block a fresh join.
|
|
655
655
|
const activeDmStreams = new Map<string, AbortController>();
|
|
656
656
|
|
|
657
|
+
// Cross-run dedup watermark per DM conversation (#427). `startDmStream` aborts
|
|
658
|
+
// and RESTARTS runDmStream on every re-pushed join, and each run's `handledIds`
|
|
659
|
+
// starts empty + re-fetches the full history — which replayed every already-
|
|
660
|
+
// delivered message to the agent (the "pong loop"). This map persists the
|
|
661
|
+
// highest delivered id ACROSS those restarts so a re-join only surfaces genuinely
|
|
662
|
+
// new messages. Kept for the process lifetime and NOT purged on stream end:
|
|
663
|
+
// dropping an entry while a re-join is possible would replay the whole history
|
|
664
|
+
// again (Opus's purge-safety nuance). Growth is O(distinct DM conversations seen)
|
|
665
|
+
// — NOT O(1) and NOT bounded by concurrent streams — but each entry is a single
|
|
666
|
+
// number and the distinct-peer count is small in practice, so it is acceptable
|
|
667
|
+
// without a TTL. Revisit if conversation_ids ever churn (many ephemeral DMs).
|
|
668
|
+
const dmWatermarks = new Map<string, DmWatermark>();
|
|
669
|
+
|
|
657
670
|
try {
|
|
658
671
|
// #269: acquire this channel's server-issued instance identity — env-injected
|
|
659
672
|
// VIBER_INSTANCE_TOKEN > persisted auth.json instance_token > register a new
|
|
@@ -1136,6 +1149,13 @@ function startDmStream(minted: ConversationMintResponse): void {
|
|
|
1136
1149
|
}
|
|
1137
1150
|
const controller = new AbortController();
|
|
1138
1151
|
activeDmStreams.set(minted.conversation_id, controller);
|
|
1152
|
+
// Reuse this conversation's persistent watermark (or seed one) so the restart
|
|
1153
|
+
// above does not replay history already delivered in the prior run (#427).
|
|
1154
|
+
let watermark = dmWatermarks.get(minted.conversation_id);
|
|
1155
|
+
if (!watermark) {
|
|
1156
|
+
watermark = { lastId: 0 };
|
|
1157
|
+
dmWatermarks.set(minted.conversation_id, watermark);
|
|
1158
|
+
}
|
|
1139
1159
|
process.stderr.write(`[viber-channel] joining agent DM ${minted.conversation_id}\n`);
|
|
1140
1160
|
// The agent's liveness (and thus the DM's presence in the "Active" sidebar) is
|
|
1141
1161
|
// proven by the INSTANCE heartbeat, not a per-conversation beat — the legacy
|
|
@@ -1147,6 +1167,7 @@ function startDmStream(minted: ConversationMintResponse): void {
|
|
|
1147
1167
|
conversationToken: minted.conversation_token,
|
|
1148
1168
|
log: (m) => process.stderr.write(m),
|
|
1149
1169
|
signal: controller.signal,
|
|
1170
|
+
watermark,
|
|
1150
1171
|
onMessage: (msg) => pushMessage(msg, { conversationId: minted.conversation_id }),
|
|
1151
1172
|
}).finally(() => {
|
|
1152
1173
|
// Only evict if WE are still the registered controller — a newer join may
|