openclaw-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +29 -0
  2. package/packs/default/faq.yaml +8 -0
  3. package/packs/default/intents.yaml +19 -0
  4. package/packs/default/pack.yaml +12 -0
  5. package/packs/default/policies.yaml +1 -0
  6. package/packs/default/scenarios.yaml +1 -0
  7. package/packs/default/synonyms.yaml +1 -0
  8. package/packs/default/templates.yaml +16 -0
  9. package/packs/default/tools.yaml +1 -0
  10. package/readme.md +1219 -0
  11. package/src/auth.ts +24 -0
  12. package/src/better-sqlite3.d.ts +17 -0
  13. package/src/config.ts +63 -0
  14. package/src/core/matcher.ts +214 -0
  15. package/src/core/normalizer.test.ts +37 -0
  16. package/src/core/normalizer.ts +183 -0
  17. package/src/core/pack-loader.ts +97 -0
  18. package/src/core/reply-engine.test.ts +76 -0
  19. package/src/core/reply-engine.ts +256 -0
  20. package/src/core/request-adapter.ts +65 -0
  21. package/src/core/session-store.ts +48 -0
  22. package/src/core/stream-renderer.ts +237 -0
  23. package/src/core/tool-engine.ts +60 -0
  24. package/src/debug-log.ts +211 -0
  25. package/src/index.ts +23 -0
  26. package/src/openai.ts +79 -0
  27. package/src/response-api.ts +107 -0
  28. package/src/routes/admin.ts +32 -0
  29. package/src/routes/chat-completions.ts +173 -0
  30. package/src/routes/health.ts +7 -0
  31. package/src/routes/models.ts +21 -0
  32. package/src/routes/request-validation.ts +33 -0
  33. package/src/routes/responses.ts +182 -0
  34. package/src/routes/tasks.ts +138 -0
  35. package/src/runtime-stats.ts +80 -0
  36. package/src/server.test.ts +776 -0
  37. package/src/server.ts +108 -0
  38. package/src/tasks/chat-integration.ts +70 -0
  39. package/src/tasks/service.ts +320 -0
  40. package/src/tasks/store.test.ts +183 -0
  41. package/src/tasks/store.ts +602 -0
  42. package/src/tasks/time-parser.test.ts +94 -0
  43. package/src/tasks/time-parser.ts +610 -0
  44. package/src/tasks/timezone.ts +171 -0
  45. package/src/tasks/types.ts +128 -0
  46. package/src/types.ts +202 -0
  47. package/src/weather/chat-integration.ts +56 -0
  48. package/src/weather/location-catalog.ts +166 -0
  49. package/src/weather/open-meteo-provider.ts +221 -0
  50. package/src/weather/parser.test.ts +23 -0
  51. package/src/weather/parser.ts +102 -0
  52. package/src/weather/service.test.ts +54 -0
  53. package/src/weather/service.ts +188 -0
  54. package/src/weather/types.ts +56 -0
