@anteros/core 0.0.41 → 0.0.44

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 (133) hide show
  1. package/database/audit.ts +9 -59
  2. package/database/collection.ts +3 -1
  3. package/database/deleteLog.ts +67 -3
  4. package/database/file.ts +4 -3
  5. package/database/mongodbadapter.ts +103 -42
  6. package/database/replication.ts +724 -58
  7. package/database/ttl.ts +68 -0
  8. package/database/workflow.ts +466 -240
  9. package/index.ts +6 -0
  10. package/lib/agent.ts +1567 -0
  11. package/lib/agentMemory.ts +689 -0
  12. package/lib/agents.ts +180 -0
  13. package/lib/ai.ts +0 -0
  14. package/lib/api.ts +246 -0
  15. package/lib/attachment.ts +351 -0
  16. package/lib/auditResult.ts +13 -0
  17. package/lib/define.ts +43 -1
  18. package/lib/endpoints.ts +80 -0
  19. package/lib/files.ts +2 -1
  20. package/lib/jsonSchema.ts +183 -0
  21. package/lib/lifecycle.ts +7 -1
  22. package/lib/load.ts +17 -0
  23. package/lib/mcp.ts +3 -2
  24. package/lib/mcpClient.ts +128 -0
  25. package/lib/messages.ts +299 -0
  26. package/lib/providers.ts +970 -0
  27. package/lib/routes.ts +14 -1
  28. package/lib/scriptWorker.ts +4 -1
  29. package/lib/scripts.ts +6 -1
  30. package/lib/skills.ts +481 -0
  31. package/lib/sockets.ts +3 -1
  32. package/lib/workflow.ts +209 -37
  33. package/lib/zip.ts +109 -0
  34. package/package.json +1 -1
  35. package/server/access.ts +96 -0
  36. package/server/agents.ts +472 -0
  37. package/server/api.ts +80 -65
  38. package/server/boot.ts +11 -0
  39. package/server/hono.ts +8 -3
  40. package/server/mcp.ts +17 -63
  41. package/server/routes.ts +19 -24
  42. package/tests/agent-mcp.test.ts +116 -0
  43. package/tests/agent-memory.test.ts +617 -0
  44. package/tests/agent-provider.test.ts +288 -0
  45. package/tests/agent-runtime.test.ts +369 -0
  46. package/tests/agent-skills.test.ts +177 -0
  47. package/tests/agent-summarize.test.ts +124 -0
  48. package/tests/agent-working-memory.test.ts +404 -0
  49. package/tests/agent.test.ts +1241 -0
  50. package/tests/agents-api.test.ts +593 -0
  51. package/tests/api-private-fields.test.ts +196 -0
  52. package/tests/api.test.ts +51 -0
  53. package/tests/attachment.test.ts +241 -0
  54. package/tests/audit-operations.test.ts +1 -1
  55. package/tests/audit-redact.test.ts +29 -3
  56. package/tests/audit-run-action.test.ts +1 -1
  57. package/tests/endpoints.test.ts +80 -0
  58. package/tests/file-collection-replication.test.ts +2 -0
  59. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  60. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  61. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  62. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  63. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  64. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  65. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  66. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  67. package/tests/fixtures/attachment/data.csv +2 -0
  68. package/tests/fixtures/fake-provider.ts +128 -0
  69. package/tests/fixtures/repl-only/app.ts +3 -0
  70. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  71. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  72. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  73. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  74. package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
  75. package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
  76. package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
  77. package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
  78. package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
  79. package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
  80. package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
  81. package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
  82. package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
  83. package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
  84. package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
  85. package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
  86. package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
  87. package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
  88. package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
  89. package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
  90. package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
  91. package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
  92. package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
  93. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  94. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  95. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  96. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  97. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  98. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  99. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  100. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  101. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  102. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  103. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  104. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  105. package/tests/hono.test.ts +6 -3
  106. package/tests/locks.test.ts +120 -0
  107. package/tests/mcp.test.ts +11 -11
  108. package/tests/messages.test.ts +258 -0
  109. package/tests/replication-change-stream.test.ts +240 -0
  110. package/tests/replication-control.test.ts +4 -2
  111. package/tests/replication-initial-sync.test.ts +2 -0
  112. package/tests/replication-meta.test.ts +246 -0
  113. package/tests/replication.test.ts +2 -0
  114. package/tests/upload.test.ts +44 -12
  115. package/tests/vars-api.test.ts +1 -1
  116. package/tests/vars-replication.test.ts +5 -2
  117. package/tests/workflow-engine.test.ts +340 -0
  118. package/tests/workflow-retention.test.ts +103 -0
  119. package/tests/workflow.test.ts +186 -0
  120. package/types/agent.d.ts +1067 -0
  121. package/types/config.d.ts +16 -0
  122. package/types/file.d.ts +7 -7
  123. package/types/hook.d.ts +6 -0
  124. package/types/lifecycle.d.ts +6 -0
  125. package/types/mcp.d.ts +11 -1
  126. package/types/replication.d.ts +70 -4
  127. package/types/route.d.ts +7 -1
  128. package/types/scripts.d.ts +6 -0
  129. package/types/service.d.ts +7 -1
  130. package/types/tenant.d.ts +13 -0
  131. package/types/vars.d.ts +2 -2
  132. package/types/websocket.d.ts +6 -0
  133. package/types/workflow.d.ts +50 -3
