@segmentstream/agent 0.3.0 → 0.4.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 (40) hide show
  1. package/README.md +18 -3
  2. package/dist/extension/_manifest.json +4 -1
  3. package/dist/extension/hooks/conversations.d.ts +2 -0
  4. package/dist/extension/hooks/conversations.mjs +10 -0
  5. package/dist/extension/instructions/conversations.d.ts +3 -0
  6. package/dist/extension/instructions/conversations.mjs +20 -0
  7. package/dist/extension/instructions.md +21 -6
  8. package/dist/extension/lib/confirmations.d.ts +26 -0
  9. package/dist/extension/lib/confirmations.mjs +96 -0
  10. package/dist/extension/lib/conversation-runtime.d.ts +19 -0
  11. package/dist/extension/lib/conversation-runtime.mjs +104 -0
  12. package/dist/extension/lib/conversations.d.ts +65 -0
  13. package/dist/extension/lib/conversations.mjs +107 -0
  14. package/dist/extension/lib/ontology-connection.d.ts +1 -0
  15. package/dist/extension/lib/ontology-connection.mjs +3 -0
  16. package/dist/extension/lib/ontology-tools.d.ts +11 -0
  17. package/dist/extension/lib/ontology-tools.mjs +41 -0
  18. package/dist/extension/lib/questions.d.ts +14 -0
  19. package/dist/extension/lib/questions.mjs +30 -0
  20. package/dist/extension/lib/telegram/adapter.d.ts +3 -1
  21. package/dist/extension/lib/telegram/adapter.mjs +63 -12
  22. package/dist/extension/lib/telegram/confirmations.d.ts +18 -0
  23. package/dist/extension/lib/telegram/confirmations.mjs +280 -0
  24. package/dist/extension/lib/telegram/conversations.d.ts +30 -0
  25. package/dist/extension/lib/telegram/conversations.mjs +287 -0
  26. package/dist/extension/lib/telegram/inbound-request.d.ts +4 -0
  27. package/dist/extension/lib/telegram/inbound-request.mjs +2 -0
  28. package/dist/extension/lib/telegram/inbound.d.ts +3 -0
  29. package/dist/extension/lib/telegram/inbound.mjs +63 -13
  30. package/dist/extension/lib/telegram/principal.mjs +1 -0
  31. package/dist/extension/lib/telegram/requests.mjs +3 -1
  32. package/dist/extension/lib/telegram/router.d.ts +1 -1
  33. package/dist/extension/lib/telegram/routing-context.mjs +2 -2
  34. package/dist/extension/lib/telegram/sessions.d.ts +4 -7
  35. package/dist/extension/tools/attachments_promote.mjs +3 -0
  36. package/dist/extension/tools/questions_ask.d.ts +12 -0
  37. package/dist/extension/tools/questions_ask.mjs +50 -0
  38. package/dist/tools/index.d.ts +1 -0
  39. package/dist/tools/index.mjs +2 -1
  40. package/package.json +1 -1
