@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.
- 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 +724 -58
- package/database/ttl.ts +68 -0
- package/database/workflow.ts +466 -240
- package/index.ts +6 -0
- package/lib/agent.ts +1567 -0
- package/lib/agentMemory.ts +689 -0
- package/lib/agents.ts +180 -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/mcpClient.ts +128 -0
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +970 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- package/lib/skills.ts +481 -0
- 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 +472 -0
- package/server/api.ts +80 -65
- 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-mcp.test.ts +116 -0
- package/tests/agent-memory.test.ts +617 -0
- package/tests/agent-provider.test.ts +288 -0
- package/tests/agent-runtime.test.ts +369 -0
- package/tests/agent-skills.test.ts +177 -0
- package/tests/agent-summarize.test.ts +124 -0
- package/tests/agent-working-memory.test.ts +404 -0
- package/tests/agent.test.ts +1241 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api-private-fields.test.ts +196 -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/file-collection-replication.test.ts +2 -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/repl-only/app.ts +3 -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/skills-tenant/agents/docs.agent.ts +23 -0
- package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
- package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
- package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
- package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
- package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
- package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
- package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
- package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
- package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
- package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
- package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
- package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
- package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -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-change-stream.test.ts +240 -0
- package/tests/replication-control.test.ts +4 -2
- package/tests/replication-initial-sync.test.ts +2 -0
- package/tests/replication-meta.test.ts +246 -0
- package/tests/replication.test.ts +2 -0
- package/tests/upload.test.ts +44 -12
- package/tests/vars-api.test.ts +1 -1
- package/tests/vars-replication.test.ts +5 -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 +1067 -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 +70 -4
- 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
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
|
+
}
|
package/lib/scriptWorker.ts
CHANGED
|
@@ -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
|
|
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`)
|