@segmentstream/agent 0.6.5 → 0.6.6

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 CHANGED
@@ -82,7 +82,7 @@ Both entry points export the same handle: `@segmentstream/agent` and
82
82
  | Field | Default | Meaning |
83
83
  | ------------------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------ |
84
84
  | `channels.telegram.connection` | none | The Connect key whose credential is the bot token. Without `channels.telegram`, no Telegram. |
85
- | `channels.telegram.policy` | `"smart"` | Group messages that do not address the bot: `smart` asks the decision model, `mention` observes |
85
+ | `channels.telegram.policy` | `"smart"` | Unaddressed group text: `smart` asks the decision model, `mention` observes; attachments always trigger a turn |
86
86
  | `models.stt` | `"spacexai/grok-stt"` | The AI Gateway transcription model for voice messages; `false` leaves them untranscribed |
87
87
  | `models.imageDescription` | automatic | A separate vision model for ingestion descriptions; omitted prefers native images, `false` disables description fallback |
88
88
  | `models.decision` | `"typesafe-ai/jev"` | The AI Gateway decision model of the inbound router |
@@ -140,33 +140,38 @@ share a group chat's session never share a client.
140
140
  - **Who the agent is.** A paired sender's turn runs with the principal id `telegram:<user>`
141
141
  (`telegram:<chat>:<user>` in groups) and the attributes `memberId`, `memberName`, `roles`,
142
142
  `chatId`, `chatType` and `username`.
143
- - **Routing.** A private chat, a mention of the bot, a reply to it and a command are always
144
- answered. Under `smart`, any other group message is put to one `decide` question on the decision
143
+ - **Routing.** Any file attachment from a paired member starts a turn under both `smart` and
144
+ `mention`, without a routing model call, caption or mention. This includes PDFs, photos,
145
+ spreadsheets and other media, even when replying to or tagging another person: the agent can
146
+ file or reconcile the upload. The decision archive records `attachment`. Eve 0.76.3 does not
147
+ batch inbound albums; each Telegram message starts one turn, regardless of its file count.
148
+ A private chat, a mention of the bot, a reply to it and a command are always answered.
149
+ Under `smart`, any other group message is put to one `decide` question on the decision
145
150
  model, with the agent's description and the chat's recent observed messages. A message that is not
146
151
  answered joins a ring of the chat's last 30 observed messages. The next answered message carries
147
152
  the ring into its turn as context. A failed decision observes.
148
- Replies to a known human or another bot, and explicit mentions of another recipient, are
153
+ Text-only replies to a known human or another bot, and explicit mentions of another recipient, are
149
154
  observed before smart inference unless this bot is addressed. This also takes precedence over
150
155
  a pending-question follow-up. Both mention entities and user-ID `text_mention` entities count;
151
156
  quoted/code mentions do not. Smart routing receives the original body, reply author/text,
152
157
  tagged users and whether this bot was tagged. Decision events retain a short routing audit note
153
158
  alongside the reason code; this is an observable summary, not model chain-of-thought.
154
159
  - **Voice.** Voice messages, audio files and video notes are downloaded through the connection and
155
- transcribed with `models.stt` through AI Gateway before routing, including observations under
156
- `mention`. The transcript becomes the message, with a note such as `[voice message, 12 s]` in
157
- context. `stt: false` skips download and transcription. Transcription accepts at most 20 MiB
160
+ transcribed with `models.stt` through AI Gateway before the turn.
161
+ The transcript becomes the message, with a note such as `[voice message, 12 s]` in
162
+ context. `stt: false` skips transcription. Transcription accepts at most 20 MiB
158
163
  and ten minutes of declared duration, with a 60-second inference timeout. A skipped or failed
159
- transcription reaches the agent as a note. Originals are not stored as ontology attachments.
164
+ transcription reaches the agent as a note. Originals are stored as ontology attachments.
160
165
  Eve 0.76.3 only hydrates inbound images and PDFs; it does **not** deliver native audio bytes to
161
166
  the chat model. Even an audio-capable chat model therefore uses the configured STT fallback,
162
167
  with an explicit host limitation note. Native audio requires a compatible Eve host change.
