@disco_trooper/apple-notes-mcp 1.0.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.
package/src/setup.ts ADDED
@@ -0,0 +1,540 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Setup wizard for apple-notes-mcp
4
+ *
5
+ * Interactive CLI for configuring:
6
+ * - Embedding provider (Local HuggingFace / OpenRouter)
7
+ * - API keys
8
+ * - Read-only mode
9
+ * - Auto-indexing settings
10
+ * - Claude Code integration
11
+ */
12
+
13
+ import * as p from "@clack/prompts";
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ // Paths
17
+ const PROJECT_DIR = path.dirname(new URL(import.meta.url).pathname);
18
+ const ENV_FILE = path.join(PROJECT_DIR, "..", ".env");
19
+ const CLAUDE_CONFIG_PATH = path.join(
20
+ process.env.HOME || "~",
21
+ ".claude.json"
22
+ );
23
+
24
+ interface Config {
25
+ provider: "local" | "openrouter";
26
+ openrouterApiKey?: string;
27
+ embeddingModel?: string;
28
+ embeddingDims?: number;
29
+ readonlyMode: boolean;
30
+ autoIndex: "none" | "on-search" | "ttl";
31
+ indexTtl?: number;
32
+ debug: boolean;
33
+ }
34
+
35
+ /**
36
+ * Read existing .env file if it exists
37
+ */
38
+ function readExistingEnv(): Record<string, string> {
39
+ if (!fs.existsSync(ENV_FILE)) {
40
+ return {};
41
+ }
42
+
43
+ const content = fs.readFileSync(ENV_FILE, "utf-8");
44
+ const env: Record<string, string> = {};
45
+
46
+ for (const line of content.split("\n")) {
47
+ const trimmed = line.trim();
48
+ if (!trimmed || trimmed.startsWith("#")) continue;
49
+
50
+ const eqIndex = trimmed.indexOf("=");
51
+ if (eqIndex === -1) continue;
52
+
53
+ const key = trimmed.slice(0, eqIndex);
54
+ let value = trimmed.slice(eqIndex + 1);
55
+
56
+ // Remove quotes if present
57
+ if ((value.startsWith('"') && value.endsWith('"')) ||
58
+ (value.startsWith("'") && value.endsWith("'"))) {
59
+ value = value.slice(1, -1);
60
+ }
61
+
62
+ env[key] = value;
63
+ }
64
+
65
+ return env;
66
+ }
67
+
68
+ /**
69
+ * Write configuration to .env file
70
+ */
71
+ function writeEnvFile(config: Config): void {
72
+ const lines: string[] = [
73
+ "# apple-notes-mcp configuration",
74
+ "# Generated by setup wizard",
75
+ "",
76
+ ];
77
+
78
+ if (config.provider === "openrouter") {
79
+ lines.push("# Embedding provider: OpenRouter");
80
+ if (config.openrouterApiKey) {
81
+ lines.push(`OPENROUTER_API_KEY="${config.openrouterApiKey}"`);
82
+ }
83
+ if (config.embeddingModel) {
84
+ lines.push(`EMBEDDING_MODEL="${config.embeddingModel}"`);
85
+ }
86
+ if (config.embeddingDims) {
87
+ lines.push(`EMBEDDING_DIMS="${config.embeddingDims}"`);
88
+ }
89
+ } else {
90
+ lines.push("# Embedding provider: Local HuggingFace");
91
+ lines.push("# No OPENROUTER_API_KEY = uses local embeddings");
92
+ }
93
+
94
+ lines.push("");
95
+
96
+ if (config.readonlyMode) {
97
+ lines.push("# Read-only mode (no write operations)");
98
+ lines.push("READONLY_MODE=true");
99
+ }
100
+
101
+ lines.push("");
102
+
103
+ if (config.autoIndex === "ttl" && config.indexTtl) {
104
+ lines.push("# Auto-index TTL in seconds");
105
+ lines.push(`INDEX_TTL=${config.indexTtl}`);
106
+ }
107
+
108
+ if (config.debug) {
109
+ lines.push("");
110
+ lines.push("# Debug logging");
111
+ lines.push("DEBUG=true");
112
+ }
113
+
114
+ fs.writeFileSync(ENV_FILE, lines.join("\n") + "\n");
115
+ }
116
+
117
+ /**
118
+ * Read Claude Code config if it exists
119
+ */
120
+ function readClaudeConfig(): Record<string, unknown> | null {
121
+ if (!fs.existsSync(CLAUDE_CONFIG_PATH)) {
122
+ return null;
123
+ }
124
+
125
+ try {
126
+ const content = fs.readFileSync(CLAUDE_CONFIG_PATH, "utf-8");
127
+ return JSON.parse(content);
128
+ } catch (error) {
129
+ // Config doesn't exist or is invalid JSON
130
+ if (process.env.DEBUG === "true") {
131
+ console.error("[SETUP] Could not read Claude config:", error);
132
+ }
133
+ return null;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Add MCP server to Claude Code config
139
+ */
140
+ function addToClaudeConfig(): boolean {
141
+ const projectPath = path.resolve(PROJECT_DIR, "..");
142
+ const serverEntry = {
143
+ command: "bun",
144
+ args: ["run", path.join(projectPath, "src", "index.ts")],
145
+ env: {},
146
+ };
147
+
148
+ let config = readClaudeConfig();
149
+
150
+ if (!config) {
151
+ // Create new config
152
+ config = {
153
+ mcpServers: {
154
+ "apple-notes": serverEntry,
155
+ },
156
+ };
157
+ } else {
158
+ // Add to existing config
159
+ const mcpServers = (config.mcpServers || {}) as Record<string, unknown>;
160
+ mcpServers["apple-notes"] = serverEntry;
161
+ config.mcpServers = mcpServers;
162
+ }
163
+
164
+ try {
165
+ fs.writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
166
+ return true;
167
+ } catch (error) {
168
+ if (process.env.DEBUG === "true") {
169
+ console.error("[SETUP] Failed to write Claude config:", error);
170
+ }
171
+ return false;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Generate config snippet for manual setup
177
+ */
178
+ function getConfigSnippet(): string {
179
+ const projectPath = path.resolve(PROJECT_DIR, "..");
180
+ return JSON.stringify(
181
+ {
182
+ "apple-notes": {
183
+ command: "bun",
184
+ args: ["run", path.join(projectPath, "src", "index.ts")],
185
+ env: {},
186
+ },
187
+ },
188
+ null,
189
+ 2
190
+ );
191
+ }
192
+
193
+ /**
194
+ * Run indexing with specified mode
195
+ */
196
+ async function runIndexing(mode: "full" | "incremental"): Promise<{ count: number; timeMs: number; skipped?: number }> {
197
+ // Dynamic import to avoid loading embeddings before config is set
198
+ const { indexNotes } = await import("./search/indexer.js");
199
+ const result = await indexNotes(mode);
200
+ return {
201
+ count: result.indexed,
202
+ timeMs: result.timeMs,
203
+ skipped: result.breakdown?.skipped,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Download local model (warm up the pipeline)
209
+ */
210
+ async function downloadLocalModel(): Promise<void> {
211
+ const { getLocalEmbedding } = await import("./embeddings/local.js");
212
+ // Generate a test embedding to trigger model download
213
+ await getLocalEmbedding("test");
214
+ }
215
+
216
+ /**
217
+ * Main setup wizard
218
+ */
219
+ async function main(): Promise<void> {
220
+ console.clear();
221
+
222
+ p.intro("apple-notes-mcp Setup Wizard");
223
+
224
+ // Check existing configuration
225
+ const existingEnv = readExistingEnv();
226
+ const hasExistingConfig = Object.keys(existingEnv).length > 0;
227
+
228
+ if (hasExistingConfig) {
229
+ p.note(
230
+ "Existing configuration found in .env\n" +
231
+ "Your current settings will be shown as defaults.",
232
+ "Configuration Detected"
233
+ );
234
+ }
235
+
236
+ // Provider selection
237
+ const provider = await p.select({
238
+ message: "Which embedding provider would you like to use?",
239
+ options: [
240
+ {
241
+ value: "local",
242
+ label: "Local (HuggingFace)",
243
+ hint: "Free, runs on your machine, ~200MB model download",
244
+ },
245
+ {
246
+ value: "openrouter",
247
+ label: "OpenRouter API",
248
+ hint: "Fast, requires API key, pay-per-use",
249
+ },
250
+ ],
251
+ initialValue: existingEnv.OPENROUTER_API_KEY ? "openrouter" : "local",
252
+ });
253
+
254
+ if (p.isCancel(provider)) {
255
+ p.cancel("Setup cancelled.");
256
+ process.exit(0);
257
+ }
258
+
259
+ let openrouterApiKey: string | undefined;
260
+ let embeddingModel: string | undefined;
261
+ let embeddingDims: number | undefined;
262
+
263
+ if (provider === "openrouter") {
264
+ const apiKey = await p.text({
265
+ message: "Enter your OpenRouter API key:",
266
+ placeholder: "sk-or-v1-...",
267
+ initialValue: existingEnv.OPENROUTER_API_KEY || "",
268
+ validate: (value) => {
269
+ if (!value.trim()) return "API key is required";
270
+ if (!value.startsWith("sk-or-")) return "Invalid API key format (should start with sk-or-)";
271
+ },
272
+ });
273
+
274
+ if (p.isCancel(apiKey)) {
275
+ p.cancel("Setup cancelled.");
276
+ process.exit(0);
277
+ }
278
+
279
+ openrouterApiKey = apiKey;
280
+
281
+ const useCustomModel = await p.confirm({
282
+ message: "Use custom embedding model? (default: qwen/qwen3-embedding-8b)",
283
+ initialValue: false,
284
+ });
285
+
286
+ if (p.isCancel(useCustomModel)) {
287
+ p.cancel("Setup cancelled.");
288
+ process.exit(0);
289
+ }
290
+
291
+ if (useCustomModel) {
292
+ const model = await p.text({
293
+ message: "Enter model name:",
294
+ placeholder: "qwen/qwen3-embedding-8b",
295
+ initialValue: existingEnv.EMBEDDING_MODEL || "qwen/qwen3-embedding-8b",
296
+ });
297
+
298
+ if (p.isCancel(model)) {
299
+ p.cancel("Setup cancelled.");
300
+ process.exit(0);
301
+ }
302
+
303
+ embeddingModel = model;
304
+
305
+ const dims = await p.text({
306
+ message: "Enter embedding dimensions:",
307
+ placeholder: "4096",
308
+ initialValue: existingEnv.EMBEDDING_DIMS || "4096",
309
+ validate: (value) => {
310
+ const num = parseInt(value, 10);
311
+ if (isNaN(num) || num <= 0) return "Must be a positive number";
312
+ },
313
+ });
314
+
315
+ if (p.isCancel(dims)) {
316
+ p.cancel("Setup cancelled.");
317
+ process.exit(0);
318
+ }
319
+
320
+ embeddingDims = parseInt(dims, 10);
321
+ }
322
+ }
323
+
324
+ // Read-only mode
325
+ const readonlyMode = await p.confirm({
326
+ message: "Enable read-only mode? (prevents all write operations to Apple Notes)",
327
+ initialValue: existingEnv.READONLY_MODE === "true",
328
+ });
329
+
330
+ if (p.isCancel(readonlyMode)) {
331
+ p.cancel("Setup cancelled.");
332
+ process.exit(0);
333
+ }
334
+
335
+ // Auto-indexing
336
+ const autoIndex = await p.select({
337
+ message: "Auto-indexing mode:",
338
+ options: [
339
+ {
340
+ value: "none",
341
+ label: "Manual only",
342
+ hint: "Run index-notes manually when needed",
343
+ },
344
+ {
345
+ value: "ttl",
346
+ label: "Time-based (TTL)",
347
+ hint: "Auto-reindex after specified time",
348
+ },
349
+ ],
350
+ initialValue: existingEnv.INDEX_TTL ? "ttl" : "none",
351
+ });
352
+
353
+ if (p.isCancel(autoIndex)) {
354
+ p.cancel("Setup cancelled.");
355
+ process.exit(0);
356
+ }
357
+
358
+ let indexTtl: number | undefined;
359
+
360
+ if (autoIndex === "ttl") {
361
+ const ttl = await p.select({
362
+ message: "Reindex after:",
363
+ options: [
364
+ { value: "3600", label: "1 hour" },
365
+ { value: "21600", label: "6 hours" },
366
+ { value: "86400", label: "24 hours" },
367
+ { value: "604800", label: "1 week" },
368
+ ],
369
+ initialValue: existingEnv.INDEX_TTL || "86400",
370
+ });
371
+
372
+ if (p.isCancel(ttl)) {
373
+ p.cancel("Setup cancelled.");
374
+ process.exit(0);
375
+ }
376
+
377
+ indexTtl = parseInt(ttl, 10);
378
+ }
379
+
380
+ // Debug mode
381
+ const debug = await p.confirm({
382
+ message: "Enable debug logging?",
383
+ initialValue: existingEnv.DEBUG === "true",
384
+ });
385
+
386
+ if (p.isCancel(debug)) {
387
+ p.cancel("Setup cancelled.");
388
+ process.exit(0);
389
+ }
390
+
391
+ // Build configuration
392
+ const config: Config = {
393
+ provider: provider as "local" | "openrouter",
394
+ openrouterApiKey,
395
+ embeddingModel,
396
+ embeddingDims,
397
+ readonlyMode,
398
+ autoIndex: autoIndex as "none" | "ttl",
399
+ indexTtl,
400
+ debug,
401
+ };
402
+
403
+ // Save configuration
404
+ const s = p.spinner();
405
+
406
+ s.start("Saving configuration...");
407
+ writeEnvFile(config);
408
+ s.stop("Configuration saved to .env");
409
+
410
+ // Download local model if needed
411
+ if (provider === "local") {
412
+ const downloadModel = await p.confirm({
413
+ message: "Download local embedding model now? (~200MB, recommended)",
414
+ initialValue: true,
415
+ });
416
+
417
+ if (p.isCancel(downloadModel)) {
418
+ p.cancel("Setup cancelled.");
419
+ process.exit(0);
420
+ }
421
+
422
+ if (downloadModel) {
423
+ s.start("Downloading embedding model (this may take a minute)...");
424
+ try {
425
+ await downloadLocalModel();
426
+ s.stop("Model downloaded successfully");
427
+ } catch (error) {
428
+ s.stop("Model download failed (will download on first use)");
429
+ p.log.warn(
430
+ `Download error: ${error instanceof Error ? error.message : String(error)}`
431
+ );
432
+ }
433
+ }
434
+ }
435
+
436
+ // Claude Code integration
437
+ const addToClaude = await p.confirm({
438
+ message: "Add to Claude Code configuration (~/.claude.json)?",
439
+ initialValue: true,
440
+ });
441
+
442
+ if (p.isCancel(addToClaude)) {
443
+ p.cancel("Setup cancelled.");
444
+ process.exit(0);
445
+ }
446
+
447
+ if (addToClaude) {
448
+ s.start("Updating Claude Code configuration...");
449
+ const success = addToClaudeConfig();
450
+ if (success) {
451
+ s.stop("Added to ~/.claude.json");
452
+ } else {
453
+ s.stop("Failed to update Claude config");
454
+ p.log.warn("You may need to add the server manually.");
455
+ p.note(getConfigSnippet(), "Add this to mcpServers in ~/.claude.json");
456
+ }
457
+ } else {
458
+ p.note(getConfigSnippet(), "Add this to mcpServers in ~/.claude.json");
459
+ }
460
+
461
+ // Initial indexing
462
+ const runIndex = await p.confirm({
463
+ message: "Index your Apple Notes now?",
464
+ initialValue: true,
465
+ });
466
+
467
+ if (p.isCancel(runIndex)) {
468
+ p.cancel("Setup cancelled.");
469
+ process.exit(0);
470
+ }
471
+
472
+ if (runIndex) {
473
+ // Ask for indexing mode
474
+ const indexMode = await p.select({
475
+ message: "Indexing mode:",
476
+ options: [
477
+ { value: "incremental", label: "Incremental", hint: "Only new/changed notes (faster)" },
478
+ { value: "full", label: "Full", hint: "Reindex everything (slower)" },
479
+ ],
480
+ initialValue: "incremental",
481
+ });
482
+
483
+ if (p.isCancel(indexMode)) {
484
+ p.cancel("Setup cancelled.");
485
+ process.exit(0);
486
+ }
487
+
488
+ const mode = indexMode as "full" | "incremental";
489
+ const indexingMsg = `${mode === "full" ? "Full" : "Incremental"} indexing...`;
490
+
491
+ // Don't use spinner in debug mode - it conflicts with debug output
492
+ if (!debug) {
493
+ s.start(indexingMsg);
494
+ } else {
495
+ p.log.info(indexingMsg);
496
+ }
497
+
498
+ try {
499
+ // Reload environment with new config
500
+ const dotenv = await import("dotenv");
501
+ dotenv.config({ path: ENV_FILE });
502
+
503
+ const result = await runIndexing(mode);
504
+ const skippedInfo = result.skipped ? `, ${result.skipped} unchanged` : "";
505
+ const doneMsg = `Indexed ${result.count} notes in ${(result.timeMs / 1000).toFixed(1)}s${skippedInfo}`;
506
+
507
+ if (!debug) {
508
+ s.stop(doneMsg);
509
+ } else {
510
+ p.log.success(doneMsg);
511
+ }
512
+ } catch (error) {
513
+ if (!debug) {
514
+ s.stop("Indexing failed");
515
+ }
516
+ p.log.error(
517
+ `Error: ${error instanceof Error ? error.message : String(error)}`
518
+ );
519
+ p.log.info("You can run indexing later with the index-notes tool.");
520
+ }
521
+ }
522
+
523
+ // Summary
524
+ p.note(
525
+ [
526
+ `Provider: ${provider === "local" ? "Local HuggingFace" : "OpenRouter"}`,
527
+ `Read-only: ${readonlyMode ? "Yes" : "No"}`,
528
+ `Auto-index: ${autoIndex === "ttl" ? `Every ${indexTtl! / 3600}h` : "Manual"}`,
529
+ `Debug: ${debug ? "Enabled" : "Disabled"}`,
530
+ ].join("\n"),
531
+ "Configuration Summary"
532
+ );
533
+
534
+ p.outro("Setup complete! Restart Claude Code to use apple-notes-mcp.");
535
+ }
536
+
537
+ main().catch((error) => {
538
+ console.error("Setup failed:", error);
539
+ process.exit(1);
540
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared type definitions for Apple Notes MCP.
3
+ */
4
+
5
+ /**
6
+ * Search result returned from the database layer.
7
+ * Contains the full content of the note.
8
+ */
9
+ export interface DBSearchResult {
10
+ /** Note title */
11
+ title: string;
12
+ /** Folder containing the note */
13
+ folder: string;
14
+ /** Full content of the note */
15
+ content: string;
16
+ /** Last modified date (ISO string) */
17
+ modified: string;
18
+ /** Relevance score (higher = more relevant) */
19
+ score: number;
20
+ }
21
+
22
+ /**
23
+ * Search result returned to clients.
24
+ * Contains a preview instead of full content by default.
25
+ */
26
+ export interface SearchResult {
27
+ /** Note title */
28
+ title: string;
29
+ /** Folder containing the note */
30
+ folder: string;
31
+ /** Preview of content (200 chars) or full content if include_content=true */
32
+ preview: string;
33
+ /** Full content (only when include_content=true) */
34
+ content?: string;
35
+ /** Last modified date (ISO string) */
36
+ modified: string;
37
+ /** Combined relevance score (higher = more relevant) */
38
+ score: number;
39
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ describe("debug utility", () => {
4
+ const originalEnv = process.env.DEBUG;
5
+
6
+ beforeEach(() => {
7
+ vi.resetModules();
8
+ });
9
+
10
+ afterEach(() => {
11
+ if (originalEnv !== undefined) {
12
+ process.env.DEBUG = originalEnv;
13
+ } else {
14
+ delete process.env.DEBUG;
15
+ }
16
+ });
17
+
18
+ it("createDebugLogger returns a function", async () => {
19
+ const { createDebugLogger } = await import("./debug.js");
20
+ const logger = createDebugLogger("TEST");
21
+ expect(typeof logger).toBe("function");
22
+ });
23
+
24
+ it("isDebugEnabled returns false when DEBUG not set", async () => {
25
+ delete process.env.DEBUG;
26
+ const { isDebugEnabled } = await import("./debug.js");
27
+ expect(isDebugEnabled()).toBe(false);
28
+ });
29
+
30
+ it("isDebugEnabled returns true when DEBUG is true", async () => {
31
+ process.env.DEBUG = "true";
32
+ const { isDebugEnabled } = await import("./debug.js");
33
+ expect(isDebugEnabled()).toBe(true);
34
+ });
35
+
36
+ it("isDebugEnabled returns false when DEBUG is false", async () => {
37
+ process.env.DEBUG = "false";
38
+ const { isDebugEnabled } = await import("./debug.js");
39
+ expect(isDebugEnabled()).toBe(false);
40
+ });
41
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Shared debug logging utility.
3
+ * Logs to stderr to avoid polluting stdout/MCP protocol.
4
+ * Uses dim styling to distinguish from errors.
5
+ */
6
+
7
+ // ANSI color codes
8
+ const COLORS = {
9
+ reset: "\x1b[0m",
10
+ dim: "\x1b[2m",
11
+ cyan: "\x1b[36m",
12
+ yellow: "\x1b[33m",
13
+ red: "\x1b[31m",
14
+ green: "\x1b[32m",
15
+ } as const;
16
+
17
+ /**
18
+ * Create a debug logger with a specific prefix.
19
+ * Checks DEBUG env var at call time for runtime control.
20
+ * Output is dim cyan to distinguish from errors.
21
+ */
22
+ export function createDebugLogger(prefix: string) {
23
+ return (...args: unknown[]): void => {
24
+ // Check at call time, not load time
25
+ if (process.env.DEBUG === "true") {
26
+ const formattedPrefix = `${COLORS.dim}${COLORS.cyan}[${prefix}]${COLORS.reset}`;
27
+ const formattedArgs = args.map(arg =>
28
+ typeof arg === "string" ? `${COLORS.dim}${arg}${COLORS.reset}` : arg
29
+ );
30
+ console.error(formattedPrefix, ...formattedArgs);
31
+ }
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Create a warning logger (yellow output).
37
+ */
38
+ export function createWarningLogger(prefix: string) {
39
+ return (...args: unknown[]): void => {
40
+ const formattedPrefix = `${COLORS.yellow}[${prefix}]${COLORS.reset}`;
41
+ console.error(formattedPrefix, ...args);
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Check if debug mode is enabled.
47
+ * Checks at call time for runtime control.
48
+ */
49
+ export function isDebugEnabled(): boolean {
50
+ return process.env.DEBUG === "true";
51
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { sanitizeErrorMessage } from "./errors.js";
3
+
4
+ describe("sanitizeErrorMessage", () => {
5
+ it("preserves user-friendly messages", () => {
6
+ expect(sanitizeErrorMessage("Note not found")).toBe("Note not found");
7
+ expect(sanitizeErrorMessage("Invalid title")).toBe("Invalid title");
8
+ });
9
+
10
+ it("removes file paths", () => {
11
+ const error = "ENOENT: no such file at /Users/john/secret/file.ts";
12
+ expect(sanitizeErrorMessage(error)).not.toContain("/Users/john");
13
+ });
14
+
15
+ it("removes stack traces", () => {
16
+ const error = "Error: failed\n at Function.module (/path/to/file.js:123:45)";
17
+ expect(sanitizeErrorMessage(error)).not.toContain("/path/to");
18
+ });
19
+
20
+ it("handles generic errors gracefully", () => {
21
+ const error = "TypeError: Cannot read property 'x' of undefined";
22
+ expect(sanitizeErrorMessage(error)).toBe("An internal error occurred");
23
+ });
24
+
25
+ it("preserves known safe error patterns", () => {
26
+ expect(sanitizeErrorMessage("Title must be a non-empty string")).toBe("Title must be a non-empty string");
27
+ expect(sanitizeErrorMessage("Note not found: \"My Note\"")).toBe("Note not found: \"My Note\"");
28
+ });
29
+ });