@@ -0,0 +1,287 @@
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 { conversationEvent } from "../conversations.mjs";
8
+ import { observedChatKey } from "./observed.mjs";
9
+ import { currentInboundRequest } from "./inbound-request.mjs";
10
+ import { parseTelegramUpdate } from "eve/channels/telegram";
11
+ import { randomUUID } from "node:crypto";
12
+ const captures = new WeakMap();
13
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
14
+ const id = (value) => typeof value === "number" || typeof value === "string" ? String(value) : null;
15
+ function attachmentRefs(message) {
16
+ const files = message.attachments.map((file) => ({
17
+ id: file.fileId,
18
+ kind: file.kind,
19
+ ...file.fileName === void 0 ? {} : { filename: file.fileName },
20
+ ...file.mediaType === void 0 ? {} : { contentType: file.mediaType },
21
+ ...file.size === void 0 ? {} : { size: file.size },
22
+ ...file.fileUniqueId === void 0 ? {} : { uniqueId: file.fileUniqueId }
23
+ }));
24
+ for (const kind of [
25
+ "voice",
26
+ "audio",
27
+ "video",
28
+ "video_note",
29
+ "sticker",
30
+ "animation"
31
+ ]) {
32
+ const file = record(message.raw[kind]);
33
+ const fileId = id(file.file_id);
34
+ if (fileId !== null) files.push({
35
+ id: fileId,
36
+ kind,
37
+ ...id(file.file_unique_id) ? { uniqueId: id(file.file_unique_id) } : {},
38
+ ...typeof file.file_size === "number" ? { size: file.file_size } : {},
39
+ ...typeof file.mime_type === "string" ? { contentType: file.mime_type } : {},
40
+ ...typeof file.file_name === "string" ? { filename: file.file_name } : {}
41
+ });
42
+ }
43
+ return files;
44
+ }
45
+ async function participants(message, identities, resolved) {
46
+ if (message.chat.type !== "private" || message.from === void 0 || message.from.isBot) return [];
47
+ try {
48
+ const lookup = await identities.lookup({
49
+ provider: "telegram",
50
+ externalId: message.from.id
51
+ });
52
+ resolved(lookup);
53
+ return lookup.status === "paired" ? [lookup.member.id] : [];
54
+ } catch (error) {
55
+ console.error("[segmentstream] conversation participant lookup failed", { error });
56
+ return [];
57
+ }
58
+ }
59
+ async function captureTelegramWebhook(input) {
60
+ let raw;
61
+ try {
62
+ raw = await input.request.clone().json();
63
+ } catch {
64
+ return input.next();
65
+ }
66
+ const update = record(raw);
67
+ const edited = update.edited_message ?? update.edited_channel_post;
68
+ const messagePayload = edited ?? update.channel_post ?? update.message;
69
+ const parsed = parseTelegramUpdate({ message: messagePayload });
70
+ if (parsed?.kind !== "message") return input.next();
71
+ const message = parsed.message;
72
+ const sentAt = typeof message.raw.date === "number" ? message.raw.date * 1e3 : Date.now();
73
+ const senderChat = record(message.raw.sender_chat);
74
+ let lookup;
75
+ const event = conversationEvent({
76
+ eventId: [
77
+ "telegram",
78
+ message.chat.id,
79
+ message.messageId,
80
+ edited === void 0 ? "message" : `edit:${id(update.update_id) ?? id(message.raw.edit_date)}`
81
+ ].join(":"),
82
+ provider: "telegram",
83
+ chatId: message.chat.id,
84
+ chatType: message.chat.type === "supergroup" ? "group" : message.chat.type,
85
+ threadId: message.messageThreadId === void 0 ? null : String(message.messageThreadId),
86
+ kind: "inbound",
87
+ text: message.text || message.caption || null,
88
+ messageId: message.messageId,
89
+ replyToMessageId: message.replyToMessage?.messageId ?? null,
90
+ senderId: id(senderChat.id) ?? message.from?.id ?? null,
91
+ senderName: typeof senderChat.title === "string" ? senderChat.title : message.from?.username ?? message.from?.firstName ?? null,
92
+ senderIsBot: message.from?.isBot ?? false,
93
+ participantIds: await participants(message, input.identities, (result) => {
94
+ lookup = result;
95
+ }),
96
+ attachments: attachmentRefs(message),
97
+ at: new Date(sentAt).toISOString()
98
+ });
99
+ await input.archive.append(event);
100
+ const capture = {
101
+ event,
102
+ message,
103
+ ...lookup === void 0 ? {} : { lookup }
104
+ };
105
+ const inbound = currentInboundRequest();
106
+ if (inbound !== void 0) captures.set(inbound, capture);
107
+ const chat = observedChatKey({
108
+ chatId: message.chat.id,
109
+ ...message.messageThreadId === void 0 ? {} : { messageThreadId: message.messageThreadId }
110
+ });
111
+ if (!(message.from === void 0 || message.from.isBot || message.chat.type === "channel") && edited === void 0) {
112
+ const history = await input.archive.context({
113
+ provider: "telegram",
114
+ chatId: message.chat.id,
115
+ ...event.threadId === null ? {} : { threadId: event.threadId },
116
+ limit: 100
117
+ });
118
+ for (const earlier of (input.observed.recent(chat).length === 0 ? history.events : []).filter((item) => item.kind === "inbound" && item.eventId !== event.eventId).slice(0, 30).reverse()) input.observed.add(chat, {
119
+ author: earlier.senderName ?? earlier.senderId ?? "automated post",
120
+ text: earlier.text ?? "[attachment or service message]",
121
+ at: earlier.at,
122
+ ...earlier.senderId === null ? {} : { authorId: earlier.senderId }
123
+ });
124
+ } else {
125
+ if (message.chat.type === "group" || message.chat.type === "supergroup") await input.chats?.seen({
126
+ chatId: message.chat.id,
127
+ title: message.chat.title ?? ""
128
+ });
129
+ input.observed.add(chat, {
130
+ author: event.senderName ?? event.senderId ?? "automated post",
131
+ text: event.text ?? "[attachment or service message]",
132
+ at: event.at
133
+ });
134
+ capture.decision = "silent";
135
+ capture.reason = edited !== void 0 ? "message_edit" : "automated_or_channel_message";
136
+ }
137
+ let response;
138
+ try {
139
+ response = await input.next();
140
+ } catch (error) {
141
+ try {
142
+ await input.archive.append({
143
+ ...event,
144
+ eventId: `${event.eventId}:decision`,
145
+ kind: "decision",
146
+ text: null,
147
+ decision: "silent",
148
+ reason: "inbound_handler_failed",
149
+ attachments: []
150
+ });
151
+ } catch (storageError) {
152
+ console.error("[segmentstream] recording inbound failure failed", {
153
+ storageError,
154
+ cause: error
155
+ });
156
+ }
157
+ throw error;
158
+ }
159
+ await input.archive.append({
160
+ ...event,
161
+ eventId: `${event.eventId}:decision`,
162
+ kind: "decision",
163
+ text: null,
164
+ decision: capture.decision ?? "silent",
165
+ reason: capture.reason ?? (message.raw.left_chat_member ? "member_departure" : message.raw.migrate_to_chat_id || message.raw.migrate_from_chat_id ? "chat_migration" : "unpaired_refused_or_empty_message"),
166
+ attachments: []
167
+ });
168
+ return response;
169
+ }
170
+ function recordGateDecision(reason, decision = "silent") {
171
+ const inbound = currentInboundRequest();
172
+ const capture = inbound === void 0 ? void 0 : captures.get(inbound);
173
+ if (capture !== void 0) {
174
+ capture.reason = reason;
175
+ capture.decision = decision;
176
+ }
177
+ }
178
+ function recordRouteDecision(route, addressed, policy) {
179
+ const inbound = currentInboundRequest();
180
+ const capture = inbound === void 0 ? void 0 : captures.get(inbound);
181
+ if (capture !== void 0) {
182
+ capture.decision = route === "reply" ? "reply" : "silent";
183
+ capture.reason ??= typeof addressed === "string" ? addressed : addressed ? "addressed_message" : policy === "mention" ? "mention_policy" : `decision_model_${route}`;
184
+ }
185
+ }
186
+ function captureRouteDecider(decide) {
187
+ return async (question) => {
188
+ try {
189
+ return await decide(question);
190
+ } catch (error) {
191
+ const inbound = currentInboundRequest();
192
+ const capture = inbound === void 0 ? void 0 : captures.get(inbound);
193
+ if (capture !== void 0) capture.reason = error instanceof Error && error.name === "TimeoutError" ? "decision_model_timeout" : "decision_model_failed";
194
+ throw error;
195
+ }
196
+ };
197
+ }
198
+ function captureTelegramFetch(fetchImpl, archive, identities) {
199
+ return async (input, init) => {
200
+ if (!(typeof input === "string" ? input : input instanceof URL ? input.href : input.url).endsWith("/sendMessage") || typeof init?.body !== "string") return fetchImpl(input, init);
201
+ const body = record(JSON.parse(init.body));
202
+ const chatId = id(body.chat_id);
203
+ if (chatId === null) return fetchImpl(input, init);
204
+ const inbound = currentInboundRequest();
205
+ const capture = inbound === void 0 ? void 0 : captures.get(inbound);
206
+ let participantIds = capture?.event.participantIds ?? [];
207
+ const chatType = capture?.event.chatType ?? (chatId.startsWith("-") ? "group" : "private");
208
+ if (chatType === "private" && participantIds.length === 0 && capture === void 0) try {
209
+ const lookup = await identities().lookup({
210
+ provider: "telegram",
211
+ externalId: chatId
212
+ });
213
+ if (lookup.status === "paired") participantIds = [lookup.member.id];
214
+ } catch (error) {
215
+ console.error("[segmentstream] outbound participant lookup failed", { error });
216
+ }
217
+ const event = conversationEvent({
218
+ eventId: `telegram:send:${randomUUID()}`,
219
+ provider: "telegram",
220
+ chatId,
221
+ chatType,
222
+ kind: "outbound",
223
+ text: typeof body.text === "string" ? body.text : null,
224
+ senderIsBot: true,
225
+ senderId: "agent",
226
+ senderName: "agent",
227
+ participantIds,
228
+ threadId: id(body.message_thread_id),
229
+ replyToMessageId: id(body.reply_to_message_id) ?? id(record(body.reply_parameters).message_id),
230
+ status: "requested"
231
+ });
232
+ await archive.append(event);
233
+ let response;
234
+ try {
235
+ response = await fetchImpl(input, init);
236
+ } catch (error) {
237
+ try {
238
+ await archive.append({
239
+ ...event,
240
+ eventId: `${event.eventId}:result`,
241
+ status: "unknown"
242
+ });
243
+ } catch (storageError) {
244
+ console.error("[segmentstream] recording uncertain delivery failed", {
245
+ storageError,
246
+ cause: error
247
+ });
248
+ }
249
+ throw error;
250
+ }
251
+ const result = record(await response.clone().json());
252
+ await archive.append({
253
+ ...event,
254
+ eventId: `${event.eventId}:result`,
255
+ status: response.ok && result.ok === true ? "completed" : "failed",
256
+ messageId: id(record(result.result).message_id)
257
+ });
258
+ return response;
259
+ };
260
+ }
261
+ function conversationIdentities(identities) {
262
+ return {
263
+ ...identities,
264
+ lookup: async (account) => {
265
+ const inbound = currentInboundRequest();
266
+ const capture = inbound === void 0 ? void 0 : captures.get(inbound);
267
+ return account.provider === "telegram" && capture?.message.from?.id === account.externalId && capture.lookup !== void 0 ? capture.lookup : identities.lookup(account);
268
+ }
269
+ };
270
+ }
271
+ function telegramConversationCapture(input) {
272
+ return async (request, next) => {
273
+ if (!input.verify(request)) return next();
274
+ try {
275
+ return await captureTelegramWebhook({
276
+ ...input,
277
+ request,
278
+ identities: input.identities(),
279
+ next
280
+ });
281
+ } catch (error) {
282
+ console.error("[segmentstream] conversation capture failed", { error });
283
+ return new Response("conversation storage unavailable", { status: 503 });
284
+ }
285
+ };
286
+ }
287
+ export { captureRouteDecider, captureTelegramFetch, captureTelegramWebhook, conversationIdentities, recordGateDecision, recordRouteDecision, telegramConversationCapture };
@@ -1,4 +1,5 @@
1
1
  import type { ChannelSource } from "eve/channels";
