@segmentstream/agent 0.1.5 → 0.2.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 (41) hide show
  1. package/README.md +21 -9
  2. package/dist/extension/instructions.md +6 -0
  3. package/dist/extension/lib/attachment-tools.d.ts +68 -0
  4. package/dist/extension/lib/attachment-tools.mjs +110 -0
  5. package/dist/extension/lib/attachments.d.ts +96 -0
  6. package/dist/extension/lib/attachments.mjs +233 -0
  7. package/dist/extension/lib/connection-proxy.d.ts +14 -1
  8. package/dist/extension/lib/connection-proxy.mjs +135 -39
  9. package/dist/extension/lib/telegram/adapter.d.ts +3 -0
  10. package/dist/extension/lib/telegram/adapter.mjs +52 -7
  11. package/dist/extension/lib/telegram/files.d.ts +25 -0
  12. package/dist/extension/lib/telegram/files.mjs +77 -0
  13. package/dist/extension/lib/telegram/inbound-request.d.ts +20 -1
  14. package/dist/extension/lib/telegram/inbound-request.mjs +29 -1
  15. package/dist/extension/lib/telegram/inbound.d.ts +12 -5
  16. package/dist/extension/lib/telegram/inbound.mjs +37 -10
  17. package/dist/extension/lib/telegram/observed.d.ts +29 -3
  18. package/dist/extension/lib/telegram/observed.mjs +48 -14
  19. package/dist/extension/lib/telegram/proxy-fetch.mjs +3 -2
  20. package/dist/extension/lib/telegram/router.d.ts +11 -3
  21. package/dist/extension/lib/telegram/router.mjs +89 -13
  22. package/dist/extension/lib/telegram/routing-context.d.ts +13 -0
  23. package/dist/extension/lib/telegram/routing-context.mjs +40 -0
  24. package/dist/extension/lib/telegram/sessions.d.ts +5 -0
  25. package/dist/extension/lib/telegram/silent.d.ts +2 -0
  26. package/dist/extension/lib/telegram/silent.mjs +30 -5
  27. package/dist/extension/lib/telegram/turn-errors.d.ts +9 -0
  28. package/dist/extension/lib/telegram/turn-errors.mjs +20 -0
  29. package/dist/extension/lib/telegram/voice.d.ts +2 -2
  30. package/dist/extension/lib/telegram/voice.mjs +2 -2
  31. package/dist/extension/tools/attachments_discard.d.ts +30 -0
  32. package/dist/extension/tools/attachments_discard.mjs +22 -0
  33. package/dist/extension/tools/attachments_list.d.ts +30 -0
  34. package/dist/extension/tools/attachments_list.mjs +25 -0
  35. package/dist/extension/tools/attachments_promote.d.ts +36 -0
  36. package/dist/extension/tools/attachments_promote.mjs +69 -0
  37. package/dist/extension/tools/attachments_read.d.ts +9 -0
  38. package/dist/extension/tools/attachments_read.mjs +43 -0
  39. package/dist/tools/index.d.ts +4 -0
  40. package/dist/tools/index.mjs +5 -1
  41. package/package.json +1 -1
@@ -6,8 +6,10 @@ __eveDirname(__filename);
6
6
  __eveCreateRequire(import.meta.url);
7
7
  import { telegramPrincipal } from "./principal.mjs";
8
8
  import { chatContext } from "./chats.mjs";
9
+ import { attachmentsContext, stageFiles } from "./files.mjs";
9
10
  import { observedChatKey, observedContext } from "./observed.mjs";
10
- import { isAddressed, routeMessage } from "./router.mjs";
11
+ import { addressingReason, routeMessage } from "./router.mjs";
12
+ import { routingContext } from "./routing-context.mjs";
11
13
  import { messageAddress, scopedAddress } from "./sessions.mjs";
12
14
  import { voiceNote, voiceOf, voiceText } from "./voice.mjs";
13
15
  const UNKNOWN_PRIVATE = "Hi, I don't know you yet. Connect your Telegram account to the workspace and I'll act as you. The button works for ten minutes.";
