@segmentstream/agent 0.2.1 → 0.3.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.
Files changed (41) hide show
  1. package/README.md +8 -2
  2. package/dist/extension/_manifest.json +2 -1
  3. package/dist/extension/instructions.md +29 -7
  4. package/dist/extension/lib/attachment-formats.d.ts +16 -0
  5. package/dist/extension/lib/attachment-formats.mjs +238 -0
  6. package/dist/extension/lib/attachment-query.d.ts +31 -0
  7. package/dist/extension/lib/attachment-query.mjs +125 -0
  8. package/dist/extension/lib/attachment-tables.d.ts +33 -0
  9. package/dist/extension/lib/attachment-tables.mjs +186 -0
  10. package/dist/extension/lib/attachment-tools.d.ts +34 -3
  11. package/dist/extension/lib/attachment-tools.mjs +139 -30
  12. package/dist/extension/lib/confirmations.d.ts +26 -0
  13. package/dist/extension/lib/confirmations.mjs +96 -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 +1 -1
  21. package/dist/extension/lib/telegram/adapter.mjs +31 -4
  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/inbound-request.d.ts +4 -0
  25. package/dist/extension/lib/telegram/inbound-request.mjs +2 -0
  26. package/dist/extension/lib/telegram/inbound.d.ts +3 -0
  27. package/dist/extension/lib/telegram/inbound.mjs +50 -9
  28. package/dist/extension/lib/telegram/principal.mjs +1 -0
  29. package/dist/extension/lib/telegram/requests.mjs +3 -1
  30. package/dist/extension/lib/telegram/router.d.ts +1 -1
  31. package/dist/extension/lib/telegram/routing-context.mjs +2 -2
  32. package/dist/extension/lib/telegram/sessions.d.ts +4 -7
  33. package/dist/extension/tools/attachments_promote.mjs +3 -0
  34. package/dist/extension/tools/attachments_read.mjs +47 -5
  35. package/dist/extension/tools/attachments_table.d.ts +64 -0
  36. package/dist/extension/tools/attachments_table.mjs +198 -0
  37. package/dist/extension/tools/questions_ask.d.ts +12 -0
  38. package/dist/extension/tools/questions_ask.mjs +50 -0
  39. package/dist/tools/index.d.ts +2 -0
  40. package/dist/tools/index.mjs +3 -1
  41. package/package.json +9 -2
@@ -0,0 +1,30 @@
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 { defineState } from "eve/context";
8
+ const pendingQuestions = defineState("segmentstream.pending-questions", () => ({}));
9
+ function questionKey(question) {
10
+ return JSON.stringify([
11
+ question.memberId,
12
+ question.chatId,
13
+ question.messageThreadId
14
+ ]);
15
+ }
16
+ function isPendingQuestion(value) {
17
+ return typeof value === "object" && value !== null && [
18
+ "id",
19
+ "question",
20
+ "task",
21
+ "memberId",
22
+ "chatId",
23
+ "messageThreadId",
24
+ "address"
25
+ ].every((key) => typeof value[key] === "string");
26
+ }
27
+ function answerMarker(id) {
28
+ return `[Answer to pending question ${id}]`;
29
+ }
30
+ export { answerMarker, isPendingQuestion, pendingQuestions, questionKey };
@@ -31,7 +31,7 @@ export interface TelegramAdapterOptions {
31
31
  readonly observed?: ObservedMessages;
32
32
  /** Verifies callers of the messages route; tests pass one on a local key set. */
33
33
  readonly verify?: RuntimeTokenVerifier;
34
- /** Reads the tail of a session's durable stream; tests pass a fake. */
34
+ /** Reads session events for routing, policy and interactions; tests pass a fake. */
35
35
  readonly history?: HistoryReader;
36
36
  /** The attachment store; tests pass a fake. */
37
37
  readonly attachments?: AgentAttachments;
@@ -11,14 +11,15 @@ import { agentAttachments, parseAttachmentUrl } from "../attachments.mjs";
11
11
  import { agentIdentities } from "../identities.mjs";
12
12
  import { runtimeTokenVerifier } from "../runtime-identity.mjs";
13
13
  import { botIdentityCache } from "./bot.mjs";
14
+ import { InboundRequest, currentInboundRequest, runInboundRequest, withReplacedText } from "./inbound-request.mjs";
15
+ import { scopedAddress, sessionPolicy } from "./sessions.mjs";
16
+ import { confirmationEvents, handleConfirmationCallback } from "./confirmations.mjs";
14
17
  import { agentChats } from "./chats.mjs";
15
18
  import { observedMessages } from "./observed.mjs";
16
19
  import { decisionRouter } from "./router.mjs";
17
20
  import { replyDelivery } from "./silent.mjs";
18
- import { scopedAddress, sessionPolicy } from "./sessions.mjs";
19
21
  import { downloadTelegramFile, gatewayTranscriber } from "./voice.mjs";
20
22
  import { handleInboundMessage } from "./inbound.mjs";
21
- import { InboundRequest, currentInboundRequest, runInboundRequest, withReplacedText } from "./inbound-request.mjs";
22
23
  import { TELEGRAM_PLACEHOLDER_TOKEN, TELEGRAM_PROXY_ORIGIN, telegramProxyFetch } from "./proxy-fetch.mjs";
23
24
  import { messagesRoute } from "./requests.mjs";
24
25
  import { withTurnErrorLogging } from "./turn-errors.mjs";
@@ -40,11 +41,22 @@ const eveHistory = async (sessionId) => {
40
41
  });
