@runtypelabs/persona 3.22.0 → 3.24.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.
@@ -0,0 +1,427 @@
1
+ // ============================================================================
2
+ // Output Throughput Tracker
3
+ // ============================================================================
4
+ //
5
+ // Derives an output tokens-per-second metric from the widget's existing SSE
6
+ // event stream, for display in the Events diagnostics screen. This is a passive
7
+ // consumer: it never mutates dispatch payloads, never forces debug mode, and
8
+ // never changes the wire contract — it only inspects the `(type, payload)`
9
+ // events that already flow through the SSE tap.
10
+ //
11
+ // Throughput is estimated live from visible text deltas while a run streams,
12
+ // then prefers exact provider usage (output tokens) when terminal events carry
13
+ // it. A run starts when the stream starts (or lazily on the first visible
14
+ // delta), stays "running" across intermediate step/turn completions, and only
15
+ // finalizes on terminal `flow_complete` / `agent_complete`. Stream errors mark
16
+ // the metric unavailable rather than leaving it stuck "running".
17
+
18
+ export type ThroughputMetricStatus = "idle" | "running" | "complete" | "error";
19
+
20
+ export type ThroughputMetricSource = "usage" | "estimate";
21
+
22
+ export interface ThroughputMetric {
23
+ status: ThroughputMetricStatus;
24
+ /** Output tokens per second, when computable. */
25
+ tokensPerSecond?: number;
26
+ /** Output tokens counted/estimated for the run. */
27
+ outputTokens?: number;
28
+ /** Duration window the rate was computed over (ms). */
29
+ durationMs?: number;
30
+ /** Whether `outputTokens` came from provider usage or a text estimate. */
31
+ source?: ThroughputMetricSource;
32
+ }
33
+
34
+ interface ThroughputRunStats {
35
+ startedAt: number;
36
+ firstDeltaAt?: number;
37
+ /**
38
+ * Running character count of accumulated visible text, estimated via the
39
+ * ~4 chars/token heuristic. Tracked as a counter (not the concatenated
40
+ * string) so the estimate stays O(1) per delta over a long stream.
41
+ */
42
+ visibleCharCount: number;
43
+ exactOutputTokens: number;
44
+ }
45
+
46
+ // Below this streamed window we don't trust the rate; fall back to provider
47
+ // execution time or whole-request duration instead.
48
+ const THROUGHPUT_MIN_DURATION_MS = 250;
49
+
50
+ // Request-level lifecycle events: each marks the beginning of a NEW request.
51
+ // The SSE tap fires for every payload type regardless of whether the client has
52
+ // a handler for it, so any of these that the server emits starts the run with
53
+ // an accurate `startedAt` (capturing time-to-first-token). These RESET any run
54
+ // already in progress, so a prior stream that ended without a terminal/error
55
+ // frame (e.g. `session.cancel()`) doesn't bleed its tokens into the next one.
56
+ const REQUEST_START_EVENTS = new Set([
57
+ "flow_start",
58
+ "flow_run_start",
59
+ "agent_start",
60
+ "dispatch_start",
61
+ "run_start",
62
+ ]);
63
+
64
+ // Per-step markers that fire repeatedly WITHIN a single request (a flow emits
65
+ // one per step). These only lazily begin a run — they must never reset, or a
66
+ // multi-step response would restart the metric between steps. If no request- or
67
+ // step-start event is emitted, the first visible delta lazily starts the run.
68
+ const STEP_START_EVENTS = new Set(["step_start", "execution_start"]);
69
+
70
+ const VISIBLE_DELTA_EVENTS = new Set([
71
+ "step_delta",
72
+ "step_chunk",
73
+ "chunk",
74
+ "agent_turn_delta",
75
+ ]);
76
+
77
+ const INTERMEDIATE_COMPLETE_EVENTS = new Set([
78
+ "step_complete",
79
+ "agent_turn_complete",
80
+ ]);
81
+
82
+ const TERMINAL_COMPLETE_EVENTS = new Set(["flow_complete", "agent_complete"]);
83
+
84
+ const ERROR_EVENTS = new Set([
85
+ "step_error",
86
+ "flow_error",
87
+ "agent_error",
88
+ "dispatch_error",
89
+ "error",
90
+ ]);
91
+
92
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
93
+ typeof value === "object" && value !== null && !Array.isArray(value);
94
+
95
+ const toFiniteNumber = (value: unknown): number | undefined =>
96
+ typeof value === "number" && Number.isFinite(value) ? value : undefined;
97
+
98
+ const getRecord = (
99
+ value: Record<string, unknown>,
100
+ key: string
101
+ ): Record<string, unknown> | undefined => {
102
+ const nested = value[key];
103
+ return isRecord(nested) ? nested : undefined;
104
+ };
105
+
106
+ /** Token estimate from a character count: ~4 chars/token, floor of 1. */
107
+ function estimateTokensFromCharCount(charCount: number): number {
108
+ return charCount > 0 ? Math.max(1, Math.ceil(charCount / 4)) : 0;
109
+ }
110
+
111
+ /** Simple token estimate matching the dashboard heuristic: ~4 chars/token. */
112
+ export function estimateOutputTokens(text: string): number {
113
+ return estimateTokensFromCharCount(text.trim().length);
114
+ }
115
+
116
+ function calculateTokensPerSecond(
117
+ outputTokens: number,
118
+ durationMs: number | undefined
119
+ ): number | undefined {
120
+ if (
121
+ outputTokens <= 0 ||
122
+ durationMs === undefined ||
123
+ durationMs < THROUGHPUT_MIN_DURATION_MS
124
+ ) {
125
+ return undefined;
126
+ }
127
+ return outputTokens / (durationMs / 1000);
128
+ }
129
+
130
+ function resolveEventType(
131
+ eventType: string,
132
+ payload: Record<string, unknown>
133
+ ): string {
134
+ return typeof payload.type === "string" ? payload.type : eventType;
135
+ }
136
+
137
+ function getTextDelta(payload: Record<string, unknown>): string {
138
+ if (typeof payload.text === "string") return payload.text;
139
+ if (typeof payload.delta === "string") return payload.delta;
140
+ if (typeof payload.content === "string") return payload.content;
141
+ if (typeof payload.chunk === "string") return payload.chunk;
142
+ return "";
143
+ }
144
+
145
+ /**
146
+ * Only count visible model output.
147
+ *
148
+ * For `agent_turn_delta`, count only a contentType of exactly `text` as
149
+ * visible, matching the client renderer (which appends streaming assistant
150
+ * text only when `contentType === "text"`); `thinking`, `tool_input`, any
151
+ * other value, and a missing contentType are ignored so throughput never
152
+ * includes deltas the chat UI doesn't render.
153
+ *
154
+ * For `step_delta` / `step_chunk`, skip tool and context steps — those carry
155
+ * tool I/O, not model-visible text — mirroring the widget's own renderer.
156
+ */
157
+ function isVisibleTextDelta(
158
+ type: string,
159
+ payload: Record<string, unknown>
160
+ ): boolean {
161
+ if (type === "step_delta" || type === "step_chunk") {
162
+ return payload.stepType !== "tool" && payload.executionType !== "context";
163
+ }
164
+
165
+ if (type !== "agent_turn_delta") return true;
166
+
167
+ const contentType =
168
+ typeof payload.contentType === "string"
169
+ ? payload.contentType
170
+ : typeof payload.content_type === "string"
171
+ ? payload.content_type
172
+ : undefined;
173
+
174
+ return contentType === "text";
175
+ }
176
+
177
+ /** Extract exact output tokens from a variety of usage payload shapes. */
178
+ function getOutputTokens(payload: Record<string, unknown>): number | undefined {
179
+ const result = getRecord(payload, "result");
180
+ const candidates = [
181
+ getRecord(payload, "tokens"),
182
+ getRecord(payload, "totalTokens"),
183
+ result ? getRecord(result, "tokens") : undefined,
184
+ getRecord(payload, "usage"),
185
+ result ? getRecord(result, "usage") : undefined,
186
+ ];
187
+
188
+ for (const candidate of candidates) {
189
+ if (!candidate) continue;
190
+ const outputTokens =
191
+ toFiniteNumber(candidate.output) ??
192
+ toFiniteNumber(candidate.outputTokens) ??
193
+ toFiniteNumber(candidate.completionTokens);
194
+ if (outputTokens !== undefined) return outputTokens;
195
+ }
196
+
197
+ return (
198
+ toFiniteNumber(payload.outputTokens) ??
199
+ toFiniteNumber(payload.completionTokens) ??
200
+ (result
201
+ ? (toFiniteNumber(result.outputTokens) ??
202
+ toFiniteNumber(result.completionTokens))
203
+ : undefined)
204
+ );
205
+ }
206
+
207
+ /** Extract provider execution time (ms) from a variety of payload shapes. */
208
+ function getExecutionTimeMs(
209
+ payload: Record<string, unknown>
210
+ ): number | undefined {
211
+ const result = getRecord(payload, "result");
212
+ return (
213
+ toFiniteNumber(payload.executionTime) ??
214
+ toFiniteNumber(payload.executionTimeMs) ??
215
+ toFiniteNumber(payload.execution_time) ??
216
+ toFiniteNumber(payload.duration) ??
217
+ (result
218
+ ? (toFiniteNumber(result.executionTime) ??
219
+ toFiniteNumber(result.executionTimeMs))
220
+ : undefined)
221
+ );
222
+ }
223
+
224
+ function defaultClock(): number {
225
+ if (
226
+ typeof performance !== "undefined" &&
227
+ typeof performance.now === "function"
228
+ ) {
229
+ return performance.now();
230
+ }
231
+ return Date.now();
232
+ }
233
+
234
+ /**
235
+ * Tracks output throughput across one streamed run at a time. Feed it every SSE
236
+ * event via {@link processEvent}; read the current state via {@link getMetric}.
237
+ */
238
+ export class ThroughputTracker {
239
+ private metric: ThroughputMetric = { status: "idle" };
240
+ private run: ThroughputRunStats | null = null;
241
+ private readonly now: () => number;
242
+
243
+ constructor(now: () => number = defaultClock) {
244
+ this.now = now;
245
+ }
246
+
247
+ getMetric(): ThroughputMetric {
248
+ // While a run is streaming, recompute the elapsed window (and rate) from the
249
+ // clock on each read. The view polls this every ~200ms, so without this a
250
+ // pause between deltas would keep showing the stale rate from the last
251
+ // event; recomputing lets the displayed tok/s decay as time passes.
252
+ const run = this.run;
253
+ if (
254
+ run &&
255
+ this.metric.status === "running" &&
256
+ run.firstDeltaAt !== undefined &&
257
+ this.metric.outputTokens !== undefined
258
+ ) {
259
+ const durationMs = this.now() - run.firstDeltaAt;
260
+ return {
261
+ ...this.metric,
262
+ durationMs,
263
+ tokensPerSecond: calculateTokensPerSecond(
264
+ this.metric.outputTokens,
265
+ durationMs
266
+ ),
267
+ };
268
+ }
269
+ return this.metric;
270
+ }
271
+
272
+ /** Reset back to idle (e.g. when the chat is cleared). */
273
+ reset(): void {
274
+ this.run = null;
275
+ this.metric = { status: "idle" };
276
+ }
277
+
278
+ private startRun(now: number): void {
279
+ this.run = {
280
+ startedAt: now,
281
+ visibleCharCount: 0,
282
+ exactOutputTokens: 0,
283
+ };
284
+ this.metric = { status: "running" };
285
+ }
286
+
287
+ processEvent(eventType: string, payload: unknown): void {
288
+ if (!isRecord(payload)) {
289
+ // Non-object payloads can still signal lifecycle (e.g. bare "error").
290
+ if (ERROR_EVENTS.has(eventType) && this.run) {
291
+ this.run = null;
292
+ this.metric = { status: "error" };
293
+ }
294
+ return;
295
+ }
296
+
297
+ const type = resolveEventType(eventType, payload);
298
+ const now = this.now();
299
+
300
+ if (REQUEST_START_EVENTS.has(type)) {
301
+ // New request — start fresh, discarding any incomplete prior run.
302
+ this.startRun(now);
303
+ return;
304
+ }
305
+
306
+ if (STEP_START_EVENTS.has(type)) {
307
+ // Mid-request step marker — only begin a run if none is active.
308
+ if (!this.run) this.startRun(now);
309
+ return;
310
+ }
311
+
312
+ if (VISIBLE_DELTA_EVENTS.has(type)) {
313
+ if (!isVisibleTextDelta(type, payload)) return;
314
+ const text = getTextDelta(payload);
315
+ if (!text) return;
316
+
317
+ // Lazily start a run if the stream began without a recognized start event.
318
+ if (!this.run) this.startRun(now);
319
+ const stats = this.run!;
320
+
321
+ stats.firstDeltaAt ??= now;
322
+ stats.visibleCharCount += text.length;
323
+
324
+ // Add the live char estimate of the CURRENT (not-yet-completed) step on
325
+ // top of any exact usage already booked from completed steps, so the
326
+ // count only grows — it never drops back to a bare estimate mid-run.
327
+ const outputTokens =
328
+ stats.exactOutputTokens +
329
+ estimateTokensFromCharCount(stats.visibleCharCount);
330
+ const durationMs = now - stats.firstDeltaAt;
331
+ this.metric = {
332
+ status: "running",
333
+ tokensPerSecond: calculateTokensPerSecond(outputTokens, durationMs),
334
+ outputTokens,
335
+ durationMs,
336
+ source: stats.exactOutputTokens > 0 ? "usage" : "estimate",
337
+ };
338
+ return;
339
+ }
340
+
341
+ if (INTERMEDIATE_COMPLETE_EVENTS.has(type)) {
342
+ // Accumulate exact usage but keep the run going — these fire per
343
+ // step/turn, not at the end of the whole run.
344
+ if (!this.run) return;
345
+ const stats = this.run;
346
+ const exact = getOutputTokens(payload);
347
+ if (exact !== undefined) {
348
+ stats.exactOutputTokens += exact;
349
+ // This step's visible text is now represented exactly by provider
350
+ // usage — drop it from the running char estimate so the two don't
351
+ // double-count once the next step starts streaming.
352
+ stats.visibleCharCount = 0;
353
+ }
354
+
355
+ const usingExact = stats.exactOutputTokens > 0;
356
+ const outputTokens =
357
+ stats.exactOutputTokens +
358
+ estimateTokensFromCharCount(stats.visibleCharCount);
359
+ const durationMs = this.resolveDuration(stats, payload, now);
360
+ this.metric = {
361
+ status: "running",
362
+ tokensPerSecond: calculateTokensPerSecond(outputTokens, durationMs),
363
+ outputTokens,
364
+ durationMs,
365
+ source: usingExact ? "usage" : "estimate",
366
+ };
367
+ return;
368
+ }
369
+
370
+ if (TERMINAL_COMPLETE_EVENTS.has(type)) {
371
+ if (!this.run) return;
372
+ const stats = this.run;
373
+ // Prefer exact output tokens from this terminal event, else accumulated
374
+ // usage from intermediate completes, else the text estimate.
375
+ const terminalExact = getOutputTokens(payload);
376
+ // Prefer a total from the terminal event; otherwise sum exact usage booked
377
+ // from intermediate completes plus the estimate of any still-streamed text
378
+ // not yet covered by a usage report.
379
+ const outputTokens =
380
+ terminalExact ??
381
+ stats.exactOutputTokens +
382
+ estimateTokensFromCharCount(stats.visibleCharCount);
383
+ const source: ThroughputMetricSource =
384
+ terminalExact !== undefined || stats.exactOutputTokens > 0
385
+ ? "usage"
386
+ : "estimate";
387
+ const durationMs = this.resolveDuration(stats, payload, now);
388
+ this.metric = {
389
+ status: "complete",
390
+ tokensPerSecond: calculateTokensPerSecond(outputTokens, durationMs),
391
+ outputTokens,
392
+ durationMs,
393
+ source,
394
+ };
395
+ this.run = null;
396
+ return;
397
+ }
398
+
399
+ if (ERROR_EVENTS.has(type)) {
400
+ if (!this.run) return;
401
+ this.run = null;
402
+ this.metric = { status: "error" };
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Prefer the streamed visible-output window when it clears the minimum
408
+ * threshold; otherwise fall back to provider execution time, then to the
409
+ * whole-request duration.
410
+ */
411
+ private resolveDuration(
412
+ stats: ThroughputRunStats,
413
+ payload: Record<string, unknown>,
414
+ now: number
415
+ ): number {
416
+ const streamedDurationMs =
417
+ stats.firstDeltaAt !== undefined ? now - stats.firstDeltaAt : undefined;
418
+ if (
419
+ streamedDurationMs !== undefined &&
420
+ streamedDurationMs >= THROUGHPUT_MIN_DURATION_MS
421
+ ) {
422
+ return streamedDurationMs;
423
+ }
424
+ const executionTimeMs = getExecutionTimeMs(payload);
425
+ return executionTimeMs ?? now - stats.startedAt;
426
+ }
427
+ }
@@ -29,7 +29,9 @@ import {
29
29
  WebMcpBridge,
30
30
  isWebMcpToolName,
31
31
  stripWebMcpPrefix,
32
+ computeClientToolsFingerprint,
32
33
  } from "./webmcp-bridge";
34
+ import type { ClientToolDefinition } from "./types";
33
35
 
34
36
  type MockClient = { requestUserInteraction: (cb: () => unknown) => Promise<unknown> };
35
37
 
@@ -427,3 +429,81 @@ describe("WebMcpBridge.executeToolCall", () => {
427
429
  expect(executeSpy).not.toHaveBeenCalled();
428
430
  });
429
431
  });