package/lib/routes.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Glob } from "bun"
2
2
  import { cfg } from "../server/config"
3
+ import { isReservedFamily } from "./endpoints"
3
4
  import path from "path"
4
5
  import fs from "fs/promises"
5
6
 
@@ -25,6 +26,18 @@ async function loadRoutes() {
25
26
  _tenant_: tenant.id,
26
27
  _prefix_: tenant.routes?.prefix,
27
28
  })
29
+
30
+ // A route mounted under a family segment the framework owns
31
+ // (`/api/v1/vars/x`) is registered first and would shadow the
32
+ // framework API silently — say it out loud.
33
+ const path = String(routeModule.default.path ?? '');
34
+ const family = path.replace(/^\/+/, '').split('/')[0];
35
+ if (family && isReservedFamily(family)) {
36
+ console.warn(
37
+ `Route '${tenant.routes?.prefix}${path}' of tenant '${tenant.id}' shadows the `
38
+ + `framework '/api/…/${family}/…' API: rename it or change the route prefix.`,
39
+ );
40
+ }
28
41
  }
29
42
  }
30
43
  }
@@ -41,4 +54,4 @@ async function loadRoutes() {
41
54
 
42
55
  export {
43
56
  loadRoutes
44
- }
57
+ }
@@ -3,6 +3,8 @@ import { useRest } from "../database/rest";
3
3
  import { syncTenants } from "../database/tenant";
4
4
  import { syncCollections } from "../database/collection";
5
5
  import { syncFileCollections } from "../database/file";
6
+ import { syncAgents, createAgents } from "./agents";
7
+ import { createApi } from "./api";
6
8
  import { asyncContextStorage, requestCtxStorage } from "./asyncContextStorage";
7
9
 
8
10
  // ─── Heavy-script worker entrypoint ─────────────────────────────────────────
