@segmentstream/agent 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +8 -2
  2. package/dist/extension/instructions.md +8 -1
  3. package/dist/extension/lib/attachment-formats.d.ts +16 -0
  4. package/dist/extension/lib/attachment-formats.mjs +238 -0
  5. package/dist/extension/lib/attachment-query.d.ts +31 -0
  6. package/dist/extension/lib/attachment-query.mjs +125 -0
  7. package/dist/extension/lib/attachment-tables.d.ts +33 -0
  8. package/dist/extension/lib/attachment-tables.mjs +186 -0
  9. package/dist/extension/lib/attachment-tools.d.ts +34 -3
  10. package/dist/extension/lib/attachment-tools.mjs +139 -30
  11. package/dist/extension/lib/telegram/adapter.mjs +14 -5
  12. package/dist/extension/lib/telegram/inbound.d.ts +2 -1
  13. package/dist/extension/lib/telegram/inbound.mjs +25 -10
  14. package/dist/extension/lib/telegram/observed.d.ts +29 -3
  15. package/dist/extension/lib/telegram/observed.mjs +48 -14
  16. package/dist/extension/lib/telegram/router.d.ts +11 -3
  17. package/dist/extension/lib/telegram/router.mjs +89 -13
  18. package/dist/extension/lib/telegram/routing-context.d.ts +13 -0
  19. package/dist/extension/lib/telegram/routing-context.mjs +40 -0
  20. package/dist/extension/lib/telegram/sessions.d.ts +5 -0
  21. package/dist/extension/lib/telegram/silent.d.ts +2 -0
  22. package/dist/extension/lib/telegram/silent.mjs +30 -5
  23. package/dist/extension/lib/telegram/turn-errors.d.ts +9 -0
  24. package/dist/extension/lib/telegram/turn-errors.mjs +20 -0
  25. package/dist/extension/tools/attachments_read.mjs +47 -5
  26. package/dist/extension/tools/attachments_table.d.ts +64 -0
  27. package/dist/extension/tools/attachments_table.mjs +198 -0
  28. package/dist/tools/index.d.ts +1 -0
  29. package/dist/tools/index.mjs +2 -1
  30. package/package.json +9 -2
@@ -1,5 +1,6 @@
1
1
  import type { SessionAuthContext } from "eve/context";
2
2
  import { type AgentAttachments, type Attachment, type AttachmentOwner } from "./attachments.js";
3
+ import { type DateOrder } from "./attachment-tables.js";
3
4
  import { type Environment } from "./environment.js";
4
5
  import { type RuntimeToken } from "./runtime-token.js";
5
6
  /**
@@ -7,7 +8,7 @@ import { type RuntimeToken } from "./runtime-token.js";
7
8
  * the member and chat on the session's principal, which the channel set from the pairing, and the
8
9
  * session's own id. A tool's arguments name only the attachment and what to do with it.
9
10
  */
10
- /** The longest text `attachments_read` returns; the rest is cut and marked as truncated. */
11
+ /** Maximum characters in a read/query response; remaining data is available by continuation. */
11
12
  export declare const MAXIMUM_TEXT_CHARACTERS = 50000;
12
13
  /** The largest file `attachments_read` hands the model inline. */
13
14
  export declare const MAXIMUM_INLINE_BYTES: number;
@@ -33,22 +34,52 @@ export declare function attachmentForModel(attachment: Attachment): {
33
34
  state: string;
34
35
  expires_at: string;
35
36
  };
37
+ export interface ReadOptions {
38
+ readonly sheet?: string;
39
+ readonly offset?: number;
40
+ readonly limit?: number;
41
+ readonly character_offset?: number;
42
+ readonly header_row?: number;
43
+ readonly date_order?: DateOrder;
44
+ }
36
45
  export type ReadOutput = {
37
46
  readonly attachment: ReturnType<typeof attachmentForModel>;
38
47
  readonly kind: "text";
48
+ readonly format: string;
39
49
  readonly text: string;
40
50
  readonly truncated: boolean;
51
+ readonly pagination: {
52
+ readonly unit: "characters" | "rows";
53
+ readonly offset: number;
54
+ readonly limit: number;
55
+ readonly total: number;
56
+ readonly total_pages: number;
57
+ readonly next_offset: number | null;
58
+ readonly next_character_offset?: number;
59
+ };
60
+ readonly sheet?: string;
61
+ readonly sheets?: ReadonlyArray<{
62
+ readonly name: string;
63
+ readonly total_rows: number;
64
+ }>;
65
+ readonly columns?: unknown;
66
+ readonly warnings?: ReadonlyArray<string>;
41
67
  } | {
42
68
  readonly attachment: ReturnType<typeof attachmentForModel>;
43
69
  readonly kind: "file";
70
+ readonly media_type: string;
44
71
  readonly data: string;
45
72
  } | {
46
73
  readonly attachment: ReturnType<typeof attachmentForModel>;
47
74
  readonly kind: "unreadable";
48
75
  readonly note: string;
49
76
  };