432
+
433
+ describe("computeClientToolsFingerprint — diff-only / send-once", () => {
434
+ const tool = (over: Partial<ClientToolDefinition> = {}): ClientToolDefinition => ({
435
+ name: "search",
436
+ description: "Search the catalog",
437
+ parametersSchema: { type: "object", properties: { q: { type: "string" } } },
438
+ origin: "webmcp",
439
+ ...over,
440
+ });
441
+
442
+ it("returns a stable sentinel for an empty set", () => {
443
+ expect(computeClientToolsFingerprint([])).toBe(computeClientToolsFingerprint([]));
444
+ expect(computeClientToolsFingerprint([])).toBe("0:empty");
445
+ });
446
+
447
+ it("is deterministic for the same set", () => {
448
+ const a = computeClientToolsFingerprint([tool({ name: "a" }), tool({ name: "b" })]);
449
+ const b = computeClientToolsFingerprint([tool({ name: "a" }), tool({ name: "b" })]);
450
+ expect(a).toBe(b);
451
+ });
452
+
453
+ it("is order-independent (tool order does not matter)", () => {
454
+ const ab = computeClientToolsFingerprint([tool({ name: "a" }), tool({ name: "b" })]);
455
+ const ba = computeClientToolsFingerprint([tool({ name: "b" }), tool({ name: "a" })]);
456
+ expect(ab).toBe(ba);
457
+ });
458
+
459
+ it("changes when a description changes", () => {
460
+ expect(computeClientToolsFingerprint([tool({ description: "x" })])).not.toBe(
461
+ computeClientToolsFingerprint([tool({ description: "y" })]),
462
+ );
463
+ });
464
+
465
+ it("changes when the schema changes", () => {
466
+ const base = computeClientToolsFingerprint([tool()]);
467
+ const changed = computeClientToolsFingerprint([
468
+ tool({ parametersSchema: { type: "object", properties: { q: { type: "number" } } } }),
469
+ ]);
470
+ expect(changed).not.toBe(base);
471
+ });
472
+
473
+ it("changes when a tool is added", () => {
474
+ const one = computeClientToolsFingerprint([tool({ name: "a" })]);
475
+ const two = computeClientToolsFingerprint([tool({ name: "a" }), tool({ name: "b" })]);
476
+ expect(two).not.toBe(one);
477
+ });
478
+
479
+ it("ignores pageOrigin (audit metadata, not part of the contract)", () => {
480
+ const withOrigin = computeClientToolsFingerprint([tool({ pageOrigin: "https://a.example" })]);
481
+ const without = computeClientToolsFingerprint([tool({ pageOrigin: undefined })]);
482
+ expect(withOrigin).toBe(without);
483
+ });
484
+
485
+ it("reflects annotations (they ride along to the server)", () => {
486
+ const plain = computeClientToolsFingerprint([tool()]);
487
+ const annotated = computeClientToolsFingerprint([
488
+ tool({ annotations: { readOnlyHint: true } }),
489
+ ]);
490
+ expect(annotated).not.toBe(plain);
491
+ });
492
+
493
+ it("stays within the server's 128-char wire bound for large tool sets", () => {
494
+ // The server validates `clientToolsFingerprint` as `z.string().max(128)`.
495
+ // A fingerprint that grew with the tool content would 400 the first turn.
496
+ const many = Array.from({ length: 50 }, (_, i) =>
497
+ tool({
498
+ name: `tool_${i}`,
499
+ description: `A fairly long description for tool number ${i} `.repeat(8),
500
+ parametersSchema: {
501
+ type: "object",
502
+ properties: { a: { type: "string" }, b: { type: "number" }, c: { type: "boolean" } },
503
+ },
504
+ }),
505
+ );
506
+ const fp = computeClientToolsFingerprint(many);
507
+ expect(fp.length).toBeLessThanOrEqual(128);
508
+ });
509
+ });
@@ -86,6 +86,74 @@ const log = {
86
86
  },
