@argentic/chest-sdk 0.1.1 → 0.3.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 (57) hide show
  1. package/README.md +573 -37
  2. package/client/index.ts +12 -5
  3. package/client/src/ai.ts +374 -0
  4. package/client/src/api.ts +85 -0
  5. package/client/src/chest.ts +80 -0
  6. package/client/src/errors.ts +58 -3
  7. package/client/src/events.ts +228 -0
  8. package/client/src/files.ts +93 -88
  9. package/client/src/member.ts +61 -17
  10. package/client/src/members.ts +167 -0
  11. package/client/src/notifications.ts +148 -0
  12. package/client/src/testing.ts +564 -0
  13. package/dist/index.d.ts +5 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +12 -5
  16. package/dist/index.js.map +1 -1
  17. package/dist/src/ai.d.ts +131 -0
  18. package/dist/src/ai.d.ts.map +1 -0
  19. package/dist/src/ai.js +290 -0
  20. package/dist/src/ai.js.map +1 -0
  21. package/dist/src/api.d.ts +11 -0
  22. package/dist/src/api.d.ts.map +1 -0
  23. package/dist/src/api.js +93 -0
  24. package/dist/src/api.js.map +1 -0
  25. package/dist/src/chest.d.ts +10 -0
  26. package/dist/src/chest.d.ts.map +1 -0
  27. package/dist/src/chest.js +49 -0
  28. package/dist/src/chest.js.map +1 -0
  29. package/dist/src/errors.d.ts +19 -0
  30. package/dist/src/errors.d.ts.map +1 -1
  31. package/dist/src/errors.js +49 -3
  32. package/dist/src/errors.js.map +1 -1
  33. package/dist/src/events.d.ts +56 -0
  34. package/dist/src/events.d.ts.map +1 -0
  35. package/dist/src/events.js +193 -0
  36. package/dist/src/events.js.map +1 -0
  37. package/dist/src/files.d.ts +17 -1
  38. package/dist/src/files.d.ts.map +1 -1
  39. package/dist/src/files.js +95 -92
  40. package/dist/src/files.js.map +1 -1
  41. package/dist/src/member.d.ts +10 -3
  42. package/dist/src/member.d.ts.map +1 -1
  43. package/dist/src/member.js +34 -11
  44. package/dist/src/member.js.map +1 -1
  45. package/dist/src/members.d.ts +34 -0
  46. package/dist/src/members.d.ts.map +1 -0
  47. package/dist/src/members.js +146 -0
  48. package/dist/src/members.js.map +1 -0
  49. package/dist/src/notifications.d.ts +25 -0
  50. package/dist/src/notifications.d.ts.map +1 -0
  51. package/dist/src/notifications.js +121 -0
  52. package/dist/src/notifications.js.map +1 -0
  53. package/dist/src/testing.d.ts +101 -0
  54. package/dist/src/testing.d.ts.map +1 -0
  55. package/dist/src/testing.js +552 -0
  56. package/dist/src/testing.js.map +1 -0
  57. package/package.json +40 -4