2
+ import type { SessionAuthContext } from "eve/context";
2
3
  import type { Attachment } from "../attachments.js";
3
4
  import type { SessionOperations } from "./sessions.js";
4
5
  /** What became of one Telegram file of the message: stored as an attachment, or why not. */
@@ -32,6 +33,9 @@ export declare class InboundRequest {
32
33
  sessions: SessionOperations | undefined;
33
34
  /** Keeps work the request started, such as a chat report, alive after it answers. */
34
35
  waitUntil: ((task: Promise<unknown>) => void) | undefined;
36
+ /** Continues the exact pending question's session, including under thread scope. */
37
+ address: string | undefined;
38
+ respond: ((address: string, requestId: string, decision: "approve" | "cancel", principal: SessionAuthContext) => Promise<unknown>) | undefined;
35
39
  constructor(
36
40
  /** The agent's description from the compiled manifest, for the routing decision. */
37
41
  agentDescription: () => Promise<string>);
@@ -11,6 +11,8 @@ var InboundRequest = class {
11
11
  files;
12
12
  sessions;
13
13
  waitUntil;
14
+ address;
15
+ respond;
14
16
  constructor(agentDescription) {
15
17
  this.agentDescription = agentDescription;
16
18
  }
@@ -38,11 +38,14 @@ export interface InboundDependencies {
38
38
  readonly identities: AgentIdentities;
39
39
  readonly bot: BotIdentityLookup;
40
40
  readonly decide: RouteDecider;
41
+ /** Full session history for open questions, which can survive compaction. */
42
+ readonly history?: HistoryReader;
41
43
  readonly transcribe: Transcriber;
42
44
  readonly download: (fileId: string) => Promise<Uint8Array>;
43
45
  /** The attachment store; without it, files reach the turn as eve's Telegram file parts. */
44
46
  readonly attachments?: AgentAttachments;
45
47
  readonly observed: ObservedMessages;
48
+ /** Recent session events for smart routing. */
46
49
  readonly readHistory: HistoryReader;
47
50
  readonly chats: AgentChats;
48
51
  /** The app's `/agent/connect` link for a pairing code. */
@@ -5,12 +5,15 @@ const __filename = __eveFileURLToPath(import.meta.url);
5
5
  __eveDirname(__filename);
6
6
  __eveCreateRequire(import.meta.url);
7
7
  import { telegramPrincipal } from "./principal.mjs";
8
+ import { observedChatKey, observedContext } from "./observed.mjs";
9
+ import { recordGateDecision, recordRouteDecision } from "./conversations.mjs";
10
+ import { answerMarker } from "../questions.mjs";
11
+ import { formatAddress, messageAddress, scopedAddress } from "./sessions.mjs";
12
+ import { questionAddress, questionFromHistory } from "./confirmations.mjs";
8
13
  import { chatContext } from "./chats.mjs";
9
14
  import { attachmentsContext, stageFiles } from "./files.mjs";
10
- import { observedChatKey, observedContext } from "./observed.mjs";
11
15
  import { addressingReason, routeMessage } from "./router.mjs";
12
16
  import { routingContext } from "./routing-context.mjs";
13
- import { messageAddress, scopedAddress } from "./sessions.mjs";
14
17
  import { voiceNote, voiceOf, voiceText } from "./voice.mjs";
15
18
  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.";
16
19
  const UNKNOWN_IN_GROUP = "I don't know you yet. Message me privately to connect your account.";
@@ -141,6 +144,7 @@ async function handleInboundMessage(input) {
141
144
  const left = message.raw.left_chat_member;
142
145
  const migration = isGroupChat(message) ? migrationOf(message) : void 0;
143
146
  if (isGroupChat(message) && isRecord(left)) {
147
+ recordGateDecision("member_departure");
144
148
  await reportDeparture({
145
149
  context,
146
150
  message,
@@ -150,6 +154,7 @@ async function handleInboundMessage(input) {
150
154
  return null;
151
155
  }
152
156
  if (migration !== void 0) {
157
+ recordGateDecision("chat_migration");
153
158
  await dependencies.chats.migrated({
154
159
  ...migration,
155
160
  title: message.chat.title ?? ""
@@ -175,6 +180,7 @@ async function handleInboundMessage(input) {
175
180
  }) : void 0;
176
181
  if (report !== void 0) input.request?.waitUntil?.(report);
177
182
  if (lookup.status !== "paired") {
183
+ recordGateDecision(`identity_${lookup.status}`, addressed !== void 0 ? "reply" : "silent");
178
184
  if (addressed !== void 0) await answerUnpaired({
179
185
  context,
180
186
  message: sent,
@@ -184,13 +190,32 @@ async function handleInboundMessage(input) {
184
190
  });
185
191
  return null;
186
192
  }
187
- const address = scopedAddress(messageAddress(sent), dependencies.sessions().scope);
193
+ let address = scopedAddress(messageAddress(sent), dependencies.sessions().scope);
188
194
  const operations = input.request?.sessions;
195
+ const topicAddress = formatAddress({
196
+ chatId: sent.chat.id,
197
+ messageThreadId: String(sent.messageThreadId ?? ""),
198
+ conversationId: ""
199
+ });
200
+ const questionOwner = {
201
+ memberId: lookup.member.id,
202
+ chatId: sent.chat.id,
203
+ messageThreadId: String(sent.messageThreadId ?? "")
204
+ };
205
+ const memberQuestionAddress = questionAddress(questionOwner);
206
+ const questionSession = await operations?.resolveSession(memberQuestionAddress);
207
+ const owner = questionSession ?? await operations?.resolveSession(address) ?? await operations?.resolveSession(topicAddress);
208
+ const pending = owner === void 0 || dependencies.history === void 0 ? void 0 : questionFromHistory(await dependencies.history(owner.id), questionOwner);
209
+ let answering = pending !== void 0 && pending.memberId === lookup.member.id && pending.chatId === sent.chat.id && pending.messageThreadId === String(sent.messageThreadId ?? "");
210
+ if (answering && pending !== void 0 && input.request !== void 0) {
211
+ address = pending.address || (questionSession === void 0 ? topicAddress : memberQuestionAddress);
212
+ input.request.address = address;
213
+ }
189
214
  const voice = voiceOf(sent.raw);
190
215
  const caption = sent.text || sent.caption;
191
216
  const notes = [];
192
217
  let text = caption;
193
- if (voice !== void 0 && (addressed !== void 0 || dependencies.policy === "smart")) {
218
+ if (voice !== void 0 && (addressed !== void 0 || answering || dependencies.policy === "smart")) {
194
219
  const spoken = await voiceText({
195
220
  voice,
196
221
  model: dependencies.sttModel,
@@ -200,16 +225,19 @@ async function handleInboundMessage(input) {
200
225
  text = [caption, spoken.text].filter((part) => part.trim() !== "").join("\n\n");
201
226
  notes.push(spoken.note);
202
227
  } else if (voice !== void 0) text = caption || voiceNote(voice);
203
- if (text.trim() === "" && sent.attachments.length === 0) return null;
228
+ if (text.trim() === "" && sent.attachments.length === 0) {
229
+ recordGateDecision("empty_message");
230
+ return null;
231
+ }
204
232
  const chat = observedChatKey({
205
233
  chatId: sent.chat.id,
206
234
  ...sent.messageThreadId === void 0 ? {} : { messageThreadId: sent.messageThreadId }
207
235
  });
208
- if (await routeMessage({
236
+ const route = await routeMessage({
209
237
  chatId: sent.chat.id,
210
238
  memberId: lookup.member.id,
211
239
  policy: dependencies.policy,
212
- addressed,
240
+ addressed: addressed ?? (answering ? "pending-question" : void 0),
213
241
  decide: dependencies.decide,
214
242
  question: async () => {
215
243
  const recent = await routingContext({
@@ -225,19 +253,34 @@ async function handleInboundMessage(input) {
225
253
  message: observedMessage(sent, text, lookup.member)
226
254
  };
227
255
  }
228
- }) === "observe") {
256
+ });
257
+ recordRouteDecision(route, addressed ?? (answering ? "pending-question" : void 0), dependencies.policy);
258
+ if (route === "observe") {
229
259
  dependencies.observed.add(chat, observedMessage(sent, text === "" ? "[attachment]" : text, lookup.member));
230
260
  return null;
231
261
  }
232
262
  if (voice !== void 0 && input.request !== void 0) input.request.text = text;
233
263
  else if (voice !== void 0) notes.push(text);
264
+ if (answering && pending !== void 0 && input.request !== void 0) {
265
+ input.request.text = `${answerMarker(pending.id)}\n${text}`;
266
+ notes.push(`<pending_question>\nTask: ${pending.task}\nQuestion: ${pending.question}\nUse this member's answer to continue the pending task with their current permissions.
267
+ </pending_question>`);
268
+ }
234
269
  if (operations !== void 0) {
235
270
  const newest = dependencies.observed.list(chat).at(-1);
236
271
  if (await dependencies.sessionPolicy.apply({
237
272
  address,
238
273
  operations,
239
274
  ...newest === void 0 ? {} : { activeAt: Date.parse(newest.at) || 0 }
240
- }) === "cleared") dependencies.observed.clear(chat);
275
+ }) === "cleared") {
276
+ dependencies.observed.clear(chat);
277
+ if (answering && input.request !== void 0) {
278
+ answering = false;
279
+ input.request.text = text;
280
+ const questionNote = notes.findIndex((note) => note.startsWith("<pending_question>"));
281
+ if (questionNote !== -1) notes.splice(questionNote, 1);
282
+ }
283
+ }
241
284
  dependencies.sessionPolicy.delivered(address);
242
285
  }
243
286
  await context.telegram.startTyping();
@@ -250,11 +293,18 @@ async function handleInboundMessage(input) {
250
293
  if (input.request !== void 0 && files.size > 0) input.request.files = files;
251
294
  await report;
252
295
  dependencies.observed.remember(chat, observedMessage(sent, text, lookup.member));
296
+ const principal = telegramPrincipal({
297
+ message: sent,
298
+ member: lookup.member
299
+ });
253
300
  return {
254
- auth: telegramPrincipal({
255
- message: sent,
256
- member: lookup.member
257
- }),
301
+ auth: answering && pending !== void 0 ? {
302
+ ...principal,
303
+ attributes: {
304
+ ...principal.attributes,
305
+ pendingQuestionId: pending.id
306
+ }
307
+ } : principal,
258
308
  context: [
259
309
  ...observedContext(dependencies.observed.take(chat)),
260
310
  ...group ? chatContext(dependencies.chats.chat(sent.chat.id)) : [],
@@ -18,6 +18,7 @@ function telegramPrincipal(input) {
18
18
  chatType: message.chat.type
19
19
  };
20
20
  if (member.name !== null) attributes.memberName = member.name;
21
+ if (message.messageThreadId !== void 0) attributes.messageThreadId = String(message.messageThreadId);
21
22
  if (message.from.username !== void 0) attributes.username = message.from.username;
22
23
  return {
23
24
  authenticator: TELEGRAM_AUTHENTICATOR,
@@ -87,7 +87,9 @@ function requestContext(request) {
87
87
  const lines = [
88
88
  "<agent_request>",
89
89
  `A request from ${requester.name} (${requester.kind}, user_id ${requester.id}) arrived through the ontology action agent_chat.send_message; the message is the request.`,
90
- `Write in this chat${to}, ask what the request needs, and handle the replies here.`
90
+ "This turn already runs in the target chat. Your reply here is the message delivered to this chat. Do not look for or call agent_chat.send_message to deliver it, even if the ontology catalog is empty or that action is unavailable.",
91
+ `Write in this chat${to}, carry out the clear request, ask only for missing or ambiguous information, and handle the replies here.`,
92
+ "If the addressed person has a linked Telegram username, mention them with @username; otherwise address them by name without a tag. Never invent a username."
91
93
  ];
92
94
  if (requester.kind === "service" || !requester.actAs) lines.push(`${requester.kind === "service" ? "The requester is a service, not a member" : "The requester has not paired a Telegram account with the agent"}, so this turn runs without a member and the ontology is not available in it. Each member who replies acts with their own permissions.`);
93
95
  lines.push("</agent_request>");
@@ -8,7 +8,7 @@ import type { ObservedMessage, RecentMessage } 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
+ export type AddressingReason = "private" | "mention" | "reply-to-bot" | "command" | "pending-question";
12
12
  /** The bot, as Telegram's `getMe` describes it. Unknown when `getMe` failed. */
13
13
  export interface BotIdentity {
14
14
  readonly id: string;
@@ -23,8 +23,8 @@ async function routingContext(input) {
23
23
  resetAt = Date.parse(event.meta.at);
24
24
  }
25
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({
26
+ const data = typeof event.data === "object" && event.data !== null ? event.data : void 0;
27
+ if (event.type === "message.completed" && data !== void 0 && typeof data.message === "string" && typeof data.finishReason === "string" && data.finishReason !== "tool-calls" && data.message.trim() !== "" && !isSilentReply(data.message) && age >= 0 && age <= 18e5) recovered.push(agentMessage({
28
28
  text: data.message,
29
29
  at: event.meta.at
30
30
  }));
@@ -37,19 +37,16 @@ export interface SessionHistory {
37
37
  /** Turns completed since `resetAt`; a compaction is worth nothing before the first. */
38
38
  readonly turnsSinceReset: number;
39
39
  }
40
- /** One recorded event, as much of eve's envelope as the policy reads. */
40
+ /** One recorded event used by session policy, routing context and interaction projections. */
41
41
  export interface RecordedEvent {
42
42
  readonly type: string;
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
+ /** Consumers validate the payload for the event type they project. */
47
+ readonly data?: unknown;
51
48
  }
52
- /** The last events of a session, oldest first. Production reads eve's durable stream. */
49
+ /** Session events, oldest first; readers choose a tail or full stream for their consumers. */
53
50
  export type HistoryReader = (sessionId: string) => Promise<ReadonlyArray<RecordedEvent>>;
54
51
  /** How many events of the tail the reader fetches: enough for a day of a busy group. */
55
52
  export declare const HISTORY_TAIL = 400;
@@ -4,9 +4,12 @@ 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 { actionApproval } from "../lib/confirmations.mjs";
8
+ import { actionToolName, ontologyToolLabels } from "../lib/ontology-tools.mjs";
7
9
  import { attachmentForModel, attachmentOwner, optionalObject, optionalString, stringArgument, toolAttachments } from "../lib/attachment-tools.mjs";
8
10
  import { defineTool } from "eve/tools";
9
11
  var attachments_promote_default = defineTool({
12
+ approval: actionApproval({ labels: (memberId, _tool, input) => ontologyToolLabels()(memberId, actionToolName(stringArgument(input, "entity"), stringArgument(input, "action"))) }),
10
13
  description: "Keep a file the member sent by storing it in a record through an ontology action that takes a file, such as document.upload. Give the entity, the action and the action's input without the file field: the platform fills it with the attachment, and fills sha256 when the action takes one. Runs with the member's permissions. The same file promoted again through the same action returns the earlier result instead of a duplicate.",
11
14
  inputSchema: {
12
15
  type: "object",
@@ -0,0 +1,12 @@
1
+ declare const _default: import("eve/tools").ToolDefinition<Record<string, unknown>, {
2
+ pendingQuestion: {
3
+ id: string;
4
+ question: string;
5
+ task: string;
6
+ memberId: string;
7
+ chatId: string;
8
+ messageThreadId: string;
9
+ address: string;
10
+ };
11
+ }>;
12
+ export default _default;
@@ -0,0 +1,50 @@
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 "../lib/telegram/principal.mjs";
8
+ import { pendingQuestions, questionKey } from "../lib/questions.mjs";
9
+ import { stringArgument } from "../lib/attachment-tools.mjs";
10
+ import { defineTool } from "eve/tools";
11
+ var questions_ask_default = defineTool({
12
+ availableInSubagents: false,
13
+ description: "Ask for missing or ambiguous information needed to continue the member's task. Saves the task and question in the session, sends the question, and waits for their answer. Never use this for approval: destructive or irreversible actions have Confirm / Cancel buttons.",
14
+ inputSchema: {
15
+ type: "object",
16
+ properties: {
17
+ question: {
18
+ type: "string",
19
+ description: "The one question to send to the member."
20
+ },
21
+ task: {
22
+ type: "string",
23
+ description: "The pending task, including known targets and supplied values."
24
+ }
25
+ },
26
+ required: ["question", "task"],
27
+ additionalProperties: false
28
+ },
29
+ endsTurn: true,
30
+ execute(input, ctx) {
31
+ const memberId = memberIdOf(ctx.session.auth.current);
32
+ const chatId = ctx.session.auth.current?.attributes.chatId;
33
+ if (memberId === void 0 || typeof chatId !== "string") throw new Error("A member in a chat is required.");
34
+ const question = {
35
+ id: ctx.callId,
36
+ question: stringArgument(input, "question"),
37
+ task: stringArgument(input, "task"),
38
+ memberId,
39
+ chatId,
40
+ messageThreadId: String(ctx.session.auth.current?.attributes.messageThreadId ?? ""),
41
+ address: ""
42
+ };
43
+ pendingQuestions.update((all) => ({
44
+ ...all,
45
+ [questionKey(question)]: question
46
+ }));
47
+ return { pendingQuestion: question };
48
+ }
49
+ });
50
+ export { questions_ask_default as default };
@@ -5,3 +5,4 @@ export { default as attachments_list } from "../extension/tools/attachments_list
5
5
  export { default as attachments_promote } from "../extension/tools/attachments_promote.js";
6
6
  export { default as attachments_read } from "../extension/tools/attachments_read.js";
7
7
  export { default as attachments_table } from "../extension/tools/attachments_table.js";
8
+ export { default as questions_ask } from "../extension/tools/questions_ask.js";
@@ -9,4 +9,5 @@ import attachments_list_default from "../extension/tools/attachments_list.mjs";
9
9
  import attachments_promote_default from "../extension/tools/attachments_promote.mjs";
10
10
  import attachments_read_default from "../extension/tools/attachments_read.mjs";
11
11
  import attachments_table_default from "../extension/tools/attachments_table.mjs";
12
- export { attachments_discard_default as attachments_discard, attachments_list_default as attachments_list, attachments_promote_default as attachments_promote, attachments_read_default as attachments_read, attachments_table_default as attachments_table };
12
+ import questions_ask_default from "../extension/tools/questions_ask.mjs";
13
+ export { attachments_discard_default as attachments_discard, attachments_list_default as attachments_list, attachments_promote_default as attachments_promote, attachments_read_default as attachments_read, attachments_table_default as attachments_table, questions_ask_default as questions_ask };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream/agent",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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",