41
42
  return events;
42
43
  };
44
+ const eveInteractions = async (sessionId) => {
45
+ const { sessions } = await import("eve/server");
46
+ const events = [];
47
+ for await (const event of sessions.attach(sessionId).stream({ follow: false })) events.push({
48
+ type: event.type,
49
+ meta: { at: event.meta.at },
50
+ data: event.data
51
+ });
52
+ return events;
53
+ };
43
54
  function segmentstreamTelegram(options) {
44
55
  const environment = options.environment ?? process.env;
45
56
  const token = options.token ?? runtimeToken;
46
57
  const observed = options.observed ?? observedMessages();
47
58
  const readHistory = options.history ?? eveHistory;
59
+ const readInteractions = options.history ?? eveInteractions;
48
60
  const policy = sessionPolicy({
49
61
  settings: options.sessions,
50
62
  read: readHistory
@@ -124,11 +136,22 @@ function segmentstreamTelegram(options) {
124
136
  attachments: attachments(),
125
137
  observed,
126
138
  readHistory,
139
+ history: readInteractions,
127
140
  chats,
128
141
  connectUrl: (code) => `${appOrigin(environment)}/agent/connect?code=${encodeURIComponent(code)}`
129
142
  }
130
143
  }),
131
- events: { "message.completed": replyDelivery(observed) }
144
+ onCallbackQuery: (context, query) => handleConfirmationCallback({
145
+ context,
146
+ query,
147
+ identities: pairing(),
148
+ read: readInteractions,
149
+ scope: options.sessions().scope
150
+ }),
151
+ events: {
152
+ ...confirmationEvents,
153
+ "message.completed": replyDelivery(observed)
154
+ }
132
155
  });
133
156
  const adapter = channel.adapter;
134
157
  const fetchTelegramFile = adapter?.fetchFile;
@@ -188,9 +211,13 @@ function gatedRoute(input) {
188
211
  from: args.from
189
212
  };
190
213
  inbound.waitUntil = args.waitUntil;
214
+ inbound.respond = (address, requestId, decision, auth) => args.from(scoped(address)).respond([{
215
+ requestId,
216
+ optionId: decision
217
+ }], { auth });
191
218
  return runInboundRequest(inbound, () => route.handler(request, {
192
219
  ...args,
193
- from: (address) => withReplacedText(args.from(scoped(address)), inbound)
220
+ from: (address) => withReplacedText(args.from(inbound.address ?? scoped(address)), inbound)
194
221
  }));
195
222
  }
196
223
  };
