@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.
- package/README.md +8 -2
- package/dist/extension/instructions.md +8 -1
- package/dist/extension/lib/attachment-formats.d.ts +16 -0
- package/dist/extension/lib/attachment-formats.mjs +238 -0
- package/dist/extension/lib/attachment-query.d.ts +31 -0
- package/dist/extension/lib/attachment-query.mjs +125 -0
- package/dist/extension/lib/attachment-tables.d.ts +33 -0
- package/dist/extension/lib/attachment-tables.mjs +186 -0
- package/dist/extension/lib/attachment-tools.d.ts +34 -3
- package/dist/extension/lib/attachment-tools.mjs +139 -30
- package/dist/extension/lib/telegram/adapter.mjs +14 -5
- package/dist/extension/lib/telegram/inbound.d.ts +2 -1
- package/dist/extension/lib/telegram/inbound.mjs +25 -10
- package/dist/extension/lib/telegram/observed.d.ts +29 -3
- package/dist/extension/lib/telegram/observed.mjs +48 -14
- package/dist/extension/lib/telegram/router.d.ts +11 -3
- package/dist/extension/lib/telegram/router.mjs +89 -13
- package/dist/extension/lib/telegram/routing-context.d.ts +13 -0
- package/dist/extension/lib/telegram/routing-context.mjs +40 -0
- package/dist/extension/lib/telegram/sessions.d.ts +5 -0
- package/dist/extension/lib/telegram/silent.d.ts +2 -0
- package/dist/extension/lib/telegram/silent.mjs +30 -5
- package/dist/extension/lib/telegram/turn-errors.d.ts +9 -0
- package/dist/extension/lib/telegram/turn-errors.mjs +20 -0
- package/dist/extension/tools/attachments_read.mjs +47 -5
- package/dist/extension/tools/attachments_table.d.ts +64 -0
- package/dist/extension/tools/attachments_table.mjs +198 -0
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.mjs +2 -1
- 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
|
-
/**
|
|
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
|
-
|
|
51
|
-
export declare function
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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 {
|
|
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:
|
|
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":
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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.
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Bounded group conversation context for routing, and the backlog observed without answering.
|
|
3
|
+
* A reply drains only the backlog: recent member messages and the agent's own replies remain
|
|
4
|
+
* available to decide whether a later unaddressed message answers one of the agent's questions.
|
|
5
5
|
*
|
|
6
6
|
* eve's `defineState` would be the durable home, but it is session state and needs an active eve
|
|
7
7
|
* context; an observed message has neither, because observing must not start a session. The ring
|
|
@@ -15,14 +15,40 @@ export interface ObservedMessage {
|
|
|
15
15
|
readonly text: string;
|
|
16
16
|
/** ISO time the message was sent. */
|
|
17
17
|
readonly at: string;
|
|
18
|
+
/** Stable Telegram identity, independent of changes to the member's display name or roles. */
|
|
19
|
+
readonly authorId?: string;
|
|
20
|
+
readonly username?: string;
|
|
18
21
|
}
|
|
22
|
+
export interface MessageRecipient {
|
|
23
|
+
readonly author: string;
|
|
24
|
+
readonly id?: string;
|
|
25
|
+
readonly username?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface AgentMessage extends ObservedMessage {
|
|
28
|
+
readonly role: "agent";
|
|
29
|
+
/** Explicit @mentions take precedence over the member whose turn produced the reply. */
|
|
30
|
+
readonly addressedTo: ReadonlyArray<MessageRecipient>;
|
|
31
|
+
readonly inReplyTo?: MessageRecipient;
|
|
32
|
+
}
|
|
33
|
+
export type RecentMessage = ObservedMessage | AgentMessage;
|
|
19
34
|
export interface ObservedMessages {
|
|
20
35
|
add(chat: string, message: ObservedMessage): void;
|
|
36
|
+
/** Records a received message that starts a turn, or a successfully delivered agent reply. */
|
|
37
|
+
remember(chat: string, message: RecentMessage): void;
|
|
21
38
|
list(chat: string): ReadonlyArray<ObservedMessage>;
|
|
39
|
+
recent(chat: string): ReadonlyArray<RecentMessage>;
|
|
22
40
|
/** Returns and forgets the chat's ring, once a reply has carried it into a turn. */
|
|
23
41
|
take(chat: string): ReadonlyArray<ObservedMessage>;
|
|
42
|
+
/** An idle reset forgets both the backlog and the earlier conversation. */
|
|
43
|
+
clear(chat: string): void;
|
|
24
44
|
}
|
|
25
45
|
export declare function observedMessages(capacity?: number): ObservedMessages;
|
|
46
|
+
/** Recipient metadata for an agent reply; explicit mentions are visible even in proactive turns. */
|
|
47
|
+
export declare function agentMessage(input: {
|
|
48
|
+
readonly text: string;
|
|
49
|
+
readonly at: string;
|
|
50
|
+
readonly inReplyTo?: MessageRecipient;
|
|
51
|
+
}): AgentMessage;
|
|
26
52
|
/** The key of a chat's ring: the chat and, in a forum, the topic. */
|
|
27
53
|
export declare function observedChatKey(input: {
|
|
28
54
|
readonly chatId: string;
|
|
@@ -9,29 +9,63 @@ const MAXIMUM_CHATS = 500;
|
|
|
9
9
|
const MAXIMUM_TEXT_LENGTH = 1e3;
|
|
10
10
|
function observedMessages(capacity = 30) {
|
|
11
11
|
const chats = new Map();
|
|
12
|
+
function remember(chat, message, observe) {
|
|
13
|
+
const previous = chats.get(chat);
|
|
14
|
+
const bounded = {
|
|
15
|
+
...message,
|
|
16
|
+
text: message.text.slice(0, MAXIMUM_TEXT_LENGTH)
|
|
17
|
+
};
|
|
18
|
+
const backlog = previous?.observed ?? [];
|
|
19
|
+
chats.delete(chat);
|
|
20
|
+
chats.set(chat, {
|
|
21
|
+
observed: observe ? [...backlog, bounded].slice(-capacity) : backlog,
|
|
22
|
+
recent: [...previous?.recent ?? [], bounded].slice(-capacity)
|
|
23
|
+
});
|
|
24
|
+
if (chats.size > MAXIMUM_CHATS) {
|
|
25
|
+
const oldest = chats.keys().next();
|
|
26
|
+
if (oldest.done !== true) chats.delete(oldest.value);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
12
29
|
return {
|
|
13
30
|
add(chat, message) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
chats.delete(chat);
|
|
19
|
-
chats.set(chat, ring.slice(-capacity));
|
|
20
|
-
if (chats.size > MAXIMUM_CHATS) {
|
|
21
|
-
const oldest = chats.keys().next();
|
|
22
|
-
if (oldest.done !== true) chats.delete(oldest.value);
|
|
23
|
-
}
|
|
31
|
+
remember(chat, message, true);
|
|
32
|
+
},
|
|
33
|
+
remember(chat, message) {
|
|
34
|
+
remember(chat, message, false);
|
|
24
35
|
},
|
|
25
36
|
list(chat) {
|
|
26
|
-
return chats.get(chat) ?? [];
|
|
37
|
+
return chats.get(chat)?.observed ?? [];
|
|
38
|
+
},
|
|
39
|
+
recent(chat) {
|
|
40
|
+
return chats.get(chat)?.recent ?? [];
|
|
27
41
|
},
|
|
28
42
|
take(chat) {
|
|
29
|
-
const
|
|
43
|
+
const previous = chats.get(chat);
|
|
44
|
+
if (previous !== void 0) chats.set(chat, {
|
|
45
|
+
...previous,
|
|
46
|
+
observed: []
|
|
47
|
+
});
|
|
48
|
+
return previous?.observed ?? [];
|
|
49
|
+
},
|
|
50
|
+
clear(chat) {
|
|
30
51
|
chats.delete(chat);
|
|
31
|
-
return ring;
|
|
32
52
|
}
|
|
33
53
|
};
|
|
34
54
|
}
|
|
55
|
+
function agentMessage(input) {
|
|
56
|
+
const usernames = Array.from(input.text.matchAll(/(?:^|[^A-Za-z0-9_])@([A-Za-z0-9_]+)/gu), (match) => match[1] ?? "");
|
|
57
|
+
const addressedTo = usernames.length > 0 ? [...new Set(usernames)].map((username) => ({
|
|
58
|
+
author: `@${username}`,
|
|
59
|
+
username
|
|
60
|
+
})) : input.inReplyTo === void 0 ? [] : [input.inReplyTo];
|
|
61
|
+
return {
|
|
62
|
+
...input,
|
|
63
|
+
text: input.text.slice(0, MAXIMUM_TEXT_LENGTH),
|
|
64
|
+
author: "the agent",
|
|
65
|
+
role: "agent",
|
|
66
|
+
addressedTo
|
|
67
|
+
};
|
|
68
|
+
}
|
|
35
69
|
function observedChatKey(input) {
|
|
36
70
|
return input.messageThreadId === void 0 ? input.chatId : `${input.chatId}:${input.messageThreadId}`;
|
|
37
71
|
}
|
|
@@ -44,4 +78,4 @@ function observedContext(messages) {
|
|
|
44
78
|
"</observed_messages>"
|
|
45
79
|
].join("\n")];
|
|
46
80
|
}
|
|
47
|
-
export { OBSERVED_MESSAGES, observedChatKey, observedContext, observedMessages };
|
|
81
|
+
export { OBSERVED_MESSAGES, agentMessage, observedChatKey, observedContext, observedMessages };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { TelegramMessage } from "eve/channels/telegram";
|
|
2
2
|
import type { TelegramPolicy } from "../../config.js";
|
|
3
|
-
import type { ObservedMessage } from "./observed.js";
|
|
3
|
+
import type { ObservedMessage, RecentMessage } from "./observed.js";
|
|
4
4
|
/**
|
|
5
5
|
* The inbound router (`agent-design.md`, section 4): every message from a paired member is either
|
|
6
6
|
* answered or observed before eve starts a turn. A private chat, a mention, a reply to the bot and
|
|
@@ -8,18 +8,22 @@ import type { ObservedMessage } from "./observed.js";
|
|
|
8
8
|
* policy, put to one decision on the decision model.
|
|
9
9
|
*/
|
|
10
10
|
export type Route = "observe" | "reply";
|
|
11
|
+
export type AddressingReason = "private" | "mention" | "reply-to-bot" | "command";
|
|
11
12
|
/** The bot, as Telegram's `getMe` describes it. Unknown when `getMe` failed. */
|
|
12
13
|
export interface BotIdentity {
|
|
13
14
|
readonly id: string;
|
|
14
15
|
readonly username: string | undefined;
|
|
15
16
|
}
|
|
17
|
+
/** Why the message is for the bot without asking any model. */
|
|
18
|
+
export declare function addressingReason(message: TelegramMessage, bot: BotIdentity | undefined): AddressingReason | undefined;
|
|
16
19
|
/** Whether the message is for the bot without asking any model. */
|
|
17
20
|
export declare function isAddressed(message: TelegramMessage, bot: BotIdentity | undefined): boolean;
|
|
18
21
|
/** What the decision model sees. */
|
|
19
22
|
export interface RouteQuestion {
|
|
20
23
|
/** The agent's own description, so the model knows what it can be asked for. */
|
|
21
24
|
readonly agent: string;
|
|
22
|
-
|
|
25
|
+
/** Recent received messages and delivered agent replies, oldest first. */
|
|
26
|
+
readonly recent: ReadonlyArray<RecentMessage>;
|
|
23
27
|
readonly message: ObservedMessage;
|
|
24
28
|
}
|
|
25
29
|
/** Decides whether a group message not addressed to the bot is still for it. */
|
|
@@ -51,6 +55,8 @@ export type DecideFunction = (options: {
|
|
|
51
55
|
};
|
|
52
56
|
};
|
|
53
57
|
}>;
|
|
58
|
+
/** An unthreaded answer minutes later may continue a question, but old questions are not hints. */
|
|
59
|
+
export declare const FOLLOW_UP_WINDOW_MS: number;
|
|
54
60
|
/** A {@link RouteDecider} that asks one `decide` question on the configured decision model. */
|
|
55
61
|
export declare function decisionRouter(input: {
|
|
56
62
|
readonly decide: DecideFunction;
|
|
@@ -61,8 +67,10 @@ export declare function decisionRouter(input: {
|
|
|
61
67
|
* mistake in a group chat.
|
|
62
68
|
*/
|
|
63
69
|
export declare function routeMessage(input: {
|
|
70
|
+
readonly chatId: string;
|
|
71
|
+
readonly memberId: string;
|
|
64
72
|
readonly policy: TelegramPolicy;
|
|
65
|
-
readonly addressed:
|
|
73
|
+
readonly addressed: AddressingReason | undefined;
|
|
66
74
|
/** Builds the question only when a decision is needed: it reads the agent's description. */
|
|
67
75
|
readonly question: () => Promise<RouteQuestion>;
|
|
68
76
|
readonly decide: RouteDecider;
|