87
87
  };
88
88
 
89
+ /**
90
+ * Compute a stable, order-independent fingerprint of a `ClientToolDefinition[]`
91
+ * snapshot, for the diff-only / send-once dispatch path (client-token mode).
92
+ *
93
+ * The widget caches "the fingerprint of the tool set last sent in full" for the
94
+ * current session; an unchanged set on a follow-up turn lets it ship only the
95
+ * fingerprint instead of the whole array. Per-tool strings are sorted so tool
96
+ * ordering does not affect the result. `pageOrigin` is deliberately excluded —
97
+ * it is audit metadata, not part of the tool contract.
98
+ *
99
+ * This is a fast, non-cryptographic content key. The canonical per-tool content
100
+ * is hashed down to a short, fixed-length digest so the result fits the server's
101
+ * `clientToolsFingerprint` wire field (`z.string().max(128)`) regardless of how
102
+ * many tools the page registers — sending the raw concatenated content would
103
+ * overflow that bound and be rejected with a 400. The server stores and compares
104
+ * the widget's fingerprint verbatim, so cross-implementation byte-equality is NOT
105
+ * required — only self-consistency across this widget's turns.
106
+ */
107
+ export function computeClientToolsFingerprint(
108
+ tools: ClientToolDefinition[],
109
+ ): string {
110
+ if (tools.length === 0) return "0:empty";
111
+ const parts = tools
112
+ .map((t) =>
113
+ [
114
+ t.name,
115
+ t.description ?? "",
116
+ t.parametersSchema ? JSON.stringify(t.parametersSchema) : "",
117
+ t.origin ?? "",
118
+ t.annotations ? JSON.stringify(t.annotations) : "",
119
+ ].join("\x1f"),
120
+ )
121
+ .sort();
122
+ return `${tools.length}:${hashFingerprintContent(parts.join("\x1e"))}`;
123
+ }
124
+
125
+ /**
126
+ * cyrb53 — a fast, well-distributed non-cryptographic string hash. Returns a
127
+ * 53-bit value (safe-integer range). Two independent seeds are combined by the
128
+ * caller for a ~106-bit digest, which makes accidental collisions across a
129
+ * single conversation's handful of tool-set variants infeasible.
130
+ */
131
+ function cyrb53(str: string, seed: number): number {
132
+ let h1 = 0xdeadbeef ^ seed;
133
+ let h2 = 0x41c6ce57 ^ seed;
134
+ for (let i = 0; i < str.length; i++) {
135
+ const ch = str.charCodeAt(i);
136
+ h1 = Math.imul(h1 ^ ch, 2654435761);
137
+ h2 = Math.imul(h2 ^ ch, 1597334677);
138
+ }
139
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
140
+ h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
141
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
142
+ h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
143
+ return 4294967296 * (2097151 & h2) + (h1 >>> 0);
144
+ }
145
+
146
+ /**
147
+ * Compress the canonical tool-set content string into a short, fixed-length
148
+ * fingerprint (≤ ~24 chars) that fits the server's 128-char wire bound. Uses two
149
+ * seeded cyrb53 passes, base-36 encoded.
150
+ */
151
+ function hashFingerprintContent(content: string): string {
152
+ const a = cyrb53(content, 0).toString(36);
153
+ const b = cyrb53(content, 0x9e3779b1).toString(36);
154
+ return `${a}.${b}`;
155
+ }
156
+
89
157
  export class WebMcpBridge {
90
158
  private confirmHandler: WebMcpConfirmHandler | null;
91
159
  private readonly timeoutMs: number;