@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,186 @@
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 { decodeText, detectFile, extensionOf } from "./attachment-formats.mjs";
8
+ async function delimitedRows(text) {
9
+ const { parse } = await import("csv-parse/sync");
10
+ const chosen = [
11
+ ",",
12
+ ";",
13
+ " "
14
+ ].map((delimiter) => {
15
+ try {
16
+ const records = parse(text, {
17
+ delimiter,
18
+ bom: true,
19
+ skip_empty_lines: true,
20
+ relax_column_count: true
21
+ });
22
+ const widths = records.slice(0, 50).map((row) => row.length);
23
+ const common = Math.max(0, ...widths.map((width) => widths.filter((item) => item === width).length));
24
+ const width = widths.find((item) => widths.filter((candidate) => candidate === item).length === common) ?? 1;
25
+ return {
26
+ records,
27
+ score: width > 1 ? common * 100 + width : 0
28
+ };
29
+ } catch (error) {
30
+ return {
31
+ records: null,
32
+ score: -1,
33
+ error
34
+ };
35
+ }
36
+ }).sort((a, b) => b.score - a.score)[0];
37
+ if (chosen.records === null) throw new Error("Unable to parse delimited file; check quotes and delimiter.", { cause: chosen.error });
38
+ return chosen.records;
39
+ }
40
+ async function readSheets(attachment, bytes) {
41
+ const file = await detectFile(attachment, bytes);
42
+ if (file.format !== "table") throw new Error("This attachment is not a CSV/TSV/XLSX/XLS/ODS table.");
43
+ const extension = extensionOf(attachment.filename);
44
+ const compound = bytes[0] === 208 && bytes[1] === 207;
45
+ if (!file.zip && !compound) {
46
+ if ([
47
+ "xlsx",
48
+ "xls",
49
+ "ods"
50
+ ].includes(extension) || file.mediaType.includes("spreadsheetml")) throw new Error("Spreadsheet container is invalid or encrypted.");
51
+ return [{
52
+ name: "Sheet1",
53
+ rows: await delimitedRows(decodeText(bytes))
54
+ }];
55
+ }
56
+ const XLSX = await import("xlsx");
57
+ const workbook = XLSX.read(Buffer.from(bytes), {
58
+ type: "buffer",
59
+ cellDates: true
60
+ });
61
+ return workbook.SheetNames.map((name) => ({
62
+ name,
63
+ rows: XLSX.utils.sheet_to_json(workbook.Sheets[name], {
64
+ header: 1,
65
+ defval: null,
66
+ raw: true
67
+ })
68
+ }));
69
+ }
70
+ function numeric(value) {
71
+ if (value === null || value === "") return null;
72
+ let text = String(value).trim();
73
+ const negative = /^\(.*\)$/.test(text) || /-$/.test(text);
74
+ text = text.replace(/[£$€¥\s\u00a0]/g, "").replace(/^\((.*)\)$/, "$1").replace(/-$/, "");
75
+ if (text === "") return null;
76
+ const comma = text.lastIndexOf(",");
77
+ const dot = text.lastIndexOf(".");
78
+ if (comma >= 0 && dot >= 0) text = comma > dot ? text.replace(/\./g, "").replace(",", ".") : text.replace(/,/g, "");
79
+ else if (comma >= 0) text = /^[+-]?\d{1,3}(,\d{3})+$/.test(text) ? text.replace(/,/g, "") : text.replace(",", ".");
80
+ if (!/^[+-]?\d+(?:\.\d+)?$/.test(text) || text.replace(/\D/g, "").length > 40) return null;
81
+ return negative ? `-${text.replace(/^[+-]/, "")}` : text;
82
+ }
83
+ function dateParts(value) {
84
+ if (typeof value !== "string") return;
85
+ const match = value.trim().match(/^(\d{1,2})[/.\-](\d{1,2})[/.\-](\d{2}|\d{4})(?:\s.*)?$/);
86
+ return match ? match.slice(1).map(Number) : void 0;
87
+ }
88
+ function dateValue(value, order, serial = false) {
89
+ if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : null;
90
+ if (serial && typeof value === "number" && value >= 1 && value < 1e5) return new Date((value < 60 ? Date.UTC(1899, 11, 31) : Date.UTC(1899, 11, 30)) + Math.round(value * 864e5)).toISOString();
91
+ if (typeof value !== "string") return null;
92
+ const text = value.trim();
93
+ const iso = text.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|[+-]\d{2}:\d{2})?)?$/);
94
+ const local = text.match(/^(\d{1,2})[/.\-](\d{1,2})[/.\-](\d{2}|\d{4})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
95
+ if (!iso && !local) return null;
96
+ const match = iso ?? local;
97
+ let year = Number(iso ? match[1] : match[3]);
98
+ const month = Number(iso ? match[2] : order === "day_first" ? match[2] : match[1]);
99
+ const day = Number(iso ? match[3] : order === "day_first" ? match[1] : match[2]);
100
+ const hour = Number(match[4] ?? 0);
101
+ const minute = Number(match[5] ?? 0);
102
+ const second = Number(match[6] ?? 0);
103
+ const millisecond = iso ? Number((match[7] ?? "0").padEnd(3, "0")) : 0;
104
+ if (year < 100) year += year < 70 ? 2e3 : 1900;
105
+ const stamp = new Date(Date.UTC(year, month - 1, day, hour, minute, second, millisecond));
106
+ if (stamp.getUTCFullYear() !== year || stamp.getUTCMonth() !== month - 1 || stamp.getUTCDate() !== day || hour > 23 || minute > 59 || second > 59) return null;
107
+ if (iso?.[8] && iso[8] !== "Z") {
108
+ const shifted = Date.parse(`${iso[1]}-${iso[2]}-${iso[3]}T${iso[4]}:${iso[5]}:${iso[6] ?? "00"}${iso[8]}`);
109
+ return Number.isFinite(shifted) ? new Date(shifted).toISOString() : null;
110
+ }
111
+ return stamp.toISOString();
112
+ }
113
+ function present(value) {
114
+ return value !== void 0 && value !== null && String(value).trim() !== "";
115
+ }
116
+ function detectedHeader(rows) {
117
+ const candidates = rows.slice(0, 50);
118
+ const width = candidates.reduce((maximum, row) => Math.max(maximum, row.length), 0);
119
+ const labels = candidates.findIndex((row) => row.length >= Math.max(1, width - 1) && row.some(present) && row.filter(present).every((value) => typeof value === "string" && numeric(value) === null && dateValue(value, "day_first") === null && dateValue(value, "month_first") === null));
120
+ if (labels >= 0) return labels;
121
+ return -1;
122
+ }
123
+ function typedTable(sheets, options = {}) {
124
+ const sheet = options.sheet === void 0 ? sheets[0] : sheets.find((item) => item.name === options.sheet);
125
+ if (!sheet) throw new Error(`Unknown sheet. Available sheets: ${sheets.map((item) => item.name).join(", ")}`);
126
+ const headerRow = options.header_row ?? detectedHeader(sheet.rows);
127
+ const header = sheet.rows[headerRow] ?? [];
128
+ const rows = sheet.rows.slice(headerRow + 1).filter((row) => row.some(present));
129
+ const width = rows.reduce((maximum, row) => Math.max(maximum, row.length), header.length);
130
+ if (headerRow >= 0 && rows.some((row) => row.slice(header.length).some(present))) throw new Error("Data has more columns than the header. Check CSV quoting/delimiter or override header_row.");
131
+ const names = new Set();
132
+ const warnings = [];
133
+ const columns = [];
134
+ const parsed = [];
135
+ for (let index = 0; index < width; index++) {
136
+ const base = present(header[index]) ? String(header[index]).trim() : `Column ${index + 1}`;
137
+ let name = base;
138
+ let suffix = 2;
139
+ while (names.has(name)) name = `${base} (${suffix++})`;
140
+ names.add(name);
141
+ const values = rows.map((row) => row[index] ?? null);
142
+ const nonblank = values.filter(present);
143
+ const dayFirst = nonblank.some((value) => (dateParts(value)?.[0] ?? 0) > 12);
144
+ const monthFirst = nonblank.some((value) => (dateParts(value)?.[1] ?? 0) > 12);
145
+ const order = options.date_order ?? (monthFirst && !dayFirst ? "month_first" : "day_first");
146
+ const dateLike = /date|time/i.test(name) || nonblank.some((value) => value instanceof Date || typeof value === "string" && /^(\d{4}-\d{2}-\d{2}|\d{1,2}\/\d{1,2}\/\d{2,4})/.test(value));
147
+ const dates = values.map((value) => dateValue(value, order, dateLike));
148
+ const numbers = values.map(numeric);
149
+ const allDates = nonblank.length > 0 && dateLike && values.every((value, at) => !present(value) || dates[at] !== null);
150
+ const allNumbers = nonblank.length > 0 && values.every((value, at) => !present(value) || numbers[at] !== null);
151
+ if (allDates && (options.date_order !== void 0 || !dayFirst || !monthFirst)) {
152
+ columns.push({
153
+ name,
154
+ type: "date",
155
+ date_order: order
156
+ });
157
+ parsed.push(dates);
158
+ if (options.date_order === void 0 && !dayFirst && !monthFirst && nonblank.some((value) => dateParts(value))) warnings.push(`${name}: ambiguous day/month order; ${order} used. Set date_order to override.`);
159
+ } else if (allNumbers && !dateLike) {
160
+ columns.push({
161
+ name,
162
+ type: "decimal"
163
+ });
164
+ parsed.push(numbers);
165
+ } else {
166
+ columns.push({
167
+ name,
168
+ type: "text"
169
+ });
170
+ parsed.push(values.map((value) => present(value) ? String(value) : null));
171
+ if (dateLike || /debit|credit|amount|balance/i.test(name)) warnings.push(`${name}: contains unparseable or conflicting values; kept as text. No values replaced with zero.`);
172
+ }
173
+ }
174
+ return {
175
+ sheets: sheets.map((item) => ({
176
+ name: item.name,
177
+ total_rows: item.rows.slice((item === sheet ? headerRow : detectedHeader(item.rows)) + 1).filter((row) => row.some(present)).length
178
+ })),
179
+ sheet: sheet.name,
180
+ header_row: headerRow,
181
+ columns,
182
+ warnings,
183
+ rows: rows.map((_, index) => Object.fromEntries(columns.map((column, at) => [column.name, parsed[at][index]])))
184
+ };
185
+ }
186
+ export { dateValue, numeric, readSheets, typedTable };
@@ -1,5 +1,6 @@
1
1
  import type { SessionAuthContext } from "eve/context";