@@ -0,0 +1,18 @@
1
+ import type { ChannelEvents } from "eve/channels";
2
+ import type { TelegramCallbackQuery, TelegramChannelEvents, TelegramContext, TelegramEventContext } from "eve/channels/telegram";
3
+ import type { AgentIdentities } from "../identities.js";
4
+ import { type PendingQuestion } from "../questions.js";
5
+ import { type HistoryReader, type RecordedEvent } from "./sessions.js";
6
+ export declare function confirmationCallback(requestId: string, memberId: string, decision: "approve" | "cancel"): string;
7
+ export declare function questionFromHistory(events: readonly RecordedEvent[], owner?: Pick<PendingQuestion, "memberId" | "chatId" | "messageThreadId">): PendingQuestion | undefined;
8
+ export declare function questionAddress(owner: Pick<PendingQuestion, "memberId" | "chatId" | "messageThreadId">): string;
9
+ /** Custom callback data avoids Eve's default, unpaired Telegram callback principal. */
10
+ export declare function handleConfirmationCallback(input: {
11
+ readonly context: TelegramContext;
12
+ readonly query: TelegramCallbackQuery;
13
+ readonly identities: AgentIdentities;
14
+ readonly read: HistoryReader;
15
+ readonly scope: "chat" | "thread";
16
+ readonly now?: number;
17
+ }): Promise<void>;
18
+ export declare const confirmationEvents: TelegramChannelEvents & Pick<ChannelEvents<TelegramEventContext>, "context.cleared">;
@@ -0,0 +1,280 @@
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, telegramPrincipal } from "./principal.mjs";
8
+ import { confirmations } from "../confirmations.mjs";
9
+ import { answerMarker, isPendingQuestion, pendingQuestions, questionKey } from "../questions.mjs";
10
+ import { currentInboundRequest } from "./inbound-request.mjs";
11
+ import { formatAddress, scopedAddress } from "./sessions.mjs";
12
+ import { registerTelegramFreeformPrompt, renderTelegramInputRequest } from "eve/channels/telegram";
13
+ import { createHash } from "node:crypto";
14
+ const PREFIX = "ss-confirm:";
15
+ function shortHash(value) {
16
+ return createHash("sha256").update(value).digest("hex").slice(0, 20);
17
+ }
18
+ function confirmationCallback(requestId, memberId, decision) {
19
+ return `${PREFIX}${shortHash(requestId)}:${shortHash(memberId)}:${decision === "approve" ? "y" : "n"}`;
20
+ }
21
+ function object(value) {
22
+ return typeof value === "object" && value !== null ? value : {};
23
+ }
24
+ function openConfirmations(events) {
25
+ const open = new Map();
26
+ for (const event of events) {
27
+ const data = object(event.data);
28
+ if (event.type === "context.cleared") open.clear();
29
+ if (event.type === "input.requested" && Array.isArray(data.requests)) for (const item of data.requests) {
30
+ const request = object(item);
31
+ const action = object(request.action);
32
+ if (request.kind === "tool-approval" && typeof request.requestId === "string" && typeof action.callId === "string") open.set(request.requestId, {
33
+ requestId: request.requestId,
34
+ callId: action.callId,
35
+ at: Date.parse(event.meta.at)
36
+ });
37
+ }
38
+ if (event.type === "input.resolved" && Array.isArray(data.resolutions)) for (const item of data.resolutions) {
39
+ const id = object(item).requestId;
40
+ if (typeof id === "string") open.delete(id);
41
+ }
42
+ }
43
+ return [...open.values()];
44
+ }
45
+ function questionFromHistory(events, owner) {
46
+ const pending = new Map();
47
+ for (const event of events) {
48
+ const data = object(event.data);
49
+ if (event.type === "context.cleared") pending.clear();
50
+ if (event.type === "action.result") {
51
+ const result = object(data.result);
52
+ const candidate = object(result.output).pendingQuestion;
53
+ if (typeof result.toolName === "string" && result.toolName.endsWith("__questions_ask") && isPendingQuestion(candidate)) pending.set(questionKey(candidate), candidate);
54
+ }
55
+ if (event.type === "message.received" && typeof data.message === "string" && data.message.startsWith("[Answer to pending question ")) {
56
+ for (const [key, question] of pending) if (data.message.startsWith(answerMarker(question.id))) pending.delete(key);
57
+ }
58
+ }
59
+ return owner === void 0 ? [...pending.values()].at(-1) : pending.get(questionKey(owner));
60
+ }
61
+ function questionAddress(owner) {
62
+ return formatAddress({
63
+ ...owner,
64
+ conversationId: `question-${shortHash(owner.memberId)}`
65
+ });
66
+ }
67
+ async function handleConfirmationCallback(input) {
68
+ const { context, query } = input;
69
+ const notice = (text) => context.telegram.answerCallbackQuery({
70
+ callbackQueryId: query.id,
71
+ text
72
+ });
73
+ if (!query.data?.startsWith(PREFIX) || query.message === void 0 || query.from.isBot) {
74
+ await notice("Unsupported action.");
75
+ return;
76
+ }
77
+ const [requestHash, memberHash, choice] = query.data.slice(11).split(":");
78
+ const lookup = await input.identities.lookup({
79
+ provider: "telegram",
80
+ externalId: query.from.id
81
+ });
82
+ if (lookup.status !== "paired" || shortHash(lookup.member.id) !== memberHash) {
83
+ await notice("Only the requesting member can confirm or cancel this action.");
84
+ return;
85
+ }
86
+ const address = scopedAddress(formatAddress({
87
+ chatId: query.message.chat.id,
88
+ messageThreadId: String(query.message.messageThreadId ?? ""),
89
+ conversationId: query.message.chat.type === "private" ? "" : query.message.messageId
90
+ }), input.scope);
91
+ const session = await (currentInboundRequest()?.sessions)?.resolveSession(address);
92
+ const prompt = session === void 0 ? void 0 : openConfirmations(await input.read(session.id)).find((candidate) => shortHash(candidate.requestId) === requestHash);
93
+ if (prompt === void 0 || choice !== "y" && choice !== "n") {
94
+ await notice("This confirmation is no longer pending.");
95
+ return;
96
+ }
97
+ const expired = !Number.isFinite(prompt.at) || prompt.at + 6e5 <= (input.now ?? Date.now());
98
+ const decision = expired || choice === "n" ? "cancel" : "approve";
99
+ const principal = telegramPrincipal({
100
+ member: lookup.member,
101
+ message: {
102
+ ...query.message,
103
+ from: query.from,
104
+ text: "",
105
+ caption: "",
106
+ raw: {},
107
+ attachments: []
108
+ }
109
+ });
110
+ const source = currentInboundRequest()?.respond;
111
+ if (source === void 0) throw new Error("Confirmation callback has no session continuation.");
112
+ await source(address, prompt.requestId, decision, {
113
+ ...principal,
114
+ attributes: {
115
+ ...principal.attributes,
116
+ confirmationRequestId: prompt.requestId,
117
+ confirmationDecision: decision
118
+ }
119
+ });
120
+ await notice(expired ? "This confirmation expired; nothing executed." : decision === "cancel" ? "Cancellation received." : "Confirmation received.");
121
+ }
122
+ const confirmationEvents = {
123
+ "context.cleared"(_data, channel) {
124
+ confirmations.update(() => ({}));
125
+ pendingQuestions.update(() => ({}));
126
+ const state = channel.state;
127
+ state.confirmationMessages = {};
128
+ state.pendingQuestions = {};
129
+ },
130
+ async "input.requested"(data, channel, ctx) {
131
+ for (const request of data.requests) {
132
+ if (request.kind !== "tool-approval") {
133
+ const rendered = renderTelegramInputRequest(request, channel.state);
134
+ const sent = await channel.telegram.post({
135
+ text: rendered.text,
136
+ reply_markup: rendered.replyMarkup
137
+ });
138
+ if (rendered.freeformRequestId !== void 0) registerTelegramFreeformPrompt(channel.state, {
139
+ messageId: sent.id,
140
+ requestId: request.requestId
141
+ });
142
+ else channel.state.hitlPrompts = {
143
+ ...channel.state.hitlPrompts,
144
+ [request.requestId]: {
145
+ messageId: sent.id,
146
+ text: rendered.text,
147
+ options: (request.options ?? []).map(({ id, label }) => ({
148
+ id,
149
+ label
150
+ }))
151
+ }
152
+ };
153
+ continue;
154
+ }
155
+ const memberId = confirmations.get()[request.action.callId]?.memberId ?? memberIdOf(ctx.session.auth.current);
156
+ if (memberId === void 0) throw new Error("Confirmation requires a member.");
157
+ const text = `Confirm ${request.action.toolName}?\n${JSON.stringify(request.action.input, null, 2)}`.slice(0, 3900);
158
+ const sent = await channel.telegram.post({
159
+ text,
160
+ reply_markup: { inline_keyboard: [[{
161
+ text: "Confirm",
162
+ callback_data: confirmationCallback(request.requestId, memberId, "approve")
163
+ }, {
164
+ text: "Cancel",
165
+ callback_data: confirmationCallback(request.requestId, memberId, "cancel")
166
+ }]] }
167
+ });
168
+ const state = channel.state;
169
+ state.confirmationMessages = {
170
+ ...state.confirmationMessages,
171
+ [request.action.callId]: {
172
+ requestId: request.requestId,
173
+ messageId: sent.id,
174
+ text
175
+ }
176
+ };
177
+ }
178
+ },
179
+ async "input.resolved"(data, channel) {
180
+ const state = channel.state;
181
+ for (const resolution of data.resolutions) {
182
+ const entry = Object.entries(state.confirmationMessages ?? {}).find(([, message]) => message.requestId === resolution.requestId);
183
+ if (entry === void 0) {
184
+ const prompt = channel.state.hitlPrompts?.[resolution.requestId];
185
+ if (prompt !== void 0) {
186
+ const response = resolution.response;
187
+ const label = response?.text ?? prompt.options.find((option) => option.id === response?.optionId)?.label;
188
+ await channel.telegram.editMessageText({
189
+ messageId: prompt.messageId,
190
+ replyMarkup: { inline_keyboard: [] },
191
+ text: `${prompt.text}\n\n${label ?? resolution.outcome}`
192
+ });
193
+ delete channel.state.hitlPrompts?.[resolution.requestId];
194
+ }
195
+ continue;
196
+ }
197
+ const [callId, message] = entry;
198
+ const text = resolution.outcome === "approved" ? "Confirmed. Executing…" : "Cancelled. Nothing executed.";
199
+ await channel.telegram.editMessageText({
200
+ messageId: message.messageId,
201
+ replyMarkup: { inline_keyboard: [] },
202
+ text: `${message.text}\n\n${text}`
203
+ });
204
+ if (resolution.outcome !== "approved") {
205
+ delete state.confirmationMessages?.[callId];
206
+ confirmations.update((all) => ({
207
+ ...all,
208
+ ...all[callId] === void 0 ? {} : { [callId]: {
209
+ ...all[callId],
210
+ status: "cancelled"
211
+ } }
212
+ }));
213
+ }
214
+ }
215
+ },
216
+ async "action.result"(data, channel) {
217
+ const state = channel.state;
218
+ if (data.result.kind === "tool-result") {
219
+ const question = object(data.result.output).pendingQuestion;
220
+ if (data.result.toolName.endsWith("__questions_ask") && isPendingQuestion(question)) {
221
+ const address = channel.continuation?.token ?? formatAddress({
222
+ chatId: channel.telegram.chatId,
223
+ messageThreadId: String(channel.telegram.messageThreadId ?? ""),
224
+ conversationId: channel.telegram.conversationId ?? ""
225
+ });
226
+ const saved = {
227
+ ...question,
228
+ address
229
+ };
230
+ pendingQuestions.update((all) => ({
231
+ ...all,
232
+ [questionKey(saved)]: saved
233
+ }));
234
+ state.pendingQuestions = {
235
+ ...state.pendingQuestions,
236
+ [questionKey(saved)]: saved
237
+ };
238
+ channel.continuation?.alias(formatAddress({
239
+ chatId: question.chatId,
240
+ messageThreadId: question.messageThreadId,
241
+ conversationId: ""
242
+ }));
243
+ channel.continuation?.alias(questionAddress(saved));
244
+ await channel.telegram.post({
245
+ text: question.question,
246
+ reply_markup: {
247
+ force_reply: true,
248
+ selective: true
249
+ }
250
+ });
251
+ }
252
+ }
253
+ const message = state.confirmationMessages?.[data.result.callId];
254
+ if (message === void 0) return;
255
+ const failed = data.status !== "completed" || data.result.kind === "tool-result" && (data.result.isError === true || object(data.result.output).isError === true || object(object(data.result.output).validation).ok === false);
256
+ const result = data.result.kind === "tool-result" ? JSON.stringify(data.result.output) : "";
257
+ await channel.telegram.editMessageText({
258
+ messageId: message.messageId,
259
+ replyMarkup: { inline_keyboard: [] },
260
+ text: `${message.text}\n\n${failed ? "Failed" : "Completed"}: ${result}`.slice(0, 4096)
261
+ });
262
+ delete state.confirmationMessages?.[data.result.callId];
263
+ },
264
+ "turn.started"(_data, channel, ctx) {
265
+ const memberId = memberIdOf(ctx.session.auth.current);
266
+ const answeredId = ctx.session.auth.current?.attributes.pendingQuestionId;
267
+ if (memberId !== void 0 && typeof answeredId === "string") {
268
+ const key = questionKey({
269
+ memberId,
270
+ chatId: channel.telegram.chatId,
271
+ messageThreadId: String(channel.telegram.messageThreadId ?? "")
272
+ });
273
+ if (pendingQuestions.get()[key]?.id === answeredId) {
274
+ pendingQuestions.update((all) => Object.fromEntries(Object.entries(all).filter(([name]) => name !== key)));
275
+ delete channel.state.pendingQuestions?.[key];
276
+ }
277
+ }
278
+ }
279
+ };
280
+ export { confirmationCallback, confirmationEvents, handleConfirmationCallback, questionAddress, questionFromHistory };
@@ -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,14 @@ 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 { answerMarker } from "../questions.mjs";
9
+ import { formatAddress, messageAddress, scopedAddress } from "./sessions.mjs";
10
+ import { questionAddress, questionFromHistory } from "./confirmations.mjs";
8
11
  import { chatContext } from "./chats.mjs";