@@ -0,0 +1,564 @@
1
+ import { createHash, createHmac, randomBytes } from "node:crypto";
2
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
3
+ import type { AddressInfo } from "node:net";
4
+ import type { Alias, Provider } from "./ai.js";
5
+ import type { AiUnavailableReason } from "./errors.js";
6
+ import type { ChestEvent } from "./events.js";
7
+ import { groupIdPattern, memberIdPattern, type Member } from "./member.js";
8
+ import { forget } from "./members.js";
9
+
10
+ // For a tool's own tests, never imported by its production code: a member's
11
+ // assertion signed as the Chest signs it, and a Chest's API in the test's
12
+ // process that answers members, groups, files (stat, move, links; an upload
13
+ // it authorises but does not receive), badges, notifications, AI (chat,
14
+ // streamed or not, embeddings, models, usage: deterministic answers, no
15
+ // provider) and the acknowledgment of an erasure with the Chest's bounds,
16
+ // quotas and errors; and that delivers an event to the tool, signed as the
17
+ // Chest signs it.
18
+ //
19
+ // import { fakeChest, withMember } from "@argentic/chest-sdk/testing";
20
+ // const chest = await fakeChest({ members: [camille], capabilities: ["members", "files", "notifications"] });
21
+ // const response = await app(withMember(new Request("http://tool/chest"), camille));
22
+ // assert.equal(chest.notifications[0]?.member, camille.id);
23
+ // assert.equal(await chest.emit({ type: "access.revoked", data: { id: camille.id } }, request => app(request)), 204);
24
+ // await chest.close();
25
+
26
+ // A group as a fake Chest keeps it: its identifier, its name, and the
27
+ // identifiers of the members it gives the tool to.
28
+ export type FakeGroup = { id: string; name: string; members: string[] };
29
+ // A file as a fake Chest keeps it.
30
+ export type FakeFile = { data: Uint8Array; type: string; updated: string };
31
+ // A notification as a fake Chest keeps it: the member it went to, its text
32
+ // cleaned as the Chest cleans it, its path (/chest when not said) and its
33
+ // key.
34
+ export type FakeNotification = { member: string; title: string; body?: string; path: string; key?: string };
35
+ // An alias a fake Chest maps: the model behind it, its provider (openrouter
36
+ // by default) and its prices in US dollars per million tokens (1 and 2 by
37
+ // default).
38
+ export type FakeAiModel = { alias: Alias; model: string; provider?: Provider; input?: number; output?: number };
39
+ // What a fake model answers: text, or text and tool calls (an id of call_…
40
+ // by default; arguments are JSON text).
41
+ export type FakeAiReply = string | { text?: string; toolCalls?: { name: string; arguments: string; id?: string }[] };
42
+ // The AI of a fake Chest: the aliases the tool declared, each mapped (all
43
+ // four by default: fake-default, fake-fast, fake-smart, fake-embedding); what
44
+ // its models answer to a chat, given the request as the tool sent it (the
45
+ // last user message's text, echoed, by default); the tool's monthly cap in
46
+ // euros (5 by default; a call is refused cap_reached once the spending
47
+ // reaches it, 0 refuses at once); and a reason that makes chat and
48
+ // embeddings unavailable.
49
+ export type FakeAi = { models?: FakeAiModel[]; reply?: (request: Record<string, unknown>) => FakeAiReply; cap?: number; unavailable?: AiUnavailableReason };
50
+ // A call of the tool to the AI of a fake Chest: its path and its body as
51
+ // sent (null for a GET).
52
+ export type FakeAiCall = { path: string; body: unknown };
53
+
54
+ // What a fake Chest is given: the members who have the tool, those who left
55
+ // it (erased: their data was erased, the name gone), its groups, the
56
+ // capabilities its version holds (a capability left out answers 403;
57
+ // members, files, notifications and ai by default, members.email to read the
58
+ // addresses), the events it receives (["member.*"] by default, [] to answer
59
+ // an acknowledgment 403), the files it keeps, its AI, and what the Chest is
60
+ // (the chest module: "Test organization", UTC and English by default).
61
+ export type FakeChestOptions = {
62
+ members?: Member[];
63
+ former?: { id: string; name?: string; erased?: boolean }[];
64
+ groups?: FakeGroup[];
65
+ capabilities?: string[];
66
+ receives?: string[];
67
+ files?: Record<string, { data: Uint8Array | string; type?: string }>;
68
+ ai?: FakeAi;
69
+ chest?: { organization?: string; timeZone?: string; language?: string };
70
+ };
71
+
72
+ // An event for emit: its type and data; its id (a new evt_… by default) and
73
+ // when it happened (now by default) may be named, to deliver the same event
74
+ // twice.
75
+ export type FakeEvent = { [K in ChestEvent["type"]]: { type: K; data: Extract<ChestEvent, { type: K }>["data"]; id?: string; occurredAt?: string } }[ChestEvent["type"]];
76
+
77
+ // A fake Chest in the test's process: its address, the token and the tool it
78
+ // set in the environment, what it keeps (members, groups and files a test
79
+ // changes or reads; the notifications the tool sent, in the order sent, a
80
+ // replaced one last; each member's badge; the erasures the tool
81
+ // acknowledged; its calls to AI, in order), emit, which delivers an event to the tool — POST
82
+ // /chest-events of its address, or a handler of Web Requests — and says the
83
+ // status it answered, and close, which stops it and restores the
84
+ // environment. Its members are those who have the tool: the others are
85
+ // skipped.
86
+ export type FakeChest = {
87
+ api: string;
88
+ token: string;
89
+ tool: string;
90
+ members: Member[];
91
+ groups: FakeGroup[];
92
+ files: Map<string, FakeFile>;
93
+ notifications: FakeNotification[];
94
+ badges: Map<string, number>;
95
+ acknowledged: string[];
96
+ ai: FakeAiCall[];
97
+ emit(event: FakeEvent, to: string | ((request: Request) => Response | Promise<Response>)): Promise<number>;
98
+ close(): Promise<void>;
99
+ };
100
+
101
+ const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString("base64url");
102
+
103
+ // What an assertion is signed with and says besides the member: the token
104
+ // (CHEST_TOKEN by default) and the tool (CHEST_TOOL by default) it is for,
105
+ // and when it is issued (now by default). The member is signed as given: a
106
+ // language or a zone the Chest would never send makes member() refuse the
107
+ // assertion, as it refuses the Chest's.
108
+ export type AssertionOptions = { token?: string; tool?: string; now?: Date };
109
+
110
+ // signAssertion is the Chest-Member value the Chest's front would send for
111
+ // that member: HS256 under the key of the token, for the tool, valid 60
112
+ // seconds from when it is issued.
113
+ export function signAssertion(member: Member, options: AssertionOptions = {}): string {
114
+ const token = options.token ?? process.env["CHEST_TOKEN"];
115
+ const tool = options.tool ?? process.env["CHEST_TOOL"];
116
+ if (!token || !tool) throw new Error("signAssertion needs a token and a tool: start a fakeChest, or name them");
117
+ if (!memberIdPattern.test(member.id) || !member.groups.every(g => groupIdPattern.test(g))) throw new Error("signAssertion needs identifiers of the Chest's shape (mbr_…, grp_…)");
118
+ const iat = Math.floor((options.now ?? new Date()).getTime() / 1000);
119
+ const body = encode({ alg: "HS256", typ: "JWT" }) + "." + encode({
120
+ iss: `https://${tool}-chest.chest.test`, aud: tool, iat, exp: iat + 60, sub: member.id,
121
+ given_name: member.firstName, family_name: member.lastName, name: member.name, picture: member.photo ?? "", role: member.role ?? "",
122
+ admin: member.isAdmin, builder: member.isBuilder, groups: member.groups, time_zone: member.timeZone, ...(member.email === undefined ? {} : { email: member.email }),
123
+ language: member.language,
124
+ });
125
+ // The key as the Chest derives it, and member() reads it: HMAC-SHA256 of
126
+ // the label of the assertion's shape under the text of the token.
127
+ const key = createHmac("sha256", Buffer.from(token, "utf8")).update("Chest-Member v2").digest();
128
+ return body + "." + createHmac("sha256", key).update(body).digest("base64url");
129
+ }
130
+
131
+ // signEvent is the Chest-Event value the Chest would send with that body:
132
+ // HS256 under the key the token derives for events, for the tool, naming the
133
+ // event and the digest of the body, valid 60 seconds.
134
+ function signEvent(id: string, body: string, options: { token: string; tool: string }): string {
135
+ const iat = Math.floor(Date.now() / 1000);
136
+ const signed = encode({ alg: "HS256", typ: "JWT" }) + "." + encode({ aud: options.tool, iat, exp: iat + 60, jti: id, digest: createHash("sha256").update(body).digest("base64url") });
137
+ const key = createHmac("sha256", Buffer.from(options.token, "utf8")).update("Chest-Event v1").digest();
138
+ return signed + "." + createHmac("sha256", key).update(signed).digest("base64url");
139
+ }
140
+
141
+ // withMember is the request carrying that member's assertion, signed with
142
+ // the options of signAssertion: a new Web Request, or the same Node request
143
+ // with its header set.
144
+ export function withMember<R extends Request | IncomingMessage>(request: R, member: Member, options: AssertionOptions = {}): R {
145
+ const assertion = signAssertion(member, options);
146
+ if (request instanceof Request) {
147
+ const headers = new Headers(request.headers);
148
+ headers.set("Chest-Member", assertion);
149
+ return new Request(request, { headers }) as R;
150
+ }
151
+ (request as IncomingMessage).headers["chest-member"] = assertion;
152
+ return request;
153
+ }
154
+
155
+ // The bounds of a Chest (chest/toolmembers, chest/toolfiles).
156
+ const maxLimit = 500, defaultLimit = 100, maxLookup = 200, callsPerMinute = 600;
157
+ const maxObject = 32 << 20, maxObjects = 10000, maxTotal = 1 << 30;
158
+ const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}(\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}){0,7}$/u;
159
+ // Its notifications: recipients and badges a call, text, quotas.
160
+ const maxRecipients = 500, maxTitle = 80, maxText = 280, maxPath = 512, maxCount = 9999;
161
+ const recipientsPerHour = 1000, itemsPerDay = 100, badgesPerMinute = 600;
162
+ const keyPattern = /^[a-z0-9._:-]{1,64}$/u;
163
+ const reordering = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
164
+ const cleanTitle = (s: string): string => s.replace(/[\t\r\n]/gu, " ").replace(/\p{Cc}/gu, "").replace(reordering, "").trim();
165
+ const cleanText = (s: string): string => s.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ").replace(/[^\P{Cc}\n]/gu, "").replace(reordering, "").trim();
166
+ const isPath = (p: unknown): boolean => typeof p === "string" && p.length <= maxPath && /^\/chest([/?#][\x21-\x5b\x5d-\x7e]*)?$/u.test(p) && !p.includes("//") && !p.split(/[?#]/u)[0]!.split("/").some(x => /^(\.|%2e){1,2}$/iu.test(x));
167
+
168
+ // Its AI: aliases in order, bounds of a request, requests a minute.
169
+ const aliasOrder: readonly Alias[] = ["default", "fast", "smart", "embedding"];
170
+ const chatKeys = ["model", "messages", "max_tokens", "stream", "temperature", "top_p", "stop", "tools", "tool_choice", "response_format", "parallel_tool_calls", "seed", "reasoning_effort", "member"];
171
+ const maxAiBody = 10 << 20, maxAiOutput = 128000, maxInputs = 256, aiPerMinute = 60, fakeDimensions = 8;
172
+ // A fake count of tokens: one per 4 characters.
173
+ const tokensOf = (text: string): number => Math.ceil(text.length / 4);
174
+ // A vector of a text, the same for the same text: unit length.
175
+ function vectorOf(text: string, dimensions: number): number[] {
176
+ const values: number[] = [];
177
+ for (let block = 0; values.length < dimensions; block++) {
178
+ for (const b of createHash("sha256").update(block + ":" + text).digest()) values.push(b / 127.5 - 1);
179
+ }
180
+ const kept = values.slice(0, dimensions), norm = Math.hypot(...kept) || 1;
181
+ return kept.map(x => x / norm);
182
+ }
183
+
184
+ const fold = (s: string): string => s.normalize("NFD").replace(/\p{Mn}/gu, "").toLowerCase();
185
+
186
+ function send(response: ServerResponse, status: number, value?: unknown, headers: Record<string, string> = {}): void {
187
+ if (value === undefined) return void response.writeHead(status, headers).end();
188
+ const raw = JSON.stringify(value);
189
+ response.writeHead(status, { ...headers, "Content-Type": "application/json", "Content-Length": String(Buffer.byteLength(raw)) }).end(raw);
190
+ }
191
+ async function body(request: IncomingMessage, limit: number): Promise<Buffer | null> {
192
+ const chunks: Buffer[] = [];
193
+ let size = 0;
194
+ for await (const chunk of request) {
195
+ size += (chunk as Buffer).length;
196
+ if (size > limit) return null;
197
+ chunks.push(chunk as Buffer);
198
+ }
199
+ return Buffer.concat(chunks);
200
+ }
201
+
202
+ // fakeChest starts a Chest's API on 127.0.0.1 and points the environment at
203
+ // it: CHEST_API, CHEST_TOKEN (a new one) and CHEST_TOOL ("tool" unless the
204
+ // environment names one), and what the Chest is (CHEST_ORGANIZATION,
205
+ // CHEST_TIME_ZONE, CHEST_LANGUAGE, as options.chest says). What member(),
206
+ // chest and the modules of the SDK read is then this Chest's.
207
+ export async function fakeChest(options: FakeChestOptions = {}): Promise<FakeChest> {
208
+ const capabilities = new Set(options.capabilities ?? ["members", "files", "notifications", "ai"]);
209
+ const email = capabilities.has("members.email");
210
+ const tool = process.env["CHEST_TOOL"] || "tool";
211
+ const token = randomBytes(32).toString("base64url");
212
+ const files = new Map<string, FakeFile>();
213
+ for (const [name, file] of Object.entries(options.files ?? {})) {
214
+ files.set(name, { data: typeof file.data === "string" ? new TextEncoder().encode(file.data) : file.data, type: file.type ?? "application/octet-stream", updated: new Date().toISOString() });
215
+ }
216
+ const receives = options.receives ?? ["member.*"];
217
+ // The erasures the tool was told of, by emit: those it may acknowledge.
218
+ const erasures = new Set<string>();
219
+ const chest: FakeChest = { api: "", token, tool, members: [...(options.members ?? [])], groups: [...(options.groups ?? [])], files, notifications: [], badges: new Map(), acknowledged: [], ai: [], emit: async () => 0, close: async () => {} };
220
+ const former = [...(options.former ?? [])];
221
+ let window = 0, calls = 0;
222
+ const shown = (m: Member) => ({ id: m.id, first_name: m.firstName, last_name: m.lastName, name: m.name, photo: m.photo, role: m.role, admin: m.isAdmin, builder: m.isBuilder, groups: m.groups, language: m.language, time_zone: m.timeZone, ...(email && m.email !== undefined ? { email: m.email } : {}) });
223
+ const key = (m: Member) => fold(m.name) + "\u0000" + m.id;
224
+ const described = (name: string, f: FakeFile) => ({ name, type: f.type, size: f.data.byteLength, updated: f.updated });
225
+
226
+ async function members(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
227
+ if (!capabilities.has("members")) return send(response, 403, { error: "capability_not_granted" });
228
+ const now = Date.now();
229
+ if (now - window >= 60_000) [window, calls] = [now, 0];
230
+ if (++calls > callsPerMinute) return send(response, 429, { error: "rate_limited" }, { "Retry-After": "60" });
231
+ if (request.method === "GET" && url.pathname === "/members") {
232
+ const q = url.searchParams, keys = [...q.keys()];
233
+ const limit = q.has("limit") ? Number(q.get("limit")) : defaultLimit;
234
+ const after = q.has("after") ? Buffer.from(q.get("after")!, "base64url").toString() : "";
235
+ if (q.has("after") && !memberIdPattern.test(after.split("\u0000")[1] ?? "")) return send(response, 400, { error: "invalid_query" });
236
+ const group = q.get("group"), role = q.get("role"), search = fold(q.get("q") ?? "");
237
+ if (keys.some(k => !["after", "limit", "q", "role", "group"].includes(k) || q.getAll(k).length !== 1) || !Number.isInteger(limit) || limit < 1 || limit > maxLimit || String(limit) !== (q.get("limit") ?? String(defaultLimit)) || (group !== null && !groupIdPattern.test(group)) || search.length > 64) return send(response, 400, { error: "invalid_query" });
238
+ const shownList = chest.members
239
+ .filter(m => (role === null || m.role === role) && (group === null || m.groups.includes(group)) && (search === "" || [m.firstName, m.lastName, m.name, ...(email && m.email ? [m.email] : [])].some(n => fold(n).startsWith(search))))
240
+ .filter(m => after === "" || key(m) > after)
241
+ .sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
242
+ const page = shownList.slice(0, limit);
243
+ return send(response, 200, { members: page.map(shown), next: shownList.length > limit ? Buffer.from(key(page.at(-1)!)).toString("base64url") : null });
244
+ }
245
+ if (request.method === "POST" && url.pathname === "/members/lookup") {
246
+ const raw = await body(request, 64 << 10);
247
+ let ids: unknown;
248
+ try { ids = (JSON.parse(raw?.toString() ?? "") as { ids?: unknown }).ids; } catch { ids = undefined; }
249
+ if (!Array.isArray(ids) || ids.length > maxLookup) return send(response, 400, { error: "invalid_body" });
250
+ if (!ids.every(id => typeof id === "string" && memberIdPattern.test(id))) return send(response, 400, { error: "invalid_id" });
251
+ const answer = { members: [] as unknown[], former: [] as unknown[], unknown: [] as string[] };
252
+ for (const id of new Set(ids as string[])) {
253
+ const m = chest.members.find(x => x.id === id), f = former.find(x => x.id === id);
254
+ if (m) answer.members.push(shown(m));
255
+ else if (f?.erased) answer.former.push({ id, status: "erased" });
256
+ else if (f) answer.former.push({ id, ...(f.name ? { name: f.name } : {}), status: "former" });
257
+ else answer.unknown.push(id);
258
+ }
259
+ return send(response, 200, answer);
260
+ }
261
+ if (request.method === "GET" && url.pathname.startsWith("/members/")) {
262
+ const id = url.pathname.slice("/members/".length);
263
+ if (!memberIdPattern.test(id)) return send(response, 400, { error: "invalid_id" });
264
+ const m = chest.members.find(x => x.id === id);
265
+ return m ? send(response, 200, shown(m)) : send(response, 404, { error: "member_not_found" });
266
+ }
267
+ if (request.method === "GET" && url.pathname === "/groups") return send(response, 200, { groups: chest.groups });
268
+ send(response, 404, { error: "not_found" });
269
+ }
270
+
271
+ async function filesRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
272
+ if (!capabilities.has("files")) return send(response, 403, { error: "capability_not_granted" });
273
+ if (request.method === "GET" && url.pathname === "/files") {
274
+ const prefix = url.searchParams.get("prefix") ?? "", after = url.searchParams.get("after") ?? "";
275
+ const names = [...files.keys()].filter(n => n.startsWith(prefix) && n > after).sort();
276
+ return send(response, 200, { files: names.slice(0, 1000).map(n => described(n, files.get(n)!)), next: names.length > 1000 ? names[999] : null });
277
+ }
278
+ if (request.method === "POST" && url.pathname === "/files/url") {
279
+ const raw = await body(request, 4096);
280
+ let name: unknown;
281
+ try { name = (JSON.parse(raw?.toString() ?? "") as { name?: unknown }).name; } catch { name = undefined; }
282
+ if (typeof name !== "string") return send(response, 400, { error: "invalid_body" });
283
+ if (!files.has(name)) return send(response, 404, { error: "not_found" });
284
+ return send(response, 200, { url: `https://${tool}-chest.chest.test/_chest/files/${Buffer.from(name).toString("base64url")}.fake`, expires_in: 900 });
285
+ }
286
+ if (request.method === "POST" && (url.pathname === "/files/move" || url.pathname === "/files/upload-url")) {
287
+ const raw = await body(request, 4096);
288
+ let command: Record<string, unknown> = {};
289
+ try { command = JSON.parse(raw?.toString() ?? "") as Record<string, unknown>; } catch { command = {}; }
290
+ if (url.pathname === "/files/upload-url") {
291
+ // The upload itself goes from a member's browser to the team host,
292
+ // which a fake Chest does not play: it only authorises it.
293
+ if (typeof command["name"] !== "string") return send(response, 400, { error: "invalid_body" });
294
+ return send(response, 200, { url: `https://${tool}-chest.chest.test/_chest/files/upload/${Buffer.from(command["name"]).toString("base64url")}.fake`, method: "PUT", expires_in: typeof command["expires_in"] === "number" ? command["expires_in"] : 900 });
295
+ }
296
+ const from = command["from"], to = command["to"];
297
+ if (typeof from !== "string" || typeof to !== "string" || !namePattern.test(from) || !namePattern.test(to)) return send(response, 400, { error: "invalid_name" });
298
+ const moving = files.get(from);
299
+ if (!moving) return send(response, 404, { error: "not_found" });
300
+ files.delete(from);
301
+ files.set(to, moving);
302
+ return send(response, 200, described(to, moving));
303
+ }
304
+ const name = decodeURIComponent(url.pathname.slice("/files/".length));
305
+ if (!namePattern.test(name)) return send(response, 400, { error: "invalid_name" });
306
+ const object = files.get(name);
307
+ if (request.method === "PUT") {
308
+ const data = await body(request, maxObject);
309
+ if (data === null) return send(response, 413, { error: "too_large" });
310
+ const total = [...files.entries()].reduce((sum, [n, f]) => n === name ? sum : sum + f.data.byteLength, 0);
311
+ if (total + data.length > maxTotal || (!object && files.size >= maxObjects)) return send(response, 429, { error: "quota_exceeded" });
312
+ const kept = { data: new Uint8Array(data), type: request.headers["content-type"] ?? "application/octet-stream", updated: new Date().toISOString() };
313
+ files.set(name, kept);
314
+ return send(response, 201, described(name, kept));
315
+ }
316
+ if (!object) return send(response, 404, { error: "not_found" });
317
+ if (request.method === "GET" && url.searchParams.has("stat")) return send(response, 200, described(name, object));
318
+ if (request.method === "GET") return void response.writeHead(200, { "Content-Type": object.type, "Content-Length": String(object.data.byteLength) }).end(object.data);
319
+ if (request.method === "DELETE") {
320
+ files.delete(name);
321
+ return send(response, 204);
322
+ }
323
+ send(response, 404, { error: "not_found" });
324
+ }
325
+
326
+ // The windows of the notification quotas, each from the first call it
327
+ // counts: the tool's recipients this hour, each member's items this day,
328
+ // the tool's badge writes this minute.
329
+ type Window = { start: number; count: number };
330
+ const hour: Window = { start: 0, count: 0 }, minute: Window = { start: 0, count: 0 }, days = new Map<string, Window>();
331
+ const live = (w: Window | undefined, span: number, now: number): boolean => w !== undefined && w.count > 0 && now - w.start < span;
332
+ const wait = (w: Window, span: number, now: number): Record<string, string> => ({ "Retry-After": String(Math.max(1, Math.ceil((w.start + span - now) / 1000))) });
333
+ const count = (w: Window, span: number, now: number, n: number): void => {
334
+ if (!live(w, span, now)) [w.start, w.count] = [now, 0];
335
+ w.count += n;
336
+ };
337
+ const access = (id: string) => chest.members.some(m => m.id === id);
338
+ // drop removes kept notifications in place: a test may hold the list.
339
+ const drop = (gone: (n: FakeNotification) => boolean): void => {
340
+ for (let i = chest.notifications.length - 1; i >= 0; i--) if (gone(chest.notifications[i]!)) chest.notifications.splice(i, 1);
341
+ };
342
+ // recipients reads 1 to 500 member identifiers, each once.
343
+ function recipients(value: unknown): string[] | { error: string } {
344
+ if (!Array.isArray(value) || value.length < 1 || value.length > maxRecipients) return { error: "invalid_body" };
345
+ if (!value.every(v => typeof v === "string" && memberIdPattern.test(v))) return { error: "invalid_id" };
346
+ return [...new Set(value as string[])];
347
+ }
348
+
349
+ async function notificationsRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
350
+ if (!capabilities.has("notifications")) return send(response, 403, { error: "capability_not_granted" });
351
+ const badge = request.method === "PUT" && url.pathname.startsWith("/badges/");
352
+ if (!badge && !(request.method === "PUT" && url.pathname === "/badges") && !(request.method === "POST" && (url.pathname === "/notifications" || url.pathname === "/notifications/withdraw"))) return send(response, 404, { error: "not_found" });
353
+ const raw = await body(request, 64 << 10);
354
+ let command: Record<string, unknown> | null = null;
355
+ try {
356
+ const value = JSON.parse(raw?.toString() ?? "") as unknown;
357
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) command = value as Record<string, unknown>;
358
+ } catch {
359
+ command = null;
360
+ }
361
+ const keys = (...allowed: string[]) => command !== null && Object.keys(command).every(k => allowed.includes(k));
362
+ const now = Date.now();
363
+ if (badge || url.pathname === "/badges") {
364
+ let writes: { member: string; count: number }[];
365
+ if (badge) {
366
+ const id = url.pathname.slice("/badges/".length);
367
+ if (!memberIdPattern.test(id)) return send(response, 400, { error: "invalid_id" });
368
+ if (!keys("count") || !("count" in command!)) return send(response, 400, { error: "invalid_body" });
369
+ writes = [{ member: id, count: command!["count"] as number }];
370
+ } else {
371
+ const list = command?.["badges"];
372
+ if (!keys("badges") || !Array.isArray(list) || list.length < 1 || list.length > maxRecipients || !list.every(b => b !== null && typeof b === "object" && !Array.isArray(b) && Object.keys(b).every(k => k === "member" || k === "count"))) return send(response, 400, { error: "invalid_body" });
373
+ writes = list as { member: string; count: number }[];
374
+ if (!writes.every(b => typeof b.member === "string" && memberIdPattern.test(b.member))) return send(response, 400, { error: "invalid_id" });
375
+ }
376
+ if (!writes.every(b => typeof b.count === "number" && Number.isInteger(b.count) && b.count >= 0 && b.count <= maxCount)) return send(response, 400, { error: "invalid_count" });
377
+ if (new Set(writes.map(b => b.member)).size !== writes.length) return send(response, 400, { error: "invalid_body" });
378
+ if (live(minute, 60_000, now) && minute.count + writes.length > badgesPerMinute) return send(response, 429, { error: "quota_exceeded" }, wait(minute, 60_000, now));
379
+ count(minute, 60_000, now, writes.length);
380
+ const answer = { set: [] as string[], skipped: [] as string[] };
381
+ for (const b of writes) {
382
+ if (!access(b.member)) {
383
+ answer.skipped.push(b.member);
384
+ continue;
385
+ }
386
+ if (b.count === 0) chest.badges.delete(b.member);
387
+ else chest.badges.set(b.member, b.count);
388
+ answer.set.push(b.member);
389
+ }
390
+ return send(response, 200, answer);
391
+ }
392
+ if (url.pathname === "/notifications/withdraw") {
393
+ if (!keys("key", "members")) return send(response, 400, { error: "invalid_body" });
394
+ const key = command!["key"];
395
+ if (typeof key !== "string" || !keyPattern.test(key)) return send(response, 400, { error: "invalid_key" });
396
+ const named = command!["members"] === undefined ? null : recipients(command!["members"]);
397
+ if (named !== null && !Array.isArray(named)) return send(response, 400, named);
398
+ drop(n => n.key === key && (named === null || named.includes(n.member)));
399
+ return send(response, 204);
400
+ }
401
+ if (!keys("members", "title", "body", "path", "key")) return send(response, 400, { error: "invalid_body" });
402
+ const { title, body: text, path, key } = command!;
403
+ const ids = recipients(command!["members"]);
404
+ if (!Array.isArray(ids)) return send(response, 400, ids);
405
+ if (typeof title !== "string" || [...title].length < 1 || [...title].length > maxTitle || cleanTitle(title) === "") return send(response, 400, { error: "invalid_title" });
406
+ if (text !== undefined && (typeof text !== "string" || [...text].length > maxText)) return send(response, 400, { error: "invalid_text" });
407
+ if (path !== undefined && !isPath(path)) return send(response, 400, { error: "invalid_path" });
408
+ if (key !== undefined && (typeof key !== "string" || !keyPattern.test(key))) return send(response, 400, { error: "invalid_key" });
409
+ const kept = ids.filter(access);
410
+ if (live(hour, 3_600_000, now) && hour.count + kept.length > recipientsPerHour) return send(response, 429, { error: "quota_exceeded" }, wait(hour, 3_600_000, now));
411
+ const full = kept.map(id => days.get(id)).filter((w): w is Window => live(w, 86_400_000, now) && w!.count >= itemsPerDay);
412
+ if (full.length > 0) return send(response, 429, { error: "quota_exceeded" }, wait(full.reduce((a, b) => a.start > b.start ? a : b), 86_400_000, now));
413
+ count(hour, 3_600_000, now, kept.length);
414
+ const cleaned = typeof text === "string" ? cleanText(text) : "";
415
+ for (const id of kept) {
416
+ if (!days.has(id)) days.set(id, { start: 0, count: 0 });
417
+ count(days.get(id)!, 86_400_000, now, 1);
418
+ if (key !== undefined) drop(n => n.member === id && n.key === key);
419
+ chest.notifications.push({ member: id, title: cleanTitle(title), ...(cleaned ? { body: cleaned } : {}), path: (path as string | undefined) ?? "/chest", ...(key !== undefined ? { key: key as string } : {}) });
420
+ }
421
+ send(response, 200, { delivered: kept, skipped: ids.filter(id => !access(id)) });
422
+ }
423
+
424
+ // Its AI: the declared aliases, the spending this month, the requests this
425
+ // minute.
426
+ const mapped = (options.ai?.models ?? aliasOrder.map((alias): FakeAiModel => ({ alias, model: "fake-" + alias }))).map(m => ({ alias: m.alias, model: m.model, provider: m.provider ?? "openrouter", input: m.input ?? 1, output: m.output ?? 2 }));
427
+ const cap = options.ai?.cap ?? 5;
428
+ const aiMinute: Window = { start: 0, count: 0 };
429
+ let spent = 0;
430
+ const month = () => new Date().toISOString().slice(0, 7);
431
+ const resets = () => {
432
+ const now = new Date();
433
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString().replace(".000Z", "Z");
434
+ };
435
+ const spend = (m: { input: number; output: number }, input: number, output: number): number => {
436
+ const cost = Math.round((input * m.input + output * m.output) / 1e6 * 1e6) / 1e6;
437
+ spent = Math.round((spent + cost) * 1e6) / 1e6;
438
+ return cost;
439
+ };
440
+ // echo is the text of the last user message: its text, or its text parts.
441
+ const echo = (request: Record<string, unknown>): string => {
442
+ const last = [...request["messages"] as { role?: unknown; content?: unknown }[]].reverse().find(m => m?.role === "user")?.content;
443
+ return typeof last === "string" ? last : Array.isArray(last) ? last.map(p => (p as { text?: unknown })?.text).filter(t => typeof t === "string").join("") : "";
444
+ };
445
+
446
+ async function aiRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
447
+ const raw = request.method === "POST" ? await body(request, maxAiBody) : null;
448
+ let command: Record<string, unknown> | null = null;
449
+ try {
450
+ const value = JSON.parse(raw?.toString() ?? "") as unknown;
451
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) command = value as Record<string, unknown>;
452
+ } catch {
453
+ command = null;
454
+ }
455
+ chest.ai.push({ path: url.pathname, body: command });
456
+ if (!capabilities.has("ai")) return send(response, 403, { error: "capability_not_granted" });
457
+ if (request.method === "GET" && url.pathname === "/ai/models") return send(response, 200, { models: [...mapped].sort((a, b) => aliasOrder.indexOf(a.alias) - aliasOrder.indexOf(b.alias)) });
458
+ if (request.method === "GET" && url.pathname === "/ai/usage") return send(response, 200, { month: month(), spent, cap, resets: resets() });
459
+ if (request.method !== "POST" || (url.pathname !== "/ai/chat" && url.pathname !== "/ai/embeddings")) return send(response, 404, { error: "not_found" });
460
+ if (raw === null) return send(response, 413, { error: "too_large" });
461
+ const chat = url.pathname === "/ai/chat";
462
+ if (!command || !Object.keys(command).every(k => chat ? chatKeys.includes(k) : ["model", "input", "dimensions", "member"].includes(k))) return send(response, 400, { error: "invalid_body" });
463
+ const model = mapped.find(m => m.alias === command!["model"]);
464
+ if (!model) return send(response, 403, { error: "model_not_allowed" });
465
+ const member = command["member"];
466
+ if (member !== undefined && (typeof member !== "string" || !memberIdPattern.test(member))) return send(response, 400, { error: "invalid_body" });
467
+ const now = Date.now();
468
+ // refused says the refusal of a valid request, if any: the rate, the
469
+ // provider, the cap.
470
+ const refused = (): boolean => {
471
+ const busy = live(aiMinute, 60_000, now) && aiMinute.count >= aiPerMinute;
472
+ if (busy) send(response, 429, { error: "rate_limited" }, wait(aiMinute, 60_000, now));
473
+ else {
474
+ count(aiMinute, 60_000, now, 1);
475
+ if (options.ai?.unavailable) send(response, options.ai.unavailable === "provider_key_invalid" ? 502 : 503, { error: options.ai.unavailable });
476
+ else if (spent >= cap) send(response, 402, { error: "cap_reached", scope: "tool", resets: resets() });
477
+ }
478
+ return response.headersSent;
479
+ };
480
+ if (!chat) {
481
+ const input = command["input"], texts = typeof input === "string" ? [input] : input, dimensions = command["dimensions"] ?? fakeDimensions;
482
+ if (!Array.isArray(texts) || texts.length < 1 || texts.length > maxInputs || !texts.every(t => typeof t === "string") || typeof dimensions !== "number" || !Number.isInteger(dimensions) || dimensions < 1 || dimensions > 4096) return send(response, 400, { error: "invalid_body" });
483
+ if (refused()) return;
484
+ const used = texts.reduce((sum: number, t: string) => sum + tokensOf(t), 0);
485
+ const cost = spend(model, used, 0);
486
+ return send(response, 200, { object: "list", data: texts.map((t: string, index) => ({ object: "embedding", index, embedding: vectorOf(t, dimensions) })), model: model.model, usage: { prompt_tokens: used, total_tokens: used, cost } });
487
+ }
488
+ const messages = command["messages"], limit = command["max_tokens"] ?? 4096;
489
+ if (!Array.isArray(messages) || messages.length < 1 || !messages.every(m => m !== null && typeof m === "object" && typeof (m as { role?: unknown }).role === "string") || typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > maxAiOutput || !(command["stream"] === undefined || typeof command["stream"] === "boolean")) return send(response, 400, { error: "invalid_body" });
490
+ if (refused()) return;
491
+ const given = options.ai?.reply ? options.ai.reply(command) : echo(command);
492
+ const text = typeof given === "string" ? given : given.text ?? "";
493
+ const calls = (typeof given === "string" ? [] : given.toolCalls ?? []).map((c, i) => ({ id: c.id ?? `call_${i + 1}`, type: "function" as const, function: { name: c.name, arguments: c.arguments } }));
494
+ const input = tokensOf(JSON.stringify(messages)), output = tokensOf(text + calls.map(c => c.function.name + c.function.arguments).join(""));
495
+ const usage = { prompt_tokens: input, completion_tokens: output, total_tokens: input + output, prompt_tokens_details: { cached_tokens: 0 }, cost: spend(model, input, output) };
496
+ const finish = calls.length ? "tool_calls" : "stop";
497
+ const head = { id: "chatcmpl-fake", created: Math.floor(now / 1000), model: model.model };
498
+ if (command["stream"] !== true) {
499
+ return send(response, 200, { ...head, object: "chat.completion", choices: [{ index: 0, message: { role: "assistant", content: calls.length && !text ? null : text, ...(calls.length ? { tool_calls: calls } : {}) }, finish_reason: finish }], usage });
500
+ }
501
+ // Streamed: the role, the text word by word, each call's name then its
502
+ // arguments in two halves, the finish reason, the usage, [DONE].
503
+ const chunk = (choices: unknown[], extra: Record<string, unknown> = {}) => `data: ${JSON.stringify({ ...head, object: "chat.completion.chunk", choices, ...extra })}\n\n`;
504
+ const delta = (d: Record<string, unknown>, finishReason: string | null = null) => chunk([{ index: 0, delta: d, finish_reason: finishReason }]);
505
+ const parts = [delta({ role: "assistant", content: "" })];
506
+ for (const word of text.match(/\S+\s*|\s+/gu) ?? []) parts.push(delta({ content: word }));
507
+ calls.forEach((c, index) => {
508
+ const half = Math.ceil(c.function.arguments.length / 2);
509
+ parts.push(delta({ tool_calls: [{ index, id: c.id, type: "function", function: { name: c.function.name, arguments: "" } }] }));
510
+ for (const piece of [c.function.arguments.slice(0, half), c.function.arguments.slice(half)]) if (piece) parts.push(delta({ tool_calls: [{ index, function: { arguments: piece } }] }));
511
+ });
512
+ parts.push(delta({}, finish), chunk([], { usage }), "data: [DONE]\n\n");
513
+ response.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
514
+ for (const part of parts) response.write(part);
515
+ response.end();
516
+ }
517
+
518
+ // The acknowledgment of an erasure the tool was told of (emit).
519
+ async function erasuresRoute(request: IncomingMessage, response: ServerResponse, url: URL): Promise<void> {
520
+ if (!receives.includes("member.*")) return send(response, 403, { error: "capability_not_granted" });
521
+ const done = /^\/erasures\/([^/]+)\/done$/u.exec(url.pathname);
522
+ if (request.method !== "POST" || !done || url.search) return send(response, 404, { error: "not_found" });
523
+ const id = done[1]!;
524
+ if (!/^era_[a-z2-7]{26}$/u.test(id)) return send(response, 400, { error: "invalid_id" });
525
+ if (!erasures.has(id)) return send(response, 404, { error: "erasure_not_found" });
526
+ if (!chest.acknowledged.includes(id)) chest.acknowledged.push(id);
527
+ send(response, 204);
528
+ }
529
+
530
+ const server = createServer((request, response) => {
531
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
532
+ const route = url.pathname.startsWith("/erasures/") ? erasuresRoute
533
+ : url.pathname.startsWith("/ai/") ? aiRoute
534
+ : url.pathname === "/members" || url.pathname.startsWith("/members/") || url.pathname === "/groups" ? members
535
+ : url.pathname === "/files" || url.pathname.startsWith("/files/") ? filesRoute
536
+ : url.pathname === "/badges" || url.pathname.startsWith("/badges/") || url.pathname.startsWith("/notifications") ? notificationsRoute : null;
537
+ if (!route) return send(response, 404, { error: "not_found" });
538
+ route(request, response, url).catch(() => { if (!response.headersSent) send(response, 503, { error: "unavailable" }); else response.destroy(); });
539
+ });
540
+ await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
541
+ const saved = Object.fromEntries(["CHEST_API", "CHEST_TOKEN", "CHEST_TOOL", "CHEST_ORGANIZATION", "CHEST_TIME_ZONE", "CHEST_LANGUAGE"].map(name => [name, process.env[name]]));
542
+ chest.api = "http://127.0.0.1:" + (server.address() as AddressInfo).port;
543
+ Object.assign(process.env, { CHEST_API: chest.api, CHEST_TOKEN: token, CHEST_TOOL: tool, CHEST_ORGANIZATION: options.chest?.organization ?? "Test organization", CHEST_TIME_ZONE: options.chest?.timeZone ?? "UTC", CHEST_LANGUAGE: options.chest?.language ?? "en" });
544
+ forget();
545
+ chest.emit = async (event, to) => {
546
+ const id = event.id ?? "evt_" + Array.from(randomBytes(26), b => "abcdefghijklmnopqrstuvwxyz234567"[b & 31]).join("");
547
+ const body = JSON.stringify({ id, type: event.type, occurredAt: event.occurredAt ?? new Date().toISOString(), data: event.data });
548
+ if (event.type === "member.erased") erasures.add(event.data.erasure);
549
+ const request = new Request(typeof to === "string" ? to.replace(/\/$/u, "") + "/chest-events" : "http://tool.test/chest-events", { method: "POST", headers: { "Content-Type": "application/json", "Chest-Event": signEvent(id, body, { token, tool }) }, body });
550
+ const answer = typeof to === "string" ? await fetch(request, { redirect: "manual" }) : await to(request);
551
+ await answer.body?.cancel();
552
+ return answer.status;
553
+ };
554
+ chest.close = async () => {
555
+ server.closeAllConnections();
556
+ await new Promise<void>(resolve => server.close(() => resolve()));
557
+ for (const [name, value] of Object.entries(saved)) {
558
+ if (value === undefined) delete process.env[name];
559
+ else process.env[name] = value;
560
+ }
561
+ forget();
562
+ };
563
+ return chest;
564
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export * from "./src/errors.js";
2
2
  export * from "./src/member.js";
