@segmentstream/agent 0.2.0 → 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.
@@ -14,13 +14,14 @@ import { botIdentityCache } from "./bot.mjs";
14
14
  import { agentChats } from "./chats.mjs";
15
15
  import { observedMessages } from "./observed.mjs";
16
16
  import { decisionRouter } from "./router.mjs";
17
+ import { replyDelivery } from "./silent.mjs";
17
18
  import { scopedAddress, sessionPolicy } from "./sessions.mjs";
18
19
  import { downloadTelegramFile, gatewayTranscriber } from "./voice.mjs";
19
20
  import { handleInboundMessage } from "./inbound.mjs";
20
21
  import { InboundRequest, currentInboundRequest, runInboundRequest, withReplacedText } from "./inbound-request.mjs";
21
22
  import { TELEGRAM_PLACEHOLDER_TOKEN, TELEGRAM_PROXY_ORIGIN, telegramProxyFetch } from "./proxy-fetch.mjs";
22
23
  import { messagesRoute } from "./requests.mjs";
23
- import { deliverUnlessSilent } from "./silent.mjs";
24
+ import { withTurnErrorLogging } from "./turn-errors.mjs";
24
25
  import { telegramWebhookVerifier } from "./webhook.mjs";
25
26
  import { telegramChannel } from "eve/channels/telegram";
26
27
  const eveHistory = async (sessionId) => {
@@ -31,7 +32,11 @@ const eveHistory = async (sessionId) => {
31
32
  follow: false
32
33
  })) events.push({
33
34
  type: event.type,
34
- meta: { at: event.meta.at }
35
+ meta: { at: event.meta.at },
36
+ ...event.type === "message.completed" ? { data: {
37
+ message: event.data.message,
38
+ finishReason: event.data.finishReason
39
+ } } : {}
35
40
  });
36
41
  return events;
37
42
  };
@@ -39,9 +44,10 @@ function segmentstreamTelegram(options) {
39
44
  const environment = options.environment ?? process.env;
40
45
  const token = options.token ?? runtimeToken;
41
46
  const observed = options.observed ?? observedMessages();
47
+ const readHistory = options.history ?? eveHistory;
42
48
  const policy = sessionPolicy({
43
49
  settings: options.sessions,
44
- read: options.history ?? eveHistory
50
+ read: readHistory
45
51
  });
46
52
  const bot = botIdentityCache();
47
53
  const proxies = new Map();
@@ -117,14 +123,16 @@ function segmentstreamTelegram(options) {
117
123
  }),
118
124
  attachments: attachments(),
119
125
  observed,
126
+ readHistory,
120
127
  chats,
121
128
  connectUrl: (code) => `${appOrigin(environment)}/agent/connect?code=${encodeURIComponent(code)}`
122
129
  }
123
130
  }),
124
- events: { "message.completed": deliverUnlessSilent }
131
+ events: { "message.completed": replyDelivery(observed) }
125
132
  });
126
133
  const adapter = channel.adapter;
127
134
  const fetchTelegramFile = adapter?.fetchFile;