9
12
  import { attachmentsContext, stageFiles } from "./files.mjs";
10
13
  import { observedChatKey, observedContext } from "./observed.mjs";
11
14
  import { addressingReason, routeMessage } from "./router.mjs";
12
15
  import { routingContext } from "./routing-context.mjs";
13
- import { messageAddress, scopedAddress } from "./sessions.mjs";
14
16
  import { voiceNote, voiceOf, voiceText } from "./voice.mjs";
15
17
  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
18
  const UNKNOWN_IN_GROUP = "I don't know you yet. Message me privately to connect your account.";
@@ -184,13 +186,32 @@ async function handleInboundMessage(input) {
184
186
  });
185
187
  return null;
186
188
  }
187
- const address = scopedAddress(messageAddress(sent), dependencies.sessions().scope);
189
+ let address = scopedAddress(messageAddress(sent), dependencies.sessions().scope);
188
190
  const operations = input.request?.sessions;
191
+ const topicAddress = formatAddress({
192
+ chatId: sent.chat.id,
193
+ messageThreadId: String(sent.messageThreadId ?? ""),
194
+ conversationId: ""
195
+ });
196
+ const questionOwner = {
197
+ memberId: lookup.member.id,
198
+ chatId: sent.chat.id,
199
+ messageThreadId: String(sent.messageThreadId ?? "")
200
+ };
201
+ const memberQuestionAddress = questionAddress(questionOwner);
202
+ const questionSession = await operations?.resolveSession(memberQuestionAddress);
203
+ const owner = questionSession ?? await operations?.resolveSession(address) ?? await operations?.resolveSession(topicAddress);
204
+ const pending = owner === void 0 || dependencies.history === void 0 ? void 0 : questionFromHistory(await dependencies.history(owner.id), questionOwner);
205
+ let answering = pending !== void 0 && pending.memberId === lookup.member.id && pending.chatId === sent.chat.id && pending.messageThreadId === String(sent.messageThreadId ?? "");
206
+ if (answering && pending !== void 0 && input.request !== void 0) {
207
+ address = pending.address || (questionSession === void 0 ? topicAddress : memberQuestionAddress);
208
+ input.request.address = address;
209
+ }
189
210
  const voice = voiceOf(sent.raw);
