@anteros/core 0.0.41 → 0.0.42
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/database/audit.ts +9 -59
- package/database/collection.ts +3 -1
- package/database/deleteLog.ts +67 -3
- package/database/file.ts +4 -3
- package/database/mongodbadapter.ts +103 -42
- package/database/replication.ts +110 -32
- package/database/ttl.ts +68 -0
- package/database/workflow.ts +466 -240
- package/index.ts +6 -0
- package/lib/agent.ts +1327 -0
- package/lib/agentMemory.ts +586 -0
- package/lib/agents.ts +157 -0
- package/lib/ai.ts +0 -0
- package/lib/api.ts +246 -0
- package/lib/attachment.ts +351 -0
- package/lib/auditResult.ts +13 -0
- package/lib/define.ts +43 -1
- package/lib/endpoints.ts +80 -0
- package/lib/files.ts +2 -1
- package/lib/jsonSchema.ts +183 -0
- package/lib/lifecycle.ts +7 -1
- package/lib/load.ts +17 -0
- package/lib/mcp.ts +3 -2
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +867 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- package/lib/sockets.ts +3 -1
- package/lib/workflow.ts +209 -37
- package/lib/zip.ts +109 -0
- package/package.json +1 -1
- package/server/access.ts +96 -0
- package/server/agents.ts +451 -0
- package/server/api.ts +14 -72
- package/server/boot.ts +11 -0
- package/server/hono.ts +8 -3
- package/server/mcp.ts +17 -63
- package/server/routes.ts +19 -24
- package/tests/agent-memory.test.ts +617 -0
- package/tests/agent-working-memory.test.ts +402 -0
- package/tests/agent.test.ts +1239 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api.test.ts +51 -0
- package/tests/attachment.test.ts +241 -0
- package/tests/audit-operations.test.ts +1 -1
- package/tests/audit-redact.test.ts +29 -3
- package/tests/audit-run-action.test.ts +1 -1
- package/tests/endpoints.test.ts +80 -0
- package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
- package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
- package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
- package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
- package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
- package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
- package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
- package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
- package/tests/fixtures/attachment/data.csv +2 -0
- package/tests/fixtures/fake-provider.ts +128 -0
- package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
- package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
- package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
- package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
- package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
- package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
- package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
- package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
- package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
- package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
- package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
- package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
- package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
- package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
- package/tests/hono.test.ts +6 -3
- package/tests/locks.test.ts +120 -0
- package/tests/mcp.test.ts +11 -11
- package/tests/messages.test.ts +258 -0
- package/tests/replication-control.test.ts +2 -2
- package/tests/replication-meta.test.ts +243 -0
- package/tests/upload.test.ts +44 -12
- package/tests/vars-api.test.ts +1 -1
- package/tests/vars-replication.test.ts +3 -2
- package/tests/workflow-engine.test.ts +340 -0
- package/tests/workflow-retention.test.ts +103 -0
- package/tests/workflow.test.ts +186 -0
- package/types/agent.d.ts +837 -0
- package/types/config.d.ts +16 -0
- package/types/file.d.ts +7 -7
- package/types/hook.d.ts +6 -0
- package/types/lifecycle.d.ts +6 -0
- package/types/mcp.d.ts +11 -1
- package/types/replication.d.ts +29 -0
- package/types/route.d.ts +7 -1
- package/types/scripts.d.ts +6 -0
- package/types/service.d.ts +7 -1
- package/types/tenant.d.ts +13 -0
- package/types/vars.d.ts +2 -2
- package/types/websocket.d.ts +6 -0
- package/types/workflow.d.ts +50 -3
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { AppError } from "./error";
|
|
3
|
+
import { openZip, type ZipArchive } from "./zip";
|
|
4
|
+
import type { AgentAttachment, AgentMediaPart, AgentTextPart } from "../types/agent";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Attachments — images and documents handed to a model with a run.
|
|
8
|
+
*
|
|
9
|
+
* An LLM only understands three things: **text**, **images** and — for some
|
|
10
|
+
* models — **documents** (PDF). So every file is resolved to one of those:
|
|
11
|
+
*
|
|
12
|
+
* | Input | What the model receives |
|
|
13
|
+
* | --- | --- |
|
|
14
|
+
* | `.md` `.txt` `.json` `.csv` `.tsv` `.yaml` `.xml` `.html` `.log`, code… | the **text**, under a header naming the file |
|
|
15
|
+
* | `.png` `.jpg` `.jpeg` `.webp` `.gif` | an **image** part (base64) — native vision |
|
|
16
|
+
* | `.pdf` | a **file** part (base64) — native document input, where the model supports it |
|
|
17
|
+
* | `.docx` | the **text** extracted from `word/document.xml` (no dependency: the file is a ZIP) |
|
|
18
|
+
* | `.xlsx` | the **rows**, one CSV block per sheet, from the workbook parts |
|
|
19
|
+
* | `.doc` (legacy), anything else | refused — `AGENT_FILE_UNSUPPORTED` |
|
|
20
|
+
*
|
|
21
|
+
* The extraction of `.docx` / `.xlsx` is a deliberate simplification: it reads
|
|
22
|
+
* the text content, not the layout, the styles or the embedded objects. Good
|
|
23
|
+
* enough for a model; never a replacement for a document renderer.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Text formats read as-is — the cheapest and most faithful thing to do. */
|
|
27
|
+
const TEXT_EXTENSIONS = new Set([
|
|
28
|
+
"md", "markdown", "mdx", "txt", "text", "log",
|
|
29
|
+
"json", "jsonc", "ndjson", "csv", "tsv",
|
|
30
|
+
"yaml", "yml", "toml", "ini", "env",
|
|
31
|
+
"xml", "html", "htm", "svg",
|
|
32
|
+
"ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "rb", "go", "rs", "java", "php",
|
|
33
|
+
"sql", "sh", "bash", "zsh", "css", "scss", "vue", "svelte", "graphql", "gql",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** Image formats both providers accept natively. */
|
|
37
|
+
const IMAGE_MIME: Record<string, string> = {
|
|
38
|
+
png: "image/png",
|
|
39
|
+
jpg: "image/jpeg",
|
|
40
|
+
jpeg: "image/jpeg",
|
|
41
|
+
webp: "image/webp",
|
|
42
|
+
gif: "image/gif",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Formats the model can receive as a document part. */
|
|
46
|
+
const DOCUMENT_MIME: Record<string, string> = {
|
|
47
|
+
pdf: "application/pdf",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Extensions worth naming when we refuse a file. */
|
|
51
|
+
const OFFICE_BINARY: Record<string, string> = {
|
|
52
|
+
doc: "the legacy .doc format is not readable — convert it to .docx or PDF",
|
|
53
|
+
xls: "the legacy .xls format is not readable — convert it to .xlsx or CSV",
|
|
54
|
+
ppt: "the legacy .ppt format is not readable — convert it to .pptx or PDF",
|
|
55
|
+
pptx: "PowerPoint is not extracted — convert it to PDF",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** One file never exceeds this — a model is not a file server. */
|
|
59
|
+
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
60
|
+
/** Inlined text is capped too: a 40 MB CSV must not become a 40 MB prompt. */
|
|
61
|
+
const MAX_TEXT_CHARS = 200_000;
|
|
62
|
+
|
|
63
|
+
function extensionOf(name: string): string {
|
|
64
|
+
return path.extname(name).replace(/^\./, "").toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function unsupported(name: string): AppError {
|
|
68
|
+
const extension = extensionOf(name);
|
|
69
|
+
const hint = OFFICE_BINARY[extension];
|
|
70
|
+
return new AppError(
|
|
71
|
+
hint
|
|
72
|
+
? `Cannot read '${name}': ${hint}`
|
|
73
|
+
: `Cannot read '${name}': supported attachments are images (png, jpg, webp, gif), PDF, `
|
|
74
|
+
+ `docx, xlsx and text formats (md, txt, json, csv, yaml, code…)`,
|
|
75
|
+
{ status: 400, code: "AGENT_FILE_UNSUPPORTED" },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function tooLarge(name: string, size: number): AppError {
|
|
80
|
+
return new AppError(
|
|
81
|
+
`'${name}' is ${Math.round(size / 1024 / 1024)}MB — the limit is ${MAX_FILE_BYTES / 1024 / 1024}MB`,
|
|
82
|
+
{ status: 413, code: "AGENT_FILE_TOO_LARGE" },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toBase64(bytes: Uint8Array): string {
|
|
87
|
+
return Buffer.from(bytes).toString("base64");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Decode the XML entities an Office part can contain. */
|
|
91
|
+
function decodeEntities(text: string): string {
|
|
92
|
+
return text
|
|
93
|
+
.replace(/</g, "<")
|
|
94
|
+
.replace(/>/g, ">")
|
|
95
|
+
.replace(/"/g, '"')
|
|
96
|
+
.replace(/'/g, "'")
|
|
97
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
|
|
98
|
+
.replace(/&/g, "&");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function stripTags(xml: string): string {
|
|
102
|
+
return decodeEntities(xml.replace(/<[^>]*>/g, ""));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── Word ────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/** `word/document.xml` → plain text (paragraphs, tabs and breaks preserved). */
|
|
108
|
+
function docxText(zip: ZipArchive): string {
|
|
109
|
+
const document = zip.read("word/document.xml");
|
|
110
|
+
if (!document) throw new AppError("Not a readable .docx (no word/document.xml)", {
|
|
111
|
+
status: 400, code: "AGENT_FILE_INVALID",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const xml = new TextDecoder().decode(document);
|
|
115
|
+
const text = xml
|
|
116
|
+
.replace(/<w:tab\b[^>]*\/>/g, "\t")
|
|
117
|
+
.replace(/<w:br\b[^>]*\/>/g, "\n")
|
|
118
|
+
.replace(/<\/w:p>/g, "\n")
|
|
119
|
+
.replace(/<[^>]*>/g, "");
|
|
120
|
+
|
|
121
|
+
return decodeEntities(text)
|
|
122
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
123
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
124
|
+
.trim();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Excel ───────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/** `xl/sharedStrings.xml` → the strings the cells refer to by index. */
|
|
130
|
+
function xlsxSharedStrings(zip: ZipArchive): string[] {
|
|
131
|
+
const shared = zip.read("xl/sharedStrings.xml");
|
|
132
|
+
if (!shared) return [];
|
|
133
|
+
|
|
134
|
+
const xml = new TextDecoder().decode(shared);
|
|
135
|
+
return [...xml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)].map((match) =>
|
|
136
|
+
[...(match[1] ?? "").matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)]
|
|
137
|
+
.map((run) => decodeEntities(run[1] ?? ""))
|
|
138
|
+
.join(""));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** `B7` → 1 (0-based column index), so gaps in a row are preserved. */
|
|
142
|
+
function columnOf(reference: string): number {
|
|
143
|
+
const letters = /^([A-Z]+)/.exec(reference)?.[1] ?? "";
|
|
144
|
+
let index = 0;
|
|
145
|
+
for (const letter of letters) index = index * 26 + (letter.charCodeAt(0) - 64);
|
|
146
|
+
return Math.max(0, index - 1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function csvCell(value: string): string {
|
|
150
|
+
return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** One worksheet → CSV rows (values, not formulas — a model reads values). */
|
|
154
|
+
function xlsxSheet(xml: string, shared: string[]): string[] {
|
|
155
|
+
const rows: string[] = [];
|
|
156
|
+
|
|
157
|
+
for (const rowMatch of xml.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/g)) {
|
|
158
|
+
const cells: string[] = [];
|
|
159
|
+
for (const cellMatch of (rowMatch[1] ?? "").matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
|
160
|
+
const attributes = cellMatch[1] ?? "";
|
|
161
|
+
const body = cellMatch[2] ?? "";
|
|
162
|
+
const type = /\bt="([^"]+)"/.exec(attributes)?.[1];
|
|
163
|
+
const reference = /\br="([^"]+)"/.exec(attributes)?.[1] ?? "";
|
|
164
|
+
const position = reference ? columnOf(reference) : cells.length;
|
|
165
|
+
|
|
166
|
+
let value = "";
|
|
167
|
+
if (type === "s") {
|
|
168
|
+
const index = Number(stripTags(/\<v\>([\s\S]*?)\<\/v\>/.exec(body)?.[1] ?? ""));
|
|
169
|
+
value = shared[index] ?? "";
|
|
170
|
+
} else if (type === "inlineStr") {
|
|
171
|
+
value = [...body.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)]
|
|
172
|
+
.map((run) => decodeEntities(run[1] ?? "")).join("");
|
|
173
|
+
} else {
|
|
174
|
+
value = decodeEntities(/\<v\>([\s\S]*?)\<\/v\>/.exec(body)?.[1] ?? "");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
while (cells.length < position) cells.push("");
|
|
178
|
+
cells[position] = value;
|
|
179
|
+
}
|
|
180
|
+
rows.push(cells.map(csvCell).join(","));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return rows;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Every worksheet, in tab order, as `### <sheet name>` + CSV rows. */
|
|
187
|
+
function xlsxText(zip: ZipArchive): string {
|
|
188
|
+
const sheets = zip.names()
|
|
189
|
+
.filter((name) => /^xl\/worksheets\/sheet\d+\.xml$/.test(name))
|
|
190
|
+
.sort((a, b) => Number(/(\d+)\.xml$/.exec(a)?.[1]) - Number(/(\d+)\.xml$/.exec(b)?.[1]));
|
|
191
|
+
|
|
192
|
+
if (!sheets.length) throw new AppError("Not a readable .xlsx (no worksheet)", {
|
|
193
|
+
status: 400, code: "AGENT_FILE_INVALID",
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const shared = xlsxSharedStrings(zip);
|
|
197
|
+
|
|
198
|
+
return sheets.map((sheet, index) => {
|
|
199
|
+
const xml = new TextDecoder().decode(zip.read(sheet) ?? new Uint8Array());
|
|
200
|
+
return `### Sheet ${index + 1}\n\n${xlsxSheet(xml, shared).join("\n")}`;
|
|
201
|
+
}).join("\n\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── Resolution ──────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
/** What a caller can attach — a path (server-side), a `Blob`/`File`, raw bytes, or base64. */
|
|
207
|
+
export type NormalizedAttachment = {
|
|
208
|
+
name: string;
|
|
209
|
+
mimeType: string;
|
|
210
|
+
bytes: Uint8Array;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Read what the caller attached. A string is a **path** on the server (read
|
|
215
|
+
* now, at call time) — the SDK sends base64 instead, since a browser has no
|
|
216
|
+
* filesystem.
|
|
217
|
+
*/
|
|
218
|
+
async function materialize(attachment: AgentAttachment, baseDir: string): Promise<NormalizedAttachment> {
|
|
219
|
+
if (typeof attachment === "string") {
|
|
220
|
+
const file = path.resolve(baseDir, attachment);
|
|
221
|
+
if (!(await Bun.file(file).exists())) {
|
|
222
|
+
throw new AppError(`Attachment '${attachment}' not found`, { status: 400, code: "AGENT_FILE_NOT_FOUND" });
|
|
223
|
+
}
|
|
224
|
+
const bytes = new Uint8Array(await Bun.file(file).arrayBuffer());
|
|
225
|
+
return { name: path.basename(file), mimeType: "", bytes };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (attachment instanceof Uint8Array) {
|
|
229
|
+
return { name: "attachment", mimeType: "", bytes: attachment };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (typeof Blob !== "undefined" && attachment instanceof Blob) {
|
|
233
|
+
const name = (attachment as File).name ?? "attachment";
|
|
234
|
+
return {
|
|
235
|
+
name,
|
|
236
|
+
mimeType: (attachment as File).type ?? "",
|
|
237
|
+
bytes: new Uint8Array(await attachment.arrayBuffer()),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const explicit = attachment as { name?: string; mimeType?: string; data: string | Uint8Array; encoding?: "base64" | "utf8" };
|
|
242
|
+
const name = explicit.name ?? "attachment";
|
|
243
|
+
if (explicit.data instanceof Uint8Array) {
|
|
244
|
+
return { name, mimeType: explicit.mimeType ?? "", bytes: explicit.data };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const encoding = explicit.encoding ?? "base64";
|
|
248
|
+
return {
|
|
249
|
+
name,
|
|
250
|
+
mimeType: explicit.mimeType ?? "",
|
|
251
|
+
bytes: encoding === "utf8"
|
|
252
|
+
? new TextEncoder().encode(explicit.data)
|
|
253
|
+
: new Uint8Array(Buffer.from(explicit.data, "base64")),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Turn attachments into **content parts** the providers understand.
|
|
259
|
+
*
|
|
260
|
+
* @returns the parts to append to the current user turn (`[]` when there is
|
|
261
|
+
* nothing to attach). Throws `AGENT_FILE_*` when a file cannot be sent — a
|
|
262
|
+
* silently dropped attachment is worse than a loud failure.
|
|
263
|
+
*/
|
|
264
|
+
async function resolveAttachments(
|
|
265
|
+
attachments: AgentAttachment | AgentAttachment[] | undefined,
|
|
266
|
+
options: { baseDir?: string } = {},
|
|
267
|
+
): Promise<AgentMediaPart[]> {
|
|
268
|
+
if (!attachments) return [];
|
|
269
|
+
const list = Array.isArray(attachments) ? attachments : [attachments];
|
|
270
|
+
if (!list.length) return [];
|
|
271
|
+
|
|
272
|
+
const baseDir = options.baseDir ?? process.cwd();
|
|
273
|
+
const parts: AgentMediaPart[] = [];
|
|
274
|
+
|
|
275
|
+
for (const attachment of list) {
|
|
276
|
+
const file = await materialize(attachment, baseDir);
|
|
277
|
+
if (file.bytes.byteLength > MAX_FILE_BYTES) throw tooLarge(file.name, file.bytes.byteLength);
|
|
278
|
+
|
|
279
|
+
const extension = extensionOf(file.name);
|
|
280
|
+
const declared = file.mimeType.toLowerCase();
|
|
281
|
+
const mimeType = declared || IMAGE_MIME[extension] || DOCUMENT_MIME[extension] || "";
|
|
282
|
+
|
|
283
|
+
// 1. Images — native vision on both protocols
|
|
284
|
+
const imageMime = IMAGE_MIME[extension] ?? (declared.startsWith("image/") ? declared : "");
|
|
285
|
+
if (imageMime) {
|
|
286
|
+
parts.push({ type: "image", data: toBase64(file.bytes), mimeType: imageMime });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 2/3. PDF — a document part (the provider decides what it does with it)
|
|
291
|
+
const documentMime = DOCUMENT_MIME[extension] ?? (declared === "application/pdf" ? declared : "");
|
|
292
|
+
if (documentMime) {
|
|
293
|
+
parts.push({ type: "file", name: file.name, mimeType: documentMime, data: toBase64(file.bytes) });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 4. Office documents — extracted to text, no dependency
|
|
298
|
+
if (extension === "docx" || extension === "xlsx") {
|
|
299
|
+
const text = extension === "docx"
|
|
300
|
+
? docxText(openZip(file.bytes))
|
|
301
|
+
: xlsxText(openZip(file.bytes));
|
|
302
|
+
parts.push({ type: "text", text: textPart(file.name, text) });
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 5. Text formats — read as-is
|
|
307
|
+
if (TEXT_EXTENSIONS.has(extension) || declared.startsWith("text/") || declared === "application/json") {
|
|
308
|
+
const text = new TextDecoder().decode(file.bytes);
|
|
309
|
+
parts.push({ type: "text", text: textPart(file.name, text) });
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
throw unsupported(file.name);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return parts;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** A file's text under a header naming it — the model must know where it comes from. */
|
|
320
|
+
function textPart(name: string, text: string): string {
|
|
321
|
+
const trimmed = text.trim();
|
|
322
|
+
if (trimmed.length <= MAX_TEXT_CHARS) return `--- ${name} ---\n${trimmed}`;
|
|
323
|
+
return `--- ${name} (truncated) ---\n${trimmed.slice(0, MAX_TEXT_CHARS)}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* What a **memory** keeps of an attachment. Conversations are text: storing a
|
|
328
|
+
* 5 MB image in every turn would fill the store for nothing, and the model
|
|
329
|
+
* already read it once. The reply and the question stay verbatim.
|
|
330
|
+
*/
|
|
331
|
+
function attachmentNote(part: AgentMediaPart): string {
|
|
332
|
+
if (part.type === "image") return "[image]";
|
|
333
|
+
if (part.type === "file") return `[file ${part.name ?? "document"}]`;
|
|
334
|
+
const header = /^--- (.+?) ---/.exec(part.text)?.[1];
|
|
335
|
+
return header ? `[file ${header}]` : "";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** A part as it should be *stored* — the heavy payloads replaced by a note. */
|
|
339
|
+
function forMemory(part: AgentMediaPart): AgentTextPart {
|
|
340
|
+
if (part.type === "text") return part;
|
|
341
|
+
return { type: "text", text: attachmentNote(part) };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export {
|
|
345
|
+
MAX_FILE_BYTES,
|
|
346
|
+
MAX_TEXT_CHARS,
|
|
347
|
+
docxText,
|
|
348
|
+
forMemory,
|
|
349
|
+
resolveAttachments,
|
|
350
|
+
xlsxText,
|
|
351
|
+
};
|
package/lib/auditResult.ts
CHANGED
|
@@ -107,6 +107,19 @@ function summarizeAuditResult(action: string, result: any): any {
|
|
|
107
107
|
return typeof result === 'string' ? result : null
|
|
108
108
|
case 'dropIndexes':
|
|
109
109
|
return result?.nIndexesWas !== undefined ? { nIndexesWas: result.nIndexesWas } : null
|
|
110
|
+
case 'agent.generate':
|
|
111
|
+
case 'agent.stream':
|
|
112
|
+
case 'agent.object':
|
|
113
|
+
// The model's answer is never stored — only the envelope that proves the
|
|
114
|
+
// run happened and what it cost (nothing the caller did not already send).
|
|
115
|
+
return result ? {
|
|
116
|
+
finishReason: result.finishReason ?? null,
|
|
117
|
+
usage: result.usage ?? null,
|
|
118
|
+
tools: Array.isArray(result.toolCalls)
|
|
119
|
+
? result.toolCalls.map((call: any) => call?.name).filter(Boolean)
|
|
120
|
+
: [],
|
|
121
|
+
steps: Array.isArray(result.steps) ? result.steps.length : null,
|
|
122
|
+
} : null
|
|
110
123
|
default:
|
|
111
124
|
// Anything unknown: a document must never be stored.
|
|
112
125
|
return null
|
package/lib/define.ts
CHANGED
|
@@ -159,14 +159,54 @@ function Middleware(config: GlobalMiddlewareConfig): GlobalMiddlewareConfig {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
import type { WorkflowDefinition } from "../types/workflow";
|
|
162
|
+
import type { AgentConfig, AgentTool, AgentToolDefinition } from "../types/agent";
|
|
162
163
|
|
|
163
164
|
export function Workflow<TData = any>(workflow: WorkflowDefinition<TData>): WorkflowDefinition<TData> {
|
|
164
165
|
return {
|
|
165
166
|
...workflow,
|
|
167
|
+
enabled: workflow.enabled ?? true,
|
|
166
168
|
_isWorkflow_: true,
|
|
167
169
|
}
|
|
168
170
|
}
|
|
169
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Declares an LLM agent — `{tenant.dir}/agents/**\/*.agent.ts`.
|
|
174
|
+
*
|
|
175
|
+
* `id` and `description` are **required**: the id identifies it (and looks it up),
|
|
176
|
+
* the description says what it is for — it is served by `info` and read by humans.
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```ts
|
|
180
|
+
* export default define.Agent({
|
|
181
|
+
* id: 'weather',
|
|
182
|
+
* description: 'Answers weather questions with a live forecast.',
|
|
183
|
+
* instructions: 'You are a concise weather assistant.',
|
|
184
|
+
* provider: { model: 'gpt-4o-mini', compatible: 'openai' },
|
|
185
|
+
* tools: { forecast },
|
|
186
|
+
* })
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
function Agent(config: AgentConfig & { id: string; description: string }): AgentConfig {
|
|
190
|
+
return {
|
|
191
|
+
...config,
|
|
192
|
+
enabled: config.enabled ?? true,
|
|
193
|
+
_isAgent_: true,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Declares an agent tool — `define.Tool({ id, description, inputSchema, execute })`.
|
|
199
|
+
* The `id` is **required** (it is the name the model calls).
|
|
200
|
+
* A `define.McpTool` is accepted by an agent as-is.
|
|
201
|
+
*/
|
|
202
|
+
function Tool(tool: AgentToolDefinition): AgentToolDefinition {
|
|
203
|
+
return {
|
|
204
|
+
...tool,
|
|
205
|
+
enabled: tool.enabled ?? true,
|
|
206
|
+
_isTool_: true,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
170
210
|
|
|
171
211
|
|
|
172
212
|
export const define = {
|
|
@@ -186,5 +226,7 @@ export const define = {
|
|
|
186
226
|
McpTool,
|
|
187
227
|
McpResource,
|
|
188
228
|
Lifecycle,
|
|
189
|
-
Vars
|
|
229
|
+
Vars,
|
|
230
|
+
Agent,
|
|
231
|
+
Tool,
|
|
190
232
|
}
|
package/lib/endpoints.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The URL of everything — one place.
|
|
3
|
+
*
|
|
4
|
+
* Every framework surface lives under a **single namespace**, `/api/:tenant_id`,
|
|
5
|
+
* and a **family** segment says what it is:
|
|
6
|
+
*
|
|
7
|
+
* | Surface | Route |
|
|
8
|
+
* | --- | --- |
|
|
9
|
+
* | Collections (CRUD, actions, login…) | `POST /api/:tenant_id/collections/:collection/:action` |
|
|
10
|
+
* | Services | `POST /api/:tenant_id/services/:service/:action` |
|
|
11
|
+
* | Variables | `POST /api/:tenant_id/vars/:action` |
|
|
12
|
+
* | Upload | `POST /api/:tenant_id/upload/:collection` |
|
|
13
|
+
* | Files (serve, delete) | `GET\|DELETE /api/:tenant_id/files/:collection/:file` |
|
|
14
|
+
* | Agents | `POST /api/:tenant_id/agents/:agent/:action` |
|
|
15
|
+
* | MCP (tools & resources) | `GET\|POST /api/:tenant_id/mcp` |
|
|
16
|
+
*
|
|
17
|
+
* Two reasons for the shape. A tenant's own routes (`routes: { prefix: '/api/v1' }`)
|
|
18
|
+
* are mounted **before** this one, so they always win — and nothing is ambiguous:
|
|
19
|
+
* a collection may be called `services` or `login` without ever colliding with the
|
|
20
|
+
* family segment, since a collection always sits under `/collections/`.
|
|
21
|
+
*
|
|
22
|
+
* `patterns` is what the server registers (Hono patterns); `endpoints` is what a
|
|
23
|
+
* client builds — the two are read together, so a path is never written twice.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The family segments — the component right after the tenant id. */
|
|
27
|
+
const FAMILIES = ["collections", "services", "vars", "upload", "files", "agents", "mcp"] as const;
|
|
28
|
+
|
|
29
|
+
const patterns = {
|
|
30
|
+
collection: "/api/:tenant_id/collections/:collection/:action",
|
|
31
|
+
/** The login action of an auth-enabled collection — its own rate limit. */
|
|
32
|
+
login: "/api/:tenant_id/collections/:collection/login",
|
|
33
|
+
service: "/api/:tenant_id/services/:service/:action",
|
|
34
|
+
vars: "/api/:tenant_id/vars/:action",
|
|
35
|
+
upload: "/api/:tenant_id/upload/:collection",
|
|
36
|
+
file: "/api/:tenant_id/files/:collection/:file",
|
|
37
|
+
agent: "/api/:tenant_id/agents/:agent/:action",
|
|
38
|
+
mcp: "/api/:tenant_id/mcp",
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
/** One path segment, encoded. */
|
|
42
|
+
function segment(value: string): string {
|
|
43
|
+
return encodeURIComponent(String(value));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const endpoints = {
|
|
47
|
+
/** Everything of a collection — `find`, `insertOne`, `login`, a custom action… */
|
|
48
|
+
collection: (tenant: string, collection: string, action: string) =>
|
|
49
|
+
`/api/${segment(tenant)}/collections/${segment(collection)}/${segment(action)}`,
|
|
50
|
+
|
|
51
|
+
service: (tenant: string, service: string, action: string) =>
|
|
52
|
+
`/api/${segment(tenant)}/services/${segment(service)}/${segment(action)}`,
|
|
53
|
+
|
|
54
|
+
/** Variables — the namespace and the key travel in the body. */
|
|
55
|
+
vars: (tenant: string, action: string) =>
|
|
56
|
+
`/api/${segment(tenant)}/vars/${segment(action)}`,
|
|
57
|
+
|
|
58
|
+
upload: (tenant: string, collection: string) =>
|
|
59
|
+
`/api/${segment(tenant)}/upload/${segment(collection)}`,
|
|
60
|
+
|
|
61
|
+
/** One file of a file collection — the document id, or its filename. */
|
|
62
|
+
file: (tenant: string, collection: string, file: string) =>
|
|
63
|
+
`/api/${segment(tenant)}/files/${segment(collection)}/${segment(file)}`,
|
|
64
|
+
|
|
65
|
+
agent: (tenant: string, agent: string, action: string) =>
|
|
66
|
+
`/api/${segment(tenant)}/agents/${segment(agent)}/${segment(action)}`,
|
|
67
|
+
|
|
68
|
+
mcp: (tenant: string) => `/api/${segment(tenant)}/mcp`,
|
|
69
|
+
} as const;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Is this a segment the framework owns? A tenant route mounted at
|
|
73
|
+
* `/api/:tenant_id/<family>/…` is registered **first** and would shadow the
|
|
74
|
+
* framework API silently — the route loader warns about it.
|
|
75
|
+
*/
|
|
76
|
+
function isReservedFamily(name: string): boolean {
|
|
77
|
+
return (FAMILIES as readonly string[]).includes(name);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export { endpoints, FAMILIES, isReservedFamily, patterns, segment };
|
package/lib/files.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { toBson } from "../utils/func";
|
|
|
5
5
|
import { useRest } from "../database/rest";
|
|
6
6
|
import { cfg } from "../server/config";
|
|
7
7
|
import { AppError } from "./error";
|
|
8
|
+
import { endpoints } from "./endpoints";
|
|
8
9
|
import { S3Client } from "bun";
|
|
9
10
|
import type { FileCollection } from "../types/file";
|
|
10
11
|
import path from "path";
|
|
@@ -360,7 +361,7 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
|
|
|
360
361
|
const { path: filepath, size } = await storage.save(tenant_id, collection, file, { id: _id, mimetype, subpath });
|
|
361
362
|
|
|
362
363
|
// Update MongoDB with actual file info
|
|
363
|
-
const url =
|
|
364
|
+
const url = endpoints.file(tenant_id, collection, filename);
|
|
364
365
|
try {
|
|
365
366
|
const rest = new useRest({ tenant_id, internal: true, useHook: false, useCustomApi: false });
|
|
366
367
|
await rest.db.collection(collection).updateOne(
|