163
- - **Attachments.** Photos and documents up to 20 MB stream through the connection proxy (its
168
+ - **Attachments.** Photos, documents and other Telegram files up to 20 MB stream through the connection proxy (its
164
169
  framed responses are not bound by the 2 MiB buffered limit) and are stored, before the turn,
165
170
  as the member's temporary attachments in the ontology's file storage
166
171
  (`POST /api/ontology/agent/attachments`), for this chat, for 24 hours.
167
- Observed group uploads are stored too, after participant registration, without starting a turn
168
- or sending a typing indication. The next reply receives their references; attachment tools can
169
- also list the stored files after a cold start. The turn's file parts
172
+ Uploads start a turn after participant registration; attachment tools can also list the stored
173
+ files after a cold start. Eve supplies native file parts for photos and documents; other media
174
+ reaches the turn through stored attachment IDs in context. The turn's file parts
170
175
  point at the stored copies, which eve reads through the channel's `fetchFile` as the turn's
171
176
  session, which then owns them, and an `<attachments>` block names each attachment's id. A file
172
177
  that is too large or could not be stored becomes a note. The `segmentstream__attachments_*`
@@ -1,4 +1,4 @@
1
- import type { TelegramMessage } from "eve/channels/telegram";
1
+ import type { TelegramAttachment, TelegramMessage } from "eve/channels/telegram";
2
2
  import { type AgentAttachments } from "../attachments.js";
3
3
  import type { StagedFile } from "./inbound-request.js";
4
4
  import { type ImageDescriber } from "./images.js";
@@ -13,6 +13,8 @@ import type { MediaPlan } from "./media-policy.js";
13
13
  */
14
14
  /** The chat an attachment belongs to: `telegram:<chat id>`. */
15
15
  export declare function telegramConversation(chatId: string): string;
16
+ /** Eve normalizes photos/documents only. Keep other Telegram files available to attachment tools. */
17
+ export declare function inboundAttachments(message: TelegramMessage): ReadonlyArray<TelegramAttachment>;
16
18
  /** Stores the message's files one after another, so that a large album holds one file at a time. */
17
19
  export declare function stageFiles(input: {
18
20
  readonly message: TelegramMessage;
@@ -11,6 +11,31 @@ import { describableImage, describeReceivedImage } from "./images.mjs";
11
11
  function telegramConversation(chatId) {
12
12
  return `telegram:${chatId}`;
13
13
  }
14
+ function inboundAttachments(message) {
15
+ const files = [...message.attachments];
16
+ for (const kind of [
17
+ "voice",
18
+ "audio",
19
+ "video",
20
+ "video_note",
21
+ "sticker",
22
+ "animation"
23
+ ]) {
24
+ const value = message.raw[kind];
25
+ if (value === null || typeof value !== "object" || Array.isArray(value)) continue;
26
+ const file = value;
27
+ if (typeof file.file_id !== "string" || file.file_id === "" || files.some((existing) => existing.fileId === file.file_id)) continue;
28
+ files.push({
29
+ kind: "document",
30
+ fileId: file.file_id,
31
+ fileName: typeof file.file_name === "string" ? file.file_name : kind,
32
+ ...typeof file.file_unique_id === "string" ? { fileUniqueId: file.file_unique_id } : {},
33
+ ...typeof file.mime_type === "string" ? { mediaType: file.mime_type } : {},
34
+ ...typeof file.file_size === "number" ? { size: file.file_size } : {}
35
+ });
36
+ }
37
+ return files;
38
+ }
14
39
  function described(file, index) {
15
40
  return {
16
41
  filename: file.fileName ?? (file.kind === "photo" ? `photo-${index}.jpg` : `file-${index}`),
@@ -106,4 +131,4 @@ function attachmentsContext(input) {
106
131
  lines.push("</attachments>");
107
132
  return [lines.join("\n")];
108
133
  }
109
- export { attachmentsContext, stageFiles, telegramConversation };
134
+ export { attachmentsContext, inboundAttachments, stageFiles, telegramConversation };
@@ -21,8 +21,9 @@ import { type Transcriber } from "./voice.js";
21
21
  * and so is the agent's own, which unlinks every member. Neither is the notice, in the old chat
22
22
  * or the new one, that a group became a supergroup: the migration is reported.
23
23
  * 2. Voice becomes text, so the router and the agent read what was said.
24
- * 3. The router answers or observes. Photos and documents are stored after the registry knows the
25
- * sender, including on observed messages. Observing starts no session or typing indication;
24
+ * 3. The router answers or observes. Files always start a turn after the identity gates, even
25
+ * without a caption or when addressed to another person. They are stored after the registry knows the
26
+ * sender. Observing starts no session or typing indication;
26
27
  * the ring carries the stored files' references (or storage failures) into the next reply.
27
28
  * 4. The session policy runs on the session an answered message continues: an idle one is cleared, the
28
29
  * first message after the day's compaction time compacts it.
@@ -14,7 +14,7 @@ import { formatAddress, messageAddress, scopedAddress } from "./sessions.mjs";
14
14
  import { questionAddress, questionFromHistory } from "./confirmations.mjs";
15
15
  import { MAXIMUM_NATIVE_IMAGE_BYTES } from "./images.mjs";
16
16
  import { chatContext } from "./chats.mjs";
17
- import { attachmentsContext, stageFiles } from "./files.mjs";
17
+ import { attachmentsContext, inboundAttachments, stageFiles } from "./files.mjs";
18
18
  import { messageAudience } from "./audience.mjs";
19
19
  import { addressingReason, routeMessage } from "./router.mjs";
20
20
  import { routingContext } from "./routing-context.mjs";
@@ -175,7 +175,8 @@ async function handleInboundMessage(input) {
175
175
  if (message.from === void 0 || message.from.isBot || message.chat.type === "channel") return null;
176
176
  const sent = {
177
177
  ...message,
178
- from: message.from
178
+ from: message.from,
179
+ attachments: inboundAttachments(message)
179
180
  };
180
181
  const bot = await dependencies.bot(context.telegram);
181
182
  const audience = messageAudience(sent, bot);
@@ -227,6 +228,7 @@ async function handleInboundMessage(input) {
227
228
  const caption = sent.text || sent.caption;
228
229
  const notes = [];
229
230
  let text = caption;
231
+ let voiceBytes;
230
232
  const chat = observedChatKey({
231
233
  chatId: sent.chat.id,
232
234
  ...sent.messageThreadId === void 0 ? {} : { messageThreadId: sent.messageThreadId }
@@ -236,7 +238,10 @@ async function handleInboundMessage(input) {
236
238
  const spoken = await voiceText({
237
239
  voice,
238
240
  model: dependencies.sttModel,
239
- download: dependencies.download,
241
+ download: async (fileId) => {
242
+ voiceBytes = await dependencies.download(fileId);
243
+ return voiceBytes;
244
+ },
240
245
  transcribe: dependencies.transcribe
241
246
  });
242
247
  text = [caption, spoken.text].filter((part) => part.trim() !== "").join("\n\n");
@@ -252,12 +257,16 @@ async function handleInboundMessage(input) {
252
257
  recordGateDecision("empty_message");
253
258
  return null;
254
259
  }
260
+ if (text.trim() === "" && sent.attachments.length > 0) {
261
+ text = "[attachment]";
262
+ if (input.request !== void 0) input.request.text = text;
263
+ }
255
264
  if (sent.attachments.length > 0) await report;
256
265
  const files = sent.attachments.length === 0 || dependencies.attachments === void 0 || input.request === void 0 ? new Map() : await stageFiles({
257
266
  message: sent,
258
267
  memberId: lookup.member.id,
259
268
  attachments: dependencies.attachments,
260
- download: dependencies.download,
269
+ download: (fileId) => voiceBytes !== void 0 && voice?.fileId === fileId ? Promise.resolve(voiceBytes) : dependencies.download(fileId),
261
270
  ...mediaPlan === void 0 ? {} : { imagePlan: mediaPlan },
262
271
  ...dependencies.describeImage === void 0 ? {} : { describeImage: dependencies.describeImage }
263
272
  });
@@ -269,6 +278,7 @@ async function handleInboundMessage(input) {
269
278
  chatId: sent.chat.id,
270
279
  memberId: lookup.member.id,
271
280
  policy: dependencies.policy,
281
+ hasAttachments: sent.attachments.length > 0,
272
282
  addressed: addressed ?? (answering ? "pending-question" : void 0),
273
283
  audience,
274
284
  record: (decision) => recordRouteDecision(decision.route, addressed, dependencies.policy, decision),
@@ -4,8 +4,8 @@ import type { ObservedMessage, RecentMessage } from "./observed.js";
4
4
  import { type MessageAudience } from "./audience.js";
5
5
  /**
6
6
  * The inbound router (`agent-design.md`, section 4): every message from a paired member is either
7
- * answered or observed before eve starts a turn. A private chat, a mention, a reply to the bot and
8
- * a command are always answered. Any other group message is observed, or, with the `smart`
7
+ * answered or observed before eve starts a turn. Attachments, a private chat, a mention, a reply
8
+ * to the bot and a command are always answered. Any other group message is observed, or, with the `smart`
9
9
  * policy, put to one decision on the decision model.
10
10
  */
11
11
  export type Route = "observe" | "reply";
@@ -78,6 +78,8 @@ export declare function routeMessage(input: {
78
78
  readonly chatId: string;
79
79
  readonly memberId: string;
80
80
  readonly policy: TelegramPolicy;
81
+ /** Files always start a turn, including replies to other people and captionless media. */
82
+ readonly hasAttachments?: boolean;
81
83
  readonly addressed: AddressingReason | undefined;
82
84
  readonly audience?: MessageAudience;
83
85
  readonly record?: (decision: RouteDecision) => void;
@@ -115,6 +115,7 @@ async function routeMessage(input) {
115
115
  });
116
116
  return route;
117
117
  };
118
+ if (input.hasAttachments) return complete("reply", "attachment", "Inbound attachment starts an agent turn regardless of caption or recipient.");
118
119
  if (input.addressed !== void 0 && input.addressed !== "pending-question") return complete("reply", input.addressed, `Bot addressed directly: ${input.addressed}.`);
119
120
  const elsewhere = otherRecipient(input.audience);
120
121
  if (elsewhere !== void 0) return complete("observe", "other_recipient", elsewhere);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream/agent",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
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",