190
211
  const caption = sent.text || sent.caption;
191
212
  const notes = [];
192
213
  let text = caption;
193
- if (voice !== void 0 && (addressed !== void 0 || dependencies.policy === "smart")) {
214
+ if (voice !== void 0 && (addressed !== void 0 || answering || dependencies.policy === "smart")) {
194
215
  const spoken = await voiceText({
195
216
  voice,
196
217
  model: dependencies.sttModel,
@@ -209,7 +230,7 @@ async function handleInboundMessage(input) {
209
230
  chatId: sent.chat.id,
210
231
  memberId: lookup.member.id,
211
232
  policy: dependencies.policy,
212
- addressed,
233
+ addressed: addressed ?? (answering ? "pending-question" : void 0),
213
234
  decide: dependencies.decide,
214
235
  question: async () => {
215
236
  const recent = await routingContext({
@@ -231,13 +252,26 @@ async function handleInboundMessage(input) {
231
252
  }
232
253
  if (voice !== void 0 && input.request !== void 0) input.request.text = text;
233
254
  else if (voice !== void 0) notes.push(text);
255
+ if (answering && pending !== void 0 && input.request !== void 0) {
256
+ input.request.text = `${answerMarker(pending.id)}\n${text}`;
257
+ notes.push(`<pending_question>\nTask: ${pending.task}\nQuestion: ${pending.question}\nUse this member's answer to continue the pending task with their current permissions.
258
+ </pending_question>`);
259
+ }
234
260
  if (operations !== void 0) {
235
261
  const newest = dependencies.observed.list(chat).at(-1);
236
262
  if (await dependencies.sessionPolicy.apply({
237
263
  address,
238
264
  operations,
239
265
  ...newest === void 0 ? {} : { activeAt: Date.parse(newest.at) || 0 }
240
- }) === "cleared") dependencies.observed.clear(chat);
266
+ }) === "cleared") {
267
+ dependencies.observed.clear(chat);
268
+ if (answering && input.request !== void 0) {
269
+ answering = false;
270
+ input.request.text = text;
271
+ const questionNote = notes.findIndex((note) => note.startsWith("<pending_question>"));
272
+ if (questionNote !== -1) notes.splice(questionNote, 1);
273
+ }
274
+ }
241
275
  dependencies.sessionPolicy.delivered(address);
242
276
  }
243
277
  await context.telegram.startTyping();
@@ -250,11 +284,18 @@ async function handleInboundMessage(input) {
250
284
  if (input.request !== void 0 && files.size > 0) input.request.files = files;
251
285
  await report;
252
286
  dependencies.observed.remember(chat, observedMessage(sent, text, lookup.member));
287
+ const principal = telegramPrincipal({
288
+ message: sent,
289
+ member: lookup.member
290
+ });
253
291
  return {
254
- auth: telegramPrincipal({
255
- message: sent,
256
- member: lookup.member
257
- }),
292
+ auth: answering && pending !== void 0 ? {
293
+ ...principal,
294
+ attributes: {
295
+ ...principal.attributes,
296
+ pendingQuestionId: pending.id
297
+ }
298
+ } : principal,
258
299
  context: [
259
300
  ...observedContext(dependencies.observed.take(chat)),
260
301
  ...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",