2
2
  import { type AgentAttachments, type Attachment, type AttachmentOwner } from "./attachments.js";
3
+ import { type DateOrder } from "./attachment-tables.js";
3
4
  import { type Environment } from "./environment.js";
4
5
  import { type RuntimeToken } from "./runtime-token.js";
5
6
  /**
@@ -7,7 +8,7 @@ import { type RuntimeToken } from "./runtime-token.js";
7
8
  * the member and chat on the session's principal, which the channel set from the pairing, and the
8
9
  * session's own id. A tool's arguments name only the attachment and what to do with it.
9
10
  */
10
- /** The longest text `attachments_read` returns; the rest is cut and marked as truncated. */
11
+ /** Maximum characters in a read/query response; remaining data is available by continuation. */
11
12
  export declare const MAXIMUM_TEXT_CHARACTERS = 50000;
12
13
  /** The largest file `attachments_read` hands the model inline. */
13
14
  export declare const MAXIMUM_INLINE_BYTES: number;
@@ -33,22 +34,52 @@ export declare function attachmentForModel(attachment: Attachment): {
33
34
  state: string;
34
35
  expires_at: string;
35
36
  };
37
+ export interface ReadOptions {
38
+ readonly sheet?: string;
39
+ readonly offset?: number;
40
+ readonly limit?: number;
41
+ readonly character_offset?: number;
42
+ readonly header_row?: number;
43
+ readonly date_order?: DateOrder;
44
+ }
36
45
  export type ReadOutput = {
37
46
  readonly attachment: ReturnType<typeof attachmentForModel>;
38
47
  readonly kind: "text";
48
+ readonly format: string;
39
49
  readonly text: string;
40
50
  readonly truncated: boolean;
51
+ readonly pagination: {
52
+ readonly unit: "characters" | "rows";
53
+ readonly offset: number;
54
+ readonly limit: number;
55
+ readonly total: number;
56
+ readonly total_pages: number;
57
+ readonly next_offset: number | null;
58
+ readonly next_character_offset?: number;
59
+ };
60
+ readonly sheet?: string;
61
+ readonly sheets?: ReadonlyArray<{
62
+ readonly name: string;
63
+ readonly total_rows: number;
64
+ }>;
65
+ readonly columns?: unknown;
66
+ readonly warnings?: ReadonlyArray<string>;
41
67
  } | {
42
68
  readonly attachment: ReturnType<typeof attachmentForModel>;
43
69
  readonly kind: "file";
70
+ readonly media_type: string;
44
71
  readonly data: string;
45
72
  } | {
46
73
  readonly attachment: ReturnType<typeof attachmentForModel>;
47
74
  readonly kind: "unreadable";
48
75
  readonly note: string;
49
76
  };