50
- /** What `attachments_read` answers for a file: its text, the file itself, or why neither. */
51
- export declare function readOutput(attachment: Attachment, bytes: Uint8Array): ReadOutput;
77
+ export declare function pageInteger(value: unknown, fallback: number, maximum?: number): number;
78
+ export declare function headerArgument(value: unknown): number | undefined;
79
+ /** Keep envelopes and structured query results inside the same response budget as file text. */
80
+ export declare function boundedResultPage<T>(items: readonly T[], offset: number, limit: number, envelope: unknown): T[];
81
+ /** Reads never discard the remainder. Parsers process the entire file before selecting a page. */
82
+ export declare function readOutput(attachment: Attachment, bytes: Uint8Array, options?: ReadOptions): Promise<ReadOutput>;
52
83
  /** A required string argument. */
53
84
  export declare function stringArgument(input: Readonly<Record<string, unknown>>, name: string): string;
54
85
  /** An optional string argument. */
@@ -9,22 +9,10 @@ import { memberIdOf } from "./telegram/principal.mjs";
9
9
  import { runtimeToken } from "./runtime-token.mjs";
10
10
  import { agentAttachments } from "./attachments.mjs";
11
11
  import { telegramConversation } from "./telegram/files.mjs";
12
+ import { detectFile, documentText } from "./attachment-formats.mjs";
13
+ import { readSheets, typedTable } from "./attachment-tables.mjs";
12
14
  const MAXIMUM_TEXT_CHARACTERS = 5e4;
13
15
  const MAXIMUM_INLINE_BYTES = 5242880;
14
- const INLINE_TYPES = new Set([
15
- "application/pdf",
16
- "image/png",
17
- "image/jpeg",
18
- "image/webp",
19
- "image/gif"
20
- ]);
21
- function isText(contentType) {
22
- return contentType.startsWith("text/") || [
23
- "application/json",
24
- "application/xml",
25
- "application/csv"
26
- ].includes(contentType);
27
- }
28
16
  var AttachmentOwnerError = class extends Error {
29
17
  name = "AttachmentOwnerError";
30
18
  };
@@ -58,27 +46,148 @@ function attachmentForModel(attachment) {
58
46
  ...attachment.promotion === null ? {} : { promotion: attachment.promotion }
59
47
  };
60
48
  }