135
+ const turnFailed = adapter?.["turn.failed"];
128
136
  async function fetchFile(url, context) {
129
137
  const reference = parseAttachmentUrl(url);
130
138
  if (reference === void 0) return fetchTelegramFile === void 0 ? null : fetchTelegramFile(url, context);
@@ -144,7 +152,8 @@ function segmentstreamTelegram(options) {
144
152
  ...channel,
145
153
  ...adapter === void 0 ? {} : { adapter: {
146
154
  ...adapter,
147
- fetchFile
155
+ fetchFile,
156
+ ...turnFailed === void 0 ? {} : { "turn.failed": withTurnErrorLogging(turnFailed) }
148
157
  } },
149
158
  routes: [...channel.routes.map((route) => gatedRoute({
150
159
  route,
@@ -7,7 +7,7 @@ import { type AgentChats } from "./chats.js";
7
7
  import type { InboundRequest } from "./inbound-request.js";
8
8
  import { type ObservedMessages } from "./observed.js";
9
9
  import { type RouteDecider } from "./router.js";
10
- import { type SessionPolicy } from "./sessions.js";
10
+ import { type HistoryReader, type SessionPolicy } from "./sessions.js";
11
11
  import { type Transcriber } from "./voice.js";
12
12
  /**
13
13
  * eve's Telegram `onMessage` hook, in the order `agent-design.md` gives:
@@ -43,6 +43,7 @@ export interface InboundDependencies {
43
43
  /** The attachment store; without it, files reach the turn as eve's Telegram file parts. */
44
44
  readonly attachments?: AgentAttachments;
45
45
  readonly observed: ObservedMessages;
46
+ readonly readHistory: HistoryReader;
46
47
  readonly chats: AgentChats;
47
48
  /** The app's `/agent/connect` link for a pairing code. */
48
49
  readonly connectUrl: (code: string) => string;
@@ -8,7 +8,8 @@ import { telegramPrincipal } from "./principal.mjs";
8
8
  import { chatContext } from "./chats.mjs";
9
9
  import { attachmentsContext, stageFiles } from "./files.mjs";
10
10
  import { observedChatKey, observedContext } from "./observed.mjs";
11
- import { isAddressed, routeMessage } from "./router.mjs";
11
+ import { addressingReason, routeMessage } from "./router.mjs";
12
+ import { routingContext } from "./routing-context.mjs";
12
13
  import { messageAddress, scopedAddress } from "./sessions.mjs";
13
14
  import { voiceNote, voiceOf, voiceText } from "./voice.mjs";
14
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.";
@@ -39,6 +40,8 @@ function authorOf(user, member) {
39
40
  function observedMessage(message, text, member) {
40
41
  return {
41
42
  author: authorOf(message.from, member),
43
+ authorId: message.from.id,
44
+ ...message.from.username === void 0 ? {} : { username: message.from.username },
42
45
  text,
43
46
  at: sentAt(message)
44
47
  };
@@ -159,7 +162,7 @@ async function handleInboundMessage(input) {
159
162
  from: message.from
160
163
  };
161
164
  const bot = await dependencies.bot(context.telegram);
162
- const addressed = isAddressed(sent, bot);
165
+ const addressed = addressingReason(sent, bot);
163
166
  const lookup = await dependencies.identities.lookup({
164
167
  provider: "telegram",
165
168
  externalId: sent.from.id
@@ -172,7 +175,7 @@ async function handleInboundMessage(input) {
172
175
  }) : void 0;
173
176
  if (report !== void 0) input.request?.waitUntil?.(report);
174
177
  if (lookup.status !== "paired") {
175
- if (addressed) await answerUnpaired({
178
+ if (addressed !== void 0) await answerUnpaired({
176
179
  context,
177
180
  message: sent,
178
181
  lookup,
@@ -187,7 +190,7 @@ async function handleInboundMessage(input) {
187
190
  const caption = sent.text || sent.caption;
188
191
  const notes = [];
189
192
  let text = caption;
190
- if (voice !== void 0 && (addressed || dependencies.policy === "smart")) {
193
+ if (voice !== void 0 && (addressed !== void 0 || dependencies.policy === "smart")) {
191
194
  const spoken = await voiceText({
192
195
  voice,
193
196
  model: dependencies.sttModel,
@@ -203,14 +206,25 @@ async function handleInboundMessage(input) {
203
206
  ...sent.messageThreadId === void 0 ? {} : { messageThreadId: sent.messageThreadId }
204
207
  });
205
208
  if (await routeMessage({
209
+ chatId: sent.chat.id,
210
+ memberId: lookup.member.id,
206
211
  policy: dependencies.policy,
207
212
  addressed,
208
213
  decide: dependencies.decide,
209
- question: async () => ({
210
- agent: await input.request?.agentDescription() ?? "",
211
- observed: dependencies.observed.list(chat),
212
- message: observedMessage(sent, text, lookup.member)
213
- })
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
+ }
214
228
  }) === "observe") {
215
229
  dependencies.observed.add(chat, observedMessage(sent, text === "" ? "[attachment]" : text, lookup.member));
216
230
  return null;
@@ -223,7 +237,7 @@ async function handleInboundMessage(input) {
223
237
  address,
224
238
  operations,
225
239
  ...newest === void 0 ? {} : { activeAt: Date.parse(newest.at) || 0 }
226
- }) === "cleared") dependencies.observed.take(chat);
240
+ }) === "cleared") dependencies.observed.clear(chat);
227
241
  dependencies.sessionPolicy.delivered(address);
228
242
  }
229
243
  await context.telegram.startTyping();
@@ -235,6 +249,7 @@ async function handleInboundMessage(input) {
235
249
  });
236
250
  if (input.request !== void 0 && files.size > 0) input.request.files = files;
237
251
  await report;
252
+ dependencies.observed.remember(chat, observedMessage(sent, text, lookup.member));
238
253
  return {
239
254
  auth: telegramPrincipal({
240
255
  message: sent,
@@ -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 };
@@ -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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream/agent",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Mount a SegmentStream workspace's agent on eve: the ontology as tools, acting as the member who writes, and Telegram through the workspace's connection.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",