@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.
- package/README.md +21 -9
- package/dist/extension/instructions.md +6 -0
- package/dist/extension/lib/attachment-tools.d.ts +68 -0
- package/dist/extension/lib/attachment-tools.mjs +110 -0
- package/dist/extension/lib/attachments.d.ts +96 -0
- package/dist/extension/lib/attachments.mjs +233 -0
- package/dist/extension/lib/connection-proxy.d.ts +14 -1
- package/dist/extension/lib/connection-proxy.mjs +135 -39
- package/dist/extension/lib/telegram/adapter.d.ts +3 -0
- package/dist/extension/lib/telegram/adapter.mjs +52 -7
- package/dist/extension/lib/telegram/files.d.ts +25 -0
- package/dist/extension/lib/telegram/files.mjs +77 -0
- package/dist/extension/lib/telegram/inbound-request.d.ts +20 -1
- package/dist/extension/lib/telegram/inbound-request.mjs +29 -1
- package/dist/extension/lib/telegram/inbound.d.ts +12 -5
- package/dist/extension/lib/telegram/inbound.mjs +37 -10
- package/dist/extension/lib/telegram/observed.d.ts +29 -3
- package/dist/extension/lib/telegram/observed.mjs +48 -14
- package/dist/extension/lib/telegram/proxy-fetch.mjs +3 -2
- package/dist/extension/lib/telegram/router.d.ts +11 -3
- package/dist/extension/lib/telegram/router.mjs +89 -13
- package/dist/extension/lib/telegram/routing-context.d.ts +13 -0
- package/dist/extension/lib/telegram/routing-context.mjs +40 -0
- package/dist/extension/lib/telegram/sessions.d.ts +5 -0
- package/dist/extension/lib/telegram/silent.d.ts +2 -0
- package/dist/extension/lib/telegram/silent.mjs +30 -5
- package/dist/extension/lib/telegram/turn-errors.d.ts +9 -0
- package/dist/extension/lib/telegram/turn-errors.mjs +20 -0
- package/dist/extension/lib/telegram/voice.d.ts +2 -2
- package/dist/extension/lib/telegram/voice.mjs +2 -2
- package/dist/extension/tools/attachments_discard.d.ts +30 -0
- package/dist/extension/tools/attachments_discard.mjs +22 -0
- package/dist/extension/tools/attachments_list.d.ts +30 -0
- package/dist/extension/tools/attachments_list.mjs +25 -0
- package/dist/extension/tools/attachments_promote.d.ts +36 -0
- package/dist/extension/tools/attachments_promote.mjs +69 -0
- package/dist/extension/tools/attachments_read.d.ts +9 -0
- package/dist/extension/tools/attachments_read.mjs +43 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.mjs +5 -1
- 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 {
|
|
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 =
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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.
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
25
|
+
function addressingReason(message, bot) {
|
|
26
26
|
const text = message.text || message.caption;
|
|
27
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
47
|
-
reply: "The message asks the agent,
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
45
|
-
*
|
|
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.
|
|
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 >
|
|
87
|
+
if (voice.size !== void 0 && voice.size > 20971520) return {
|
|
88
88
|
text: voiceNote(voice, "too long to transcribe"),
|
|
89
89
|
note
|
|
90
90
|
};
|