50
- /** What `attachments_read` answers for a file: its text, the file itself, or why neither. */
51
- export declare function readOutput(attachment: Attachment, bytes: Uint8Array): ReadOutput;
77
+ export declare function pageInteger(value: unknown, fallback: number, maximum?: number): number;
78
+ export declare function headerArgument(value: unknown): number | undefined;
79
+ /** Keep envelopes and structured query results inside the same response budget as file text. */
80
+ export declare function boundedResultPage<T>(items: readonly T[], offset: number, limit: number, envelope: unknown): T[];
81
+ /** Reads never discard the remainder. Parsers process the entire file before selecting a page. */
82
+ export declare function readOutput(attachment: Attachment, bytes: Uint8Array, options?: ReadOptions): Promise<ReadOutput>;
52
83
  /** A required string argument. */
53
84
  export declare function stringArgument(input: Readonly<Record<string, unknown>>, name: string): string;
54
85
  /** An optional string argument. */
@@ -9,22 +9,10 @@ import { memberIdOf } from "./telegram/principal.mjs";
9
9
  import { runtimeToken } from "./runtime-token.mjs";
10
10
  import { agentAttachments } from "./attachments.mjs";
11
11
  import { telegramConversation } from "./telegram/files.mjs";
12
+ import { detectFile, documentText } from "./attachment-formats.mjs";
13
+ import { readSheets, typedTable } from "./attachment-tables.mjs";
12
14
  const MAXIMUM_TEXT_CHARACTERS = 5e4;
