ocuclaw 1.2.4 β†’ 1.3.1

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.
Files changed (60) hide show
  1. package/README.md +21 -6
  2. package/dist/config/runtime-config.js +84 -3
  3. package/dist/domain/activity-status-adapter.js +138 -605
  4. package/dist/domain/activity-status-arbiter.js +109 -0
  5. package/dist/domain/activity-status-labels.js +906 -0
  6. package/dist/domain/code-span-regions.js +103 -0
  7. package/dist/domain/conversation-state.js +14 -1
  8. package/dist/domain/debug-store.js +56 -182
  9. package/dist/domain/glasses-ui-content-summary.js +62 -0
  10. package/dist/domain/glasses-ui-system-prompt.js +28 -0
  11. package/dist/domain/message-emoji-allowlist.js +16 -0
  12. package/dist/domain/message-emoji-filter.js +33 -55
  13. package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
  14. package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
  15. package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
  16. package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
  17. package/dist/domain/tagged-span-parser.js +121 -0
  18. package/dist/domain/tagged-span-strip.js +38 -0
  19. package/dist/even-ai/even-ai-endpoint.js +91 -0
  20. package/dist/even-ai/even-ai-run-waiter.js +14 -0
  21. package/dist/even-ai/even-ai-settings-store.js +14 -0
  22. package/dist/gateway/gateway-bridge.js +14 -2
  23. package/dist/gateway/gateway-timing-ledger.js +457 -0
  24. package/dist/gateway/openclaw-client.js +462 -38
  25. package/dist/index.js +28 -1
  26. package/dist/runtime/downstream-handler.js +754 -83
  27. package/dist/runtime/ocuclaw-settings-store.js +74 -31
  28. package/dist/runtime/plugin-version-service.js +23 -0
  29. package/dist/runtime/protocol-adapter.js +9 -0
  30. package/dist/runtime/provider-usage-select.js +168 -0
  31. package/dist/runtime/relay-client-nudge-controller.js +553 -0
  32. package/dist/runtime/relay-core.js +1293 -225
  33. package/dist/runtime/relay-health-monitor.js +172 -0
  34. package/dist/runtime/relay-operation-registry.js +263 -0
  35. package/dist/runtime/relay-service.js +201 -1
  36. package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
  37. package/dist/runtime/relay-worker-entry.js +32 -0
  38. package/dist/runtime/relay-worker-health.js +272 -0
  39. package/dist/runtime/relay-worker-protocol.js +281 -0
  40. package/dist/runtime/relay-worker-queue.js +202 -0
  41. package/dist/runtime/relay-worker-supervisor.js +1004 -0
  42. package/dist/runtime/relay-worker-transport.js +1051 -0
  43. package/dist/runtime/session-context-service.js +189 -0
  44. package/dist/runtime/session-service.js +638 -27
  45. package/dist/runtime/upstream-runtime.js +1167 -60
  46. package/dist/tools/device-info-tool.js +242 -0
  47. package/dist/tools/glasses-ui-cron.js +427 -0
  48. package/dist/tools/glasses-ui-descriptors.js +261 -0
  49. package/dist/tools/glasses-ui-limits.js +21 -0
  50. package/dist/tools/glasses-ui-paint-floor.js +99 -0
  51. package/dist/tools/glasses-ui-recipes.js +581 -0
  52. package/dist/tools/glasses-ui-surfaces.js +278 -0
  53. package/dist/tools/glasses-ui-template.js +182 -0
  54. package/dist/tools/glasses-ui-tool.js +1111 -0
  55. package/dist/tools/session-title-tool.js +209 -0
  56. package/dist/version.js +2 -0
  57. package/openclaw.plugin.json +163 -15
  58. package/package.json +14 -5
  59. package/skills/glasses-ui/SKILL.md +156 -0
  60. package/dist/runtime/downstream-server.js +0 -1891