@@ -38,6 +40,8 @@ function authorOf(user, member) {
38
40
  function observedMessage(message, text, member) {
39
41
  return {
40
42
  author: authorOf(message.from, member),
43
+ authorId: message.from.id,
44
+ ...message.from.username === void 0 ? {} : { username: message.from.username },
41
45
  text,
42
46
  at: sentAt(message)
43
47
  };
@@ -158,7 +162,7 @@ async function handleInboundMessage(input) {
158
162
  from: message.from
159
163
  };
160
164
  const bot = await dependencies.bot(context.telegram);
161
- const addressed = isAddressed(sent, bot);
165
+ const addressed = addressingReason(sent, bot);
162
166
  const lookup = await dependencies.identities.lookup({
163
167
  provider: "telegram",
164
168
  externalId: sent.from.id
@@ -171,7 +175,7 @@ async function handleInboundMessage(input) {
171
175
  }) : void 0;
172
176
  if (report !== void 0) input.request?.waitUntil?.(report);
173
177
  if (lookup.status !== "paired") {
174
- if (addressed) await answerUnpaired({
178
+ if (addressed !== void 0) await answerUnpaired({
175
179
  context,
176
180
  message: sent,
177
181
  lookup,
@@ -186,7 +190,7 @@ async function handleInboundMessage(input) {
186
190
  const caption = sent.text || sent.caption;
187
191
  const notes = [];
188
192
  let text = caption;
189
- if (voice !== void 0 && (addressed || dependencies.policy === "smart")) {
193
+ if (voice !== void 0 && (addressed !== void 0 || dependencies.policy === "smart")) {
190
194
  const spoken = await voiceText({
191
195
  voice,
192
196
  model: dependencies.sttModel,
@@ -202,14 +206,25 @@ async function handleInboundMessage(input) {
202
206
  ...sent.messageThreadId === void 0 ? {} : { messageThreadId: sent.messageThreadId }
203
207
  });
204
208
  if (await routeMessage({
209
+ chatId: sent.chat.id,
210
+ memberId: lookup.member.id,
205
211
  policy: dependencies.policy,
206
212
  addressed,
207
213
  decide: dependencies.decide,
208
- question: async () => ({
209
- agent: await input.request?.agentDescription() ?? "",
210
- observed: dependencies.observed.list(chat),
211
- message: observedMessage(sent, text, lookup.member)
212
- })
214
+ question: async () => {
215
+ const recent = await routingContext({
216
+ address,
217
+ operations,
218
+ read: dependencies.readHistory,
219
+ remembered: dependencies.observed.recent(chat),
220
+ at: sentAt(sent)
221
+ });
222
+ return {
223
+ agent: await input.request?.agentDescription() ?? "",
224
+ recent,
225
+ message: observedMessage(sent, text, lookup.member)
226
+ };
227
+ }
213
228
  }) === "observe") {
214
229
  dependencies.observed.add(chat, observedMessage(sent, text === "" ? "[attachment]" : text, lookup.member));
215
230
  return null;
@@ -222,11 +237,19 @@ async function handleInboundMessage(input) {
222
237
  address,
223
238
  operations,
224
239
  ...newest === void 0 ? {} : { activeAt: Date.parse(newest.at) || 0 }
225
- }) === "cleared") dependencies.observed.take(chat);
240
+ }) === "cleared") dependencies.observed.clear(chat);
226
241
  dependencies.sessionPolicy.delivered(address);
227
242
  }
228
243
  await context.telegram.startTyping();
244
+ const files = sent.attachments.length === 0 || dependencies.attachments === void 0 || input.request === void 0 ? new Map() : await stageFiles({
245
+ message: sent,
246
+ memberId: lookup.member.id,
247
+ attachments: dependencies.attachments,
248
+ download: dependencies.download
249
+ });
250
+ if (input.request !== void 0 && files.size > 0) input.request.files = files;
229
251
  await report;
252
+ dependencies.observed.remember(chat, observedMessage(sent, text, lookup.member));
230
253
  return {
231
254
  auth: telegramPrincipal({
232
255
  message: sent,
@@ -240,6 +263,10 @@ async function handleInboundMessage(input) {
240
263
  member: lookup.member,
241
264
  bot
242
265
  }),
266
+ ...attachmentsContext({
267
+ files,
268
+ group
269
+ }),
243
270
  ...notes
244
271
  ]
245
272
  };
@@ -1,7 +1,7 @@
1
1
  /**
2
- * The bounded ring of group messages the router observed without answering. The router shows it to
3
- * the decision model, and a reply carries it into the turn as context, so the agent knows what the
4
- * chat was talking about.
2
+ * Bounded group conversation context for routing, and the backlog observed without answering.
3
+ * A reply drains only the backlog: recent member messages and the agent's own replies remain
4
+ * available to decide whether a later unaddressed message answers one of the agent's questions.
5
5
  *
6
6
  * eve's `defineState` would be the durable home, but it is session state and needs an active eve
7
7
  * context; an observed message has neither, because observing must not start a session. The ring
@@ -15,14 +15,40 @@ export interface ObservedMessage {
15
15
  readonly text: string;
16
16
  /** ISO time the message was sent. */
17
17
  readonly at: string;
18
+ /** Stable Telegram identity, independent of changes to the member's display name or roles. */
19
+ readonly authorId?: string;
20
+ readonly username?: string;
18
21
  }
22
+ export interface MessageRecipient {
23
+ readonly author: string;
24
+ readonly id?: string;
25
+ readonly username?: string;
26
+ }
27
+ export interface AgentMessage extends ObservedMessage {
28
+ readonly role: "agent";
29
+ /** Explicit @mentions take precedence over the member whose turn produced the reply. */
30
+ readonly addressedTo: ReadonlyArray<MessageRecipient>;
31
+ readonly inReplyTo?: MessageRecipient;
32
+ }
33
+ export type RecentMessage = ObservedMessage | AgentMessage;
19
34
  export interface ObservedMessages {
20
35
  add(chat: string, message: ObservedMessage): void;
36
+ /** Records a received message that starts a turn, or a successfully delivered agent reply. */
37
+ remember(chat: string, message: RecentMessage): void;
21
38
  list(chat: string): ReadonlyArray<ObservedMessage>;
39
+ recent(chat: string): ReadonlyArray<RecentMessage>;
22
40
  /** Returns and forgets the chat's ring, once a reply has carried it into a turn. */
23
41
  take(chat: string): ReadonlyArray<ObservedMessage>;
42
+ /** An idle reset forgets both the backlog and the earlier conversation. */
43
+ clear(chat: string): void;
24
44
  }
25
45
  export declare function observedMessages(capacity?: number): ObservedMessages;
46
+ /** Recipient metadata for an agent reply; explicit mentions are visible even in proactive turns. */
47
+ export declare function agentMessage(input: {
48
+ readonly text: string;
49
+ readonly at: string;
50
+ readonly inReplyTo?: MessageRecipient;
51
+ }): AgentMessage;
26
52
  /** The key of a chat's ring: the chat and, in a forum, the topic. */
27
53
  export declare function observedChatKey(input: {
28
54
  readonly chatId: string;
@@ -9,29 +9,63 @@ const MAXIMUM_CHATS = 500;
9
9
  const MAXIMUM_TEXT_LENGTH = 1e3;
10
10
  function observedMessages(capacity = 30) {
11
11
  const chats = new Map();
12
+ function remember(chat, message, observe) {
13
+ const previous = chats.get(chat);
14
+ const bounded = {
15
+ ...message,
16
+ text: message.text.slice(0, MAXIMUM_TEXT_LENGTH)
17
+ };
18
+ const backlog = previous?.observed ?? [];
19
+ chats.delete(chat);
20
+ chats.set(chat, {
21
+ observed: observe ? [...backlog, bounded].slice(-capacity) : backlog,
22
+ recent: [...previous?.recent ?? [], bounded].slice(-capacity)
23
+ });
24
+ if (chats.size > MAXIMUM_CHATS) {
25
+ const oldest = chats.keys().next();
26
+ if (oldest.done !== true) chats.delete(oldest.value);
27
+ }
28
+ }
12
29
  return {
13
30
  add(chat, message) {
14
- const ring = [...chats.get(chat) ?? [], {
15
- ...message,
16
- text: message.text.slice(0, MAXIMUM_TEXT_LENGTH)
17
- }];
18
- chats.delete(chat);
19
- chats.set(chat, ring.slice(-capacity));
20
- if (chats.size > MAXIMUM_CHATS) {
21
- const oldest = chats.keys().next();
22
- if (oldest.done !== true) chats.delete(oldest.value);
23
- }
31
+ remember(chat, message, true);
32
+ },
33
+ remember(chat, message) {
34
+ remember(chat, message, false);
24
35
  },
25
36
  list(chat) {
26
- return chats.get(chat) ?? [];
37
+ return chats.get(chat)?.observed ?? [];
38
+ },
39
+ recent(chat) {
40
+ return chats.get(chat)?.recent ?? [];
27
41
  },
28
42
  take(chat) {
29
- const ring = chats.get(chat) ?? [];
43
+ const previous = chats.get(chat);
44
+ if (previous !== void 0) chats.set(chat, {
45
+ ...previous,
46
+ observed: []
47
+ });
48
+ return previous?.observed ?? [];
49
+ },
50
+ clear(chat) {
30
51
  chats.delete(chat);
31
- return ring;
32
52
  }
33
53
  };
34
54
  }
55
+ function agentMessage(input) {
56
+ const usernames = Array.from(input.text.matchAll(/(?:^|[^A-Za-z0-9_])@([A-Za-z0-9_]+)/gu), (match) => match[1] ?? "");
57
+ const addressedTo = usernames.length > 0 ? [...new Set(usernames)].map((username) => ({
58
+ author: `@${username}`,
59
+ username
60
+ })) : input.inReplyTo === void 0 ? [] : [input.inReplyTo];
61
+ return {
62
+ ...input,
63
+ text: input.text.slice(0, MAXIMUM_TEXT_LENGTH),
64
+ author: "the agent",
65
+ role: "agent",
66
+ addressedTo
67
+ };
68
+ }
35
69
  function observedChatKey(input) {
36
70
  return input.messageThreadId === void 0 ? input.chatId : `${input.chatId}:${input.messageThreadId}`;
37
71
  }
@@ -44,4 +78,4 @@ function observedContext(messages) {
44
78
  "</observed_messages>"
45
79
  ].join("\n")];
46
80
  }
47
- export { OBSERVED_MESSAGES, observedChatKey, observedContext, observedMessages };
81
+ export { OBSERVED_MESSAGES, agentMessage, observedChatKey, observedContext, observedMessages };
@@ -53,13 +53,14 @@ function hasNullBody(status) {
53
53
  function telegramProxyFetch(proxy) {
54
54
  return async (input, init) => {
55
55
  const route = telegramProxyRoute(requestUrl(input));
56
- const reply = await proxy.request({
56
+ const call = {
57
57
  api: route.api,
58
58
  method: proxyMethod(init?.method),
59
59
  path: route.path,
60
60
  headers: headerPairs(init?.headers),
61
61
  body: await bodyBytes(init?.body)
62
- });
62
+ };
63
+ const reply = route.api === "file" ? await proxy.download(call) : await proxy.request(call);
63
64
  return new Response(hasNullBody(reply.status) ? null : Buffer.from(reply.body), {
64
65
  status: reply.status,
65
66
  headers: reply.headers.map(([name, value]) => [name, value])
@@ -1,6 +1,6 @@
1
1
  import type { TelegramMessage } from "eve/channels/telegram";
2
2
  import type { TelegramPolicy } from "../../config.js";
3
- import type { ObservedMessage } from "./observed.js";
3
+ import type { ObservedMessage, RecentMessage } from "./observed.js";
4
4
  /**
5
5
  * The inbound router (`agent-design.md`, section 4): every message from a paired member is either
6
6
  * answered or observed before eve starts a turn. A private chat, a mention, a reply to the bot and
@@ -8,18 +8,22 @@ import type { ObservedMessage } from "./observed.js";
8
8
  * policy, put to one decision on the decision model.
9
9
  */
10
10
  export type Route = "observe" | "reply";
11
+ export type AddressingReason = "private" | "mention" | "reply-to-bot" | "command";
11
12
  /** The bot, as Telegram's `getMe` describes it. Unknown when `getMe` failed. */
12
13
  export interface BotIdentity {
13
14
  readonly id: string;
14
15
  readonly username: string | undefined;
15
16
  }
17
+ /** Why the message is for the bot without asking any model. */
18
+ export declare function addressingReason(message: TelegramMessage, bot: BotIdentity | undefined): AddressingReason | undefined;
16
19
  /** Whether the message is for the bot without asking any model. */
17
20
  export declare function isAddressed(message: TelegramMessage, bot: BotIdentity | undefined): boolean;
18
21
  /** What the decision model sees. */
19
22
  export interface RouteQuestion {
20
23
  /** The agent's own description, so the model knows what it can be asked for. */
21
24
  readonly agent: string;
22
- readonly observed: ReadonlyArray<ObservedMessage>;
25
+ /** Recent received messages and delivered agent replies, oldest first. */
26
+ readonly recent: ReadonlyArray<RecentMessage>;
23
27
  readonly message: ObservedMessage;
24
28
  }
25
29
  /** Decides whether a group message not addressed to the bot is still for it. */
@@ -51,6 +55,8 @@ export type DecideFunction = (options: {
51
55
  };
52
56
  };
53
57
  }>;
58
+ /** An unthreaded answer minutes later may continue a question, but old questions are not hints. */
59
+ export declare const FOLLOW_UP_WINDOW_MS: number;
54
60
  /** A {@link RouteDecider} that asks one `decide` question on the configured decision model. */
55
61
  export declare function decisionRouter(input: {
56
62
  readonly decide: DecideFunction;
@@ -61,8 +67,10 @@ export declare function decisionRouter(input: {
61
67
  * mistake in a group chat.
62
68
  */
63
69
  export declare function routeMessage(input: {
70
+ readonly chatId: string;
71
+ readonly memberId: string;
64
72
  readonly policy: TelegramPolicy;
65
- readonly addressed: boolean;
73
+ readonly addressed: AddressingReason | undefined;
66
74
  /** Builds the question only when a decision is needed: it reads the agent's description. */
67
75
  readonly question: () => Promise<RouteQuestion>;
68
76
  readonly decide: RouteDecider;
@@ -22,43 +22,119 @@ function repliesToBot(message, bot) {
22
22
  if (author === void 0) return false;
23
23
  return bot !== void 0 && author.id === bot.id;
24
24
  }
25
- function isAddressed(message, bot) {
25
+ function addressingReason(message, bot) {
26
26
  const text = message.text || message.caption;
27
- return message.chat.type === "private" || isCommand(text, bot) || mentions(text, bot) || repliesToBot(message, bot);
27
+ if (message.chat.type === "private") return "private";
28
+ if (isCommand(text, bot)) return "command";
29
+ if (mentions(text, bot)) return "mention";
30
+ return repliesToBot(message, bot) ? "reply-to-bot" : void 0;
31
+ }
32
+ function isAddressed(message, bot) {
33
+ return addressingReason(message, bot) !== void 0;
28
34
  }
29
35
  const DECISION_TIMEOUT_MS = 15e3;
36
+ const FOLLOW_UP_WINDOW_MS = 18e5;
37
+ function isAgentMessage(message) {
38
+ return "role" in message && message.role === "agent";
39
+ }
40
+ function recipientLine(recipient) {
41
+ const identity = [...recipient.id === void 0 ? [] : [`user_id ${recipient.id}`], ...recipient.username === void 0 || recipient.author === `@${recipient.username}` ? [] : [`@${recipient.username}`]];
42
+ return identity.length === 0 ? recipient.author : `${recipient.author} (${identity.join(", ")})`;
43
+ }
30
44
  function line(message) {
31
- return `${message.author}: ${message.text}`;
45
+ if (isAgentMessage(message)) {
46
+ const addressedTo = message.addressedTo.map(recipientLine).join(", ");
47
+ const replyingTo = message.inReplyTo === void 0 ? void 0 : recipientLine(message.inReplyTo);
48
+ const recipients = addressedTo === "" ? "recipient unspecified" : `addressed to ${addressedTo}`;
49
+ const reply = replyingTo === void 0 ? "" : `; responding to ${replyingTo}`;
50
+ return `[${message.at}] THE AGENT (${recipients}${reply}): ${message.text}`;
51
+ }
52
+ const author = recipientLine({
53
+ author: message.author,
54
+ ...message.authorId === void 0 ? {} : { id: message.authorId },
55
+ ...message.username === void 0 ? {} : { username: message.username }
56
+ });
57
+ return `[${message.at}] ${author}: ${message.text}`;
58
+ }
59
+ function recentlyAddressed(message, sender) {
60
+ return message.addressedTo.some((recipient) => recipient.id !== void 0 ? recipient.id === sender.authorId : recipient.username !== void 0 && sender.username !== void 0 && recipient.username.toLowerCase() === sender.username.toLowerCase());
32
61
  }
33
62
  function decisionRouter(input) {
34
63
  return async (question) => {
64
+ const sentAt = Date.parse(question.message.at);
65
+ const recent = question.recent.filter((message) => {
66
+ if (!isAgentMessage(message)) return true;
67
+ const age = sentAt - Date.parse(message.at);
68
+ return age >= 0 && age <= 18e5;
69
+ });
70
+ const followUps = recent.filter((message) => isAgentMessage(message) && recentlyAddressed(message, question.message));
71
+ const abortSignal = AbortSignal.timeout(DECISION_TIMEOUT_MS);
35
72
  return (await input.decide({
36
73
  model: input.model,
37
74
  state: {
38
75
  agent: question.agent,
39
- recent_messages: question.observed.map(line),
76
+ recent_messages: recent.map(line),
77
+ possible_follow_up_to: followUps.map(line),
40
78
  message: line(question.message)
41
79
  },
42
80
  questions: { route: {
43
81
  type: "choice",
44
- instructions: "A group chat message arrived that does not mention the agent. Decide whether the agent should answer it, given what the agent does and what the chat was talking about.",
82
+ instructions: "A group chat message arrived that does not mention the agent. Decide whether the agent should answer it, given what the agent does and the recent conversation. THE AGENT marks the agent's own messages; recipient metadata identifies who it addressed or replied to. possible_follow_up_to lists agent messages addressed to this sender within 30 minutes. Check whether the message answers or follows up on an open question the agent asked, including numbered questions, short confirmations, corrections, and where to find data. Such answers should receive a reply without a mention or Telegram reply. Consider explicit recipients in the text; a matching sender or recent message alone does not make unrelated chatter a follow-up.",
45
83
  criteria: {
46
- observe: "The message is people talking among themselves, or nothing the agent can help with.",
47
- reply: "The message asks the agent, or asks for something the agent does, even without naming it."
84
+ observe: "The message is unrelated chatter or people talking among themselves, and does not answer or follow up on an open question the agent asked.",
85
+ reply: "The message asks the agent, asks for something the agent does, or answers or follows up on an open question the agent asked, even without naming or replying to it."
48
86
  }
49
87
  } },
50
- abortSignal: AbortSignal.timeout(DECISION_TIMEOUT_MS)
88
+ abortSignal
89
+ }).catch((error) => {
90
+ if (abortSignal.aborted) throw new RoutingTimeoutError(error);
91
+ throw error;
51
92
  })).answers.route.choice;
52
93
  };
53
94
  }
95
+ var RoutingTimeoutError = class extends Error {
96
+ constructor(cause) {
97
+ super("Telegram routing decision timed out after 15 seconds.", { cause });
98
+ this.name = "TimeoutError";
99
+ }
100
+ };
54
101
  async function routeMessage(input) {
55
- if (input.addressed) return "reply";
56
- if (input.policy === "mention") return "observe";
102
+ const identity = {
103
+ chatId: input.chatId,
104
+ memberId: input.memberId
105
+ };
106
+ if (input.addressed !== void 0) {
107
+ console.info("[segmentstream] telegram route", {
108
+ ...identity,
109
+ route: "reply",
110
+ reason: input.addressed
111
+ });
112
+ return "reply";
113
+ }
114
+ if (input.policy === "mention") {
115
+ console.info("[segmentstream] telegram route", {
116
+ ...identity,
117
+ route: "observe",
118
+ reason: "mention-policy"
119
+ });
120
+ return "observe";
121
+ }
57
122
  try {
58
- return await input.decide(await input.question());
123
+ const route = await input.decide(await input.question());
124
+ console.info("[segmentstream] telegram route", {
125
+ ...identity,
126
+ route,
127
+ reason: "decision-model"
128
+ });
129
+ return route;
59
130
  } catch (error) {
60
- console.error("[segmentstream] inbound routing decision failed; observing the message", { error });
131
+ console.error("[segmentstream] telegram route", {
132
+ ...identity,
133
+ route: "observe",
134
+ reason: error instanceof Error && error.name === "TimeoutError" ? "decision-timeout" : "decision-failure",
135
+ error
136
+ });
61
137
  return "observe";
62
138
  }
63
139
  }
64
- export { decisionRouter, isAddressed, routeMessage };
140
+ export { FOLLOW_UP_WINDOW_MS, addressingReason, decisionRouter, isAddressed, routeMessage };
@@ -0,0 +1,13 @@
1
+ import { type RecentMessage } from "./observed.js";
2
+ import type { HistoryReader, SessionOperations } from "./sessions.js";
3
+ /**
4
+ * Recover the agent's questions from the existing session without starting a turn. The local
5
+ * ring supplements this with recipient identities and chatter not written to Eve's stream.
6
+ */
7
+ export declare function routingContext(input: {
8
+ readonly address: string;
9
+ readonly operations: SessionOperations | undefined;
10
+ readonly read: HistoryReader;
11
+ readonly remembered: ReadonlyArray<RecentMessage>;
12
+ readonly at: string;
13
+ }): Promise<ReadonlyArray<RecentMessage>>;
@@ -0,0 +1,40 @@
1
+ import { fileURLToPath as __eveFileURLToPath } from "node:url";
2
+ import { dirname as __eveDirname } from "node:path";
3
+ import { createRequire as __eveCreateRequire } from "node:module";
4
+ const __filename = __eveFileURLToPath(import.meta.url);
5
+ __eveDirname(__filename);
6
+ __eveCreateRequire(import.meta.url);
7
+ import { agentMessage } from "./observed.mjs";
8
+ import "./router.mjs";
9
+ import { isSilentReply } from "./silent.mjs";
10
+ const DELIVERY_CLOCK_TOLERANCE_MS = 6e4;
11
+ async function routingContext(input) {
12
+ if (input.operations === void 0) return input.remembered;
13
+ try {
14
+ const session = await input.operations.resolveSession(input.address);
15
+ if (session === void 0) return input.remembered;
16
+ const events = await input.read(session.id);
17
+ const sentAt = Date.parse(input.at);
18
+ const recovered = [];
19
+ let resetAt = 0;
20
+ for (const event of events) {
21
+ if (event.type === "context.cleared" || event.type === "session.started") {
22
+ recovered.length = 0;
23
+ resetAt = Date.parse(event.meta.at);
24
+ }
25
+ const age = sentAt - Date.parse(event.meta.at);
26
+ const data = event.data;
27
+ if (event.type === "message.completed" && data !== void 0 && data.finishReason !== "tool-calls" && data.message.trim() !== "" && !isSilentReply(data.message) && age >= 0 && age <= 18e5) recovered.push(agentMessage({
28
+ text: data.message,
29
+ at: event.meta.at
30
+ }));
31
+ }
32
+ const remembered = input.remembered.filter((message) => Date.parse(message.at) >= resetAt);
33
+ const knownReplies = remembered.filter((message) => "role" in message);
34
+ return [...recovered.filter((message) => !knownReplies.some((known) => known.text === message.text && Math.abs(Date.parse(known.at) - Date.parse(message.at)) <= DELIVERY_CLOCK_TOLERANCE_MS)), ...remembered].sort((first, second) => Date.parse(first.at) - Date.parse(second.at)).slice(-30);
35
+ } catch (error) {
36
+ console.warn("[segmentstream] routing context could not read the session; using recent messages", { error });
37
+ return input.remembered;
38
+ }
39
+ }
40
+ export { routingContext };
@@ -43,6 +43,11 @@ export interface RecordedEvent {
43
43
  readonly meta: {
44
44
  readonly at: string;
45
45
  };
46
+ /** Visible assistant message content, also used to recover smart-routing context. */
47
+ readonly data?: {
48
+ readonly message: string;
49
+ readonly finishReason: string;
50
+ };
46
51
  }
47
52
  /** The last events of a session, oldest first. Production reads eve's durable stream. */
48
53
  export type HistoryReader = (sessionId: string) => Promise<ReadonlyArray<RecordedEvent>>;
@@ -1,9 +1,11 @@
1
1
  import type { TelegramChannelEvents } from "eve/channels/telegram";
2
+ import { type ObservedMessages } from "./observed.js";
2
3
  export declare function isSilentReply(text: string): boolean;
3
4
  type MessageCompleted = NonNullable<TelegramChannelEvents["message.completed"]>;
4
5
  /**
5
6
  * The channel's `message.completed` handler: eve's default delivery (plain text through
6
7
  * `sendMessage`, nothing for a tool-call step) unless the reply is a silence marker.
7
8
  */
9
+ export declare function replyDelivery(observed?: ObservedMessages): MessageCompleted;
8
10
  export declare const deliverUnlessSilent: MessageCompleted;
9
11
  export {};
@@ -4,12 +4,37 @@ import { createRequire as __eveCreateRequire } from "node:module";
4
4
  const __filename = __eveFileURLToPath(import.meta.url);
5
5
  __eveDirname(__filename);
6
6
  __eveCreateRequire(import.meta.url);
7
+ import "./principal.mjs";
8
+ import { agentMessage, observedChatKey } from "./observed.mjs";
7
9
  const SILENT_MARKERS = new Set(["[silent]", "no_reply"]);
8
10
  function isSilentReply(text) {
9
11
  return SILENT_MARKERS.has(text.trim().toLowerCase());
10
12
  }
11
- const deliverUnlessSilent = async (event, channel) => {
12
- if (event.finishReason === "tool-calls" || !event.message || isSilentReply(event.message)) return;
13
- await channel.telegram.post(event.message);
14
- };
15
- export { deliverUnlessSilent, isSilentReply };
13
+ function replyDelivery(observed) {
14
+ return async (event, channel, context) => {
15
+ if (event.finishReason === "tool-calls" || !event.message || isSilentReply(event.message)) return;
16
+ await channel.telegram.post(event.message);
17
+ if (observed === void 0 || channel.telegram.chatType === "private") return;
18
+ const principal = context.session.auth.current;
19
+ const sender = principal?.authenticator === "segmentstream-telegram" ? principal.subject : void 0;
20
+ const username = principal?.attributes.username;
21
+ const name = principal?.attributes.memberName;
22
+ const author = typeof name === "string" ? name : typeof username === "string" ? `@${username}` : sender;
23
+ const inReplyTo = sender === void 0 || author === void 0 ? void 0 : {
24
+ id: sender,
25
+ author,
26
+ ...typeof username === "string" ? { username } : {}
27
+ };
28
+ const chat = observedChatKey({
29
+ chatId: channel.telegram.chatId,
30
+ ...channel.telegram.messageThreadId === void 0 ? {} : { messageThreadId: channel.telegram.messageThreadId }
31
+ });
32
+ observed.remember(chat, agentMessage({
33
+ text: event.message,
34
+ at: new Date().toISOString(),
35
+ ...inReplyTo === void 0 ? {} : { inReplyTo }
36
+ }));
37
+ };
38
+ }
39
+ const deliverUnlessSilent = replyDelivery();
40
+ export { deliverUnlessSilent, isSilentReply, replyDelivery };
@@ -0,0 +1,9 @@
1
+ import type { TelegramChannelEvents, TelegramChannelState } from "eve/channels/telegram";
2
+ import type { SessionContext } from "eve/context";
3
+ /** The adapter callback shape; Eve's public channel handler is wrapped inside this callback. */
4
+ export type TurnFailedHandler = (event: Parameters<NonNullable<TelegramChannelEvents["turn.failed"]>>[0], context: {
5
+ readonly state: TelegramChannelState;
6
+ readonly session: Pick<SessionContext["session"], "id" | "auth">;
7
+ }) => void | Promise<void>;
8
+ /** Log the original failure before Eve delivers its existing customer-visible error notice. */
9
+ export declare function withTurnErrorLogging(deliver: TurnFailedHandler): TurnFailedHandler;
@@ -0,0 +1,20 @@
1
+ import { fileURLToPath as __eveFileURLToPath } from "node:url";
2
+ import { dirname as __eveDirname } from "node:path";
3
+ import { createRequire as __eveCreateRequire } from "node:module";
4
+ const __filename = __eveFileURLToPath(import.meta.url);
5
+ __eveDirname(__filename);
6
+ __eveCreateRequire(import.meta.url);
7
+ import { memberIdOf } from "./principal.mjs";
8
+ function withTurnErrorLogging(deliver) {
9
+ return async (event, context) => {
10
+ console.error("[segmentstream] telegram turn failed", {
11
+ chatId: context.state.chatId,
12
+ memberId: memberIdOf(context.session.auth.current) ?? null,
13
+ sessionId: context.session.id,
14
+ turnId: event.turnId,
15
+ error: event
16
+ });
17
+ await deliver(event, context);
18
+ };
19
+ }
20
+ export { withTurnErrorLogging };
@@ -41,8 +41,8 @@ export interface VoiceText {
41
41
  readonly note: string;
42
42
  }
43
43
  /**
44
- * Turns a voice message into text. When it cannot (transcription is off, the file exceeds what the
45
- * proxy forwards, or a call fails), the text says so, so the agent can ask for a typed message.
44
+ * Turns a voice message into text. When it cannot (transcription is off, the file exceeds what a
45
+ * bot may download, or a call fails), the text says so, so the agent can ask for a typed message.
46
46
  */
47
47
  export declare function voiceText(input: {
48
48
  readonly voice: VoiceMessage;
@@ -67,7 +67,7 @@ async function downloadTelegramFile(input) {
67
67
  throw new TelegramFileError("Telegram answered getFile with an invalid body.", { cause: error });
68
68
  }
69
69
  if (metadata.status !== 200 || typeof filePath !== "string" || filePath === "") throw new TelegramFileError(`Telegram refused getFile with HTTP ${metadata.status}${description ? `: ${description}` : "."}`);
70
- const file = await input.proxy.request({
70
+ const file = await input.proxy.download({
71
71
  api: "file",
72
72
  method: "GET",
73
73
  path: `/${filePath}`,
@@ -84,7 +84,7 @@ async function voiceText(input) {
84
84
  text: voiceNote(voice, "voice transcription is off"),
85
85
  note
86
86
  };
87
- if (voice.size !== void 0 && voice.size > 2097152) return {
87
+ if (voice.size !== void 0 && voice.size > 20971520) return {
88
88
  text: voiceNote(voice, "too long to transcribe"),
89
89
  note
90
90
  };