13
15
  const MAXIMUM_INLINE_BYTES = 5242880;
14
- const INLINE_TYPES = new Set([
15
- "application/pdf",
16
- "image/png",
17
- "image/jpeg",
18
- "image/webp",
19
- "image/gif"
20
- ]);
21
- function isText(contentType) {
22
- return contentType.startsWith("text/") || [
23
- "application/json",
24
- "application/xml",
25
- "application/csv"
26
- ].includes(contentType);
27
- }
28
16
  var AttachmentOwnerError = class extends Error {
29
17
  name = "AttachmentOwnerError";
30
18
  };
@@ -58,27 +46,148 @@ function attachmentForModel(attachment) {
58
46
  ...attachment.promotion === null ? {} : { promotion: attachment.promotion }
59
47
  };
60
48
  }
61
- function readOutput(attachment, bytes) {
49
+ function pageInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
50
+ if (value === void 0) return fallback;
51
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > maximum) throw new TypeError(`Pagination values must be integers from 0 to ${maximum}.`);
52
+ return value;
53
+ }
54
+ function headerArgument(value) {
55
+ if (value === void 0) return;
56
+ if (value === -1) return -1;
57
+ return pageInteger(value, 0);
58
+ }
59
+ function boundedResultPage(items, offset, limit, envelope) {
60
+ const budget = MAXIMUM_TEXT_CHARACTERS - JSON.stringify(envelope).length - 2e3;
61
+ const page = [];
62
+ let size = 2;
63
+ for (const item of items.slice(offset, offset + limit)) {
64
+ const length = JSON.stringify(item).length + 1;
65
+ if (size + length > budget) {
66
+ if (page.length === 0) throw new Error("One result exceeds the response budget. Use attachments_read and its character continuation.");
67
+ break;
68
+ }
69
+ page.push(item);
70
+ size += length;
71
+ }
72
+ return page;
73
+ }
74
+ async function readOutput(attachment, bytes, options = {}) {
62
75
  const described = attachmentForModel(attachment);
63
- if (isText(attachment.contentType)) {
64
- const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
76
+ const offset = pageInteger(options.offset, 0);
77
+ const requestedLimit = pageInteger(options.limit, MAXIMUM_TEXT_CHARACTERS, MAXIMUM_TEXT_CHARACTERS);
78
+ if (requestedLimit === 0) throw new TypeError("limit must be greater than zero.");
79
+ try {
80
+ const file = await detectFile(attachment, bytes);
81
+ if (file.format === "inline" && bytes.byteLength <= 5242880) return {
82
+ attachment: described,
83
+ kind: "file",
84
+ media_type: file.mediaType,
85
+ data: Buffer.from(bytes).toString("base64")
86
+ };
87
+ if (file.format === "inline" || file.format === "opaque" || file.format === "doc") return {
88
+ attachment: described,
89
+ kind: "unreadable",
90
+ note: file.format === "inline" ? "The file exceeds the inline display limit; it can still be promoted by id." : file.format === "doc" ? "Legacy binary Word document: text extraction is unavailable. Metadata is shown; the original can be promoted." : "No text extractor for this format. Metadata is shown; the original can be promoted by id."
91
+ };
92
+ if (file.format === "table") {
93
+ const table = typedTable(await readSheets(attachment, bytes), options);
94
+ const limit = pageInteger(options.limit, 100, MAXIMUM_TEXT_CHARACTERS);
95
+ const characterOffset = pageInteger(options.character_offset, 0);
96
+ const envelope = {
97
+ attachment: described,
98
+ columns: table.columns,
99
+ warnings: table.warnings,
100
+ sheets: table.sheets
101
+ };
102
+ const budget = Math.floor((MAXIMUM_TEXT_CHARACTERS - JSON.stringify(envelope).length - 2e3) / 6);
103
+ const csv = (values) => values.map((value) => `"${(value ?? "").replace(/"/g, "\"\"")}"`).join(",");
104
+ const heading = `Sheet: ${table.sheet}\n${csv(table.columns.map((column) => column.name))}\n`;
105
+ const lines = table.rows.map((row) => csv(table.columns.map((column) => row[column.name])) + "\n");
106
+ if (heading.length >= budget) throw new Error("Table headers exceed the response budget.");
107
+ const pageAt = (rowOffset, charOffset) => {
108
+ let text = heading;
109
+ let cursor = Math.min(rowOffset, lines.length);
110
+ let nextCharacter = 0;
111
+ for (; cursor < Math.min(lines.length, rowOffset + limit); cursor++) {
112
+ const line = lines[cursor];
113
+ const start = cursor === rowOffset ? charOffset : 0;
114
+ const remaining = line.slice(start);
115
+ if (start >= line.length) throw new TypeError("character_offset exceeds this row's text length.");
116
+ if (text.length + remaining.length > budget) {
117
+ if (cursor === rowOffset) {
118
+ const available = budget - text.length;
119
+ text += remaining.slice(0, available);
120
+ nextCharacter = start + available;
121
+ }
122
+ break;
123
+ }
124
+ text += remaining;
125
+ }
126
+ return {
127
+ text,
128
+ next: cursor < lines.length ? cursor : null,
129
+ nextCharacter
130
+ };
131
+ };
132
+ const { text, next, nextCharacter } = pageAt(offset, characterOffset);
133
+ let totalPages = 0;
134
+ let nextRow = 0;
135
+ let nextChar = 0;
136
+ while (nextRow !== null && lines.length > 0) {
137
+ const page = pageAt(nextRow, nextChar);
138
+ totalPages++;
139
+ nextRow = page.next;
140
+ nextChar = page.nextCharacter;
141
+ }
142
+ return {
143
+ ...envelope,
144
+ kind: "text",
145
+ format: "table",
146
+ sheet: table.sheet,
147
+ text,
148
+ truncated: next !== null,
149
+ pagination: {
150
+ unit: "rows",
151
+ offset,
152
+ limit,
153
+ total: table.rows.length,
154
+ total_pages: totalPages,
155
+ next_offset: next,
156
+ ...nextCharacter === 0 ? {} : { next_character_offset: nextCharacter }
157
+ }
158
+ };
159
+ }
160
+ const fullText = await documentText(file, bytes);
161
+ const budget = Math.floor((MAXIMUM_TEXT_CHARACTERS - JSON.stringify(described).length - 2e3) / 6);
162
+ const limit = Math.min(requestedLimit, budget);
163
+ const text = fullText.slice(offset, offset + limit);
164
+ const next = offset + text.length < fullText.length ? offset + text.length : null;
65
165
  return {
66
166
  attachment: described,
67
167
  kind: "text",
68
- text: text.slice(0, MAXIMUM_TEXT_CHARACTERS),
69
- truncated: text.length > MAXIMUM_TEXT_CHARACTERS
168
+ format: file.format,
169
+ text,
170
+ truncated: next !== null,
171
+ pagination: {
172
+ unit: "characters",
173
+ offset,
174
+ limit,
175
+ total: fullText.length,
176
+ total_pages: Math.ceil(fullText.length / limit),
177
+ next_offset: next
178
+ }
179
+ };
180
+ } catch (error) {
181
+ console.error("[segmentstream] attachment parsing failed", {
182
+ id: attachment.id,
183
+ error
184
+ });
185
+ return {
186
+ attachment: described,
187
+ kind: "unreadable",
188
+ note: "The file could not be parsed (invalid, encrypted, or over the expanded parsing limit). The original can still be promoted."
70
189
  };
71
190
  }
72
- if (INLINE_TYPES.has(attachment.contentType) && bytes.byteLength <= 5242880) return {
73
- attachment: described,
74
- kind: "file",
75
- data: Buffer.from(bytes).toString("base64")
76
- };
77
- return {
78
- attachment: described,
79
- kind: "unreadable",
80
- note: INLINE_TYPES.has(attachment.contentType) ? "The file is too large to show again; it was attached to the message it came with." : `A ${attachment.contentType} file cannot be shown; promote or discard it by its id.`
81
- };
82
191
  }
83
192
  function isRecord(value) {
84
193
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -107,4 +216,4 @@ const ID_INPUT = {
107
216
  required: ["id"],
108
217
  additionalProperties: false
109
218
  };
110
- export { AttachmentOwnerError, ID_INPUT, MAXIMUM_INLINE_BYTES, MAXIMUM_TEXT_CHARACTERS, attachmentForModel, attachmentOwner, optionalObject, optionalString, readOutput, stringArgument, toolAttachments };
219
+ export { AttachmentOwnerError, ID_INPUT, MAXIMUM_INLINE_BYTES, MAXIMUM_TEXT_CHARACTERS, attachmentForModel, attachmentOwner, boundedResultPage, headerArgument, optionalObject, optionalString, pageInteger, readOutput, stringArgument, toolAttachments };
@@ -0,0 +1,26 @@
1
+ import { type StateHandle } from "eve/context";
2
+ import type { ApprovalConfiguration } from "eve/tools/approval";
3
+ export declare const CONFIRMATION_TTL_MS: number;
4
+ export interface ActionAnnotations {
5
+ readonly readOnlyHint?: boolean;
6
+ readonly destructiveHint?: boolean;
7
+ readonly irreversibleHint?: boolean;
8
+ readonly idempotentHint?: boolean;
9
+ }
10
+ /** Safety comes from the authenticated ontology, never from the name or model prose. */
11
+ export declare function needsConfirmation(labels: ActionAnnotations): boolean;
12
+ export declare function inputHash(input: unknown): string;
13
+ export interface Confirmation {
14
+ readonly memberId: string;
15
+ readonly action: string;
16
+ readonly inputHash: string;
17
+ readonly expiresAt: number;
18
+ readonly status: "pending" | "approved" | "consumed" | "cancelled";
19
+ }
20
+ export declare const confirmations: StateHandle<Record<string, Confirmation>>;
21
+ /** Eve pauses the original call and rechecks this policy immediately before executing it. */
22
+ export declare function actionApproval(options: {
23
+ readonly labels: (memberId: string, action: string, input: Readonly<Record<string, unknown>>) => Promise<ActionAnnotations>;
24
+ readonly state?: StateHandle<Record<string, Confirmation>>;
25
+ readonly now?: () => number;
26
+ }): ApprovalConfiguration;
@@ -0,0 +1,96 @@
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 "./telegram/principal.mjs";
8
+ import { createHash } from "node:crypto";
9
+ import { defineState } from "eve/context";
10
+ const CONFIRMATION_TTL_MS = 6e5;
11
+ function needsConfirmation(labels) {
12
+ return labels.readOnlyHint !== true && (labels.destructiveHint === true || labels.irreversibleHint === true || labels.destructiveHint === void 0 && labels.irreversibleHint === void 0);
13
+ }
14
+ function canonical(value) {
15
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
16
+ if (typeof value === "object" && value !== null) return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
17
+ const encoded = JSON.stringify(value);
18
+ if (encoded === void 0) throw new TypeError("Action input must be JSON.");
19
+ return encoded;
20
+ }
21
+ function inputHash(input) {
22
+ return createHash("sha256").update(canonical(input)).digest("hex");
23
+ }
24
+ const confirmations = defineState("segmentstream.confirmations", () => ({}));
25
+ function actionApproval(options) {
26
+ const state = options.state ?? confirmations;
27
+ const now = options.now ?? Date.now;
28
+ const denied = (reason) => ({
29
+ type: "denied",
30
+ reason
31
+ });
32
+ return {
33
+ async request(ctx) {
34
+ const memberId = memberIdOf(ctx.session.auth.current);
35
+ if (memberId === void 0) return denied("A paired member is required.");
36
+ const hash = inputHash(ctx.toolInput ?? {});
37
+ const saved = state.get()[ctx.callId];
38
+ if (saved !== void 0) {
39
+ if (saved.memberId !== memberId || saved.action !== ctx.toolName || saved.inputHash !== hash) return denied("The confirmation belongs to another member, action or input.");
40
+ if (saved.expiresAt <= now()) return denied("The confirmation expired. Make a new call.");
41
+ if (saved.status === "consumed" || saved.status === "cancelled") return denied("The confirmation has already been used or cancelled.");
42
+ }
43
+ let labels;
44
+ try {
45
+ labels = await options.labels(memberId, ctx.toolName, ctx.toolInput ?? {});
46
+ } catch (error) {
47
+ console.warn("The ontology action permission check failed.", error);
48
+ return denied("The action is unavailable or the member no longer has permission.");
49
+ }
50
+ if (saved?.status === "approved") {
51
+ state.update((all) => ({
52
+ ...all,
53
+ [ctx.callId]: {
54
+ ...saved,
55
+ status: "consumed"
56
+ }
57
+ }));
58
+ return "approved";
59
+ }
60
+ if (saved !== void 0) return denied("The original call is waiting for its Confirm / Cancel buttons.");
61
+ if (!needsConfirmation(labels)) return "approved";
62
+ state.update((all) => ({
63
+ ...all,
64
+ [ctx.callId]: {
65
+ memberId,
66
+ action: ctx.toolName,
67
+ inputHash: hash,
68
+ expiresAt: now() + CONFIRMATION_TTL_MS,
69
+ status: "pending"
70
+ }
71
+ }));
72
+ return "user-approval";
73
+ },
74
+ response(ctx) {
75
+ const saved = state.get()[ctx.request.callId];
76
+ const reject = (reason) => ({
77
+ status: "rejected",
78
+ reason
79
+ });
80
+ if (saved === void 0 || saved.status !== "pending") return reject("This confirmation is no longer pending.");
81
+ if (ctx.response.principal.attributes.confirmationRequestId !== ctx.request.requestId || ctx.response.principal.attributes.confirmationDecision !== ctx.response.decision) return reject("Use the Confirm or Cancel button on the action's message.");
82
+ if (memberIdOf(ctx.response.principal) !== saved.memberId || memberIdOf(ctx.request.principal) !== saved.memberId) return reject("Only the requesting member can confirm or cancel this action.");
83
+ if (saved.action !== ctx.request.toolName || saved.inputHash !== inputHash(ctx.request.toolInput ?? {})) return reject("The action or input changed. Make a new call.");
84
+ if (ctx.response.decision === "approve" && saved.expiresAt <= now()) return reject("This confirmation expired.");
85
+ state.update((all) => ({
86
+ ...all,
87
+ [ctx.request.callId]: {
88
+ ...saved,
89
+ status: ctx.response.decision === "approve" ? "approved" : "cancelled"
90
+ }
91
+ }));
92
+ return { status: "allowed" };
93
+ }
94
+ };
95
+ }
96
+ export { CONFIRMATION_TTL_MS, actionApproval, confirmations, inputHash, needsConfirmation };
@@ -33,4 +33,5 @@ export declare function ontologyConnection(input: {
33
33
  readonly principal: SessionAuthContext | null;
34
34
  readonly environment?: Environment;
35
35
  readonly token?: RuntimeToken;
36
+ readonly fetch?: typeof fetch;
36
37
  }): McpClientConnectionDefinition | null;
@@ -6,7 +6,9 @@ __eveDirname(__filename);
6
6
  __eveCreateRequire(import.meta.url);
7
7
  import { appOrigin } from "./environment.mjs";
8
8
  import { memberIdOf } from "./telegram/principal.mjs";
9
+ import { actionApproval } from "./confirmations.mjs";
9
10
  import { runtimeToken, tokenExpiry } from "./runtime-token.mjs";
11
+ import { ontologyToolLabels } from "./ontology-tools.mjs";
10
12
  import { defineMcpClientConnection } from "eve/connections";
11
13
  const ACT_AS_HEADER = "X-SegmentStream-Act-As";
12
14
  const DESCRIPTION = "The company's ontology: its records, their history, and the actions that change them, narrowed to what the member you act for may read and do. Call describe for the catalog and me for the member.";
@@ -28,6 +30,7 @@ function ontologyConnection(input) {
28
30
  url: `${origin}/mcp`,
29
31
  description: DESCRIPTION,
30
32
  instanceKey: `${origin}/mcp#member:${memberId}`,
33
+ approval: actionApproval({ labels: ontologyToolLabels(input) }),
31
34
  auth: {
32
35
  credentialOwner: "app",
33
36
  getToken: async () => {
@@ -0,0 +1,11 @@
1
+ import type { ActionAnnotations } from "./confirmations.js";
2
+ import { type Environment } from "./environment.js";
3
+ import { type RuntimeToken } from "./runtime-token.js";
4
+ /** A fresh, permission-filtered catalog. Listing tools cannot run an action's prepare step. */
5
+ export declare function ontologyToolLabels(options?: {
6
+ readonly environment?: Environment;
7
+ readonly token?: RuntimeToken;
8
+ readonly fetch?: typeof fetch;
9
+ }): (memberId: string, action: string) => Promise<ActionAnnotations>;
10
+ /** MCP normalizes an authored camelCase action to snake_case. */
11
+ export declare function actionToolName(entity: string, action: string): string;
@@ -0,0 +1,41 @@
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 { appOrigin } from "./environment.mjs";
8
+ import { runtimeToken } from "./runtime-token.mjs";
9
+ function ontologyToolLabels(options = {}) {
10
+ return async (memberId, action) => {
11
+ const response = await (options.fetch ?? fetch)(`${appOrigin(options.environment ?? process.env)}/mcp`, {
12
+ method: "POST",
13
+ headers: {
14
+ authorization: `Bearer ${await (options.token ?? runtimeToken)()}`,
15
+ "X-SegmentStream-Act-As": memberId,
16
+ "content-type": "application/json",
17
+ accept: "application/json, text/event-stream",
18
+ "mcp-protocol-version": "2025-11-25"
19
+ },
20
+ body: JSON.stringify({
21
+ jsonrpc: "2.0",
22
+ id: "approval-tools",
23
+ method: "tools/list",
24
+ params: {}
25
+ }),
26
+ signal: AbortSignal.timeout(15e3),
27
+ redirect: "error"
28
+ });
29
+ if (!response.ok) throw new Error("Cannot check action permission.");
30
+ const body = await response.json();
31
+ const bareName = action.split("__").at(-1);
32
+ const tool = body.result?.tools?.find((candidate) => candidate.name === bareName);
33
+ if (tool === void 0) throw new Error("Action not permitted.");
34
+ return tool.annotations ?? {};
35
+ };
36
+ }
37
+ function actionToolName(entity, action) {
38
+ const snake = (name) => name.replace(/([A-Z])([A-Z][a-z])/g, "$1_$2").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
39
+ return `${snake(entity)}_${snake(action)}`;
40
+ }
41
+ export { actionToolName, ontologyToolLabels };
@@ -0,0 +1,14 @@
1
+ export interface PendingQuestion {
2
+ readonly id: string;
3
+ readonly question: string;
4
+ readonly task: string;
5
+ readonly memberId: string;
6
+ readonly chatId: string;
7
+ readonly messageThreadId: string;
8
+ readonly address: string;
9
+ }
10
+ /** Intent survives turns and compaction in the session, independently of model history. */
11
+ export declare const pendingQuestions: import("eve/context").StateHandle<Record<string, PendingQuestion>>;
12
+ export declare function questionKey(question: Pick<PendingQuestion, "memberId" | "chatId" | "messageThreadId">): string;
13
+ export declare function isPendingQuestion(value: unknown): value is PendingQuestion;
14
+ export declare function answerMarker(id: string): string;