libretto 0.4.4 → 0.5.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 (152) hide show
  1. package/dist/cli/cli.js +20 -19
  2. package/dist/cli/commands/ai.js +1 -1
  3. package/dist/cli/commands/browser.js +3 -3
  4. package/dist/cli/commands/execution.js +3 -3
  5. package/dist/cli/commands/logs.js +1 -1
  6. package/dist/cli/core/browser.js +11 -6
  7. package/dist/cli/core/context.js +4 -18
  8. package/dist/cli/core/session.js +2 -2
  9. package/dist/cli/core/snapshot-analyzer.js +2 -2
  10. package/dist/cli/router.js +1 -1
  11. package/dist/cli/workers/run-integration-runtime.js +2 -2
  12. package/dist/shared/paths/paths.js +2 -1
  13. package/dist/shared/paths/repo-root.d.ts +3 -0
  14. package/dist/shared/paths/repo-root.js +24 -0
  15. package/package.json +6 -7
  16. package/scripts/postinstall.mjs +12 -3
  17. package/skills/libretto/SKILL.md +93 -404
  18. package/skills/libretto/references/auth-profiles.md +30 -0
  19. package/skills/libretto/references/pages-and-page-targeting.md +29 -0
  20. package/skills/libretto/references/reverse-engineering-network-requests.md +39 -0
  21. package/skills/libretto/references/user-action-log.md +31 -0
  22. package/src/cli/cli.ts +173 -0
  23. package/src/cli/commands/ai.ts +35 -0
  24. package/src/cli/commands/browser.ts +165 -0
  25. package/src/cli/commands/execution.ts +691 -0
  26. package/src/cli/commands/init.ts +327 -0
  27. package/src/cli/commands/logs.ts +128 -0
  28. package/src/cli/commands/shared.ts +70 -0
  29. package/src/cli/commands/snapshot.ts +327 -0
  30. package/src/cli/core/ai-config.ts +255 -0
  31. package/src/cli/core/api-snapshot-analyzer.ts +97 -0
  32. package/src/cli/core/browser.ts +839 -0
  33. package/src/cli/core/context.ts +122 -0
  34. package/src/cli/core/pause-signals.ts +35 -0
  35. package/src/cli/core/session-telemetry.ts +553 -0
  36. package/src/cli/core/session.ts +209 -0
  37. package/src/cli/core/snapshot-analyzer.ts +875 -0
  38. package/src/cli/core/snapshot-api-config.ts +236 -0
  39. package/src/cli/core/telemetry.ts +446 -0
  40. package/src/cli/framework/simple-cli.ts +1273 -0
  41. package/src/cli/index.ts +13 -0
  42. package/src/cli/router.ts +28 -0
  43. package/src/cli/workers/run-integration-runtime.ts +311 -0
  44. package/src/cli/workers/run-integration-worker-protocol.ts +14 -0
  45. package/src/cli/workers/run-integration-worker.ts +75 -0
  46. package/src/index.ts +120 -0
  47. package/src/runtime/download/download.ts +100 -0
  48. package/src/runtime/download/index.ts +7 -0
  49. package/src/runtime/extract/extract.ts +92 -0
  50. package/src/runtime/extract/index.ts +1 -0
  51. package/src/runtime/network/index.ts +5 -0
  52. package/src/runtime/network/network.ts +113 -0
  53. package/src/runtime/recovery/agent.ts +256 -0
  54. package/src/runtime/recovery/errors.ts +152 -0
  55. package/src/runtime/recovery/index.ts +7 -0
  56. package/src/runtime/recovery/recovery.ts +50 -0
  57. package/{dist/shared/condense-dom/condense-dom.cjs → src/shared/condense-dom/condense-dom.ts} +243 -115
  58. package/src/shared/config/config.ts +22 -0
  59. package/src/shared/config/index.ts +5 -0
  60. package/src/shared/debug/index.ts +1 -0
  61. package/src/shared/debug/pause.ts +85 -0
  62. package/src/shared/instrumentation/errors.ts +82 -0
  63. package/src/shared/instrumentation/index.ts +9 -0
  64. package/src/shared/instrumentation/instrument.ts +276 -0
  65. package/src/shared/llm/ai-sdk-adapter.ts +78 -0
  66. package/src/shared/llm/client.ts +217 -0
  67. package/src/shared/llm/index.ts +3 -0
  68. package/src/shared/llm/types.ts +63 -0
  69. package/src/shared/logger/index.ts +6 -0
  70. package/src/shared/logger/logger.ts +352 -0
  71. package/src/shared/logger/sinks.ts +144 -0
  72. package/src/shared/paths/paths.ts +109 -0
  73. package/src/shared/paths/repo-root.ts +27 -0
  74. package/src/shared/run/api.ts +2 -0
  75. package/src/shared/run/browser.ts +98 -0
  76. package/src/shared/state/index.ts +11 -0
  77. package/src/shared/state/session-state.ts +74 -0
  78. package/src/shared/visualization/ghost-cursor.ts +200 -0
  79. package/src/shared/visualization/highlight.ts +146 -0
  80. package/src/shared/visualization/index.ts +18 -0
  81. package/src/shared/workflow/workflow.ts +42 -0
  82. package/dist/index.cjs +0 -144
  83. package/dist/index.d.cts +0 -21
  84. package/dist/runtime/download/download.cjs +0 -70
  85. package/dist/runtime/download/download.d.cts +0 -35
  86. package/dist/runtime/download/index.cjs +0 -30
  87. package/dist/runtime/download/index.d.cts +0 -3
  88. package/dist/runtime/extract/extract.cjs +0 -88
  89. package/dist/runtime/extract/extract.d.cts +0 -23
  90. package/dist/runtime/extract/index.cjs +0 -28
  91. package/dist/runtime/extract/index.d.cts +0 -5
  92. package/dist/runtime/network/index.cjs +0 -28
  93. package/dist/runtime/network/index.d.cts +0 -4
  94. package/dist/runtime/network/network.cjs +0 -91
  95. package/dist/runtime/network/network.d.cts +0 -28
  96. package/dist/runtime/recovery/agent.cjs +0 -223
  97. package/dist/runtime/recovery/agent.d.cts +0 -13
  98. package/dist/runtime/recovery/errors.cjs +0 -124
  99. package/dist/runtime/recovery/errors.d.cts +0 -31
  100. package/dist/runtime/recovery/index.cjs +0 -34
  101. package/dist/runtime/recovery/index.d.cts +0 -7
  102. package/dist/runtime/recovery/recovery.cjs +0 -55
  103. package/dist/runtime/recovery/recovery.d.cts +0 -12
  104. package/dist/shared/condense-dom/condense-dom.d.cts +0 -34
  105. package/dist/shared/config/config.cjs +0 -44
  106. package/dist/shared/config/config.d.cts +0 -10
  107. package/dist/shared/config/index.cjs +0 -32
  108. package/dist/shared/config/index.d.cts +0 -1
  109. package/dist/shared/debug/index.cjs +0 -28
  110. package/dist/shared/debug/index.d.cts +0 -1
  111. package/dist/shared/debug/pause.cjs +0 -86
  112. package/dist/shared/debug/pause.d.cts +0 -12
  113. package/dist/shared/instrumentation/errors.cjs +0 -81
  114. package/dist/shared/instrumentation/errors.d.cts +0 -12
  115. package/dist/shared/instrumentation/index.cjs +0 -35
  116. package/dist/shared/instrumentation/index.d.cts +0 -6
  117. package/dist/shared/instrumentation/instrument.cjs +0 -206
  118. package/dist/shared/instrumentation/instrument.d.cts +0 -32
  119. package/dist/shared/llm/ai-sdk-adapter.cjs +0 -71
  120. package/dist/shared/llm/ai-sdk-adapter.d.cts +0 -22
  121. package/dist/shared/llm/client.cjs +0 -218
  122. package/dist/shared/llm/client.d.cts +0 -13
  123. package/dist/shared/llm/index.cjs +0 -31
  124. package/dist/shared/llm/index.d.cts +0 -5
  125. package/dist/shared/llm/types.cjs +0 -16
  126. package/dist/shared/llm/types.d.cts +0 -67
  127. package/dist/shared/logger/index.cjs +0 -37
  128. package/dist/shared/logger/index.d.cts +0 -2
  129. package/dist/shared/logger/logger.cjs +0 -232
  130. package/dist/shared/logger/logger.d.cts +0 -86
  131. package/dist/shared/logger/sinks.cjs +0 -160
  132. package/dist/shared/logger/sinks.d.cts +0 -9
  133. package/dist/shared/paths/paths.cjs +0 -104
  134. package/dist/shared/paths/paths.d.cts +0 -10
  135. package/dist/shared/run/api.cjs +0 -28
  136. package/dist/shared/run/api.d.cts +0 -2
  137. package/dist/shared/run/browser.cjs +0 -98
  138. package/dist/shared/run/browser.d.cts +0 -22
  139. package/dist/shared/state/index.cjs +0 -38
  140. package/dist/shared/state/index.d.cts +0 -2
  141. package/dist/shared/state/session-state.cjs +0 -92
  142. package/dist/shared/state/session-state.d.cts +0 -40
  143. package/dist/shared/visualization/ghost-cursor.cjs +0 -174
  144. package/dist/shared/visualization/ghost-cursor.d.cts +0 -37
  145. package/dist/shared/visualization/highlight.cjs +0 -134
  146. package/dist/shared/visualization/highlight.d.cts +0 -22
  147. package/dist/shared/visualization/index.cjs +0 -45
  148. package/dist/shared/visualization/index.d.cts +0 -3
  149. package/dist/shared/workflow/workflow.cjs +0 -47
  150. package/dist/shared/workflow/workflow.d.cts +0 -21
  151. package/skills/libretto/code-generation-rules.md +0 -223
  152. package/skills/libretto/integration-approach-selection.md +0 -174