@@ -1,60 +1,15 @@
1
- const MESSAGE_EMOJI_ALLOWLIST = [
2
- "πŸ˜‚",
3
- "❀️",
4
- "🀣",
5
- "πŸ‘",
6
- "😭",
7
- "πŸ™",
8
- "😘",
9
- "πŸ₯°",
10
- "😍",
11
- "😊",
12
- "πŸŽ‰",
13
- "😁",
14
- "πŸ’•",
15
- "πŸ₯Ί",
16
- "πŸ˜…",
17
- "πŸ”₯",
18
- "☺️",
19
- "🀦",
20
- "🀷",
21
- "πŸ™„",
22
- "πŸ˜†",
23
- "πŸ€—",
24
- "πŸ˜‰",
25
- "πŸŽ‚",
26
- "πŸ€”",
27
- "πŸ‘",
28
- "πŸ™‚",
29
- "😳",
30
- "πŸ₯³",
31
- "😎",
32
- "πŸ‘Œ",
33
- "πŸ˜”",
34
- "πŸ’ͺ",
35
- "✨",
36
- "πŸ’–",
37
- "πŸ’ž",
38
- "πŸ‘€",
39
- "πŸ˜‹",
40
- "😏",
41
- "😒",
42
- "πŸ‘‰",
43
- "πŸ’—",
44
- "😩",
45
- "πŸ’―",
46
- "🌹",
47
- "🎈",
48
- "😚",
49
- "😐",
50
- "πŸ˜’",
51
- "πŸ˜€",
52
- ];
53
-
54
- const MESSAGE_EMOJI_ALLOWLIST_SET = new Set(MESSAGE_EMOJI_ALLOWLIST);
1
+ import {
2
+ MESSAGE_EMOJI_ALLOWLIST,
3
+ MESSAGE_EMOJI_ALLOWLIST_SET,
4
+ } from "./message-emoji-allowlist.js";
5
+
55
6
  const EMOJI_CLUSTER_SEGMENTER = new Intl.Segmenter(undefined, {
56
7
  granularity: "grapheme",
57
8
  });
9
+ // All emoji codepoints live above U+007F, so pure-ASCII text cannot contain
10
+ // any emoji and the segmenter walk is pure overhead. This regex is the
11
+ // gatekeeper for the fast path below.
12
+ const NON_ASCII_RE = /[^\x00-\x7F]/;
58
13
  const RGI_EMOJI_SEQUENCE_RE = createOptionalRegex("^\\p{RGI_Emoji}$", "v");
59
14
  const EMOJI_CLUSTER_FALLBACK_RE =
60
15
  /(?:\p{Extended_Pictographic}|[\u{1F1E6}-\u{1F1FF}\u{1F3FB}-\u{1F3FF}\u20E3\u{E0020}-\u{E007F}])/u;
@@ -147,10 +102,33 @@ function emitGapForTextEnd(mode, beforeRemoved, afterRemoved, removedEmojiInGap)
147
102
  return beforeRemoved + afterRemoved;
148
103
  }
149
104
 