@@ -42,6 +44,7 @@ async function main() {
42
44
  await syncTenants();
43
45
  await syncCollections();
44
46
  await syncFileCollections();
47
+ await syncAgents(); // a heavy script gets the same `agents` registry as any other context
45
48
  } catch (err: any) {
46
49
  console.error('[script-worker] failed to bootstrap context:', err?.message);
47
50
  process.exit(1);
@@ -62,7 +65,7 @@ async function main() {
62
65
  await asyncContextStorage.run(new Map(), async () => {
63
66
  requestCtxStorage.set('trace', { id: crypto.randomUUID() });
64
67
  requestCtxStorage.set('internal', true);
65
- await script.exec({ rest, progress, tenant });
68
+ await script.exec({ rest, progress, tenant, agents: createAgents(tenant.id, rest), api: createApi(rest) });
66
69
  });
67
70
  process.send?.({ type: 'done' });
68
71
  process.exit(0);
package/lib/scripts.ts CHANGED
@@ -5,6 +5,8 @@ import fs from "fs/promises"
5
5
  import type { Script } from "../types/scripts"
6
6
  import type { Tenant } from "../types/tenant"
7
7
  import { useRest } from "../database/rest"
8
+ import { createAgents } from "./agents"
9
+ import { createApi } from "./api"
8
10
  import { asyncContextStorage, requestCtxStorage } from "./asyncContextStorage"
9
11
 
10
12
 
@@ -95,9 +97,12 @@ async function runScripts() {
95
97
  await asyncContextStorage.run(new Map(), async () => {
96
98
  requestCtxStorage.set('trace', { id: crypto.randomUUID() })
97
99
  requestCtxStorage.set('internal', true)
100
+ const rest = new useRest({ tenant_id: tenant.id })
98
101
  await script.exec({
99
- rest: new useRest({ tenant_id: tenant.id }),
102
+ rest,
100
103
  tenant,
104
+ agents: createAgents(tenant.id, rest),
105
+ api: createApi(rest),
101
106
  })
102
107
  })
103
108
  }
package/lib/skills.ts ADDED
@@ -0,0 +1,481 @@
1
+ import { Glob } from "bun";
2
+ import path from "node:path";
3
+ import fs from "node:fs/promises";
4
+ import { AppError } from "./error";
5
+ import { logger } from "../utils/logger";
6
+ import type { AgentSkill, AgentSkillSource } from "../types/agent";
7
+
8
+ /**
9
+ * Agent **skills** — markdown documents declared on an agent
10
+ * (`skills: ['./docs/*.md', './guide/ml.md', /^runbooks\/.*\.md$/]`), resolved
11
+ * once at load and injected into the **system prompt** of every run.
12
+ *
13
+ * A skill file is markdown with a frontmatter header:
14
+ *
15
+ * ```md
16
+ * ---
17
+ * name: pdf-forms
18
+ * description: Fill, flatten and merge PDF forms. Use for any AcroForm work.
19
+ * license: MIT
20
+ * compatibility: Needs the `pdftk` binary.
21
+ * metadata: { owner: platform }
22
+ * allowed-tools: read_pdf write_pdf
23
+ * ---
24
+ *
25
+ * # Filling a form
26
+ * …
27
+ * ```
28
+ *
29
+ * `name` and `description` are required; the body is what the model reads.
30
+ */
31
+
32
+ /** Kebab-case, and short — the name identifies a skill in the prompt. */
33
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
34
+ const MAX_NAME_CHARS = 64;
35
+ const MAX_DESCRIPTION_CHARS = 1024;
36
+ const MAX_COMPATIBILITY_CHARS = 500;
37
+ /**
38
+ * Per-file ceiling. A skill is a prompt document, read on **every** call: past
39
+ * this, the agent is better served by a tool than by a document (same order of
40
+ * magnitude as the attachment text limit).
41
+ */
42
+ const MAX_SKILL_CHARS = 200_000;
43
+
44
+ /** The keys the format defines — anything else is kept out of the prompt. */
45
+ const KNOWN_KEYS = new Set(["name", "description", "license", "compatibility", "metadata", "allowed-tools", "allowedTools"]);
46
+
47
+ /* ------------------------------------------------------------------ */
48
+ /* Frontmatter */
49
+ /* ------------------------------------------------------------------ */
50
+
51
+ type Line = { indent: number; text: string };
52
+
53
+ /** Drop a trailing ` # comment`, unless the `#` sits inside a quoted value. */
54
+ function stripComment(line: string): string {
55
+ let quote: string | null = null;
56
+ for (let i = 0; i < line.length; i++) {
57
+ const char = line[i]!;
58
+ if (quote) {
59
+ if (char === quote) quote = null;
60
+ continue;
61
+ }
62
+ if (char === '"' || char === "'") {
63
+ quote = char;
64
+ continue;
65
+ }
66
+ if (char === "#" && (i === 0 || /\s/.test(line[i - 1]!))) {
67
+ return line.slice(0, i).replace(/\s+$/, "");
68
+ }
69
+ }
70
+ return line;
71
+ }
72
+
73
+ /** Index of the `:` that separates a key from its value (outside quotes). */
74
+ function keySeparator(text: string): number {
75
+ let quote: string | null = null;
76
+ for (let i = 0; i < text.length; i++) {
77
+ const char = text[i]!;
78
+ if (quote) {
79
+ if (char === quote) quote = null;
80
+ continue;
81
+ }
82
+ if (char === '"' || char === "'") {
83
+ quote = char;
84
+ continue;
85
+ }
86
+ if (char === ":" && (i === text.length - 1 || /\s/.test(text[i + 1]!))) return i;
87
+ }
88
+ return -1;
89
+ }
90
+
91
+ function unquote(text: string): string {
92
+ if (text.length >= 2 && (text[0] === '"' || text[0] === "'") && text[text.length - 1] === text[0]) {
93
+ const body = text.slice(1, -1);
94
+ return text[0] === '"' ? body.replace(/\\"/g, '"') : body.replace(/''/g, "'");
95
+ }
96
+ return text;
97
+ }
98
+
99
+ function scalar(text: string): any {
100
+ const raw = text.trim();
101
+ if (raw.startsWith("[") && raw.endsWith("]")) {
102
+ const inner = raw.slice(1, -1).trim();
103
+ return inner ? splitTopLevel(inner, ",").map((item) => scalar(item)).filter((item) => item !== undefined) : [];
104
+ }
105
+ if (raw.startsWith("{") && raw.endsWith("}")) {
106
+ const inner = raw.slice(1, -1).trim();
107
+ if (!inner) return {};
108
+ const out: Record<string, any> = {};
109
+ for (const pair of splitTopLevel(inner, ",")) {
110
+ const separator = keySeparator(pair.trim());
111
+ if (separator === -1) continue;
112
+ out[unquote(pair.slice(0, separator).trim())] = scalar(pair.slice(separator + 1).trim());
113
+ }
114
+ return out;
115
+ }
116
+ if (raw.length >= 2 && (raw[0] === '"' || raw[0] === "'") && raw[raw.length - 1] === raw[0]) return unquote(raw);
117
+ if (raw === "" || raw === "null" || raw === "~") return null;
118
+ if (raw === "true") return true;
119
+ if (raw === "false") return false;
120
+ if (/^-?\d+(?:\.\d+)?$/.test(raw)) return Number(raw);
121
+ return raw;
122
+ }
123
+
124
+ /** Split on a separator that is outside quotes and outside `[…]` / `{…}`. */
125
+ function splitTopLevel(text: string, separator: string): string[] {
126
+ const parts: string[] = [];
127
+ let depth = 0;
128
+ let quote: string | null = null;
129
+ let current = "";
130
+
131
+ for (const char of text) {
132
+ if (quote) {
133
+ current += char;
134
+ if (char === quote) quote = null;
135
+ continue;
136
+ }
137
+ if (char === '"' || char === "'") {
138
+ quote = char;
139
+ current += char;
140
+ continue;
141
+ }
142
+ if (char === "[" || char === "{") depth += 1;
143
+ if (char === "]" || char === "}") depth -= 1;
144
+ if (char === separator && depth === 0) {
145
+ parts.push(current.trim());
146
+ current = "";
147
+ continue;
148
+ }
149
+ current += char;
150
+ }
151
+ parts.push(current.trim());
152
+ return parts;
153
+ }
154
+
155
+ /**
156
+ * The block reader behind `parseYaml()` — mappings and sequences, nested by
157
+ * indentation, `[a, b]` inline.
158
+ */
159
+ function parseBlock(lines: Line[], start: number, indent: number): [any, number] {
160
+ const first = lines[start];
161
+ if (!first) return [{}, start];
162
+
163
+ // Sequence: `- value`, or `-` followed by a deeper block
164
+ if (first.text === "-" || first.text.startsWith("- ")) {
165
+ const out: any[] = [];
166
+ let index = start;
167
+ while (index < lines.length) {
168
+ const line = lines[index]!;
169
+ if (line.indent !== first.indent || !(line.text === "-" || line.text.startsWith("- "))) break;
170
+ const inline = line.text.slice(1).trim();
171
+ index += 1;
172
+ if (inline) {
173
+ out.push(scalar(inline));
174
+ continue;
175
+ }
176
+ const next = lines[index];
177
+ if (next && next.indent > first.indent) {
178
+ const [nested, after] = parseBlock(lines, index, next.indent);
179
+ out.push(nested);
180
+ index = after;
181
+ } else {
182
+ out.push(null);
183
+ }
184
+ }
185
+ return [out, index];
186
+ }
187
+
188
+ // Mapping
189
+ const out: Record<string, any> = {};
190
+ let index = start;
191
+ while (index < lines.length) {
192
+ const line = lines[index]!;
193
+ if (line.indent < indent) break;
194
+ if (line.indent > indent) {
195
+ index += 1; // a stray deeper line: ignore rather than guess
196
+ continue;
197
+ }
198
+ const separator = keySeparator(line.text);
199
+ if (separator === -1) {
200
+ index += 1;
201
+ continue;
202
+ }
203
+ const key = unquote(line.text.slice(0, separator).trim());
204
+ const rest = line.text.slice(separator + 1).trim();
205
+ index += 1;
206
+ if (rest) {
207
+ out[key] = scalar(rest);
208
+ continue;
209
+ }
210
+ const next = lines[index];
211
+ if (next && next.indent > indent) {
212
+ const [nested, after] = parseBlock(lines, index, next.indent);
213
+ out[key] = nested;
214
+ index = after;
215
+ } else {
216
+ out[key] = null;
217
+ }
218
+ }
219
+ return [out, index];
220
+ }
221
+
222
+ /**
223
+ * A dependency-free reader for the frontmatter of a skill file — a **documented
224
+ * subset of YAML**, the same way `lib/zip.ts` reads an archive without a library.
225
+ *
226
+ * Handled: `key: value` (quoted or not, `true`/`false`/`null`/numbers), blocks
227
+ * nested by indentation, sequences (`- item`), and the inline forms — `[a, b]`,
228
+ * `{ key: value }`.
229
+ * Not handled, on purpose: multi-line scalars (`|`, `>`), anchors, tags, multiple
230
+ * documents. A skill file does not need them.
231
+ */
232
+ function parseYaml(block: string): Record<string, any> {
233
+ const lines: Line[] = [];
234
+ for (const raw of block.split("\n")) {
235
+ if (!raw.trim()) continue;
236
+ const stripped = stripComment(raw);
237
+ if (!stripped.trim()) continue;
238
+ lines.push({ indent: stripped.length - stripped.trimStart().length, text: stripped.trim() });
239
+ }
240
+ const [value] = parseBlock(lines, 0, lines[0]?.indent ?? 0);
241
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
242
+ }
243
+
244
+ /**
245
+ * Split a skill file into its frontmatter and its body. No frontmatter at all is
246
+ * not an error here — validation says so, with a message that names the file.
247
+ */
248
+ function parseFrontmatter(text: string): { data: Record<string, any>; content: string } {
249
+ const normalized = text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
250
+ if (!/^---\s*\n/.test(normalized)) return { data: {}, content: normalized.trim() };
251
+
252
+ const end = normalized.indexOf("\n---", 3);
253
+ if (end === -1) return { data: {}, content: normalized.trim() };
254
+
255
+ const data = parseYaml(normalized.slice(normalized.indexOf("\n") + 1, end + 1));
256
+ const content = normalized.slice(end + 4).replace(/^\s*\n/, "").trim();
257
+ return { data, content };
258
+ }
259
+
260
+ /* ------------------------------------------------------------------ */
261
+ /* Resolution */
262
+ /* ------------------------------------------------------------------ */
263
+
264
+ function invalid(skill: string, message: string): AppError {
265
+ return new AppError(`Skill '${skill}': ${message}`, { status: 500, code: "AGENT_SKILL_INVALID" });
266
+ }
267
+
268
+ /** The body a skill must carry — it *is* the skill. */
269
+ function requireContent(content: any, label: string): string {
270
+ const body = typeof content === "string" ? content.trim() : "";
271
+ if (!body) throw invalid(label, "`content` is required — a skill is the text the model reads");
272
+ return body;
273
+ }
274
+
275
+ function asString(value: any): string | undefined {
276
+ if (value === undefined || value === null || value === "") return undefined;
277
+ return typeof value === "string" ? value : String(value);
278
+ }
279
+
280
+ /**
281
+ * The files a source selects, as absolute paths. A **string** is a path or a glob
282
+ * (``.` = the tenant directory); a **RegExp** is matched against every file of the
283
+ * tenant directory, on the relative POSIX path (`runbooks/incident.md`).
284
+ *
285
+ * The glob is split in two — the directory chain that holds no wildcard, then the
286
+ * pattern — so `Bun.Glob` always scans from a real root instead of depending on the
287
+ * process cwd (a relative tenant dir would otherwise be prefixed twice).
288
+ */
289
+ async function matchFiles(source: AgentSkillSource, baseDir: string): Promise<string[]> {
290
+ if (source instanceof RegExp) {
291
+ // `g`/`y` carry a `lastIndex` between calls — one regex must not skip files
292
+ const pattern = new RegExp(source.source, source.flags.replace(/[gy]/g, ""));
293
+ const found: string[] = [];
294
+ for await (const file of new Glob("**/*").scan(baseDir)) {
295
+ const relative = file.split(path.sep).join("/");
296
+ if (pattern.test(relative)) found.push(path.join(baseDir, file));
297
+ }
298
+ return found;
299
+ }
300
+
301
+ const pattern = String(source ?? "").trim();
302
+ if (!pattern) return [];
303
+
304
+ const target = path.resolve(baseDir, pattern);
305
+ const parts = target.split(path.sep);
306
+ let wildcard = parts.findIndex((part) => /[*?[\]{}]/.test(part));
307
+ if (wildcard === -1) wildcard = parts.length - 1; // a plain file: scan its directory
308
+
309
+ const root = parts.slice(0, wildcard).join(path.sep) || path.sep;
310
+ const rest = parts.slice(wildcard).join("/");
311
+
312
+ const found: string[] = [];
313
+ for await (const file of new Glob(rest).scan(root)) {
314
+ found.push(path.resolve(root, file));
315
+ }
316
+ return found;
317
+ }
318
+
319
+ /**
320
+ * Validate the header of a skill file. `label` names the source in the messages
321
+ * (the path relative to the tenant directory).
322
+ */
323
+ function validateSkill(data: Record<string, any>, label: string): Omit<AgentSkill, "content" | "source"> {
324
+ const name = asString(data.name);
325
+ if (!name) throw invalid(label, "`name` is required");
326
+ if (name.length > MAX_NAME_CHARS || !SKILL_NAME.test(name)) {
327
+ throw invalid(label, `\`name\` must be lowercase alphanumeric with hyphens, 1-${MAX_NAME_CHARS} characters (got '${name}')`);
328
+ }
329
+ // The directory-skill layout (`pdf/SKILL.md`): the name is the directory's, so a
330
+ // folder copied or renamed cannot serve a skill under a stale identity
331
+ if (label && path.basename(label).toLowerCase() === "skill.md") {
332
+ const parent = path.basename(path.dirname(label));
333
+ if (parent !== name) throw invalid(label, `\`name\` must match the parent directory ('${parent}') for a SKILL.md`);
334
+ }
335
+
336
+ const description = asString(data.description);
337
+ if (!description) throw invalid(label, "`description` is required");
338
+ if (description.length > MAX_DESCRIPTION_CHARS) {
339
+ throw invalid(label, `\`description\` is ${description.length} characters — over the ${MAX_DESCRIPTION_CHARS} limit`);
340
+ }
341
+
342
+ const compatibility = asString(data.compatibility);
343
+ if (compatibility && compatibility.length > MAX_COMPATIBILITY_CHARS) {
344
+ throw invalid(label, `\`compatibility\` is ${compatibility.length} characters — over the ${MAX_COMPATIBILITY_CHARS} limit`);
345
+ }
346
+
347
+ const metadata = data.metadata ?? undefined;
348
+ if (metadata !== undefined && (typeof metadata !== "object" || Array.isArray(metadata))) {
349
+ throw invalid(label, "`metadata` must be a mapping of key-value pairs");
350
+ }
351
+
352
+ const allowed = data["allowed-tools"] ?? data.allowedTools;
353
+ let allowedTools: string[] | undefined;
354
+ if (Array.isArray(allowed)) allowedTools = allowed.map((item) => String(item));
355
+ else if (typeof allowed === "string") allowedTools = allowed.split(/\s+/).filter(Boolean);
356
+ else if (allowed !== undefined && allowed !== null) throw invalid(label, "`allowed-tools` must be a space-separated list");
357
+
358
+ return {
359
+ name,
360
+ description,
361
+ license: asString(data.license),
362
+ compatibility,
363
+ metadata: metadata as Record<string, any> | undefined,
364
+ allowedTools,
365
+ };
366
+ }
367
+
368
+ /** Read one file and validate its frontmatter against the skill format. */
369
+ async function readSkill(file: string, relative: string): Promise<AgentSkill> {
370
+ const raw = await fs.readFile(file, "utf8");
371
+ if (raw.length > MAX_SKILL_CHARS) {
372
+ throw new AppError(
373
+ `Skill '${relative}' is ${raw.length} characters — over the ${MAX_SKILL_CHARS} limit (a skill is injected in every prompt)`,
374
+ { status: 500, code: "AGENT_SKILL_TOO_LARGE" },
375
+ );
376
+ }
377
+
378
+ const { data, content } = parseFrontmatter(raw);
379
+
380
+ for (const key of Object.keys(data)) {
381
+ if (!KNOWN_KEYS.has(key)) {
382
+ logger.file("warn", "agent skill: unknown frontmatter key ignored", { skill: relative, key });
383
+ }
384
+ }
385
+
386
+ return { ...validateSkill(data, relative), content: requireContent(content, relative), source: relative };
387
+ }
388
+
389
+ /**
390
+ * Resolve the `skills` of an agent into the documents injected into its system
391
+ * prompt, in a stable order (by name).
392
+ *
393
+ * A source is a **string** — a path or a glob — or a **RegExp**, a walk of the
394
+ * tenant: a skill is a **link to markdown files**, never a declaration. A file that
395
+ * is **not markdown** is skipped with a line in the log rather than refused: a
396
+ * pattern meant to catch a folder must not take an agent down because a `README.txt`
397
+ * lives beside the skills. Everything else — a missing `name`, a name that is not
398
+ * kebab-case, a description over the limit, an empty body, two skills sharing a name
399
+ * — refuses the agent at load, so a typo never changes the prompt silently (the
400
+ * loader logs it and keeps the other agents).
401
+ */
402
+ async function resolveSkills(
403
+ sources: AgentSkillSource[] | undefined,
404
+ options: { baseDir: string; agentId?: string },
405
+ ): Promise<AgentSkill[]> {
406
+ if (!sources?.length) return [];
407
+
408
+ const byFile = new Map<string, AgentSkill>();
409
+
410
+ for (const source of sources) {
411
+ const files = await matchFiles(source, options.baseDir);
412
+ // A pattern that selects nothing is a typo, not a choice — say so
413
+ if (!files.length) {
414
+ logger.file("warn", "agent skill: pattern matched no file", {
415
+ agent: options.agentId,
416
+ pattern: source instanceof RegExp ? source.source : source,
417
+ });
418
+ }
419
+ for (const file of files) {
420
+ const relative = path.relative(options.baseDir, file).split(path.sep).join("/");
421
+ if (byFile.has(relative)) continue;
422
+
423
+ if (!relative.toLowerCase().endsWith(".md")) {
424
+ logger.file("warn", "agent skill skipped: not markdown", {
425
+ agent: options.agentId,
426
+ skill: relative,
427
+ });
428
+ continue;
429
+ }
430
+
431
+ byFile.set(relative, await readSkill(file, relative));
432
+ }
433
+ }
434
+
435
+ const skills = [...byFile.values()].sort((a, b) => a.name.localeCompare(b.name));
436
+ const seen = new Map<string, string>();
437
+ for (const skill of skills) {
438
+ const previous = seen.get(skill.name);
439
+ if (previous) {
440
+ throw new AppError(
441
+ `Skill '${skill.source}': the name '${skill.name}' is already used by '${previous}' — two skills of an agent must not share one`,
442
+ { status: 500, code: "AGENT_SKILL_DUPLICATE" },
443
+ );
444
+ }
445
+ seen.set(skill.name, skill.source);
446
+ }
447
+
448
+ return skills;
449
+ }
450
+
451
+ /**
452
+ * The system-prompt block of the skills. Static by nature — no store, nothing
453
+ * resolved per run — which is why it is injected **before** the working memory:
454
+ * the prefix of the prompt stays cacheable.
455
+ */
456
+ function skillsBlock(skills: AgentSkill[]): string {
457
+ if (!skills.length) return "";
458
+
459
+ const parts = [
460
+ "## Skills",
461
+ "",
462
+ "Reference documents you can rely on. Read the ones that match the task at hand instead of guessing, and apply them as written.",
463
+ ];
464
+
465
+ for (const skill of skills) {
466
+ parts.push("", `### ${skill.name}`, "", skill.description);
467
+ if (skill.content) parts.push("", skill.content);
468
+ }
469
+
470
+ return parts.join("\n");
471
+ }
472
+
473
+ export {
474
+ MAX_SKILL_CHARS,
475
+ MAX_DESCRIPTION_CHARS,
476
+ MAX_COMPATIBILITY_CHARS,
477
+ MAX_NAME_CHARS,
478
+ parseFrontmatter,
479
+ resolveSkills,
480
+ skillsBlock,
481
+ };
package/lib/sockets.ts CHANGED
@@ -3,6 +3,8 @@ import { cfg } from "../server/config"
3
3
  import path from "path"
4
4
  import fs from "fs/promises"
5
5
  import { useRest } from "../database/rest"
6
+ import { createAgents } from "../lib/agents"
7
+ import { createApi } from "../lib/api"
6
8
  import { io } from "../server/io"
7
9
  import type { WebSocketHandler } from "../types/websocket"
8
10
 
@@ -26,7 +28,7 @@ async function loadSockets() {
26
28
  const rest = new useRest({ tenant_id: tenant.id })
27
29
 
28
30
  io.on('connection', (socket) => {
29
- handler.exec({ io, rest, socket })
31
+ handler.exec({ io, rest, socket, agents: createAgents(tenant.id, rest), api: createApi(rest) })
30
32
  })
31
33
 
32
34
  console.log(`[socket] ${tenant.id}/${path.basename(file, '.ws.ts')} loaded`)