@@ -0,0 +1,217 @@
1
+ import { generateObject, type LanguageModel, type ModelMessage } from "ai";
2
+ import type { ZodType, output as ZodOutput } from "zod";
3
+ import type { LLMClient, Message, MessageContentPart } from "./types.js";
4
+
5
+ export type Provider = "google" | "vertex" | "anthropic" | "openai";
6
+
7
+ const GEMINI_API_KEY_ENV_VARS = [
8
+ "GEMINI_API_KEY",
9
+ "GOOGLE_GENERATIVE_AI_API_KEY",
10
+ ] as const;
11
+
12
+ const VERTEX_PROJECT_ENV_VARS = [
13
+ "GOOGLE_CLOUD_PROJECT",
14
+ "GCLOUD_PROJECT",
15
+ ] as const;
16
+
17
+ const SUPPORTED_PROVIDER_ALIASES = {
18
+ google: "google",
19
+ gemini: "google",
20
+ vertex: "vertex",
21
+ anthropic: "anthropic",
22
+ codex: "openai",
23
+ openai: "openai",
24
+ } as const satisfies Record<string, Provider>;
25
+
26
+ function readFirstEnvValue(
27
+ env: NodeJS.ProcessEnv,
28
+ names: readonly string[],
29
+ ): string | null {
30
+ for (const name of names) {
31
+ const value = env[name]?.trim();
32
+ if (value) return value;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ export function parseModel(model: string): { provider: Provider; modelId: string } {
38
+ const slashIndex = model.indexOf("/");
39
+ if (slashIndex === -1) {
40
+ throw new Error(
41
+ `Invalid model string "${model}". Expected format: "provider/model-id" (for example "openai/gpt-5.4", "anthropic/claude-sonnet-4-6", "google/gemini-3-flash-preview", or "vertex/gemini-2.5-pro").`,
42
+ );
43
+ }
44
+ const providerInput = model.slice(0, slashIndex).toLowerCase();
45
+ const provider = SUPPORTED_PROVIDER_ALIASES[
46
+ providerInput as keyof typeof SUPPORTED_PROVIDER_ALIASES
47
+ ];
48
+ const modelId = model.slice(slashIndex + 1);
49
+
50
+ if (!provider) {
51
+ throw new Error(
52
+ `Unsupported provider "${providerInput}". Supported providers: openai/codex, anthropic, google (Gemini API), and vertex.`,
53
+ );
54
+ }
55
+
56
+ return { provider, modelId };
57
+ }
58
+
59
+ export function hasProviderCredentials(
60
+ provider: Provider,
61
+ env: NodeJS.ProcessEnv = process.env,
62
+ ): boolean {
63
+ switch (provider) {
64
+ case "google":
65
+ return readFirstEnvValue(env, GEMINI_API_KEY_ENV_VARS) !== null;
66
+ case "vertex":
67
+ return readFirstEnvValue(env, VERTEX_PROJECT_ENV_VARS) !== null;
68
+ case "anthropic":
69
+ return Boolean(env.ANTHROPIC_API_KEY?.trim());
70
+ case "openai":
71
+ return Boolean(env.OPENAI_API_KEY?.trim());
72
+ }
73
+ }
74
+
75
+ export function missingProviderCredentialsMessage(provider: Provider): string {
76
+ switch (provider) {
77
+ case "google":
78
+ return "Gemini API key is missing. Set GEMINI_API_KEY or GOOGLE_GENERATIVE_AI_API_KEY.";
79
+ case "vertex":
80
+ return "Vertex AI project is missing. Set GOOGLE_CLOUD_PROJECT (or GCLOUD_PROJECT) and ensure application default credentials are configured.";
81
+ case "anthropic": {
82
+ return "Anthropic API key is missing. Set ANTHROPIC_API_KEY.";
83
+ }
84
+ case "openai": {
85
+ return "OpenAI API key is missing. Set OPENAI_API_KEY.";
86
+ }
87
+ }
88
+ }
89
+
90
+ async function getProviderModel(
91
+ provider: Provider,
92
+ modelId: string,
93
+ ): Promise<LanguageModel> {
94
+ switch (provider) {
95
+ case "google": {
96
+ const apiKey = readFirstEnvValue(process.env, GEMINI_API_KEY_ENV_VARS);
97
+ if (!apiKey) {
98
+ throw new Error(missingProviderCredentialsMessage(provider));
99
+ }
100
+ const { createGoogleGenerativeAI } = await import("@ai-sdk/google");
101
+ const google = createGoogleGenerativeAI({ apiKey });
102
+ return google(modelId);
103
+ }
104
+ case "vertex": {
105
+ const project = readFirstEnvValue(process.env, VERTEX_PROJECT_ENV_VARS);
106
+ if (!project) {
107
+ throw new Error(missingProviderCredentialsMessage(provider));
108
+ }
109
+ const { createVertex } = await import("@ai-sdk/google-vertex");
110
+ const vertex = createVertex({
111
+ project,
112
+ location: process.env.GOOGLE_CLOUD_LOCATION || "global",
113
+ });
114
+ return vertex(modelId);
115
+ }
116
+ case "anthropic": {
117
+ const apiKey = process.env.ANTHROPIC_API_KEY?.trim();
118
+ if (!apiKey) {
119
+ throw new Error(missingProviderCredentialsMessage(provider));
120
+ }
121
+ const { createAnthropic } = await import("@ai-sdk/anthropic");
122
+ const anthropic = createAnthropic({ apiKey });
123
+ return anthropic(modelId);
124
+ }
125
+ case "openai": {
126
+ const apiKey = process.env.OPENAI_API_KEY?.trim();
127
+ if (!apiKey) {
128
+ throw new Error(missingProviderCredentialsMessage(provider));
129
+ }
130
+ const { createOpenAI } = await import("@ai-sdk/openai");
131
+ const openai = createOpenAI({ apiKey });
132
+ return openai(modelId);
133
+ }
134
+ }
135
+ }
136
+
137
+ function convertUserContentParts(parts: MessageContentPart[]) {
138
+ return parts.map((part) => {
139
+ if (part.type === "text") {
140
+ return { type: "text" as const, text: part.text };
141
+ }
142
+ return {
143
+ type: "image" as const,
144
+ image: part.image,
145
+ ...(part.mediaType ? { mediaType: part.mediaType } : {}),
146
+ };
147
+ });
148
+ }
149
+
150
+ function convertAssistantContentParts(parts: MessageContentPart[]) {
151
+ return parts
152
+ .filter((part): part is MessageContentPart & { type: "text" } => part.type === "text")
153
+ .map((part) => ({ type: "text" as const, text: part.text }));
154
+ }
155
+
156
+ function convertMessages(messages: Message[]): ModelMessage[] {
157
+ return messages.map((msg): ModelMessage => {
158
+ if (msg.role === "user") {
159
+ if (typeof msg.content === "string") {
160
+ return { role: "user", content: msg.content };
161
+ }
162
+ return {
163
+ role: "user",
164
+ content: convertUserContentParts(msg.content),
165
+ };
166
+ }
167
+ if (typeof msg.content === "string") {
168
+ return { role: "assistant", content: msg.content };
169
+ }
170
+ return {
171
+ role: "assistant",
172
+ content: convertAssistantContentParts(msg.content),
173
+ };
174
+ });
175
+ }
176
+
177
+ export function createLLMClient(model: string): LLMClient {
178
+ const { provider, modelId } = parseModel(model);
179
+ let modelPromise: Promise<LanguageModel> | null = null;
180
+
181
+ const getModel = () => {
182
+ modelPromise ??= getProviderModel(provider, modelId);
183
+ return modelPromise;
184
+ };
185
+
186
+ return {
187
+ async generateObject<T extends ZodType>(opts: {
188
+ prompt: string;
189
+ schema: T;
190
+ temperature?: number;
191
+ }): Promise<ZodOutput<T>> {
192
+ const aiModel = await getModel();
193
+ const result = await generateObject({
194
+ model: aiModel,
195
+ prompt: opts.prompt,
196
+ schema: opts.schema,
197
+ temperature: opts.temperature ?? 0,
198
+ });
199
+ return result.object as ZodOutput<T>;
200
+ },
201
+
202
+ async generateObjectFromMessages<T extends ZodType>(opts: {
203
+ messages: Message[];
204
+ schema: T;
205
+ temperature?: number;
206
+ }): Promise<ZodOutput<T>> {
207
+ const aiModel = await getModel();
208
+ const result = await generateObject({
209
+ model: aiModel,
210
+ messages: convertMessages(opts.messages),
211
+ schema: opts.schema,
212
+ temperature: opts.temperature ?? 0,
213
+ });
214
+ return result.object as ZodOutput<T>;
215
+ },
216
+ };
217
+ }
@@ -0,0 +1,3 @@
1
+ export type { LLMClient, Message, MessageContentPart } from "./types.js";
2
+ export { createLLMClient } from "./client.js";
3
+ export { createLLMClientFromModel } from "./ai-sdk-adapter.js";
@@ -0,0 +1,63 @@
1
+ import type z from "zod";
2
+
3
+ export type MessageContentPart =
4
+ | { type: "text"; text: string }
5
+ | { type: "image"; image: string | Uint8Array; mediaType?: string };
6
+
7
+ export type Message = {
8
+ role: "user" | "assistant";
9
+ content: string | MessageContentPart[];
10
+ };
11
+
12
+ /**
13
+ * Pluggable LLM client interface.
14
+ *
15
+ * Users provide their own implementation backed by any LLM provider
16
+ * (OpenAI, Anthropic, etc.). Libretto uses this interface for AI extraction,
17
+ * recovery agents, and error detection.
18
+ *
19
+ * **Error handling:** implementations should throw on failure rather than
20
+ * returning sentinel values (e.g. `null` or `undefined`). Libretto relies
21
+ * on exceptions to trigger retry/recovery logic.
22
+ *
23
+ * A ready-made adapter for the Vercel AI SDK is available via
24
+ * {@link createLLMClientFromModel} in `libretto/llm`.
25
+ */
26
+ export interface LLMClient {
27
+ /**
28
+ * Generate a structured object from a single text prompt.
29
+ *
30
+ * The underlying model **must** support structured / JSON output so that
31
+ * the response can be parsed and validated against the provided Zod schema.
32
+ *
33
+ * @param opts.prompt - The text prompt sent to the model.
34
+ * @param opts.schema - A Zod schema describing the expected response shape.
35
+ * @param opts.temperature - Sampling temperature (default chosen by implementation, typically 0).
36
+ * @returns The parsed object matching the schema.
37
+ * @throws On LLM or parsing failure.
38
+ */
39
+ generateObject<T extends z.ZodType>(opts: {
40
+ prompt: string;
41
+ schema: T;
42
+ temperature?: number;
43
+ }): Promise<z.output<T>>;
44
+
45
+ /**
46
+ * Generate a structured object from a conversation-style message array.
47
+ *
48
+ * Messages may contain **image content** (base64 data URIs via
49
+ * {@link MessageContentPart}), so the backing model must support
50
+ * vision / multimodal input when images are present.
51
+ *
52
+ * @param opts.messages - Ordered list of user/assistant messages, potentially multimodal.
53
+ * @param opts.schema - A Zod schema describing the expected response shape.
54
+ * @param opts.temperature - Sampling temperature (default chosen by implementation, typically 0).
55
+ * @returns The parsed object matching the schema.
56
+ * @throws On LLM or parsing failure.
57
+ */
58
+ generateObjectFromMessages<T extends z.ZodType>(opts: {
59
+ messages: Message[];
60
+ schema: T;
61
+ temperature?: number;
62
+ }): Promise<z.output<T>>;
63
+ }
@@ -0,0 +1,6 @@
1
+ export { Logger, defaultLogger, type LoggerApi, type MinimalLogger, type LoggerSink, type LogOptions } from "./logger.js";
2
+ export {
3
+ createFileLogSink,
4
+ prettyConsoleSink,
5
+ jsonlConsoleSink,
6
+ } from "./sinks.js";
@@ -0,0 +1,352 @@
1
+ function generateId(): string {
2
+ return Math.random().toString(36).substring(2, 15);
3
+ }
4
+
5
+ export type LogOptions = {
6
+ timestamp?: Date;
7
+ };
8
+
9
+ /**
10
+ * Minimal logger interface accepted by public-facing runtime functions.
11
+ * Any logger with info/warn/error methods satisfies this — no need to
12
+ * implement withScope, withContext, flush, etc.
13
+ */
14
+ export type MinimalLogger = {
15
+ info: (event: string, data?: any) => void;
16
+ warn: (event: string, data?: any) => void;
17
+ error: (event: string, data?: any) => any;
18
+ };
19
+
20
+ /** Default console logger used when callers omit the logger option. */
21
+ export const defaultLogger: MinimalLogger = {
22
+ info(event, data) { console.log(`[INFO] ${event}`, data ?? ""); },
23
+ warn(event, data) { console.warn(`[WARN] ${event}`, data ?? ""); },
24
+ error(event, data) { console.error(`[ERROR] ${event}`, data ?? ""); },
25
+ };
26
+
27
+ export type LoggerApi = {
28
+ log: (
29
+ event: string,
30
+ data?: Record<string, any>,
31
+ options?: LogOptions,
32
+ ) => void;
33
+ /**
34
+ * Logs an error and returns an Error object that can be thrown
35
+ *
36
+ * either pass in an Error directly as data or as { error: Error, ...other_data }
37
+ */
38
+ error: (
39
+ event: string,
40
+ data?: Error | ({ error: Error } & Record<string, any>) | unknown,
41
+ options?: LogOptions,
42
+ ) => Error;
43
+ warn: (
44
+ event: string,
45
+ data?: Error | ({ error: Error } & Record<string, any>) | unknown,
46
+ options?: LogOptions,
47
+ ) => void;
48
+ info: (
49
+ event: string,
50
+ data?: Error | ({ error: Error } & Record<string, any>) | unknown,
51
+ options?: LogOptions,
52
+ ) => void;
53
+
54
+ /**
55
+ * Context passed in will be attached to all entries in this scope.
56
+ */
57
+ withScope: (scope: string, context?: Record<string, any>) => LoggerApi;
58
+
59
+ /**
60
+ * Context passed in will be attached to all entries.
61
+ */
62
+ withContext: (context: Record<string, any>) => LoggerApi;
63
+
64
+ /**
65
+ * Flushes all sinks in reverse order (most recently added first).
66
+ */
67
+ flush: () => Promise<void>;
68
+ };
69
+
70
+ export type LoggerSink = {
71
+ write: (args: {
72
+ id: string;
73
+ scope: string;
74
+ level: "log" | "error" | "warn" | "info";
75
+ event: string;
76
+ data: Record<string, any>;
77
+ options?: LogOptions;
78
+ }) => void;
79
+ flush?: () => Promise<void>;
80
+ close?: () => Promise<void>;
81
+ };
82
+
83
+ type SinkLifecycleState = {
84
+ closed: boolean;
85
+ closing?: Promise<void>;
86
+ };
87
+
88
+ const sinkLifecycleState = new WeakMap<LoggerSink, SinkLifecycleState>();
89
+
90
+ function getSinkLifecycleState(sink: LoggerSink): SinkLifecycleState {
91
+ const existingState = sinkLifecycleState.get(sink);
92
+ if (existingState) {
93
+ return existingState;
94
+ }
95
+
96
+ const initialState: SinkLifecycleState = { closed: false };
97
+ sinkLifecycleState.set(sink, initialState);
98
+ return initialState;
99
+ }
100
+
101
+ function isSinkClosedOrClosing(sink: LoggerSink): boolean {
102
+ const state = sinkLifecycleState.get(sink);
103
+ return Boolean(state?.closed || state?.closing);
104
+ }
105
+
106
+ async function closeSinkOnce(sink: LoggerSink): Promise<void> {
107
+ if (!sink.close) {
108
+ return;
109
+ }
110
+
111
+ const state = getSinkLifecycleState(sink);
112
+ if (state.closed) {
113
+ return;
114
+ }
115
+
116
+ if (state.closing) {
117
+ return state.closing;
118
+ }
119
+
120
+ state.closing = (async () => {
121
+ try {
122
+ await sink.close?.();
123
+ } catch {
124
+ // Ignore close errors - we're likely shutting down
125
+ } finally {
126
+ state.closed = true;
127
+ state.closing = undefined;
128
+ }
129
+ })();
130
+
131
+ return state.closing;
132
+ }
133
+
134
+ function isObject(value: unknown): value is Record<string, unknown> {
135
+ return typeof value === "object" && value !== null;
136
+ }
137
+
138
+ function removeUndefined(data: any): any {
139
+ if (typeof data === "object" && data !== null) {
140
+ return Object.fromEntries(
141
+ Object.entries(data).filter(([_, value]) => value !== undefined),
142
+ );
143
+ }
144
+ return data;
145
+ }
146
+
147
+ export class Logger implements LoggerApi {
148
+ private readonly prefix: string;
149
+
150
+ constructor(
151
+ private readonly scopes: string[] = [],
152
+ private readonly sinks: LoggerSink[] = [],
153
+ private readonly scopeData: Record<string, any> = {},
154
+ ) {
155
+ this.prefix = scopes.join(".");
156
+ }
157
+
158
+ entry(entry: {
159
+ level: "log" | "error" | "warn" | "info";
160
+ event: string;
161
+ data?: Record<string, any>;
162
+ options?: LogOptions;
163
+ }) {
164
+ this.sinks.forEach((sink) => {
165
+ if (isSinkClosedOrClosing(sink)) {
166
+ return;
167
+ }
168
+
169
+ sink.write({
170
+ id: generateId(),
171
+ scope: this.prefix,
172
+ level: entry.level,
173
+ event: entry.event,
174
+ data: removeUndefined({ ...this.scopeData, ...entry.data }),
175
+ options: entry.options,
176
+ });
177
+ });
178
+ }
179
+
180
+ log(event: string, data?: Record<string, any>, options?: LogOptions) {
181
+ this.entry({ level: "log", event, data, options });
182
+ }
183
+
184
+ error(
185
+ event: string,
186
+ dataOrError?: Error | ({ error: Error } & Record<string, any>) | unknown,
187
+ options?: LogOptions,
188
+ ) {
189
+ const data =
190
+ dataOrError instanceof Error
191
+ ? {
192
+ error: {
193
+ type: dataOrError.constructor.name,
194
+ message: dataOrError.message,
195
+ stack: dataOrError.stack || null,
196
+ },
197
+ }
198
+ : isObject(dataOrError) && dataOrError.error instanceof Error
199
+ ? {
200
+ ...dataOrError,
201
+ error: {
202
+ type: dataOrError.error.constructor.name,
203
+ message: dataOrError.error.message,
204
+ stack: dataOrError.error.stack || null,
205
+ },
206
+ }
207
+ : isObject(dataOrError)
208
+ ? dataOrError
209
+ : dataOrError !== undefined
210
+ ? { error: dataOrError }
211
+ : undefined;
212
+
213
+ this.entry({
214
+ level: "error",
215
+ event,
216
+ data: data as Error | Record<string, any>,
217
+ options,
218
+ });
219
+
220
+ if (dataOrError instanceof Error) {
221
+ return dataOrError;
222
+ }
223
+
224
+ if (isObject(dataOrError) && dataOrError.error instanceof Error) {
225
+ return dataOrError.error;
226
+ }
227
+
228
+ let message = event;
229
+ if (data !== undefined) {
230
+ try {
231
+ message += "\n" + JSON.stringify(data, undefined, 2);
232
+ } catch {
233
+ message += "\n[Unserializable error data]";
234
+ }
235
+ }
236
+ return new Error(message);
237
+ }
238
+
239
+ warn(
240
+ event: string,
241
+ dataOrError?: Error | ({ error: Error } & Record<string, any>) | unknown,
242
+ options?: LogOptions,
243
+ ) {
244
+ const data =
245
+ dataOrError instanceof Error
246
+ ? {
247
+ error: {
248
+ type: dataOrError.constructor.name,
249
+ message: dataOrError.message,
250
+ stack: dataOrError.stack || null,
251
+ },
252
+ }
253
+ : isObject(dataOrError) && dataOrError.error instanceof Error
254
+ ? {
255
+ ...dataOrError,
256
+ error: {
257
+ type: dataOrError.error.constructor.name,
258
+ message: dataOrError.error.message,
259
+ stack: dataOrError.error.stack || null,
260
+ },
261
+ }
262
+ : isObject(dataOrError)
263
+ ? dataOrError
264
+ : dataOrError !== undefined
265
+ ? { error: dataOrError }
266
+ : undefined;
267
+
268
+ this.entry({
269
+ level: "warn",
270
+ event,
271
+ data: data as Record<string, any>,
272
+ options,
273
+ });
274
+ }
275
+
276
+ info(
277
+ event: string,
278
+ dataOrError?: Error | ({ error: Error } & Record<string, any>) | unknown,
279
+ options?: LogOptions,
280
+ ) {
281
+ const data =
282
+ dataOrError instanceof Error
283
+ ? {
284
+ error: {
285
+ type: dataOrError.constructor.name,
286
+ message: dataOrError.message,
287
+ stack: dataOrError.stack || null,
288
+ },
289
+ }
290
+ : isObject(dataOrError) && dataOrError.error instanceof Error
291
+ ? {
292
+ ...dataOrError,
293
+ error: {
294
+ type: dataOrError.error.constructor.name,
295
+ message: dataOrError.error.message,
296
+ stack: dataOrError.error.stack || null,
297
+ },
298
+ }
299
+ : isObject(dataOrError)
300
+ ? dataOrError
301
+ : dataOrError !== undefined
302
+ ? { error: dataOrError }
303
+ : undefined;
304
+
305
+ this.entry({
306
+ level: "info",
307
+ event,
308
+ data: data as Record<string, any>,
309
+ options,
310
+ });
311
+ }
312
+
313
+ withScope(scope: string, context: Record<string, any> = {}): LoggerApi {
314
+ return new Logger([...this.scopes, scope], this.sinks, {
315
+ ...this.scopeData,
316
+ ...context,
317
+ });
318
+ }
319
+
320
+ withContext(context: Record<string, any>): LoggerApi {
321
+ return new Logger(this.scopes, this.sinks, {
322
+ ...this.scopeData,
323
+ ...context,
324
+ });
325
+ }
326
+
327
+ withSink(sink: LoggerSink): Logger {
328
+ return new Logger(this.scopes, [...this.sinks, sink]);
329
+ }
330
+
331
+ async flush(): Promise<void> {
332
+ for (let i = this.sinks.length - 1; i >= 0; i--) {
333
+ const sink = this.sinks[i];
334
+ if (!sink) continue;
335
+ if (isSinkClosedOrClosing(sink)) continue;
336
+ try {
337
+ await sink.flush?.();
338
+ } catch {
339
+ // Ignore flush errors - we're likely shutting down
340
+ }
341
+ }
342
+ }
343
+
344
+ async close(): Promise<void> {
345
+ await this.flush();
346
+ for (let i = this.sinks.length - 1; i >= 0; i--) {
347
+ const sink = this.sinks[i];
348
+ if (!sink) continue;
349
+ await closeSinkOnce(sink);
350
+ }
351
+ }
352
+ }