61
- function readOutput(attachment, bytes) {
49
+ function pageInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
50
+ if (value === void 0) return fallback;
51
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > maximum) throw new TypeError(`Pagination values must be integers from 0 to ${maximum}.`);
52
+ return value;
53
+ }
54
+ function headerArgument(value) {
55
+ if (value === void 0) return;
56
+ if (value === -1) return -1;
57
+ return pageInteger(value, 0);
58
+ }
59
+ function boundedResultPage(items, offset, limit, envelope) {
60
+ const budget = MAXIMUM_TEXT_CHARACTERS - JSON.stringify(envelope).length - 2e3;
61
+ const page = [];
62
+ let size = 2;
63
+ for (const item of items.slice(offset, offset + limit)) {
64
+ const length = JSON.stringify(item).length + 1;
65
+ if (size + length > budget) {
66
+ if (page.length === 0) throw new Error("One result exceeds the response budget. Use attachments_read and its character continuation.");
67
+ break;
68
+ }
69
+ page.push(item);
70
+ size += length;
71
+ }
72
+ return page;
73
+ }
74
+ async function readOutput(attachment, bytes, options = {}) {
62
75
  const described = attachmentForModel(attachment);
63
- if (isText(attachment.contentType)) {
64
- const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
76
+ const offset = pageInteger(options.offset, 0);
77
+ const requestedLimit = pageInteger(options.limit, MAXIMUM_TEXT_CHARACTERS, MAXIMUM_TEXT_CHARACTERS);
78
+ if (requestedLimit === 0) throw new TypeError("limit must be greater than zero.");
79
+ try {
80
+ const file = await detectFile(attachment, bytes);
81
+ if (file.format === "inline" && bytes.byteLength <= 5242880) return {
82
+ attachment: described,
83
+ kind: "file",
84
+ media_type: file.mediaType,
85
+ data: Buffer.from(bytes).toString("base64")
86
+ };
87
+ if (file.format === "inline" || file.format === "opaque" || file.format === "doc") return {
88
+ attachment: described,
89
+ kind: "unreadable",
90
+ note: file.format === "inline" ? "The file exceeds the inline display limit; it can still be promoted by id." : file.format === "doc" ? "Legacy binary Word document: text extraction is unavailable. Metadata is shown; the original can be promoted." : "No text extractor for this format. Metadata is shown; the original can be promoted by id."
91
+ };
92
+ if (file.format === "table") {
93
+ const table = typedTable(await readSheets(attachment, bytes), options);
94
+ const limit = pageInteger(options.limit, 100, MAXIMUM_TEXT_CHARACTERS);
95
+ const characterOffset = pageInteger(options.character_offset, 0);
96
+ const envelope = {
97
+ attachment: described,
98
+ columns: table.columns,
99
+ warnings: table.warnings,
100
+ sheets: table.sheets
101
+ };
102
+ const budget = Math.floor((MAXIMUM_TEXT_CHARACTERS - JSON.stringify(envelope).length - 2e3) / 6);
103
+ const csv = (values) => values.map((value) => `"${(value ?? "").replace(/"/g, "\"\"")}"`).join(",");
104
+ const heading = `Sheet: ${table.sheet}\n${csv(table.columns.map((column) => column.name))}\n`;
105
+ const lines = table.rows.map((row) => csv(table.columns.map((column) => row[column.name])) + "\n");
106
+ if (heading.length >= budget) throw new Error("Table headers exceed the response budget.");
107
+ const pageAt = (rowOffset, charOffset) => {
108
+ let text = heading;
109
+ let cursor = Math.min(rowOffset, lines.length);
110
+ let nextCharacter = 0;
111
+ for (; cursor < Math.min(lines.length, rowOffset + limit); cursor++) {
112
+ const line = lines[cursor];
113
+ const start = cursor === rowOffset ? charOffset : 0;
114
+ const remaining = line.slice(start);
115
+ if (start >= line.length) throw new TypeError("character_offset exceeds this row's text length.");
116
+ if (text.length + remaining.length > budget) {
117
+ if (cursor === rowOffset) {
118
+ const available = budget - text.length;
119
+ text += remaining.slice(0, available);
120
+ nextCharacter = start + available;
121
+ }
122
+ break;
123
+ }
124
+ text += remaining;
125
+ }
126
+ return {
127
+ text,
128
+ next: cursor < lines.length ? cursor : null,
129
+ nextCharacter
130
+ };
131
+ };
132
+ const { text, next, nextCharacter } = pageAt(offset, characterOffset);
133
+ let totalPages = 0;
134
+ let nextRow = 0;
135
+ let nextChar = 0;
136
+ while (nextRow !== null && lines.length > 0) {
137
+ const page = pageAt(nextRow, nextChar);
138
+ totalPages++;
139
+ nextRow = page.next;
140
+ nextChar = page.nextCharacter;
141
+ }
142
+ return {
143
+ ...envelope,
144
+ kind: "text",
145
+ format: "table",
146
+ sheet: table.sheet,
147
+ text,
148
+ truncated: next !== null,
149
+ pagination: {
150
+ unit: "rows",
151
+ offset,
152
+ limit,
153
+ total: table.rows.length,
154
+ total_pages: totalPages,
155
+ next_offset: next,
156
+ ...nextCharacter === 0 ? {} : { next_character_offset: nextCharacter }
157
+ }
158
+ };
159
+ }
160
+ const fullText = await documentText(file, bytes);
161
+ const budget = Math.floor((MAXIMUM_TEXT_CHARACTERS - JSON.stringify(described).length - 2e3) / 6);
162
+ const limit = Math.min(requestedLimit, budget);
163
+ const text = fullText.slice(offset, offset + limit);
164
+ const next = offset + text.length < fullText.length ? offset + text.length : null;
65
165
  return {
66
166
  attachment: described,
67
167
  kind: "text",
68
- text: text.slice(0, MAXIMUM_TEXT_CHARACTERS),
69
- truncated: text.length > MAXIMUM_TEXT_CHARACTERS
168
+ format: file.format,
169
+ text,
170
+ truncated: next !== null,
171
+ pagination: {
172
+ unit: "characters",
173
+ offset,
174
+ limit,
175
+ total: fullText.length,
176
+ total_pages: Math.ceil(fullText.length / limit),
177
+ next_offset: next
178
+ }
179
+ };
180
+ } catch (error) {
181
+ console.error("[segmentstream] attachment parsing failed", {
182
+ id: attachment.id,
183
+ error
184
+ });
185
+ return {
186
+ attachment: described,
187
+ kind: "unreadable",
188
+ note: "The file could not be parsed (invalid, encrypted, or over the expanded parsing limit). The original can still be promoted."
70
189
  };
71
190
  }
72
- if (INLINE_TYPES.has(attachment.contentType) && bytes.byteLength <= 5242880) return {
73
- attachment: described,
74
- kind: "file",
75
- data: Buffer.from(bytes).toString("base64")
76
- };
77
- return {
78
- attachment: described,
79
- kind: "unreadable",
80
- note: INLINE_TYPES.has(attachment.contentType) ? "The file is too large to show again; it was attached to the message it came with." : `A ${attachment.contentType} file cannot be shown; promote or discard it by its id.`
81
- };
82
191
  }
83
192
  function isRecord(value) {
84
193
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -107,4 +216,4 @@ const ID_INPUT = {
107
216
  required: ["id"],
108
217
  additionalProperties: false
109
218
  };
110
- export { AttachmentOwnerError, ID_INPUT, MAXIMUM_INLINE_BYTES, MAXIMUM_TEXT_CHARACTERS, attachmentForModel, attachmentOwner, optionalObject, optionalString, readOutput, stringArgument, toolAttachments };
219
+ export { AttachmentOwnerError, ID_INPUT, MAXIMUM_INLINE_BYTES, MAXIMUM_TEXT_CHARACTERS, attachmentForModel, attachmentOwner, boundedResultPage, headerArgument, optionalObject, optionalString, pageInteger, readOutput, stringArgument, toolAttachments };
@@ -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;