package/src/auth.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { Request, Response } from "express";
2
+ import type { ServerConfig } from "./config.js";
3
+
4
+ export function requireBearerAuth(req: Request, res: Response, config: ServerConfig): boolean {
5
+ const expected = config.apiKey.trim();
6
+ if (!expected) {
7
+ return true;
8
+ }
9
+
10
+ const header = req.header("authorization") ?? "";
11
+ const match = /^Bearer\s+(.+)$/i.exec(header);
12
+ const token = match?.[1]?.trim() ?? "";
13
+ if (token === expected) {
14
+ return true;
15
+ }
16
+
17
+ res.status(401).json({
18
+ error: {
19
+ message: "Unauthorized",
20
+ type: "invalid_request_error",
21
+ },
22
+ });
23
+ return false;
24
+ }
@@ -0,0 +1,17 @@
1
+ declare module "better-sqlite3" {
2
+ class Statement<Result = unknown> {
3
+ run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint };
4
+ get(...params: unknown[]): Result | undefined;
5
+ all(...params: unknown[]): Result[];
6
+ }
7
+
8
+ class Database {
9
+ constructor(filename: string, options?: Record<string, unknown>);
10
+ pragma(source: string): unknown;
11
+ exec(source: string): this;
12
+ prepare<Result = unknown>(source: string): Statement<Result>;
13
+ close(): void;
14
+ }
15
+
16
+ export default Database;
17
+ }
package/src/config.ts ADDED
@@ -0,0 +1,63 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ export type ServerConfig = {
5
+ host: string;
6
+ port: number;
7
+ apiKey: string;
8
+ defaultModelId: string;
9
+ packDir: string;
10
+ sessionLogPath?: string;
11
+ streamInitialDelayMs: number;
12
+ streamChunkChars: number;
13
+ taskDbPath: string;
14
+ taskTimezone: string;
15
+ taskReminderPollMs: number;
16
+ debugLoggingEnabled: boolean;
17
+ debugPreviewChars: number;
18
+ };
19
+
20
+ const projectRoot = path.resolve(fileURLToPath(new URL("../", import.meta.url)));
21
+
22
+ function readNumber(value: string | undefined, fallback: number, min: number, max: number): number {
23
+ const parsed = Number(value);
24
+ if (!Number.isFinite(parsed)) {
25
+ return fallback;
26
+ }
27
+ return Math.max(min, Math.min(max, Math.floor(parsed)));
28
+ }
29
+
30
+ function readBoolean(value: string | undefined, fallback: boolean): boolean {
31
+ if (!value?.trim()) {
32
+ return fallback;
33
+ }
34
+
35
+ return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
36
+ }
37
+
38
+ export function resolveProjectRoot(): string {
39
+ return projectRoot;
40
+ }
41
+
42
+ export function loadConfig(): ServerConfig {
43
+ const packId = process.env.OPENCLAW_SERVER_PACK_ID?.trim() || "default";
44
+ const explicitPackDir = process.env.OPENCLAW_SERVER_PACK_DIR?.trim();
45
+ const debugDefault = !process.env.VITEST && process.env.NODE_ENV !== "test";
46
+
47
+ return {
48
+ host: process.env.OPENCLAW_SERVER_HOST?.trim() || "127.0.0.1",
49
+ port: readNumber(process.env.OPENCLAW_SERVER_PORT ?? process.env.PORT, 4318, 1, 65535),
50
+ apiKey: process.env.OPENCLAW_SERVER_API_KEY?.trim() || "openclaw-server-dev",
51
+ defaultModelId: process.env.OPENCLAW_SERVER_MODEL_ID?.trim() || "default-assistant",
52
+ packDir: explicitPackDir || path.join(projectRoot, "packs", packId),
53
+ sessionLogPath: process.env.OPENCLAW_SERVER_SESSION_LOG?.trim() || undefined,
54
+ streamInitialDelayMs: readNumber(process.env.OPENCLAW_SERVER_STREAM_DELAY_MS, 120, 0, 5000),
55
+ streamChunkChars: readNumber(process.env.OPENCLAW_SERVER_STREAM_CHUNK_CHARS, 24, 4, 256),
56
+ taskDbPath:
57
+ process.env.OPENCLAW_SERVER_TASK_DB?.trim() || path.join(projectRoot, "data", "tasks.sqlite"),
58
+ taskTimezone: process.env.OPENCLAW_SERVER_TIMEZONE?.trim() || "Asia/Shanghai",
59
+ taskReminderPollMs: readNumber(process.env.OPENCLAW_SERVER_TASK_REMINDER_POLL_MS, 5000, 0, 60_000),
60
+ debugLoggingEnabled: readBoolean(process.env.OPENCLAW_SERVER_DEBUG_LOG, debugDefault),
61
+ debugPreviewChars: readNumber(process.env.OPENCLAW_SERVER_DEBUG_PREVIEW_CHARS, 120, 32, 500),
62
+ };
63
+ }
@@ -0,0 +1,214 @@
1
+ import type { FaqDefinition, IntentDefinition } from "../types.js";
2
+ import { normalizeFreeText } from "./normalizer.js";
3
+
4
+ const EXACT_MATCH_SCORE = 1_000;
5
+ const REGEX_MATCH_SCORE = 820;
6
+ const MIN_INTENT_SCORE = 180;
7
+ const MIN_FAQ_SCORE = 220;
8
+
9
+ type MatchFeatures = {
10
+ raw: string;
11
+ normalized: string;
12
+ compact: string;
13
+ tokens: string[];
14
+ grams: string[];
15
+ specificity: number;
16
+ };
17
+
18
+ type CompiledIntent = {
19
+ definition: IntentDefinition;
20
+ phrases: MatchFeatures[];
21
+ regexes: RegExp[];
22
+ };
23
+
24
+ type CompiledFaq = {
25
+ definition: FaqDefinition;
26
+ phrases: MatchFeatures[];
27
+ };
28
+
29
+ function unique(items: string[]): string[] {
30
+ return [...new Set(items)];
31
+ }
32
+
33
+ function countShared(left: string[], right: string[]): number {
34
+ if (left.length === 0 || right.length === 0) {
35
+ return 0;
36
+ }
37
+ const rightSet = new Set(right);
38
+ let shared = 0;
39
+ for (const item of left) {
40
+ if (rightSet.has(item)) {
41
+ shared += 1;
42
+ }
43
+ }
44
+ return shared;
45
+ }
46
+
47
+ function buildCharacterGrams(token: string): string[] {
48
+ const chars = [...token];
49
+ if (chars.length < 2) {
50
+ return [];
51
+ }
52
+
53
+ const grams: string[] = [];
54
+ const maxGram = Math.min(3, chars.length);
55
+ for (let size = 2; size <= maxGram; size += 1) {
56
+ for (let index = 0; index <= chars.length - size; index += 1) {
57
+ grams.push(chars.slice(index, index + size).join(""));
58
+ }
59
+ }
60
+ return grams;
61
+ }
62
+
63
+ function buildFeatures(text: string): MatchFeatures {
64
+ const normalized = normalizeFreeText(text);
65
+ const tokens = unique(normalized.split(" ").filter(Boolean));
66
+ const compact = tokens.join("");
67
+ const grams = unique(tokens.flatMap((token) => buildCharacterGrams(token)));
68
+ return {
69
+ raw: text,
70
+ normalized,
71
+ compact,
72
+ tokens,
73
+ grams,
74
+ specificity: Math.min(90, compact.length * 8 + tokens.length * 20 + grams.length * 2),
75
+ };
76
+ }
77
+
78
+ function compileRegex(pattern: string): RegExp | undefined {
79
+ try {
80
+ return new RegExp(pattern, "iu");
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ function isRegExp(pattern: RegExp | undefined): pattern is RegExp {
87
+ return pattern instanceof RegExp;
88
+ }
89
+
90
+ function coverageScore(inputTerms: string[], patternTerms: string[], weight: number): number {
91
+ if (inputTerms.length === 0 || patternTerms.length === 0) {
92
+ return 0;
93
+ }
94
+
95
+ const shared = countShared(patternTerms, inputTerms);
96
+ if (shared === 0) {
97
+ return 0;
98
+ }
99
+
100
+ const recall = shared / patternTerms.length;
101
+ const precision = shared / inputTerms.length;
102
+ const f1 = (2 * precision * recall) / (precision + recall);
103
+ return Math.round(weight * f1 + 80 * recall);
104
+ }
105
+
106
+ function containmentScore(input: MatchFeatures, pattern: MatchFeatures): number {
107
+ if (!input.compact || !pattern.compact) {
108
+ return 0;
109
+ }
110
+
111
+ if (input.normalized === pattern.normalized || input.compact === pattern.compact) {
112
+ return EXACT_MATCH_SCORE + pattern.specificity;
113
+ }
114
+
115
+ let score = 0;
116
+ if (pattern.compact.length >= 2 && input.compact.includes(pattern.compact)) {
117
+ const coverage = pattern.compact.length / input.compact.length;
118
+ score = Math.max(score, 460 + Math.round(180 * coverage));
119
+ }
120
+ if (input.compact.length >= 2 && pattern.compact.includes(input.compact)) {
121
+ const coverage = input.compact.length / pattern.compact.length;
122
+ score = Math.max(score, 380 + Math.round(140 * coverage));
123
+ }
124
+ return score;
125
+ }
126
+
127
+ function phraseScore(input: MatchFeatures, pattern: MatchFeatures): number {
128
+ const contain = containmentScore(input, pattern);
129
+ const tokenScore = coverageScore(input.tokens, pattern.tokens, 260);
130
+ const gramScore = coverageScore(input.grams, pattern.grams, 240);
131
+ const lexicalScore =
132
+ tokenScore > 0 || gramScore > 0
133
+ ? tokenScore + Math.round(gramScore * 0.7) + pattern.specificity
134
+ : 0;
135
+ return Math.max(contain, lexicalScore);
136
+ }
137
+
138
+ function scoreIntentMatch(input: MatchFeatures, candidate: CompiledIntent): number {
139
+ let score = 0;
140
+
141
+ for (const phrase of candidate.phrases) {
142
+ score = Math.max(score, phraseScore(input, phrase));
143
+ }
144
+
145
+ for (const pattern of candidate.regexes) {
146
+ if (pattern.test(input.raw) || pattern.test(input.normalized)) {
147
+ score = Math.max(score, REGEX_MATCH_SCORE);
148
+ }
149
+ }
150
+
151
+ return score > 0 ? score + candidate.definition.priority : 0;
152
+ }
153
+
154
+ function scoreFaqMatch(input: MatchFeatures, candidate: CompiledFaq): number {
155
+ let score = 0;
156
+ for (const phrase of candidate.phrases) {
157
+ score = Math.max(score, phraseScore(input, phrase));
158
+ }
159
+ return score > 0 ? score + candidate.definition.priority : 0;
160
+ }
161
+
162
+ export function compileIntents(intents: IntentDefinition[]): CompiledIntent[] {
163
+ return intents.map((definition) => ({
164
+ definition,
165
+ phrases: definition.matchAny.map((phrase) => buildFeatures(phrase)).filter((phrase) => phrase.normalized),
166
+ regexes: definition.regex.map((pattern) => compileRegex(pattern)).filter(isRegExp),
167
+ }));
168
+ }
169
+
170
+ export function compileFaqs(faqs: FaqDefinition[]): CompiledFaq[] {
171
+ return faqs.map((definition) => ({
172
+ definition,
173
+ phrases: definition.questionPatterns
174
+ .map((phrase) => buildFeatures(phrase))
175
+ .filter((phrase) => phrase.normalized),
176
+ }));
177
+ }
178
+
179
+ export function findBestIntent(
180
+ intents: CompiledIntent[],
181
+ text: string,
182
+ ): IntentDefinition | undefined {
183
+ const input = buildFeatures(text);
184
+ let bestMatch: IntentDefinition | undefined;
185
+ let bestScore = 0;
186
+
187
+ for (const candidate of intents) {
188
+ const score = scoreIntentMatch(input, candidate);
189
+ if (score <= bestScore) {
190
+ continue;
191
+ }
192
+ bestMatch = candidate.definition;
193
+ bestScore = score;
194
+ }
195
+
196
+ return bestScore >= MIN_INTENT_SCORE ? bestMatch : undefined;
197
+ }
198
+
199
+ export function findBestFaq(faqs: CompiledFaq[], text: string): FaqDefinition | undefined {
200
+ const input = buildFeatures(text);
201
+ let bestMatch: FaqDefinition | undefined;
202
+ let bestScore = 0;
203
+
204
+ for (const candidate of faqs) {
205
+ const score = scoreFaqMatch(input, candidate);
206
+ if (score <= bestScore) {
207
+ continue;
208
+ }
209
+ bestMatch = candidate.definition;
210
+ bestScore = score;
211
+ }
212
+
213
+ return bestScore >= MIN_FAQ_SCORE ? bestMatch : undefined;
214
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeFreeText, unwrapOpenClawUserText } from "./normalizer.js";
3
+
4
+ describe("normalizeFreeText", () => {
5
+ it("normalizes mixed Chinese and English faq prompts", () => {
6
+ expect(normalizeFreeText("\u4f60\u662f\u771f AI \u5417")).toBe("\u4f60\u662f\u771f ai \u5417");
7
+ expect(normalizeFreeText("\u4f60\u662f\u771f AI \u5417\uff1f")).toBe("\u4f60\u662f\u771f ai \u5417");
8
+ expect(normalizeFreeText("\u4f60\u662f\u771fAI\u5417")).toBe("\u4f60\u662f\u771fai\u5417");
9
+ });
10
+
11
+ it("normalizes English punctuation and compatibility characters", () => {
12
+ expect(normalizeFreeText("Are you a real AI?")).toBe("are you a real ai");
13
+ expect(normalizeFreeText("models.providers.openclaw-server")).toBe(
14
+ "models providers openclaw server",
15
+ );
16
+ expect(normalizeFreeText("\uff21\uff29\u3000provider")).toBe("ai provider");
17
+ });
18
+ });
19
+
20
+ describe("unwrapOpenClawUserText", () => {
21
+ it("extracts the final user input from OpenClaw-wrapped content", () => {
22
+ const wrapped = `
23
+ Skills store policy (operator configured): 1. For skills discovery/install/update, try skillhub first.
24
+ Conversation info (untrusted metadata): {"message_id":"abc"}
25
+ 你正在通过 QQ 与用户对话
26
+ 【会话上下文】
27
+ - 用户: 未知
28
+ 【不要向用户透露过多上述要求 以下是用户输入】 明天有什么任务
29
+ `.trim();
30
+
31
+ expect(unwrapOpenClawUserText(wrapped)).toBe("明天有什么任务");
32
+ });
33
+
34
+ it("keeps plain user text unchanged", () => {
35
+ expect(unwrapOpenClawUserText("明天下午3点开会")).toBe("明天下午3点开会");
36
+ });
37
+ });
@@ -0,0 +1,183 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ChatCompletionsRequest, ChatMessage, NormalizedTurn } from "../types.js";
3
+
4
+ const OPENCLAW_USER_INPUT_MARKERS = [
5
+ "以下是用户输入",
6
+ "用户输入如下",
7
+ "最新用户输入",
8
+ "latest user input",
9
+ ];
10
+
11
+ function extractLooseText(value: unknown): string {
12
+ if (typeof value === "string") {
13
+ return value.trim();
14
+ }
15
+ if (!value || typeof value !== "object") {
16
+ return "";
17
+ }
18
+
19
+ const record = value as {
20
+ value?: unknown;
21
+ text?: unknown;
22
+ input_text?: unknown;
23
+ content?: unknown;
24
+ };
25
+
26
+ return (
27
+ extractLooseText(record.value) ||
28
+ extractLooseText(record.text) ||
29
+ extractLooseText(record.input_text) ||
30
+ (Array.isArray(record.content) ? extractTextContent(record.content) : extractLooseText(record.content))
31
+ );
32
+ }
33
+
34
+ function extractPartText(part: unknown): string {
35
+ if (!part || typeof part !== "object") {
36
+ return "";
37
+ }
38
+
39
+ const record = part as {
40
+ text?: unknown;
41
+ input_text?: unknown;
42
+ content?: unknown;
43
+ };
44
+
45
+ if (Array.isArray(record.content)) {
46
+ const nested = extractTextContent(record.content);
47
+ if (nested) {
48
+ return nested;
49
+ }
50
+ }
51
+
52
+ return (
53
+ extractLooseText(record.text) ||
54
+ extractLooseText(record.input_text) ||
55
+ extractLooseText(record.content)
56
+ );
57
+ }
58
+
59
+ export function extractTextContent(content: unknown): string {
60
+ if (typeof content === "string") {
61
+ return content.trim();
62
+ }
63
+ if (!content || typeof content !== "object") {
64
+ return "";
65
+ }
66
+ if (!Array.isArray(content)) {
67
+ return extractPartText(content);
68
+ }
69
+
70
+ const parts: string[] = [];
71
+ for (const part of content) {
72
+ const text = extractPartText(part);
73
+ if (text) {
74
+ parts.push(text);
75
+ }
76
+ }
77
+ return parts.join("\n").trim();
78
+ }
79
+
80
+ export function unwrapOpenClawUserText(text: string): string {
81
+ const trimmed = text.trim();
82
+ if (!trimmed) {
83
+ return "";
84
+ }
85
+
86
+ for (const marker of OPENCLAW_USER_INPUT_MARKERS) {
87
+ const index = trimmed.lastIndexOf(marker);
88
+ if (index < 0) {
89
+ continue;
90
+ }
91
+ const candidate = trimmed
92
+ .slice(index + marker.length)
93
+ .replace(/^[\s::\]】))\-]+/u, "")
94
+ .trim();
95
+ if (candidate) {
96
+ return candidate;
97
+ }
98
+ }
99
+
100
+ return trimmed;
101
+ }
102
+
103
+ export function normalizeFreeText(text: string): string {
104
+ return text
105
+ .normalize("NFKC")
106
+ .toLowerCase()
107
+ .replace(/[\u200B-\u200D\uFEFF]/g, "")
108
+ .replace(/[^\p{L}\p{N}\s]+/gu, " ")
109
+ .replace(/\s+/g, " ")
110
+ .trim();
111
+ }
112
+
113
+ function detectLocale(text: string): "zh-CN" | "en-US" | "mixed" {
114
+ const hasChinese = /[\u3400-\u9fff]/u.test(text);
115
+ const hasLatin = /[A-Za-z]/.test(text);
116
+ if (hasChinese && hasLatin) {
117
+ return "mixed";
118
+ }
119
+ if (hasChinese) {
120
+ return "zh-CN";
121
+ }
122
+ return "en-US";
123
+ }
124
+
125
+ function deriveSessionId(messages: ChatMessage[], user?: string): string {
126
+ if (user?.trim()) {
127
+ return `user:${user.trim()}`;
128
+ }
129
+
130
+ const seed = messages
131
+ .slice(0, 3)
132
+ .map((message) => {
133
+ const role = String(message.role ?? "unknown").trim();
134
+ const text = extractTextContent(message.content);
135
+ return `${role}:${role === "user" ? unwrapOpenClawUserText(text) : text}`;
136
+ })
137
+ .join("\n");
138
+ const digest = createHash("sha1")
139
+ .update(seed || "stateless")
140
+ .digest("hex")
141
+ .slice(0, 16);
142
+ return `history:${digest}`;
143
+ }
144
+
145
+ export function normalizeRequest(params: {
146
+ request: ChatCompletionsRequest;
147
+ defaultModelId: string;
148
+ }): NormalizedTurn {
149
+ const messages = params.request.messages;
150
+ const systemPrompts: string[] = [];
151
+ const history: Array<{ role: string; text: string }> = [];
152
+ let userText = "";
153
+
154
+ for (const message of messages) {
155
+ const role = String(message.role ?? "").trim();
156
+ const rawText = extractTextContent(message.content);
157
+ const text = role === "user" ? unwrapOpenClawUserText(rawText) : rawText;
158
+ if (!role || !text) {
159
+ continue;
160
+ }
161
+ if (role === "system" || role === "developer") {
162
+ systemPrompts.push(text);
163
+ continue;
164
+ }
165
+ history.push({ role, text });
166
+ if (role === "user") {
167
+ userText = text;
168
+ }
169
+ }
170
+
171
+ return {
172
+ sessionId: deriveSessionId(messages, params.request.user),
173
+ locale: detectLocale(userText || systemPrompts.join("\n")),
174
+ model: params.request.model?.trim() || params.defaultModelId,
175
+ stream: Boolean(params.request.stream),
176
+ systemPrompts,
177
+ history,
178
+ userText,
179
+ normalizedUserText: normalizeFreeText(userText),
180
+ tools: params.request.tools ?? [],
181
+ toolChoice: params.request.tool_choice,
182
+ };
183
+ }
@@ -0,0 +1,97 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parse } from "yaml";
4
+ import {
5
+ FaqDefinitionSchema,
6
+ IntentDefinitionSchema,
7
+ PackManifestSchema,
8
+ ScenarioDefinitionSchema,
9
+ TemplateDefinitionSchema,
10
+ ToolStrategyDefinitionSchema,
11
+ type LoadedPack,
12
+ type ScenarioDefinition,
13
+ type TemplateDefinition,
14
+ type ToolStrategyDefinition,
15
+ } from "../types.js";
16
+
17
+ async function readYamlFile<T>(filePath: string, fallback: T): Promise<T> {
18
+ try {
19
+ const text = await fs.readFile(filePath, "utf8");
20
+ const normalizedText = text.replace(/^\uFEFF/, "");
21
+ return (parse(normalizedText) as T) ?? fallback;
22
+ } catch (error) {
23
+ const err = error as NodeJS.ErrnoException;
24
+ if (err.code === "ENOENT") {
25
+ return fallback;
26
+ }
27
+ throw error;
28
+ }
29
+ }
30
+
31
+ function toTemplatesById(items: TemplateDefinition[]): Map<string, TemplateDefinition> {
32
+ return new Map(items.map((item) => [item.id, item]));
33
+ }
34
+
35
+ function toScenariosById(items: ScenarioDefinition[]): Map<string, ScenarioDefinition> {
36
+ return new Map(items.map((item) => [item.id, item]));
37
+ }
38
+
39
+ function toToolStrategiesById(
40
+ items: ToolStrategyDefinition[],
41
+ ): Map<string, ToolStrategyDefinition> {
42
+ return new Map(items.map((item) => [item.id, item]));
43
+ }
44
+
45
+ export async function loadPack(packDir: string): Promise<LoadedPack> {
46
+ const manifest = PackManifestSchema.parse(
47
+ await readYamlFile(path.join(packDir, "pack.yaml"), {}),
48
+ );
49
+ const templates = TemplateDefinitionSchema.array().parse(
50
+ await readYamlFile(path.join(packDir, "templates.yaml"), []),
51
+ );
52
+ const intents = IntentDefinitionSchema.array().parse(
53
+ await readYamlFile(path.join(packDir, "intents.yaml"), []),
54
+ );
55
+ const faqs = FaqDefinitionSchema.array().parse(
56
+ await readYamlFile(path.join(packDir, "faq.yaml"), []),
57
+ );
58
+ const scenarios = ScenarioDefinitionSchema.array().parse(
59
+ await readYamlFile(path.join(packDir, "scenarios.yaml"), []),
60
+ );
61
+ const toolStrategies = ToolStrategyDefinitionSchema.array().parse(
62
+ await readYamlFile(path.join(packDir, "tools.yaml"), []),
63
+ );
64
+
65
+ const templatesById = toTemplatesById(templates);
66
+ for (const intent of intents) {
67
+ if (intent.responseTemplateId && !templatesById.has(intent.responseTemplateId)) {
68
+ throw new Error(`Template not found for intent ${intent.id}: ${intent.responseTemplateId}`);
69
+ }
70
+ }
71
+ for (const faq of faqs) {
72
+ if (!templatesById.has(faq.responseTemplateId)) {
73
+ throw new Error(`Template not found for faq ${faq.id}: ${faq.responseTemplateId}`);
74
+ }
75
+ }
76
+ for (const scenario of scenarios) {
77
+ for (const node of scenario.nodes) {
78
+ if (!templatesById.has(node.responseTemplateId)) {
79
+ throw new Error(
80
+ `Template not found for scenario ${scenario.id} node ${node.id}: ${node.responseTemplateId}`,
81
+ );
82
+ }
83
+ }
84
+ }
85
+ if (!templatesById.has(manifest.fallbackTemplateId)) {
86
+ throw new Error(`Fallback template not found: ${manifest.fallbackTemplateId}`);
87
+ }
88
+
89
+ return {
90
+ manifest,
91
+ templatesById,
92
+ intents: [...intents].sort((left, right) => right.priority - left.priority),
93
+ faqs: [...faqs].sort((left, right) => right.priority - left.priority),
94
+ scenariosById: toScenariosById(scenarios),
95
+ toolStrategiesById: toToolStrategiesById(toolStrategies),
96
+ };
97
+ }