105
+ function filterAsciiDisplayFast(text) {
106
+ // Mirrors the segmenter walk for pure-ASCII display mode:
107
+ // - whitespace immediately before a newline is dropped
108
+ // - other [ \t]+ runs collapse to a single space
109
+ // - trailing horizontal whitespace at end-of-text is stripped
110
+ return text
111
+ .replace(/[ \t]+(\r\n|\r|\n)/g, "$1")
112
+ .replace(/[ \t]+/g, " ")
113
+ .replace(/[ \t]+$/g, "");
114
+ }
115
+
116
+ function filterAsciiRawFast(text) {
117
+ // Raw mode preserves inline whitespace; only the post-loop trailing-trim
118
+ // remains for pure-ASCII input.
119
+ return text.replace(/[ \t]+$/g, "");
120
+ }
121
+
150
122
  function filterEmojiText(text, mode) {
151
123
  if (!text) return "";
152
124
 
153
125
  const selectedMode = mode === "display" ? "display" : "raw";
126
+
127
+ if (!NON_ASCII_RE.test(text)) {
128
+ return selectedMode === "display"
129
+ ? filterAsciiDisplayFast(text)
130
+ : filterAsciiRawFast(text);
131
+ }
154
132
  const out = [];
155
133
  let pendingWhitespaceBeforeRemoved = "";
156
134
  let pendingWhitespaceAfterRemoved = "";
@@ -246,4 +224,4 @@ export function filterRawEmojiText(text) {
246
224
  return filterEmojiText(text, "raw");
247
225
  }
248
226
 
249
- export { MESSAGE_EMOJI_ALLOWLIST };
227
+ export { MESSAGE_EMOJI_ALLOWLIST, MESSAGE_EMOJI_ALLOWLIST_SET };
@@ -0,0 +1,43 @@
1
+ import { MESSAGE_EMOJI_ALLOWLIST } from "./message-emoji-allowlist.js";
2
+
3
+ const DISABLED_NOTICE = `<neural_emoji_reactor>
4
+ The Neural Emoji Reactor has been turned off for the remainder of
5
+ this session. Do not use <emoji:X>...</emoji> spans in your replies,
6
+ even if earlier messages in this conversation used them.
7
+ </neural_emoji_reactor>`;
8
+
9
+ const ALLOWLIST_LINES = (() => {
10
+ const rows = [];
11
+ for (let i = 0; i < MESSAGE_EMOJI_ALLOWLIST.length; i += 20) {
12
+ rows.push(MESSAGE_EMOJI_ALLOWLIST.slice(i, i + 20).join(" "));
13
+ }
14
+ return rows.map((r) => ` ${r}`).join("\n");
15
+ })();
16
+
17
+ const ACTIVE_BLOCK = `<neural_emoji_reactor>
18
+ You can briefly change a small status emoji shown above your message
19
+ on the user's display by wrapping a short phrase in your reply with:
20
+
21
+ <emoji:X>your phrase</emoji>
22
+
23
+ The tags themselves are invisible β€” only the wrapped words are shown
24
+ in the message body.
25
+
26
+ Use this sparingly. Most messages should have NO span. Use one only
27
+ at moments where it adds real warmth, surprise, care, or playfulness
28
+ for a single short phrase. Do not tag every sentence. Do not nest
29
+ spans. Always close a span you open.
30
+
31
+ Allowed emoji (use exactly one per span, copied verbatim from this
32
+ list):
33
+ ${ALLOWLIST_LINES}
34
+
35
+ Do not use off-list emoji; they will be ignored.
36
+ </neural_emoji_reactor>`;
37
+
38
+ export function composeNeuralEmojiReactorSystemPrompt(opts) {
39
+ const state = opts && opts.state;
40
+ if (state === "active") return ACTIVE_BLOCK;
41
+ if (state === "recently-disabled") return DISABLED_NOTICE;
42
+ return "";
43
+ }
@@ -0,0 +1,56 @@
1
+ import { MESSAGE_EMOJI_ALLOWLIST_SET } from "./message-emoji-allowlist.js";
2
+
3
+ const OPEN_PREFIX = "<emoji:";
4
+ const CLOSE_TAG = "</emoji>";
5
+ const CLOSE_LITERALS = Object.freeze(["</emoji>"]);
6
+
7
+ export const EMOJI_TAG_FAMILY_CONFIG = {
8
+ name: "emoji",
9
+ closeLiterals: CLOSE_LITERALS,
10
+
11
+ matchOpen(input, at) {
12
+ if (!input.startsWith(OPEN_PREFIX, at)) return null;
13
+ const closeIdx = input.indexOf(">", at + OPEN_PREFIX.length);
14
+ if (closeIdx === -1) return null;
15
+ const rawEmoji = input.slice(at + OPEN_PREFIX.length, closeIdx);
16
+ if (/\s/.test(rawEmoji)) return null;
17
+ return {
18
+ consumed: closeIdx - at + 1,
19
+ spanInit: { emoji: rawEmoji },
20
+ };
21
+ },
22
+
23
+ matchClose(input, at) {
24
+ if (!input.startsWith(CLOSE_TAG, at)) return null;
25
+ return { consumed: CLOSE_TAG.length, closeKind: "emoji" };
26
+ },
27
+
28
+ closeMatches(_activeOpen, _closeKind) {
29
+ return true;
30
+ },
31
+
32
+ validateOpen(spanInit) {
33
+ return MESSAGE_EMOJI_ALLOWLIST_SET.has(spanInit.emoji);
34
+ },
35
+
36
+ matchTrailingPartial(input, _suffixStart) {
37
+ const n = input.length;
38
+ // 1) Trailing prefix of OPEN_PREFIX
39
+ for (let len = Math.min(n, OPEN_PREFIX.length - 1); len > 0; len--) {
40
+ const tail = input.slice(n - len);
41
+ if (OPEN_PREFIX.startsWith(tail)) return len;
42
+ }
43
+ // 2) Trailing OPEN_PREFIX + bytes not yet terminated by `>`
44
+ const lastOpen = input.lastIndexOf(OPEN_PREFIX);
45
+ if (lastOpen !== -1) {
46
+ const after = input.indexOf(">", lastOpen + OPEN_PREFIX.length);
47
+ if (after === -1) return n - lastOpen;
48
+ }
49
+ // 3) Trailing prefix of CLOSE_TAG
50
+ for (let len = Math.min(n, CLOSE_TAG.length - 1); len > 0; len--) {
51
+ const tail = input.slice(n - len);
52
+ if (CLOSE_TAG.startsWith(tail)) return len;
53
+ }
54
+ return 0;
55
+ },
56
+ };
@@ -0,0 +1,32 @@
1
+ const ACTIVE_BLOCK = `<neural_pace_modulator>
2
+ You can shape how quickly a short phrase reveals on the user's display
3
+ by wrapping it with one of:
4
+
5
+ <dwell>your phrase</dwell> β€” reveals slower, lets the line land
6
+ <skim>your phrase</skim> β€” reveals faster, rushes past a recap
7
+
8
+ The tags themselves are invisible β€” only the wrapped words are shown
9
+ in the message body.
10
+
11
+ Use this sparingly. Most messages have NO pace tag. Use one only at a
12
+ moment that genuinely benefits from it: a beat where the user should
13
+ sit with what was just said, or a recap that doesn't need to breathe.
14
+ Keep spans short (a few words). Do not tag every sentence. Do not nest
15
+ the same tag inside itself. Always close a tag you open.
16
+
17
+ You may combine pace tags with <emoji:X>...</emoji> if both apply to
18
+ the same phrase. They don't interfere.
19
+ </neural_pace_modulator>`;
20
+
21
+ const DISABLED_NOTICE = `<neural_pace_modulator>
22
+ The Neural Pace Modulator has been turned off for the remainder of
23
+ this session. Do not use <dwell>...</dwell> or <skim>...</skim>
24
+ spans in your replies, even if earlier messages used them.
25
+ </neural_pace_modulator>`;
26
+
27
+ export function composeNeuralPaceModulatorSystemPrompt(opts) {
28
+ const state = opts && opts.state;
29
+ if (state === "active") return ACTIVE_BLOCK;
30
+ if (state === "recently-disabled") return DISABLED_NOTICE;
31
+ return "";
32
+ }
@@ -0,0 +1,51 @@
1
+ const OPEN_DWELL = "<dwell>";
2
+ const OPEN_SKIM = "<skim>";
3
+ const CLOSE_DWELL = "</dwell>";
4
+ const CLOSE_SKIM = "</skim>";
5
+ const OPEN_LITERALS = Object.freeze([OPEN_DWELL, OPEN_SKIM]);
6
+ const CLOSE_LITERALS = Object.freeze([CLOSE_DWELL, CLOSE_SKIM]);
7
+ const ALL_LITERALS = Object.freeze([...OPEN_LITERALS, ...CLOSE_LITERALS]);
8
+
9
+ export const PACE_TAG_FAMILY_CONFIG = {
10
+ name: "pace",
11
+ closeLiterals: CLOSE_LITERALS,
12
+
13
+ matchOpen(input, at) {
14
+ if (input.startsWith(OPEN_DWELL, at)) {
15
+ return { consumed: OPEN_DWELL.length, spanInit: { mode: "dwell" } };
16
+ }
17
+ if (input.startsWith(OPEN_SKIM, at)) {
18
+ return { consumed: OPEN_SKIM.length, spanInit: { mode: "skim" } };
19
+ }
20
+ return null;
21
+ },
22
+
23
+ matchClose(input, at) {
24
+ if (input.startsWith(CLOSE_DWELL, at)) {
25
+ return { consumed: CLOSE_DWELL.length, closeKind: "dwell" };
26
+ }
27
+ if (input.startsWith(CLOSE_SKIM, at)) {
28
+ return { consumed: CLOSE_SKIM.length, closeKind: "skim" };
29
+ }
30
+ return null;
31
+ },
32
+
33
+ closeMatches(activeOpen, closeKind) {
34
+ return activeOpen.mode === closeKind;
35
+ },
36
+
37
+ matchTrailingPartial(input, _suffixStart) {
38
+ const n = input.length;
39
+ let best = 0;
40
+ for (const lit of ALL_LITERALS) {
41
+ for (let len = Math.min(n, lit.length - 1); len > best; len--) {
42
+ const tail = input.slice(n - len);
43
+ if (lit.startsWith(tail)) {
44
+ best = len;
45
+ break;
46
+ }
47
+ }
48
+ }
49
+ return best;
50
+ },
51
+ };
@@ -0,0 +1,121 @@
1
+ import { computeCodeSpanRegions } from "./code-span-regions.js";
2
+
3
+ /**
4
+ * @typedef {Object} TagFamilyConfig
5
+ * @property {string} name
6
+ * @property {ReadonlyArray<string>} closeLiterals
7
+ * @property {(input: string, atOffset: number) => { consumed: number, spanInit: object } | null} matchOpen
8
+ * @property {(input: string, atOffset: number) => { consumed: number, closeKind: any } | null} matchClose
9
+ * @property {(activeOpen: object, closeKind: any) => boolean} closeMatches
10
+ * @property {(input: string, suffixStart: number) => number} matchTrailingPartial
11
+ * @property {(spanInit: object) => boolean} [validateOpen]
12
+ * @property {(spanInit: object) => void} [onRejected]
13
+ */
14
+
15
+ /**
16
+ * @param {string} accumulatedText
17
+ * @param {ReadonlyArray<TagFamilyConfig>} families
18
+ * @returns {{ cleanText: string, spansByFamily: Record<string, object[]>, trailingPartialTag: boolean }}
19
+ */
20
+ export function parseTaggedSpans(accumulatedText, families) {
21
+ const spansByFamily = {};
22
+ const activeOpens = new Map();
23
+ for (const fam of families) spansByFamily[fam.name] = [];
24
+
25
+ // Compute holdback once: each family declares how many trailing bytes look
26
+ // like the start of one of its tags; max wins. Held-back bytes are kept
27
+ // out of the parse loop so they neither become spans nor leak verbatim.
28
+ let holdback = 0;
29
+ for (const fam of families) {
30
+ const vote = fam.matchTrailingPartial(accumulatedText, 0);
31
+ if (vote > holdback) holdback = vote;
32
+ }
33
+ const scanEnd = accumulatedText.length - holdback;
34
+
35
+ // Tags quoted inside markdown code (inline backticks / fenced blocks) are
36
+ // literal text, not live tags β€” copy those regions through verbatim.
37
+ const codeRegions = computeCodeSpanRegions(accumulatedText);
38
+ let codeRegionIdx = 0;
39
+
40
+ let cleanText = "";
41
+ let i = 0;
42
+ const n = scanEnd;
43
+
44
+ outer: while (i < n) {
45
+ while (
46
+ codeRegionIdx < codeRegions.length &&
47
+ i >= codeRegions[codeRegionIdx][1]
48
+ ) {
49
+ codeRegionIdx += 1;
50
+ }
51
+ if (
52
+ codeRegionIdx < codeRegions.length &&
53
+ i >= codeRegions[codeRegionIdx][0] &&
54
+ i < codeRegions[codeRegionIdx][1]
55
+ ) {
56
+ cleanText += accumulatedText[i];
57
+ i += 1;
58
+ continue;
59
+ }
60
+ if (accumulatedText[i] === "<") {
61
+ // Try each family's matchClose, then matchOpen, in registration order.
62
+ for (const fam of families) {
63
+ const close = fam.matchClose(accumulatedText, i);
64
+ if (close) {
65
+ const active = activeOpens.get(fam.name);
66
+ if (active && fam.closeMatches(active.init, close.closeKind)) {
67
+ spansByFamily[fam.name].push({
68
+ ...active.init,
69
+ start: active.start,
70
+ end: cleanText.length,
71
+ });
72
+ activeOpens.delete(fam.name);
73
+ }
74
+ // mismatched or dangling close β†’ drop silently
75
+ i += close.consumed;
76
+ continue outer;
77
+ }
78
+ }
79
+ for (const fam of families) {
80
+ const open = fam.matchOpen(accumulatedText, i);
81
+ if (open) {
82
+ // Latest-wins same-family: close any active span at current offset.
83
+ const prior = activeOpens.get(fam.name);
84
+ if (prior) {
85
+ spansByFamily[fam.name].push({
86
+ ...prior.init,
87
+ start: prior.start,
88
+ end: cleanText.length,
89
+ });
90
+ activeOpens.delete(fam.name);
91
+ }
92
+ const accepted = fam.validateOpen ? fam.validateOpen(open.spanInit) : true;
93
+ if (accepted) {
94
+ activeOpens.set(fam.name, { start: cleanText.length, init: open.spanInit });
95
+ } else if (fam.onRejected) {
96
+ fam.onRejected(open.spanInit);
97
+ }
98
+ i += open.consumed;
99
+ continue outer;
100
+ }
101
+ }
102
+ }
103
+ cleanText += accumulatedText[i];
104
+ i += 1;
105
+ }
106
+
107
+ // Unclosed spans β†’ run to cleanText end.
108
+ for (const fam of families) {
109
+ const active = activeOpens.get(fam.name);
110
+ if (active) {
111
+ spansByFamily[fam.name].push({
112
+ ...active.init,
113
+ start: active.start,
114
+ end: cleanText.length,
115
+ });
116
+ activeOpens.delete(fam.name);
117
+ }
118
+ }
119
+
120
+ return { cleanText, spansByFamily, trailingPartialTag: holdback > 0 };
121
+ }
@@ -0,0 +1,38 @@
1
+ // Syntactic strip for all tagged-span grammar families.
2
+ // Removes well-formed openers and closers; keeps wrapped content.
3
+ // Removes orphan halves of either. Idempotent. No allowlist enforcement.
4
+ // Tags quoted inside markdown code (inline backticks / fenced blocks) are
5
+ // preserved verbatim so agents can talk about the tag grammar literally.
6
+ import { computeCodeSpanRegions } from "./code-span-regions.js";
7
+
8
+ const EMOJI_OPEN_RE = /<emoji:[^<>\s]+?>/g;
9
+ const EMOJI_CLOSE_RE = /<\/emoji>/g;
10
+ const PACE_OPEN_RE = /<(?:dwell|skim)>/g;
11
+ const PACE_CLOSE_RE = /<\/(?:dwell|skim)>/g;
12
+
13
+ // Single alternation so one pass over the original string keeps match
14
+ // offsets valid for the code-region exemption check.
15
+ const ALL_TAGS_RE = new RegExp(
16
+ [
17
+ EMOJI_OPEN_RE.source,
18
+ EMOJI_CLOSE_RE.source,
19
+ PACE_OPEN_RE.source,
20
+ PACE_CLOSE_RE.source,
21
+ ].join("|"),
22
+ "g",
23
+ );
24
+
25
+ export function stripAllTaggedSpans(text) {
26
+ if (typeof text !== "string") return "";
27
+ if (!text) return "";
28
+ const codeRegions = computeCodeSpanRegions(text);
29
+ if (codeRegions.length === 0) {
30
+ return text.replace(ALL_TAGS_RE, "");
31
+ }
32
+ return text.replace(ALL_TAGS_RE, (match, offset) => {
33
+ for (const [start, end] of codeRegions) {
34
+ if (offset >= start && offset < end) return match;
35
+ }
36
+ return "";
37
+ });
38
+ }
@@ -257,6 +257,10 @@ export function createEvenAiEndpoint(opts = {}) {
257
257
  typeof opts.emitListenInterceptRecovery === "function"
258
258
  ? opts.emitListenInterceptRecovery
259
259
  : null;
260
+ const emitListenInterceptBroadcast =
261
+ typeof opts.emitListenInterceptBroadcast === "function"
262
+ ? opts.emitListenInterceptBroadcast
263
+ : null;
260
264
  const isUpstreamConnected =
261
265
  typeof opts.isUpstreamConnected === "function"
262
266
  ? opts.isUpstreamConnected
@@ -269,6 +273,10 @@ export function createEvenAiEndpoint(opts = {}) {
269
273
  typeof opts.shouldSeedThinkingForRoute === "function"
270
274
  ? opts.shouldSeedThinkingForRoute
271
275
  : async () => false;
276
+ const seedFastModeForRoute =
277
+ typeof opts.seedFastModeForRoute === "function"
278
+ ? opts.seedFastModeForRoute
279
+ : null;
272
280
  const now =
273
281
  typeof opts.now === "function" ? opts.now : () => Date.now();
274
282
  const requestTimeoutMs = normalizePositiveInt(
@@ -599,8 +607,18 @@ export function createEvenAiEndpoint(opts = {}) {
599
607
  id: requestId,
600
608
  text: userText,
601
609
  sessionKey,
610
+ source: "hybrid_voice_endpoint",
602
611
  }),
603
612
  );
613
+ if (emitListenInterceptBroadcast) {
614
+ try {
615
+ emitListenInterceptBroadcast({ sessionKey });
616
+ } catch (broadcastErr) {
617
+ logger.warn(
618
+ `[evenai] listen intercept broadcast callback failed: ${broadcastErr && broadcastErr.message ? broadcastErr.message : broadcastErr}`,
619
+ );
620
+ }
621
+ }
604
622
  emitDebug(
605
623
  "evenai",
606
624
  "listen_intercept_dispatch_succeeded",
@@ -771,6 +789,47 @@ export function createEvenAiEndpoint(opts = {}) {
771
789
  startedAtMs,
772
790
  };
773
791
 
792
+ // Track the upstream runId so the disconnect handler can cancel the
793
+ // pending wait without having to wait for the full request timeout.
794
+ // `clientDisconnected` covers the pre-ack window: if the socket closes
795
+ // before sendMessage() resolves, activeRunId is still null so cancelRun
796
+ // is unreachable; we record the flag and short-circuit waitForRun once
797
+ // the ack arrives.
798
+ let activeRunId = null;
799
+ let clientDisconnected = false;
800
+ const onClientDisconnect = () => {
801
+ if (res.writableEnded) return;
802
+ clientDisconnected = true;
803
+ if (inFlight && inFlight.requestId === requestId) {
804
+ inFlight = null;
805
+ emitDebug(
806
+ "evenai",
807
+ "request_client_disconnect",
808
+ "info",
809
+ { sessionKey: sessionKey || undefined, runId: activeRunId },
810
+ () => ({
811
+ requestId,
812
+ elapsedMs: now() - startedAtMs,
813
+ preAck: activeRunId == null,
814
+ }),
815
+ );
816
+ }
817
+ if (activeRunId && typeof runWaiter.cancelRun === "function") {
818
+ try {
819
+ runWaiter.cancelRun(activeRunId, "client_disconnect");
820
+ } catch (_err) {
821
+ // Cancellation is best-effort: the upstream run still completes
822
+ // server-side because no gateway abort RPC exists.
823
+ }
824
+ }
825
+ };
826
+ // `res` emits 'close' both when the response finishes normally and when
827
+ // the client disconnects mid-flight. We discriminate via
828
+ // res.writableEnded inside the handler. (req's own 'close' is not
829
+ // reliable for premature client aborts in Node 22 β€” the socket-level
830
+ // close is what fires.)
831
+ res.once("close", onClientDisconnect);
832
+
774
833
  emitDebug(
775
834
  "evenai",
776
835
  "request_accepted",
@@ -802,6 +861,20 @@ export function createEvenAiEndpoint(opts = {}) {
802
861
  ) {
803
862
  sendOptions.thinking = configuredDefaultThinking;
804
863
  }
864
+ if (seedFastModeForRoute) {
865
+ try {
866
+ await Promise.resolve(
867
+ seedFastModeForRoute({ route, sessionKey, routingMode }),
868
+ );
869
+ } catch (err) {
870
+ // Seed failure must never block the send β€” the turn just runs
871
+ // without fast mode.
872
+ emitDebug("evenai", "fast_mode_seed_failed", "warn", { sessionKey }, () => ({
873
+ requestId,
874
+ message: err && err.message ? err.message : String(err),
875
+ }));
876
+ }
877
+ }
805
878
  const ack = await promiseWithTimeout(
806
879
  gatewayBridge.sendMessage(
807
880
  userText,
@@ -815,6 +888,7 @@ export function createEvenAiEndpoint(opts = {}) {
815
888
  if (!runId) {
816
889
  throw new Error("Even AI upstream ack was missing a runId.");
817
890
  }
891
+ activeRunId = runId;
818
892
  if (trimString(ack && ack.status) && trimString(ack.status) !== "accepted") {
819
893
  throw new Error(
820
894
  trimString(ack && ack.error) || `Even AI upstream returned ${ack.status}.`,
@@ -832,6 +906,20 @@ export function createEvenAiEndpoint(opts = {}) {
832
906
  }),
833
907
  );
834
908
 
909
+ if (clientDisconnected) {
910
+ emitDebug(
911
+ "evenai",
912
+ "request_wait_skipped_after_disconnect",
913
+ "info",
914
+ { sessionKey, runId },
915
+ () => ({
916
+ requestId,
917
+ elapsedMs: now() - startedAtMs,
918
+ }),
919
+ );
920
+ return true;
921
+ }
922
+
835
923
  const remainingTimeoutMs = Math.max(1, requestTimeoutMs - (now() - startedAtMs));
836
924
  const assistantText = await runWaiter.waitForRun({
837
925
  runId,
@@ -889,6 +977,9 @@ export function createEvenAiEndpoint(opts = {}) {
889
977
  if (inFlight && inFlight.requestId === requestId) {
890
978
  inFlight = null;
891
979
  }
980
+ if (typeof res.removeListener === "function") {
981
+ res.removeListener("close", onClientDisconnect);
982
+ }
892
983
  }
893
984
  }
894
985
 
@@ -242,6 +242,20 @@ export function createEvenAiRunWaiter(opts = {}) {
242
242
  });
243
243
  },
244
244
 
245
+ cancelRun(runId, reason) {
246
+ const normalized = normalizeRunId(runId);
247
+ if (!normalized) return false;
248
+ const message =
249
+ typeof reason === "string" && reason.length > 0
250
+ ? `Even AI run cancelled: ${reason}.`
251
+ : "Even AI run cancelled.";
252
+ return rejectRun(
253
+ normalized,
254
+ createRunWaiterError("evenai_cancelled", message, { reason: reason || null }),
255
+ "run_wait_cancelled",
256
+ );
257
+ },
258
+
245
259
  close() {
246
260
  if (typeof offMessage === "function") offMessage();
247
261
  if (typeof offActivity === "function") offActivity();
@@ -134,12 +134,19 @@ function isStoredSnapshotCanonical(value, snapshot) {
134
134
  if (normalizeEvenAiListenEnabled(value.listenEnabled) !== snapshot.listenEnabled) {
135
135
  return false;
136
136
  }
137
+ if (normalizeEvenAiDefaultFastMode(value.defaultFastMode) !== snapshot.defaultFastMode) {
138
+ return false;
139
+ }
137
140
  if (!Array.isArray(value.trackedThrowawayKeys)) {
138
141
  return snapshot.trackedThrowawayKeys.length === 0;
139
142
  }
140
143
  return arraysEqual(value.trackedThrowawayKeys, snapshot.trackedThrowawayKeys);
141
144
  }
142
145
 
146
+ export function normalizeEvenAiDefaultFastMode(value) {
147
+ return value === true;
148
+ }
149
+
143
150
  export function normalizeEvenAiSettingsSnapshot(value = {}) {
144
151
  return {
145
152
  routingMode: normalizeEvenAiRoutingMode(value.routingMode),
@@ -147,6 +154,7 @@ export function normalizeEvenAiSettingsSnapshot(value = {}) {
147
154
  defaultModel: normalizeEvenAiDefaultModel(value.defaultModel),
148
155
  defaultThinking: normalizeEvenAiDefaultThinking(value.defaultThinking),
149
156
  listenEnabled: normalizeEvenAiListenEnabled(value.listenEnabled),
157
+ defaultFastMode: normalizeEvenAiDefaultFastMode(value.defaultFastMode),
150
158
  trackedThrowawayKeys: normalizeTrackedThrowawayKeys(value.trackedThrowawayKeys),
151
159
  };
152
160
  }
@@ -180,6 +188,7 @@ export function createEvenAiSettingsStore(opts = {}) {
180
188
  systemPromptChars: snapshot.systemPrompt.length,
181
189
  defaultModel: snapshot.defaultModel,
182
190
  defaultThinking: snapshot.defaultThinking,
191
+ defaultFastMode: snapshot.defaultFastMode,
183
192
  listenEnabled: snapshot.listenEnabled,
184
193
  trackedThrowawayKeyCount: snapshot.trackedThrowawayKeys.length,
185
194
  }),
@@ -213,6 +222,7 @@ export function createEvenAiSettingsStore(opts = {}) {
213
222
  systemPromptChars: snapshot.systemPrompt.length,
214
223
  defaultModel: snapshot.defaultModel,
215
224
  defaultThinking: snapshot.defaultThinking,
225
+ defaultFastMode: snapshot.defaultFastMode,
216
226
  listenEnabled: snapshot.listenEnabled,
217
227
  trackedThrowawayKeyCount: snapshot.trackedThrowawayKeys.length,
218
228
  }),
@@ -266,6 +276,7 @@ export function createEvenAiSettingsStore(opts = {}) {
266
276
  systemPromptChars: loaded.systemPrompt.length,
267
277
  defaultModel: loaded.defaultModel,
268
278
  defaultThinking: loaded.defaultThinking,
279
+ defaultFastMode: loaded.defaultFastMode,
269
280
  listenEnabled: loaded.listenEnabled,
270
281
  trackedThrowawayKeyCount: loaded.trackedThrowawayKeys.length,
271
282
  }),
@@ -350,6 +361,9 @@ export function createEvenAiSettingsStore(opts = {}) {
350
361
  listenEnabled: hasOwn(patch, "listenEnabled")
351
362
  ? normalizeEvenAiListenEnabled(patch.listenEnabled)
352
363
  : snapshot.listenEnabled,
364
+ defaultFastMode: hasOwn(patch, "defaultFastMode")
365
+ ? normalizeEvenAiDefaultFastMode(patch.defaultFastMode)
366
+ : snapshot.defaultFastMode,
353
367
  trackedThrowawayKeys: [...snapshot.trackedThrowawayKeys],
354
368
  };
355
369
  snapshot = next;