3
+ export * from "./src/chest.js";
3
4
  export * from "./src/database.js";
4
5
  export * as files from "./src/files.js";
6
+ export * as members from "./src/members.js";
7
+ export * as notifications from "./src/notifications.js";
8
+ export * as events from "./src/events.js";
9
+ export * as ai from "./src/ai.js";
5
10
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAKA,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAOA,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,mBAAmB,CAAC;AAClC,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC;AACxC,OAAO,KAAK,OAAO,MAAM,kBAAkB,CAAC;AAC5C,OAAO,KAAK,aAAa,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,MAAM,MAAM,iBAAiB,CAAC;AAC1C,OAAO,KAAK,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -1,10 +1,17 @@
1
- // The package root: every published module of the SDK (tool contract v2).
2
- // Each one is also its own subpath (@argentic/chest-sdk/member, /database,
3
- // /files, /errors), which pulls in nothing else. The files API is a namespace
4
- // here, as its names (get, put, list, delete, url) are too plain to stand
5
- // alone.
1
+ // The package root: every published module a tool's code uses (tool contract
2
+ // v2). Each one is also its own subpath (@argentic/chest-sdk/member, /chest,
3
+ // /database, /files, /members, /notifications, /events, /ai, /errors), which
4
+ // pulls in nothing else. The files, members, notifications, events and ai
5
+ // APIs are namespaces here, as their names (get, list, stat, move, notify,
6
+ // verify, chat…) are too plain to stand alone. @argentic/chest-sdk/testing is for a tool's tests only, and
7
+ // is not here.
6
8
  export * from "./src/errors.js";
7
9
  export * from "./src/member.js";
10
+ export * from "./src/chest.js";
8
11
  export * from "./src/database.js";
9
12
  export * as files from "./src/files.js";
13
+ export * as members from "./src/members.js";
14
+ export * as notifications from "./src/notifications.js";
15
+ export * as events from "./src/events.js";
16
+ export * as ai from "./src/ai.js";
10
17
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,2EAA2E;AAC3E,8EAA8E;AAC9E,0EAA0E;AAC1E,SAAS;AACT,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,6EAA6E;AAC7E,6EAA6E;AAC7E,0EAA0E;AAC1E,2EAA2E;AAC3E,2GAA2G;AAC3G,eAAe;AACf,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,mBAAmB,CAAC;AAClC,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC;AACxC,OAAO,KAAK,OAAO,MAAM,kBAAkB,CAAC;AAC5C,OAAO,KAAK,aAAa,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,MAAM,MAAM,iBAAiB,CAAC;AAC1C,OAAO,KAAK,EAAE,MAAM,aAAa,CAAC"}