imsg-mcp 1.21.0 → 1.21.2

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 (33) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/{app-config-B_QqZFbl.js → app-config-CWVJPYcD.js} +2 -2
  3. package/dist/{app-config-B_QqZFbl.js.map → app-config-CWVJPYcD.js.map} +1 -1
  4. package/dist/{attachment-sync-DqIvfxPv.js → attachment-sync-DfYAnRQG.js} +2 -2
  5. package/dist/{attachment-sync-DqIvfxPv.js.map → attachment-sync-DfYAnRQG.js.map} +1 -1
  6. package/dist/{change-watcher-DNuxvzqL.js → change-watcher-B9Uiotj3.js} +57 -5
  7. package/dist/change-watcher-B9Uiotj3.js.map +1 -0
  8. package/dist/cli.js +22 -17
  9. package/dist/cli.js.map +1 -1
  10. package/dist/{exportStream-DjdPdE1S.js → exportStream-CtHxTI3_.js} +2 -2
  11. package/dist/{exportStream-DjdPdE1S.js.map → exportStream-CtHxTI3_.js.map} +1 -1
  12. package/dist/{imessage-db-Be6EywKE.js → imessage-db-Dr3PzK-9.js} +2 -2
  13. package/dist/{imessage-db-Be6EywKE.js.map → imessage-db-Dr3PzK-9.js.map} +1 -1
  14. package/dist/index.js +8 -8
  15. package/dist/{media-intel-cache-JJZzGCs4.js → media-intel-cache-B0_D_c45.js} +2 -2
  16. package/dist/{media-intel-cache-JJZzGCs4.js.map → media-intel-cache-B0_D_c45.js.map} +1 -1
  17. package/dist/{media-intel-runtime-DbJ3jXLG.js → media-intel-runtime-Bm9GkUdR.js} +3 -3
  18. package/dist/{media-intel-runtime-DbJ3jXLG.js.map → media-intel-runtime-Bm9GkUdR.js.map} +1 -1
  19. package/dist/{setup-DxbCKuOU.js → setup-e0Lmkl96.js} +2 -2
  20. package/dist/{setup-DxbCKuOU.js.map → setup-e0Lmkl96.js.map} +1 -1
  21. package/dist/{setup-wizard-D70bqgzB.js → setup-wizard-H2ox9qT0.js} +3 -3
  22. package/dist/{setup-wizard-D70bqgzB.js.map → setup-wizard-H2ox9qT0.js.map} +1 -1
  23. package/dist/{shutdown-Dnj088id.js → shutdown-URaLJ94f.js} +2 -2
  24. package/dist/{shutdown-Dnj088id.js.map → shutdown-URaLJ94f.js.map} +1 -1
  25. package/dist/{tui-config-Bt1yixQj.js → tui-config-CJdN9rGh.js} +3 -3
  26. package/dist/tui-config-CJdN9rGh.js.map +1 -0
  27. package/dist/tui.js +210 -143
  28. package/dist/tui.js.map +1 -1
  29. package/dist/{watchdog-j-gArC02.js → watchdog-DwmWPNkB.js} +14 -3
  30. package/dist/{watchdog-j-gArC02.js.map → watchdog-DwmWPNkB.js.map} +1 -1
  31. package/package.json +4 -4
  32. package/dist/change-watcher-DNuxvzqL.js.map +0 -1
  33. package/dist/tui-config-Bt1yixQj.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## imsg-mcp [1.21.2](https://github.com/george43g/EQStack/compare/imsg-mcp-v1.21.1...imsg-mcp-v1.21.2) (2026-08-10)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **imsg-mcp:** eliminate TUI heap leak (dev React) + 4 stress-drive bugs ([2e2447e](https://github.com/george43g/EQStack/commit/2e2447e3dcd9429d6bd0e18b46b672174ceaf628))
12
+
13
+ ## imsg-mcp [1.21.1](https://github.com/george43g/EQStack/compare/imsg-mcp-v1.21.0...imsg-mcp-v1.21.1) (2026-08-09)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * **imsg-mcp:** coalesce wheel events, bound message cache, cool down pagination ([ea3c81e](https://github.com/george43g/EQStack/commit/ea3c81e62c943d351430b18f5ab099680ff53f9b))
19
+ * **imsg-mcp:** grapheme-aware truncation for sender names and reply previews ([ee50ede](https://github.com/george43g/EQStack/commit/ee50ede8f56d9b5f4ebea6e3b878a256eed7f794))
20
+ * **imsg-mcp:** watch the WAL file itself + safety poll — dir watch is silent on ~/Library/Messages ([5051151](https://github.com/george43g/EQStack/commit/50511515585c46c9cf5d8daa54777725207b74a8))
21
+
6
22
  # imsg-mcp [1.21.0](https://github.com/george43g/EQStack/compare/imsg-mcp-v1.20.0...imsg-mcp-v1.21.0) (2026-08-09)
7
23
 
8
24
 
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync } from "n
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { z } from "zod";
5
- import { c as getTranscribeCloudConfig } from "./shutdown-Dnj088id.js";
5
+ import { c as getTranscribeCloudConfig } from "./shutdown-URaLJ94f.js";
6
6
  const HEX6 = /^#[0-9a-fA-F]{6}$/;
7
7
  const PROVIDER_PRESET_NAMES = [
8
8
  "openai",
@@ -248,4 +248,4 @@ export {
248
248
  writeCredentials,
249
249
  writeTuiConfig
250
250
  };
251
- //# sourceMappingURL=app-config-B_QqZFbl.js.map
251
+ //# sourceMappingURL=app-config-CWVJPYcD.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"app-config-B_QqZFbl.js","sources":["../src/app-config.ts"],"sourcesContent":["/**\n * Canonical persistent configuration for imsg-mcp — one JSON file that carries\n * both the flat TUI keys (theme / accentColor) AND the `interpret` block that\n * configures media interpretation (Stage 2 core: chains, providers, toggles).\n *\n * Resolution order (first one found wins) — unchanged from the original\n * tui-config module so existing files keep loading:\n * 1. $XDG_CONFIG_HOME/imsg-mcp/config.json\n * 2. $HOME/.config/imsg-mcp/config.json\n * 3. $HOME/.imsg-mcp/config.json (matches the slugs.db location)\n *\n * `src/tui-config.ts` re-exports this module verbatim for back-compat.\n *\n * API keys are NEVER stored in config.json. They live in\n * `~/.imsg-mcp/credentials.json` (chmod 600, `{ \"<providerName>\": \"sk-…\" }`)\n * or the legacy `IMSG_TRANSCRIBE_*` env vars, and are merged into providers only\n * at resolution time (`resolveInterpretConfig`).\n */\n\nimport { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport { z } from \"zod\";\nimport { getTranscribeCloudConfig } from \"./config.js\";\nimport type { AutoMode, InterpretChains, InterpretConfig } from \"./media-intel.js\";\nimport type { ProviderConfig, ProviderPreset } from \"./media-providers.js\";\n\n// ── Schema ────────────────────────────────────────────────────────────────\n\nconst HEX6 = /^#[0-9a-fA-F]{6}$/;\n\n/** Preset names accepted in the `interpret.providers[].preset` field. Kept as a\n * literal tuple so Zod can build an enum without a runtime import of the\n * providers module (and asserted to stay in sync with `ProviderPreset`). */\nconst PROVIDER_PRESET_NAMES = [\n \"openai\",\n \"groq\",\n \"openrouter\",\n \"cloudflare\",\n \"huggingface\",\n \"ollama\",\n] as const satisfies readonly ProviderPreset[];\n\nconst ProviderModelsSchema = z\n .object({ transcribe: z.string().optional(), vision: z.string().optional() })\n .optional();\n\n/** A provider profile as stored in config.json — NO apiKey (that lives in\n * credentials.json / env and is merged in at resolution time). */\nconst ProviderConfigSchema = z\n .object({\n name: z.string().min(1),\n preset: z.enum(PROVIDER_PRESET_NAMES).optional(),\n baseUrl: z.string().url().optional(),\n accountId: z.string().optional(),\n models: ProviderModelsSchema,\n })\n .refine((p) => Boolean(p.preset) || Boolean(p.baseUrl), {\n message: \"provider needs either a preset or a baseUrl\",\n });\n\nconst ChainsSchema = z\n .object({\n audio: z.array(z.string()).default([\"apple\", \"local\"]),\n image: z.array(z.string()).default([]),\n video: z.array(z.string()).default([]),\n })\n .default({});\n\nconst NudgeSchema = z\n .object({\n enabled: z.boolean().default(true),\n tier2SyncNow: z.boolean().default(false),\n timeoutSeconds: z.number().int().positive().default(30),\n })\n .default({});\n\nexport const InterpretConfigSchema = z.object({\n /** Auto-interpretation gate. \"free\" (default) never makes a paid call without\n * an explicit force; \"all\" runs the full chain incl. cloud; \"off\" disables. */\n auto: z.enum([\"all\", \"free\", \"off\"]).default(\"free\"),\n /** Inline cached transcripts/captions in read surfaces (get_messages, TUI). */\n inlineTranscripts: z.boolean().default(true),\n /** Confirm before an export triggers more than this many uncached cloud calls. */\n exportConfirmThreshold: z.number().int().nonnegative().default(25),\n chains: ChainsSchema,\n providers: z.array(ProviderConfigSchema).default([]),\n nudge: NudgeSchema,\n});\n\nexport const AppConfigSchema = z.object({\n /** Glyph preset. \"safe\" is the universally-renderable default. */\n theme: z.enum([\"safe\", \"powerline\"]).default(\"safe\"),\n /** 6-digit hex color used to derive the whole UI palette. */\n accentColor: z.string().regex(HEX6, \"must be a 6-digit hex like #RRGGBB\").default(\"#1982FC\"),\n /** Media-interpretation config (optional — absent on TUI-only configs). */\n interpret: InterpretConfigSchema.optional(),\n});\n\nexport type AppConfig = z.infer<typeof AppConfigSchema>;\nexport type InterpretConfigInput = z.infer<typeof InterpretConfigSchema>;\n\n/** Back-compat alias — the TUI only ever reads theme/accentColor. */\nexport const TuiConfigSchema = AppConfigSchema;\nexport type TuiConfig = AppConfig;\n\nexport const DEFAULT_TUI_CONFIG: TuiConfig = {\n theme: \"safe\",\n accentColor: \"#1982FC\",\n};\n\n/** Free-first interpret defaults used when the block is absent from config. */\nexport const DEFAULT_INTERPRET_CONFIG: InterpretConfigInput = InterpretConfigSchema.parse({});\n\n// ── Path resolution ───────────────────────────────────────────────────────\n\n/** Candidate paths we try to read from, in order. Per the XDG spec, `~/.config`\n * is the DEFAULT VALUE of `$XDG_CONFIG_HOME` — not an additional search path —\n * so when the env var is set we must not fall through to the real `~/.config`\n * (that fall-through also let a dev machine's real config leak into tests that\n * point XDG at a temp dir). */\nfunction candidatePaths(): string[] {\n const home = homedir();\n const xdgBase = process.env.XDG_CONFIG_HOME || join(home, \".config\");\n return [join(xdgBase, \"imsg-mcp\", \"config.json\"), join(home, \".imsg-mcp\", \"config.json\")];\n}\n\n/** Where the file actually lives (first existing candidate), or null. */\nexport function findTuiConfigPath(): string | null {\n for (const p of candidatePaths()) {\n if (existsSync(p)) return p;\n }\n return null;\n}\n\n/** Where to write a fresh config file. Returns the *first* candidate so\n * XDG-style wins on a clean machine. */\nexport function defaultTuiConfigPath(): string {\n return candidatePaths()[0] ?? candidatePaths()[1];\n}\n\n// ── Loader ────────────────────────────────────────────────────────────────\n\nexport interface LoadedTuiConfig {\n config: AppConfig;\n /** Path of the file we read from, or `null` if defaults were used. */\n source: string | null;\n /** Human-readable warnings (parse errors, schema errors). Empty when clean. */\n warnings: string[];\n}\n\nexport function loadTuiConfig(): LoadedTuiConfig {\n const warnings: string[] = [];\n const path = findTuiConfigPath();\n if (!path) return { config: { ...DEFAULT_TUI_CONFIG }, source: null, warnings };\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(readFileSync(path, \"utf8\"));\n } catch (err) {\n warnings.push(\n `config: failed to read ${path} (${err instanceof Error ? err.message : String(err)}); using defaults`,\n );\n return { config: { ...DEFAULT_TUI_CONFIG }, source: path, warnings };\n }\n\n const result = AppConfigSchema.safeParse(parsed);\n if (result.success) return { config: result.data, source: path, warnings };\n\n // Full parse failed. Degrade gracefully: if the flat TUI keys alone are valid,\n // keep them (a malformed `interpret` block should not blow away the theme).\n const tuiOnly = AppConfigSchema.pick({ theme: true, accentColor: true }).safeParse(parsed);\n const issues = result.error.issues\n .map((i) => `${i.path.join(\".\") || \"<root>\"}: ${i.message}`)\n .join(\"; \");\n if (tuiOnly.success) {\n warnings.push(`config: ${path} has invalid fields (${issues}); ignoring them`);\n return { config: { ...tuiOnly.data }, source: path, warnings };\n }\n warnings.push(`config: ${path} has invalid fields (${issues}); using defaults`);\n return { config: { ...DEFAULT_TUI_CONFIG }, source: path, warnings };\n}\n\n// ── Writer (used by `imsg config edit` + `imsg setup`) ────────────────────\n\nexport function writeTuiConfig(config: AppConfig, path = defaultTuiConfigPath()): string {\n // Validate before writing so we never persist garbage.\n const validated = AppConfigSchema.parse(config);\n mkdirSync(dirname(path), { recursive: true });\n writeFileSync(path, `${JSON.stringify(validated, null, 2)}\\n`);\n return path;\n}\n\n// ── Credentials (chmod 600) ───────────────────────────────────────────────\n\n/** `~/.imsg-mcp/credentials.json` — `{ \"<providerName>\": \"<apiKey>\" }`. */\nexport function credentialsPath(): string {\n return join(homedir(), \".imsg-mcp\", \"credentials.json\");\n}\n\nexport function readCredentials(): Record<string, string> {\n const path = credentialsPath();\n if (!existsSync(path)) return {};\n try {\n const parsed = JSON.parse(readFileSync(path, \"utf8\"));\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n const out: Record<string, string> = {};\n for (const [k, v] of Object.entries(parsed)) {\n if (typeof v === \"string\") out[k] = v;\n }\n return out;\n }\n } catch {\n // Corrupt credentials file — treat as empty (never throw on read).\n }\n return {};\n}\n\n/** Write the whole credentials map, enforcing 0600 on the file (and 0700 dir). */\nexport function writeCredentials(creds: Record<string, string>): string {\n const path = credentialsPath();\n const dir = dirname(path);\n mkdirSync(dir, { recursive: true });\n try {\n chmodSync(dir, 0o700);\n } catch {\n // Best-effort — a shared dir may already exist with other perms.\n }\n writeFileSync(path, `${JSON.stringify(creds, null, 2)}\\n`, { mode: 0o600 });\n // writeFileSync's `mode` is masked by umask on CREATE and ignored when the\n // file already exists — chmod unconditionally so 0600 is guaranteed.\n chmodSync(path, 0o600);\n return path;\n}\n\n/** Set (or clear, when key is empty) one provider's credential. */\nexport function setCredential(name: string, key: string): void {\n const creds = readCredentials();\n if (key) creds[name] = key;\n else delete creds[name];\n writeCredentials(creds);\n}\n\n// ── Interpret-config resolution (config + credentials + env → service) ─────\n\n/** The env-mapped implicit provider name (legacy IMSG_TRANSCRIBE_* path). */\nexport const ENV_PROVIDER_NAME = \"env\";\n\n/** Interpret config ready for `MediaIntelService`, plus the surfacing toggles\n * that frontends (Stage 4/6/7) read. Providers carry their merged apiKey. */\nexport interface ResolvedInterpretConfig extends InterpretConfig {\n inlineTranscripts: boolean;\n exportConfirmThreshold: number;\n nudge: { enabled: boolean; tier2SyncNow: boolean; timeoutSeconds: number };\n /** Where the config was read from (null = defaults). */\n configPath: string | null;\n warnings: string[];\n}\n\n/**\n * Resolve the full interpret configuration:\n * - start from the config file's `interpret` block (or free-first defaults),\n * - merge API keys from credentials.json into each named provider,\n * - map the legacy `IMSG_TRANSCRIBE_*` env vars to an implicit `env` provider\n * (and append `provider:env` to the audio chain if it has no provider yet),\n * preserving the shipped v1.7.0 cloud-transcription fallback.\n */\nexport function resolveInterpretConfig(): ResolvedInterpretConfig {\n const loaded = loadTuiConfig();\n const warnings = [...loaded.warnings];\n const block = InterpretConfigSchema.parse(loaded.config.interpret ?? {});\n const creds = readCredentials();\n\n const providers: ProviderConfig[] = block.providers.map((p) => ({\n ...p,\n apiKey: creds[p.name],\n }));\n const chains: InterpretChains = {\n audio: [...block.chains.audio],\n image: [...block.chains.image],\n video: [...block.chains.video],\n };\n\n // Legacy env fallback → implicit provider, if not already configured by name.\n const envCloud = getTranscribeCloudConfig();\n if (envCloud && !providers.some((p) => p.name === ENV_PROVIDER_NAME)) {\n providers.push({\n name: ENV_PROVIDER_NAME,\n baseUrl: envCloud.baseUrl,\n apiKey: envCloud.apiKey,\n models: { transcribe: envCloud.model },\n });\n if (!chains.audio.some((l) => l.startsWith(\"provider:\"))) {\n chains.audio.push(`provider:${ENV_PROVIDER_NAME}`);\n }\n }\n\n const auto: AutoMode = block.auto;\n return {\n auto,\n chains,\n providers,\n inlineTranscripts: block.inlineTranscripts,\n exportConfirmThreshold: block.exportConfirmThreshold,\n nudge: block.nudge,\n configPath: loaded.source,\n warnings,\n };\n}\n\n// ── Layered resolution (CLI > env > config > defaults) — TUI theme/accent ──\n\nexport interface TuiConfigOverrides {\n /** From `imsg --theme=...` */\n cliTheme?: string;\n /** From `imsg --accent=...` */\n cliAccent?: string;\n}\n\nexport interface ResolvedTuiConfig {\n theme: TuiConfig[\"theme\"];\n accentColor: string;\n /** Where each value came from, useful for `imsg config show`. */\n origin: {\n theme: \"cli\" | \"env\" | \"config\" | \"default\";\n accentColor: \"cli\" | \"env\" | \"config\" | \"default\";\n };\n /** The on-disk file path that contributed (or `null` if no file). */\n configPath: string | null;\n /** Any warnings raised during loading (malformed file, invalid env, etc). */\n warnings: string[];\n}\n\nexport function resolveTuiConfig(overrides: TuiConfigOverrides = {}): ResolvedTuiConfig {\n const loaded = loadTuiConfig();\n const warnings = [...loaded.warnings];\n\n // Theme precedence: CLI > env > config > default\n let theme: TuiConfig[\"theme\"] = loaded.config.theme;\n let themeOrigin: ResolvedTuiConfig[\"origin\"][\"theme\"] = loaded.source ? \"config\" : \"default\";\n\n if (process.env.IMSG_TUI_THEME) {\n const t = process.env.IMSG_TUI_THEME;\n if (t === \"safe\" || t === \"powerline\") {\n theme = t;\n themeOrigin = \"env\";\n } else {\n warnings.push(`IMSG_TUI_THEME=\"${t}\" is not \"safe\" or \"powerline\"; ignoring`);\n }\n }\n if (overrides.cliTheme !== undefined) {\n if (overrides.cliTheme === \"safe\" || overrides.cliTheme === \"powerline\") {\n theme = overrides.cliTheme;\n themeOrigin = \"cli\";\n } else {\n warnings.push(`--theme=\"${overrides.cliTheme}\" is not \"safe\" or \"powerline\"; ignoring`);\n }\n }\n\n // Accent precedence: CLI > env > config > default\n let accentColor = loaded.config.accentColor;\n let accentOrigin: ResolvedTuiConfig[\"origin\"][\"accentColor\"] = loaded.source\n ? \"config\"\n : \"default\";\n\n if (process.env.IMSG_TUI_ACCENT) {\n if (HEX6.test(process.env.IMSG_TUI_ACCENT)) {\n accentColor = process.env.IMSG_TUI_ACCENT;\n accentOrigin = \"env\";\n } else {\n warnings.push(\n `IMSG_TUI_ACCENT=\"${process.env.IMSG_TUI_ACCENT}\" is not a 6-digit hex; ignoring`,\n );\n }\n }\n if (overrides.cliAccent !== undefined) {\n if (HEX6.test(overrides.cliAccent)) {\n accentColor = overrides.cliAccent;\n accentOrigin = \"cli\";\n } else {\n warnings.push(`--accent=\"${overrides.cliAccent}\" is not a 6-digit hex; ignoring`);\n }\n }\n\n return {\n theme,\n accentColor,\n origin: { theme: themeOrigin, accentColor: accentOrigin },\n configPath: loaded.source,\n warnings,\n };\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"app-config-CWVJPYcD.js","sources":["../src/app-config.ts"],"sourcesContent":["/**\n * Canonical persistent configuration for imsg-mcp — one JSON file that carries\n * both the flat TUI keys (theme / accentColor) AND the `interpret` block that\n * configures media interpretation (Stage 2 core: chains, providers, toggles).\n *\n * Resolution order (first one found wins) — unchanged from the original\n * tui-config module so existing files keep loading:\n * 1. $XDG_CONFIG_HOME/imsg-mcp/config.json\n * 2. $HOME/.config/imsg-mcp/config.json\n * 3. $HOME/.imsg-mcp/config.json (matches the slugs.db location)\n *\n * `src/tui-config.ts` re-exports this module verbatim for back-compat.\n *\n * API keys are NEVER stored in config.json. They live in\n * `~/.imsg-mcp/credentials.json` (chmod 600, `{ \"<providerName>\": \"sk-…\" }`)\n * or the legacy `IMSG_TRANSCRIBE_*` env vars, and are merged into providers only\n * at resolution time (`resolveInterpretConfig`).\n */\n\nimport { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport { z } from \"zod\";\nimport { getTranscribeCloudConfig } from \"./config.js\";\nimport type { AutoMode, InterpretChains, InterpretConfig } from \"./media-intel.js\";\nimport type { ProviderConfig, ProviderPreset } from \"./media-providers.js\";\n\n// ── Schema ────────────────────────────────────────────────────────────────\n\nconst HEX6 = /^#[0-9a-fA-F]{6}$/;\n\n/** Preset names accepted in the `interpret.providers[].preset` field. Kept as a\n * literal tuple so Zod can build an enum without a runtime import of the\n * providers module (and asserted to stay in sync with `ProviderPreset`). */\nconst PROVIDER_PRESET_NAMES = [\n \"openai\",\n \"groq\",\n \"openrouter\",\n \"cloudflare\",\n \"huggingface\",\n \"ollama\",\n] as const satisfies readonly ProviderPreset[];\n\nconst ProviderModelsSchema = z\n .object({ transcribe: z.string().optional(), vision: z.string().optional() })\n .optional();\n\n/** A provider profile as stored in config.json — NO apiKey (that lives in\n * credentials.json / env and is merged in at resolution time). */\nconst ProviderConfigSchema = z\n .object({\n name: z.string().min(1),\n preset: z.enum(PROVIDER_PRESET_NAMES).optional(),\n baseUrl: z.string().url().optional(),\n accountId: z.string().optional(),\n models: ProviderModelsSchema,\n })\n .refine((p) => Boolean(p.preset) || Boolean(p.baseUrl), {\n message: \"provider needs either a preset or a baseUrl\",\n });\n\nconst ChainsSchema = z\n .object({\n audio: z.array(z.string()).default([\"apple\", \"local\"]),\n image: z.array(z.string()).default([]),\n video: z.array(z.string()).default([]),\n })\n .default({});\n\nconst NudgeSchema = z\n .object({\n enabled: z.boolean().default(true),\n tier2SyncNow: z.boolean().default(false),\n timeoutSeconds: z.number().int().positive().default(30),\n })\n .default({});\n\nexport const InterpretConfigSchema = z.object({\n /** Auto-interpretation gate. \"free\" (default) never makes a paid call without\n * an explicit force; \"all\" runs the full chain incl. cloud; \"off\" disables. */\n auto: z.enum([\"all\", \"free\", \"off\"]).default(\"free\"),\n /** Inline cached transcripts/captions in read surfaces (get_messages, TUI). */\n inlineTranscripts: z.boolean().default(true),\n /** Confirm before an export triggers more than this many uncached cloud calls. */\n exportConfirmThreshold: z.number().int().nonnegative().default(25),\n chains: ChainsSchema,\n providers: z.array(ProviderConfigSchema).default([]),\n nudge: NudgeSchema,\n});\n\nexport const AppConfigSchema = z.object({\n /** Glyph preset. \"safe\" is the universally-renderable default. */\n theme: z.enum([\"safe\", \"powerline\"]).default(\"safe\"),\n /** 6-digit hex color used to derive the whole UI palette. */\n accentColor: z.string().regex(HEX6, \"must be a 6-digit hex like #RRGGBB\").default(\"#1982FC\"),\n /** Media-interpretation config (optional — absent on TUI-only configs). */\n interpret: InterpretConfigSchema.optional(),\n});\n\nexport type AppConfig = z.infer<typeof AppConfigSchema>;\nexport type InterpretConfigInput = z.infer<typeof InterpretConfigSchema>;\n\n/** Back-compat alias — the TUI only ever reads theme/accentColor. */\nexport const TuiConfigSchema = AppConfigSchema;\nexport type TuiConfig = AppConfig;\n\nexport const DEFAULT_TUI_CONFIG: TuiConfig = {\n theme: \"safe\",\n accentColor: \"#1982FC\",\n};\n\n/** Free-first interpret defaults used when the block is absent from config. */\nexport const DEFAULT_INTERPRET_CONFIG: InterpretConfigInput = InterpretConfigSchema.parse({});\n\n// ── Path resolution ───────────────────────────────────────────────────────\n\n/** Candidate paths we try to read from, in order. Per the XDG spec, `~/.config`\n * is the DEFAULT VALUE of `$XDG_CONFIG_HOME` — not an additional search path —\n * so when the env var is set we must not fall through to the real `~/.config`\n * (that fall-through also let a dev machine's real config leak into tests that\n * point XDG at a temp dir). */\nfunction candidatePaths(): string[] {\n const home = homedir();\n const xdgBase = process.env.XDG_CONFIG_HOME || join(home, \".config\");\n return [join(xdgBase, \"imsg-mcp\", \"config.json\"), join(home, \".imsg-mcp\", \"config.json\")];\n}\n\n/** Where the file actually lives (first existing candidate), or null. */\nexport function findTuiConfigPath(): string | null {\n for (const p of candidatePaths()) {\n if (existsSync(p)) return p;\n }\n return null;\n}\n\n/** Where to write a fresh config file. Returns the *first* candidate so\n * XDG-style wins on a clean machine. */\nexport function defaultTuiConfigPath(): string {\n return candidatePaths()[0] ?? candidatePaths()[1];\n}\n\n// ── Loader ────────────────────────────────────────────────────────────────\n\nexport interface LoadedTuiConfig {\n config: AppConfig;\n /** Path of the file we read from, or `null` if defaults were used. */\n source: string | null;\n /** Human-readable warnings (parse errors, schema errors). Empty when clean. */\n warnings: string[];\n}\n\nexport function loadTuiConfig(): LoadedTuiConfig {\n const warnings: string[] = [];\n const path = findTuiConfigPath();\n if (!path) return { config: { ...DEFAULT_TUI_CONFIG }, source: null, warnings };\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(readFileSync(path, \"utf8\"));\n } catch (err) {\n warnings.push(\n `config: failed to read ${path} (${err instanceof Error ? err.message : String(err)}); using defaults`,\n );\n return { config: { ...DEFAULT_TUI_CONFIG }, source: path, warnings };\n }\n\n const result = AppConfigSchema.safeParse(parsed);\n if (result.success) return { config: result.data, source: path, warnings };\n\n // Full parse failed. Degrade gracefully: if the flat TUI keys alone are valid,\n // keep them (a malformed `interpret` block should not blow away the theme).\n const tuiOnly = AppConfigSchema.pick({ theme: true, accentColor: true }).safeParse(parsed);\n const issues = result.error.issues\n .map((i) => `${i.path.join(\".\") || \"<root>\"}: ${i.message}`)\n .join(\"; \");\n if (tuiOnly.success) {\n warnings.push(`config: ${path} has invalid fields (${issues}); ignoring them`);\n return { config: { ...tuiOnly.data }, source: path, warnings };\n }\n warnings.push(`config: ${path} has invalid fields (${issues}); using defaults`);\n return { config: { ...DEFAULT_TUI_CONFIG }, source: path, warnings };\n}\n\n// ── Writer (used by `imsg config edit` + `imsg setup`) ────────────────────\n\nexport function writeTuiConfig(config: AppConfig, path = defaultTuiConfigPath()): string {\n // Validate before writing so we never persist garbage.\n const validated = AppConfigSchema.parse(config);\n mkdirSync(dirname(path), { recursive: true });\n writeFileSync(path, `${JSON.stringify(validated, null, 2)}\\n`);\n return path;\n}\n\n// ── Credentials (chmod 600) ───────────────────────────────────────────────\n\n/** `~/.imsg-mcp/credentials.json` — `{ \"<providerName>\": \"<apiKey>\" }`. */\nexport function credentialsPath(): string {\n return join(homedir(), \".imsg-mcp\", \"credentials.json\");\n}\n\nexport function readCredentials(): Record<string, string> {\n const path = credentialsPath();\n if (!existsSync(path)) return {};\n try {\n const parsed = JSON.parse(readFileSync(path, \"utf8\"));\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n const out: Record<string, string> = {};\n for (const [k, v] of Object.entries(parsed)) {\n if (typeof v === \"string\") out[k] = v;\n }\n return out;\n }\n } catch {\n // Corrupt credentials file — treat as empty (never throw on read).\n }\n return {};\n}\n\n/** Write the whole credentials map, enforcing 0600 on the file (and 0700 dir). */\nexport function writeCredentials(creds: Record<string, string>): string {\n const path = credentialsPath();\n const dir = dirname(path);\n mkdirSync(dir, { recursive: true });\n try {\n chmodSync(dir, 0o700);\n } catch {\n // Best-effort — a shared dir may already exist with other perms.\n }\n writeFileSync(path, `${JSON.stringify(creds, null, 2)}\\n`, { mode: 0o600 });\n // writeFileSync's `mode` is masked by umask on CREATE and ignored when the\n // file already exists — chmod unconditionally so 0600 is guaranteed.\n chmodSync(path, 0o600);\n return path;\n}\n\n/** Set (or clear, when key is empty) one provider's credential. */\nexport function setCredential(name: string, key: string): void {\n const creds = readCredentials();\n if (key) creds[name] = key;\n else delete creds[name];\n writeCredentials(creds);\n}\n\n// ── Interpret-config resolution (config + credentials + env → service) ─────\n\n/** The env-mapped implicit provider name (legacy IMSG_TRANSCRIBE_* path). */\nexport const ENV_PROVIDER_NAME = \"env\";\n\n/** Interpret config ready for `MediaIntelService`, plus the surfacing toggles\n * that frontends (Stage 4/6/7) read. Providers carry their merged apiKey. */\nexport interface ResolvedInterpretConfig extends InterpretConfig {\n inlineTranscripts: boolean;\n exportConfirmThreshold: number;\n nudge: { enabled: boolean; tier2SyncNow: boolean; timeoutSeconds: number };\n /** Where the config was read from (null = defaults). */\n configPath: string | null;\n warnings: string[];\n}\n\n/**\n * Resolve the full interpret configuration:\n * - start from the config file's `interpret` block (or free-first defaults),\n * - merge API keys from credentials.json into each named provider,\n * - map the legacy `IMSG_TRANSCRIBE_*` env vars to an implicit `env` provider\n * (and append `provider:env` to the audio chain if it has no provider yet),\n * preserving the shipped v1.7.0 cloud-transcription fallback.\n */\nexport function resolveInterpretConfig(): ResolvedInterpretConfig {\n const loaded = loadTuiConfig();\n const warnings = [...loaded.warnings];\n const block = InterpretConfigSchema.parse(loaded.config.interpret ?? {});\n const creds = readCredentials();\n\n const providers: ProviderConfig[] = block.providers.map((p) => ({\n ...p,\n apiKey: creds[p.name],\n }));\n const chains: InterpretChains = {\n audio: [...block.chains.audio],\n image: [...block.chains.image],\n video: [...block.chains.video],\n };\n\n // Legacy env fallback → implicit provider, if not already configured by name.\n const envCloud = getTranscribeCloudConfig();\n if (envCloud && !providers.some((p) => p.name === ENV_PROVIDER_NAME)) {\n providers.push({\n name: ENV_PROVIDER_NAME,\n baseUrl: envCloud.baseUrl,\n apiKey: envCloud.apiKey,\n models: { transcribe: envCloud.model },\n });\n if (!chains.audio.some((l) => l.startsWith(\"provider:\"))) {\n chains.audio.push(`provider:${ENV_PROVIDER_NAME}`);\n }\n }\n\n const auto: AutoMode = block.auto;\n return {\n auto,\n chains,\n providers,\n inlineTranscripts: block.inlineTranscripts,\n exportConfirmThreshold: block.exportConfirmThreshold,\n nudge: block.nudge,\n configPath: loaded.source,\n warnings,\n };\n}\n\n// ── Layered resolution (CLI > env > config > defaults) — TUI theme/accent ──\n\nexport interface TuiConfigOverrides {\n /** From `imsg --theme=...` */\n cliTheme?: string;\n /** From `imsg --accent=...` */\n cliAccent?: string;\n}\n\nexport interface ResolvedTuiConfig {\n theme: TuiConfig[\"theme\"];\n accentColor: string;\n /** Where each value came from, useful for `imsg config show`. */\n origin: {\n theme: \"cli\" | \"env\" | \"config\" | \"default\";\n accentColor: \"cli\" | \"env\" | \"config\" | \"default\";\n };\n /** The on-disk file path that contributed (or `null` if no file). */\n configPath: string | null;\n /** Any warnings raised during loading (malformed file, invalid env, etc). */\n warnings: string[];\n}\n\nexport function resolveTuiConfig(overrides: TuiConfigOverrides = {}): ResolvedTuiConfig {\n const loaded = loadTuiConfig();\n const warnings = [...loaded.warnings];\n\n // Theme precedence: CLI > env > config > default\n let theme: TuiConfig[\"theme\"] = loaded.config.theme;\n let themeOrigin: ResolvedTuiConfig[\"origin\"][\"theme\"] = loaded.source ? \"config\" : \"default\";\n\n if (process.env.IMSG_TUI_THEME) {\n const t = process.env.IMSG_TUI_THEME;\n if (t === \"safe\" || t === \"powerline\") {\n theme = t;\n themeOrigin = \"env\";\n } else {\n warnings.push(`IMSG_TUI_THEME=\"${t}\" is not \"safe\" or \"powerline\"; ignoring`);\n }\n }\n if (overrides.cliTheme !== undefined) {\n if (overrides.cliTheme === \"safe\" || overrides.cliTheme === \"powerline\") {\n theme = overrides.cliTheme;\n themeOrigin = \"cli\";\n } else {\n warnings.push(`--theme=\"${overrides.cliTheme}\" is not \"safe\" or \"powerline\"; ignoring`);\n }\n }\n\n // Accent precedence: CLI > env > config > default\n let accentColor = loaded.config.accentColor;\n let accentOrigin: ResolvedTuiConfig[\"origin\"][\"accentColor\"] = loaded.source\n ? \"config\"\n : \"default\";\n\n if (process.env.IMSG_TUI_ACCENT) {\n if (HEX6.test(process.env.IMSG_TUI_ACCENT)) {\n accentColor = process.env.IMSG_TUI_ACCENT;\n accentOrigin = \"env\";\n } else {\n warnings.push(\n `IMSG_TUI_ACCENT=\"${process.env.IMSG_TUI_ACCENT}\" is not a 6-digit hex; ignoring`,\n );\n }\n }\n if (overrides.cliAccent !== undefined) {\n if (HEX6.test(overrides.cliAccent)) {\n accentColor = overrides.cliAccent;\n accentOrigin = \"cli\";\n } else {\n warnings.push(`--accent=\"${overrides.cliAccent}\" is not a 6-digit hex; ignoring`);\n }\n }\n\n return {\n theme,\n accentColor,\n origin: { theme: themeOrigin, accentColor: accentOrigin },\n configPath: loaded.source,\n warnings,\n };\n}\n"],"names":[],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
- import { s as syncNowViaSystemEvents, o as openConversationInMessages } from "./shutdown-Dnj088id.js";
2
+ import { s as syncNowViaSystemEvents, o as openConversationInMessages } from "./shutdown-URaLJ94f.js";
3
3
  function defaultAttachmentSyncDeps() {
4
4
  return {
5
5
  fileExists: (p) => existsSync(p),
@@ -76,4 +76,4 @@ export {
76
76
  defaultAttachmentSyncDeps,
77
77
  ensureAttachmentDownloaded
78
78
  };
79
- //# sourceMappingURL=attachment-sync-DqIvfxPv.js.map
79
+ //# sourceMappingURL=attachment-sync-DfYAnRQG.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"attachment-sync-DqIvfxPv.js","sources":["../src/attachment-sync.ts"],"sourcesContent":["/**\n * Attachment sync nudge (Stage 7) — best-effort, tiered coaxing of Messages.app\n * into downloading an attachment whose bytes aren't on disk yet\n * (`transfer_state === -1`, purged/never-downloaded). iCloud syncs Messages\n * media only while a conversation is OPEN, and there is no public download API\n * (IMTransferAgent is a private SIP'd daemon). So we:\n *\n * Tier 1 (default): open the conversation in Messages via an `imessage:` URL\n * — community-verified to make the app pull that thread's media — then poll\n * `existsSync` until the file appears or a timeout elapses.\n * Tier 2 (opt-in, `nudge.tier2SyncNow`): UI-script Messages ▸ Settings ▸\n * iMessage ▸ \"Sync Now\". Needs Accessibility permission; we detect the\n * missing-permission error and return an actionable hint.\n * Tier 3: per-conversation \"download all\" UI script — documented only\n * (too fragile across macOS versions), no code here.\n *\n * This module is PURE ORCHESTRATION: every side effect (AppleScript, fs, timers)\n * is injected via {@link AttachmentSyncDeps}, so the tier/poll/timeout logic is\n * unit-tested deterministically with no real app launch and no real clock. The\n * default deps wire the real AppleScript primitives (themselves mocked under\n * Vitest) + node fs/timers.\n */\nimport { existsSync } from \"node:fs\";\nimport {\n openConversationInMessages,\n type SyncNowOutcome,\n syncNowViaSystemEvents,\n} from \"./applescript.js\";\n\nexport type { SyncNowOutcome };\n\n/** The subset of the resolved `nudge` config this module needs. */\nexport interface AttachmentSyncConfig {\n /** Master switch — when false, no tier runs and we never touch Messages. */\n enabled: boolean;\n /** Enable Tier 2 (System Events \"Sync Now\"). Default off. */\n tier2SyncNow: boolean;\n /** How long to wait for the file to appear after a nudge, in seconds. */\n timeoutSeconds: number;\n /** Poll cadence while waiting. Default 1000ms; floored at 100ms. */\n pollIntervalMs?: number;\n}\n\n/** Highest tier that actually ran (0 = none). */\nexport type SyncTier = 0 | 1 | 2;\n\nexport interface EnsureDownloadedInput {\n /** Absolute path (already `~`-expanded) we expect the file to appear at. */\n filePath: string;\n /** Chat identifier (handle or GUID) to open in Messages for Tier 1. */\n chatId?: string | null;\n /** `attachment.transfer_state` from chat.db (-1 = undownloaded); advisory. */\n transferState?: number | null;\n}\n\nexport interface EnsureDownloadedResult {\n /** True iff the file is on disk when we return. */\n downloaded: boolean;\n /** True iff we actually ran at least one tier (vs short-circuited). */\n attempted: boolean;\n /** Highest tier attempted. */\n tier: SyncTier;\n /** Machine-readable reason when not downloaded / skipped. */\n reason?: string;\n /** Human-actionable remediation (e.g. grant Accessibility). */\n hint?: string;\n}\n\n/** All side effects the orchestrator needs, injectable for tests. */\nexport interface AttachmentSyncDeps {\n fileExists(path: string): boolean;\n openConversation(chatId: string): Promise<void>;\n syncNow(): Promise<SyncNowOutcome>;\n sleep(ms: number): Promise<void>;\n now(): number;\n}\n\n/** Production deps: real fs + timers + AppleScript (AppleScript self-mocks under Vitest). */\nexport function defaultAttachmentSyncDeps(): AttachmentSyncDeps {\n return {\n fileExists: (p) => existsSync(p),\n openConversation: (chatId) => openConversationInMessages(chatId),\n syncNow: () => syncNowViaSystemEvents(),\n sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),\n now: () => Date.now(),\n };\n}\n\nfunction errMsg(e: unknown): string {\n return e instanceof Error ? e.message : String(e);\n}\n\n/**\n * Poll `fileExists` until it's true or `timeoutMs` elapses (measured against\n * `deps.now()`). Checks immediately first — opening the chat may have already\n * pulled the file — then sleeps `pollIntervalMs` between checks.\n */\nasync function pollForFile(\n deps: AttachmentSyncDeps,\n path: string,\n timeoutMs: number,\n pollIntervalMs: number,\n): Promise<boolean> {\n const start = deps.now();\n if (deps.fileExists(path)) return true;\n while (deps.now() - start < timeoutMs) {\n await deps.sleep(pollIntervalMs);\n if (deps.fileExists(path)) return true;\n }\n return false;\n}\n\n/**\n * Ensure `input.filePath` is on disk, nudging Messages to download it if not.\n * Best-effort: returns a structured result rather than throwing. Idempotent and\n * cheap when the file already exists (no side effects) or when disabled.\n */\nexport async function ensureAttachmentDownloaded(\n input: EnsureDownloadedInput,\n config: AttachmentSyncConfig,\n deps: AttachmentSyncDeps = defaultAttachmentSyncDeps(),\n): Promise<EnsureDownloadedResult> {\n const { filePath } = input;\n\n // Already on disk → nothing to do (no app launch).\n if (deps.fileExists(filePath)) {\n return { downloaded: true, attempted: false, tier: 0 };\n }\n if (!config.enabled) {\n return { downloaded: false, attempted: false, tier: 0, reason: \"sync nudge disabled\" };\n }\n\n const pollIntervalMs = Math.max(100, config.pollIntervalMs ?? 1000);\n const timeoutMs = Math.max(1000, config.timeoutSeconds * 1000);\n let tier: SyncTier = 0;\n let hint: string | undefined;\n\n // ── Tier 1: open the conversation so Messages pulls its media ──\n if (input.chatId) {\n tier = 1;\n try {\n await deps.openConversation(input.chatId);\n } catch (e) {\n hint = `Could not open the conversation in Messages: ${errMsg(e)}`;\n }\n if (await pollForFile(deps, filePath, timeoutMs, pollIntervalMs)) {\n return { downloaded: true, attempted: true, tier };\n }\n } else {\n hint = \"No chat identifier available to open the conversation in Messages.\";\n }\n\n // ── Tier 2 (opt-in): System Events \"Sync Now\" ──\n if (config.tier2SyncNow) {\n tier = 2;\n const outcome = await deps.syncNow();\n if (!outcome.ok) {\n if (outcome.needsAccessibility) {\n return {\n downloaded: deps.fileExists(filePath),\n attempted: true,\n tier,\n reason: \"accessibility-permission-required\",\n hint: \"Sync Now needs Accessibility permission. Grant it in System Settings ▸ Privacy & Security ▸ Accessibility for your terminal/IDE, then retry.\",\n };\n }\n hint = outcome.error ?? hint ?? \"Sync Now failed.\";\n } else if (await pollForFile(deps, filePath, timeoutMs, pollIntervalMs)) {\n return { downloaded: true, attempted: true, tier };\n }\n }\n\n return {\n downloaded: deps.fileExists(filePath),\n attempted: tier > 0,\n tier,\n reason: tier === 0 ? \"no sync tier could run\" : \"attachment did not download in time\",\n hint,\n };\n}\n"],"names":[],"mappings":";;AA8EO,SAAS,4BAAgD;AAC9D,SAAO;AAAA,IACL,YAAY,CAAC,MAAM,WAAW,CAAC;AAAA,IAC/B,kBAAkB,CAAC,WAAW,2BAA2B,MAAM;AAAA,IAC/D,SAAS,MAAM,uBAAA;AAAA,IACf,OAAO,CAAC,OAAO,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,EAAE,CAAC;AAAA,IAC/D,KAAK,MAAM,KAAK,IAAA;AAAA,EAAI;AAExB;AAEA,SAAS,OAAO,GAAoB;AAClC,SAAO,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC;AAClD;AAOA,eAAe,YACb,MACA,MACA,WACA,gBACkB;AAClB,QAAM,QAAQ,KAAK,IAAA;AACnB,MAAI,KAAK,WAAW,IAAI,EAAG,QAAO;AAClC,SAAO,KAAK,QAAQ,QAAQ,WAAW;AACrC,UAAM,KAAK,MAAM,cAAc;AAC/B,QAAI,KAAK,WAAW,IAAI,EAAG,QAAO;AAAA,EACpC;AACA,SAAO;AACT;AAOA,eAAsB,2BACpB,OACA,QACA,OAA2B,6BACM;AACjC,QAAM,EAAE,aAAa;AAGrB,MAAI,KAAK,WAAW,QAAQ,GAAG;AAC7B,WAAO,EAAE,YAAY,MAAM,WAAW,OAAO,MAAM,EAAA;AAAA,EACrD;AACA,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,EAAE,YAAY,OAAO,WAAW,OAAO,MAAM,GAAG,QAAQ,sBAAA;AAAA,EACjE;AAEA,QAAM,iBAAiB,KAAK,IAAI,KAAK,OAAO,kBAAkB,GAAI;AAClE,QAAM,YAAY,KAAK,IAAI,KAAM,OAAO,iBAAiB,GAAI;AAC7D,MAAI,OAAiB;AACrB,MAAI;AAGJ,MAAI,MAAM,QAAQ;AAChB,WAAO;AACP,QAAI;AACF,YAAM,KAAK,iBAAiB,MAAM,MAAM;AAAA,IAC1C,SAAS,GAAG;AACV,aAAO,gDAAgD,OAAO,CAAC,CAAC;AAAA,IAClE;AACA,QAAI,MAAM,YAAY,MAAM,UAAU,WAAW,cAAc,GAAG;AAChE,aAAO,EAAE,YAAY,MAAM,WAAW,MAAM,KAAA;AAAA,IAC9C;AAAA,EACF,OAAO;AACL,WAAO;AAAA,EACT;AAGA,MAAI,OAAO,cAAc;AACvB,WAAO;AACP,UAAM,UAAU,MAAM,KAAK,QAAA;AAC3B,QAAI,CAAC,QAAQ,IAAI;AACf,UAAI,QAAQ,oBAAoB;AAC9B,eAAO;AAAA,UACL,YAAY,KAAK,WAAW,QAAQ;AAAA,UACpC,WAAW;AAAA,UACX;AAAA,UACA,QAAQ;AAAA,UACR,MAAM;AAAA,QAAA;AAAA,MAEV;AACA,aAAO,QAAQ,SAAS,QAAQ;AAAA,IAClC,WAAW,MAAM,YAAY,MAAM,UAAU,WAAW,cAAc,GAAG;AACvE,aAAO,EAAE,YAAY,MAAM,WAAW,MAAM,KAAA;AAAA,IAC9C;AAAA,EACF;AAEA,SAAO;AAAA,IACL,YAAY,KAAK,WAAW,QAAQ;AAAA,IACpC,WAAW,OAAO;AAAA,IAClB;AAAA,IACA,QAAQ,SAAS,IAAI,2BAA2B;AAAA,IAChD;AAAA,EAAA;AAEJ;"}
1
+ {"version":3,"file":"attachment-sync-DfYAnRQG.js","sources":["../src/attachment-sync.ts"],"sourcesContent":["/**\n * Attachment sync nudge (Stage 7) — best-effort, tiered coaxing of Messages.app\n * into downloading an attachment whose bytes aren't on disk yet\n * (`transfer_state === -1`, purged/never-downloaded). iCloud syncs Messages\n * media only while a conversation is OPEN, and there is no public download API\n * (IMTransferAgent is a private SIP'd daemon). So we:\n *\n * Tier 1 (default): open the conversation in Messages via an `imessage:` URL\n * — community-verified to make the app pull that thread's media — then poll\n * `existsSync` until the file appears or a timeout elapses.\n * Tier 2 (opt-in, `nudge.tier2SyncNow`): UI-script Messages ▸ Settings ▸\n * iMessage ▸ \"Sync Now\". Needs Accessibility permission; we detect the\n * missing-permission error and return an actionable hint.\n * Tier 3: per-conversation \"download all\" UI script — documented only\n * (too fragile across macOS versions), no code here.\n *\n * This module is PURE ORCHESTRATION: every side effect (AppleScript, fs, timers)\n * is injected via {@link AttachmentSyncDeps}, so the tier/poll/timeout logic is\n * unit-tested deterministically with no real app launch and no real clock. The\n * default deps wire the real AppleScript primitives (themselves mocked under\n * Vitest) + node fs/timers.\n */\nimport { existsSync } from \"node:fs\";\nimport {\n openConversationInMessages,\n type SyncNowOutcome,\n syncNowViaSystemEvents,\n} from \"./applescript.js\";\n\nexport type { SyncNowOutcome };\n\n/** The subset of the resolved `nudge` config this module needs. */\nexport interface AttachmentSyncConfig {\n /** Master switch — when false, no tier runs and we never touch Messages. */\n enabled: boolean;\n /** Enable Tier 2 (System Events \"Sync Now\"). Default off. */\n tier2SyncNow: boolean;\n /** How long to wait for the file to appear after a nudge, in seconds. */\n timeoutSeconds: number;\n /** Poll cadence while waiting. Default 1000ms; floored at 100ms. */\n pollIntervalMs?: number;\n}\n\n/** Highest tier that actually ran (0 = none). */\nexport type SyncTier = 0 | 1 | 2;\n\nexport interface EnsureDownloadedInput {\n /** Absolute path (already `~`-expanded) we expect the file to appear at. */\n filePath: string;\n /** Chat identifier (handle or GUID) to open in Messages for Tier 1. */\n chatId?: string | null;\n /** `attachment.transfer_state` from chat.db (-1 = undownloaded); advisory. */\n transferState?: number | null;\n}\n\nexport interface EnsureDownloadedResult {\n /** True iff the file is on disk when we return. */\n downloaded: boolean;\n /** True iff we actually ran at least one tier (vs short-circuited). */\n attempted: boolean;\n /** Highest tier attempted. */\n tier: SyncTier;\n /** Machine-readable reason when not downloaded / skipped. */\n reason?: string;\n /** Human-actionable remediation (e.g. grant Accessibility). */\n hint?: string;\n}\n\n/** All side effects the orchestrator needs, injectable for tests. */\nexport interface AttachmentSyncDeps {\n fileExists(path: string): boolean;\n openConversation(chatId: string): Promise<void>;\n syncNow(): Promise<SyncNowOutcome>;\n sleep(ms: number): Promise<void>;\n now(): number;\n}\n\n/** Production deps: real fs + timers + AppleScript (AppleScript self-mocks under Vitest). */\nexport function defaultAttachmentSyncDeps(): AttachmentSyncDeps {\n return {\n fileExists: (p) => existsSync(p),\n openConversation: (chatId) => openConversationInMessages(chatId),\n syncNow: () => syncNowViaSystemEvents(),\n sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),\n now: () => Date.now(),\n };\n}\n\nfunction errMsg(e: unknown): string {\n return e instanceof Error ? e.message : String(e);\n}\n\n/**\n * Poll `fileExists` until it's true or `timeoutMs` elapses (measured against\n * `deps.now()`). Checks immediately first — opening the chat may have already\n * pulled the file — then sleeps `pollIntervalMs` between checks.\n */\nasync function pollForFile(\n deps: AttachmentSyncDeps,\n path: string,\n timeoutMs: number,\n pollIntervalMs: number,\n): Promise<boolean> {\n const start = deps.now();\n if (deps.fileExists(path)) return true;\n while (deps.now() - start < timeoutMs) {\n await deps.sleep(pollIntervalMs);\n if (deps.fileExists(path)) return true;\n }\n return false;\n}\n\n/**\n * Ensure `input.filePath` is on disk, nudging Messages to download it if not.\n * Best-effort: returns a structured result rather than throwing. Idempotent and\n * cheap when the file already exists (no side effects) or when disabled.\n */\nexport async function ensureAttachmentDownloaded(\n input: EnsureDownloadedInput,\n config: AttachmentSyncConfig,\n deps: AttachmentSyncDeps = defaultAttachmentSyncDeps(),\n): Promise<EnsureDownloadedResult> {\n const { filePath } = input;\n\n // Already on disk → nothing to do (no app launch).\n if (deps.fileExists(filePath)) {\n return { downloaded: true, attempted: false, tier: 0 };\n }\n if (!config.enabled) {\n return { downloaded: false, attempted: false, tier: 0, reason: \"sync nudge disabled\" };\n }\n\n const pollIntervalMs = Math.max(100, config.pollIntervalMs ?? 1000);\n const timeoutMs = Math.max(1000, config.timeoutSeconds * 1000);\n let tier: SyncTier = 0;\n let hint: string | undefined;\n\n // ── Tier 1: open the conversation so Messages pulls its media ──\n if (input.chatId) {\n tier = 1;\n try {\n await deps.openConversation(input.chatId);\n } catch (e) {\n hint = `Could not open the conversation in Messages: ${errMsg(e)}`;\n }\n if (await pollForFile(deps, filePath, timeoutMs, pollIntervalMs)) {\n return { downloaded: true, attempted: true, tier };\n }\n } else {\n hint = \"No chat identifier available to open the conversation in Messages.\";\n }\n\n // ── Tier 2 (opt-in): System Events \"Sync Now\" ──\n if (config.tier2SyncNow) {\n tier = 2;\n const outcome = await deps.syncNow();\n if (!outcome.ok) {\n if (outcome.needsAccessibility) {\n return {\n downloaded: deps.fileExists(filePath),\n attempted: true,\n tier,\n reason: \"accessibility-permission-required\",\n hint: \"Sync Now needs Accessibility permission. Grant it in System Settings ▸ Privacy & Security ▸ Accessibility for your terminal/IDE, then retry.\",\n };\n }\n hint = outcome.error ?? hint ?? \"Sync Now failed.\";\n } else if (await pollForFile(deps, filePath, timeoutMs, pollIntervalMs)) {\n return { downloaded: true, attempted: true, tier };\n }\n }\n\n return {\n downloaded: deps.fileExists(filePath),\n attempted: tier > 0,\n tier,\n reason: tier === 0 ? \"no sync tier could run\" : \"attachment did not download in time\",\n hint,\n };\n}\n"],"names":[],"mappings":";;AA8EO,SAAS,4BAAgD;AAC9D,SAAO;AAAA,IACL,YAAY,CAAC,MAAM,WAAW,CAAC;AAAA,IAC/B,kBAAkB,CAAC,WAAW,2BAA2B,MAAM;AAAA,IAC/D,SAAS,MAAM,uBAAA;AAAA,IACf,OAAO,CAAC,OAAO,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,EAAE,CAAC;AAAA,IAC/D,KAAK,MAAM,KAAK,IAAA;AAAA,EAAI;AAExB;AAEA,SAAS,OAAO,GAAoB;AAClC,SAAO,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC;AAClD;AAOA,eAAe,YACb,MACA,MACA,WACA,gBACkB;AAClB,QAAM,QAAQ,KAAK,IAAA;AACnB,MAAI,KAAK,WAAW,IAAI,EAAG,QAAO;AAClC,SAAO,KAAK,QAAQ,QAAQ,WAAW;AACrC,UAAM,KAAK,MAAM,cAAc;AAC/B,QAAI,KAAK,WAAW,IAAI,EAAG,QAAO;AAAA,EACpC;AACA,SAAO;AACT;AAOA,eAAsB,2BACpB,OACA,QACA,OAA2B,6BACM;AACjC,QAAM,EAAE,aAAa;AAGrB,MAAI,KAAK,WAAW,QAAQ,GAAG;AAC7B,WAAO,EAAE,YAAY,MAAM,WAAW,OAAO,MAAM,EAAA;AAAA,EACrD;AACA,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,EAAE,YAAY,OAAO,WAAW,OAAO,MAAM,GAAG,QAAQ,sBAAA;AAAA,EACjE;AAEA,QAAM,iBAAiB,KAAK,IAAI,KAAK,OAAO,kBAAkB,GAAI;AAClE,QAAM,YAAY,KAAK,IAAI,KAAM,OAAO,iBAAiB,GAAI;AAC7D,MAAI,OAAiB;AACrB,MAAI;AAGJ,MAAI,MAAM,QAAQ;AAChB,WAAO;AACP,QAAI;AACF,YAAM,KAAK,iBAAiB,MAAM,MAAM;AAAA,IAC1C,SAAS,GAAG;AACV,aAAO,gDAAgD,OAAO,CAAC,CAAC;AAAA,IAClE;AACA,QAAI,MAAM,YAAY,MAAM,UAAU,WAAW,cAAc,GAAG;AAChE,aAAO,EAAE,YAAY,MAAM,WAAW,MAAM,KAAA;AAAA,IAC9C;AAAA,EACF,OAAO;AACL,WAAO;AAAA,EACT;AAGA,MAAI,OAAO,cAAc;AACvB,WAAO;AACP,UAAM,UAAU,MAAM,KAAK,QAAA;AAC3B,QAAI,CAAC,QAAQ,IAAI;AACf,UAAI,QAAQ,oBAAoB;AAC9B,eAAO;AAAA,UACL,YAAY,KAAK,WAAW,QAAQ;AAAA,UACpC,WAAW;AAAA,UACX;AAAA,UACA,QAAQ;AAAA,UACR,MAAM;AAAA,QAAA;AAAA,MAEV;AACA,aAAO,QAAQ,SAAS,QAAQ;AAAA,IAClC,WAAW,MAAM,YAAY,MAAM,UAAU,WAAW,cAAc,GAAG;AACvE,aAAO,EAAE,YAAY,MAAM,WAAW,MAAM,KAAA;AAAA,IAC9C;AAAA,EACF;AAEA,SAAO;AAAA,IACL,YAAY,KAAK,WAAW,QAAQ;AAAA,IACpC,WAAW,OAAO;AAAA,IAClB;AAAA,IACA,QAAQ,SAAS,IAAI,2BAA2B;AAAA,IAChD;AAAA,EAAA;AAEJ;"}
@@ -1,6 +1,6 @@
1
1
  import { watch } from "node:fs";
2
2
  import { dirname, basename } from "node:path";
3
- import { w as warn, i as info } from "./shutdown-Dnj088id.js";
3
+ import { w as warn, i as info } from "./shutdown-URaLJ94f.js";
4
4
  class ChangeWatcher {
5
5
  dbPath;
6
6
  db;
@@ -8,8 +8,11 @@ class ChangeWatcher {
8
8
  debounceMs;
9
9
  pollFallbackMs;
10
10
  maxBatch;
11
+ safetyPollMs;
11
12
  watcher = null;
13
+ walWatcher = null;
12
14
  pollTimer = null;
15
+ safetyTimer = null;
13
16
  debounceTimer = null;
14
17
  highWaterRowid = 0;
15
18
  draining = false;
@@ -22,6 +25,7 @@ class ChangeWatcher {
22
25
  this.bus = opts.bus;
23
26
  this.debounceMs = opts.debounceMs ?? 150;
24
27
  this.pollFallbackMs = opts.pollFallbackMs ?? 2e3;
28
+ this.safetyPollMs = opts.safetyPollMs ?? 1e4;
25
29
  this.maxBatch = opts.maxBatch ?? 500;
26
30
  }
27
31
  /**
@@ -36,8 +40,9 @@ class ChangeWatcher {
36
40
  const dir = dirname(this.dbPath);
37
41
  const dbFile = basename(this.dbPath);
38
42
  try {
39
- this.watcher = watch(dir, (_eventType, filename) => {
43
+ this.watcher = watch(dir, (eventType, filename) => {
40
44
  if (filename === null || filename === `${dbFile}-wal` || filename === dbFile) {
45
+ if (eventType === "rename" || this.walWatcher === null) this.armWalWatch();
41
46
  this.scheduleDrain();
42
47
  }
43
48
  });
@@ -47,17 +52,56 @@ class ChangeWatcher {
47
52
  this.teardownWatch();
48
53
  this.armPollFallback();
49
54
  });
55
+ this.armWalWatch();
56
+ this.armSafetyPoll();
50
57
  info("change_watcher_started", {
51
58
  dir,
52
59
  high_water_rowid: this.highWaterRowid,
53
- debounce_ms: this.debounceMs
60
+ debounce_ms: this.debounceMs,
61
+ wal_file_watch: this.walWatcher !== null,
62
+ safety_poll_ms: this.safetyPollMs
54
63
  });
55
64
  } catch (err) {
56
65
  warn("change_watcher_fs_watch_unavailable", { message: String(err) });
57
66
  this.armPollFallback();
58
67
  }
59
68
  }
60
- /** Idempotent teardown — clears the watch, timers, and pending drains. */
69
+ /**
70
+ * Watch the WAL file itself — the only fs.watch mode that fires on APPENDS
71
+ * on macOS (kqueue on the file). Best-effort: the wal may not exist yet
72
+ * (fresh db, post-checkpoint gap); the directory watch re-arms us when its
73
+ * entry appears, and the safety poll covers the meantime.
74
+ */
75
+ armWalWatch() {
76
+ if (!this.started) return;
77
+ if (this.walWatcher) {
78
+ this.walWatcher.close();
79
+ this.walWatcher = null;
80
+ }
81
+ try {
82
+ this.walWatcher = watch(`${this.dbPath}-wal`, () => {
83
+ this.scheduleDrain();
84
+ });
85
+ this.walWatcher.unref();
86
+ this.walWatcher.on("error", () => {
87
+ if (this.walWatcher) {
88
+ this.walWatcher.close();
89
+ this.walWatcher = null;
90
+ }
91
+ });
92
+ } catch {
93
+ this.walWatcher = null;
94
+ }
95
+ }
96
+ /** Slow backstop drain while fs.watch is armed — catches silent watches. */
97
+ armSafetyPoll() {
98
+ if (this.safetyTimer || this.safetyPollMs <= 0) return;
99
+ this.safetyTimer = setInterval(() => {
100
+ void this.drain();
101
+ }, this.safetyPollMs);
102
+ this.safetyTimer.unref();
103
+ }
104
+ /** Idempotent teardown — clears the watches, timers, and pending drains. */
61
105
  stop() {
62
106
  this.started = false;
63
107
  this.teardownWatch();
@@ -65,6 +109,10 @@ class ChangeWatcher {
65
109
  clearInterval(this.pollTimer);
66
110
  this.pollTimer = null;
67
111
  }
112
+ if (this.safetyTimer) {
113
+ clearInterval(this.safetyTimer);
114
+ this.safetyTimer = null;
115
+ }
68
116
  if (this.debounceTimer) {
69
117
  clearTimeout(this.debounceTimer);
70
118
  this.debounceTimer = null;
@@ -90,6 +138,10 @@ class ChangeWatcher {
90
138
  this.watcher.close();
91
139
  this.watcher = null;
92
140
  }
141
+ if (this.walWatcher) {
142
+ this.walWatcher.close();
143
+ this.walWatcher = null;
144
+ }
93
145
  }
94
146
  armPollFallback() {
95
147
  if (this.pollTimer || !this.started) return;
@@ -148,4 +200,4 @@ function classify(message) {
148
200
  export {
149
201
  ChangeWatcher
150
202
  };
151
- //# sourceMappingURL=change-watcher-DNuxvzqL.js.map
203
+ //# sourceMappingURL=change-watcher-B9Uiotj3.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"change-watcher-B9Uiotj3.js","sources":["../src/change-watcher.ts"],"sourcesContent":["/**\n * ChangeWatcher — turns chat.db writes into typed EventBus batches.\n *\n * Detection uses THREE layered primitives (the original design's single\n * directory watch was blind in production — see below):\n *\n * 1. A file watch on `chat.db-wal` ITSELF — kqueue on an opened fd, which\n * delivers write/extend events regardless of which process writes.\n * 2. A directory watch on dirname(dbPath) — re-arms the file watch when a\n * checkpoint truncates/recreates the wal (stale inode), and is the\n * drain signal on platforms where it does report file events.\n * 3. A slow safety poll (default 10s; one MAX(ROWID) query per tick) that\n * always runs alongside the watches.\n *\n * Why not the original directory watch alone: on the REAL\n * `~/Library/Messages` it armed cleanly and then delivered NOTHING while\n * rows landed in chat.db (observed live, 2026-08-10; suspected TCC/FSEvents\n * filtering on the protected directory — plain temp dirs DO report appends,\n * which is why the integration tests couldn't catch it). The file watch and\n * the safety poll are the primitives that don't depend on that delivery.\n *\n * If fs.watch throws outright (exotic filesystems), degrade to the\n * documented fast-poll fallback of the same drain path (default 2s).\n * The authoritative cursor is always a high-water ROWID, never file state.\n *\n * On a signal: debounce ~150ms (one logical message = several WAL writes),\n * then delta-read rows past the high-water mark via\n * `IMessageDB.getMessagesAfterRowid` — the same parse/convert pipeline as\n * every other reader (\"one parser, N callers\"; no forked streamer parser) —\n * classify, and emit ONE coalesced batch per drain. Bulk syncs loop the\n * delta read in `maxBatch` pages so thousands of rows become a few batches,\n * not a subscriber storm.\n *\n * The watcher never throws into the host process: drain errors are logged\n * and retried on the next event. Timers are unref'd; `stop()` is idempotent\n * and safe to register as a shutdown cleanup (the ENTRY POINT registers it —\n * this module stays side-effect-free at import, like the rest of core).\n */\n\nimport { watch } from \"node:fs\";\nimport { basename, dirname } from \"node:path\";\nimport type { ChangeEvent, EventBus } from \"./event-bus.js\";\nimport { info, warn } from \"./logger.js\";\nimport type { Message } from \"./types.js\";\n\n/** The DB surface the watcher needs — injectable for tests. */\nexport interface ChangeWatcherDb {\n getMaxMessageRowId(): number;\n getMessagesAfterRowid(afterRowid: number, limit?: number): Promise<Message[]>;\n}\n\nexport interface ChangeWatcherOptions {\n /** Path to chat.db — the watcher watches its parent directory. */\n dbPath: string;\n db: ChangeWatcherDb;\n bus: EventBus;\n /** Trailing-edge debounce between an fs event and the delta read. */\n debounceMs?: number;\n /** Poll cadence for the degenerate no-fs.watch fallback. */\n pollFallbackMs?: number;\n /** Slow backstop poll cadence while fs.watch is armed (0 disables). */\n safetyPollMs?: number;\n /** Rows per delta page; drains loop while a page comes back full. */\n maxBatch?: number;\n}\n\nexport class ChangeWatcher {\n private readonly dbPath: string;\n private readonly db: ChangeWatcherDb;\n private readonly bus: EventBus;\n private readonly debounceMs: number;\n private readonly pollFallbackMs: number;\n private readonly maxBatch: number;\n\n private readonly safetyPollMs: number;\n\n private watcher: ReturnType<typeof watch> | null = null;\n private walWatcher: ReturnType<typeof watch> | null = null;\n private pollTimer: ReturnType<typeof setInterval> | null = null;\n private safetyTimer: ReturnType<typeof setInterval> | null = null;\n private debounceTimer: ReturnType<typeof setTimeout> | null = null;\n private highWaterRowid = 0;\n private draining = false;\n private rearm = false;\n private started = false;\n private usingPollFallback = false;\n\n constructor(opts: ChangeWatcherOptions) {\n this.dbPath = opts.dbPath;\n this.db = opts.db;\n this.bus = opts.bus;\n this.debounceMs = opts.debounceMs ?? 150;\n this.pollFallbackMs = opts.pollFallbackMs ?? 2_000;\n this.safetyPollMs = opts.safetyPollMs ?? 10_000;\n this.maxBatch = opts.maxBatch ?? 500;\n }\n\n /**\n * Seed the high-water mark from the CURRENT max ROWID (history is the\n * initial-load path's job; the stream carries only what happens next) and\n * arm the directory watch — or the poll fallback if fs.watch throws.\n */\n start(): void {\n if (this.started) return;\n this.started = true;\n this.highWaterRowid = this.db.getMaxMessageRowId();\n\n const dir = dirname(this.dbPath);\n const dbFile = basename(this.dbPath);\n try {\n this.watcher = watch(dir, (eventType, filename) => {\n // Directory watches only report ENTRY changes (create/rename/delete)\n // on macOS — a checkpoint truncating/recreating the wal lands here.\n // Any wal entry event means the file watch's inode may be stale:\n // re-arm it, then drain. filename can be null on some platforms —\n // treat that as a hit.\n if (filename === null || filename === `${dbFile}-wal` || filename === dbFile) {\n if (eventType === \"rename\" || this.walWatcher === null) this.armWalWatch();\n this.scheduleDrain();\n }\n });\n // Like the timers, the fs.watch handle must never keep the process\n // alive on its own — entry points arm the watcher lazily inside\n // request handlers, and shutdown() already stops it explicitly.\n this.watcher.unref();\n this.watcher.on(\"error\", (err) => {\n warn(\"change_watcher_fs_error_falling_back_to_poll\", { message: String(err) });\n this.teardownWatch();\n this.armPollFallback();\n });\n this.armWalWatch();\n this.armSafetyPoll();\n info(\"change_watcher_started\", {\n dir,\n high_water_rowid: this.highWaterRowid,\n debounce_ms: this.debounceMs,\n wal_file_watch: this.walWatcher !== null,\n safety_poll_ms: this.safetyPollMs,\n });\n } catch (err) {\n warn(\"change_watcher_fs_watch_unavailable\", { message: String(err) });\n this.armPollFallback();\n }\n }\n\n /**\n * Watch the WAL file itself — the only fs.watch mode that fires on APPENDS\n * on macOS (kqueue on the file). Best-effort: the wal may not exist yet\n * (fresh db, post-checkpoint gap); the directory watch re-arms us when its\n * entry appears, and the safety poll covers the meantime.\n */\n private armWalWatch(): void {\n if (!this.started) return;\n if (this.walWatcher) {\n this.walWatcher.close();\n this.walWatcher = null;\n }\n try {\n this.walWatcher = watch(`${this.dbPath}-wal`, () => {\n this.scheduleDrain();\n });\n this.walWatcher.unref();\n this.walWatcher.on(\"error\", () => {\n // Stale inode (checkpoint) or vanished file — the dir watch will\n // re-arm on the next wal entry event; don't fall back to full poll.\n if (this.walWatcher) {\n this.walWatcher.close();\n this.walWatcher = null;\n }\n });\n } catch {\n // ENOENT etc. — covered by dir watch + safety poll.\n this.walWatcher = null;\n }\n }\n\n /** Slow backstop drain while fs.watch is armed — catches silent watches. */\n private armSafetyPoll(): void {\n if (this.safetyTimer || this.safetyPollMs <= 0) return;\n this.safetyTimer = setInterval(() => {\n void this.drain();\n }, this.safetyPollMs);\n this.safetyTimer.unref();\n }\n\n /** Idempotent teardown — clears the watches, timers, and pending drains. */\n stop(): void {\n this.started = false;\n this.teardownWatch();\n if (this.pollTimer) {\n clearInterval(this.pollTimer);\n this.pollTimer = null;\n }\n if (this.safetyTimer) {\n clearInterval(this.safetyTimer);\n this.safetyTimer = null;\n }\n if (this.debounceTimer) {\n clearTimeout(this.debounceTimer);\n this.debounceTimer = null;\n }\n }\n\n /** Current cursor — exposed for the dev-stats panel and tests. */\n getHighWaterRowid(): number {\n return this.highWaterRowid;\n }\n\n /** True when running in the degenerate poll mode (fs.watch unavailable). */\n isPolling(): boolean {\n return this.usingPollFallback;\n }\n\n /**\n * Trigger a delta read now, bypassing the debounce. The poll fallback and\n * tests use this; the fs.watch path goes through `scheduleDrain`.\n */\n async pump(): Promise<void> {\n await this.drain();\n }\n\n private teardownWatch(): void {\n if (this.watcher) {\n this.watcher.close();\n this.watcher = null;\n }\n if (this.walWatcher) {\n this.walWatcher.close();\n this.walWatcher = null;\n }\n }\n\n private armPollFallback(): void {\n if (this.pollTimer || !this.started) return;\n this.usingPollFallback = true;\n this.pollTimer = setInterval(() => {\n void this.drain();\n }, this.pollFallbackMs);\n this.pollTimer.unref();\n info(\"change_watcher_poll_fallback_armed\", { interval_ms: this.pollFallbackMs });\n }\n\n private scheduleDrain(): void {\n if (!this.started) return;\n if (this.debounceTimer) clearTimeout(this.debounceTimer);\n this.debounceTimer = setTimeout(() => {\n this.debounceTimer = null;\n void this.drain();\n }, this.debounceMs);\n this.debounceTimer.unref();\n }\n\n /**\n * Read rows past the high-water mark and emit them as one batch per page.\n * Re-entrancy guard: fs events landing mid-drain set `rearm` so the drain\n * runs once more instead of overlapping (overlapping reads would emit\n * duplicate rows — both would read from the same stale cursor).\n */\n private async drain(): Promise<void> {\n if (this.draining) {\n this.rearm = true;\n return;\n }\n this.draining = true;\n try {\n // Loop while pages come back full — a bulk sync writes thousands of\n // rows in one burst and each page becomes one coalesced batch.\n for (;;) {\n const messages = await this.db.getMessagesAfterRowid(this.highWaterRowid, this.maxBatch);\n if (messages.length === 0) break;\n for (const m of messages) {\n if (m.id > this.highWaterRowid) this.highWaterRowid = m.id;\n }\n this.bus.emit(messages.map(classify));\n if (messages.length < this.maxBatch) break;\n }\n } catch (err) {\n // Never throw into the host — the next WAL event retries naturally.\n warn(\"change_watcher_drain_failed\", { message: String(err) });\n } finally {\n this.draining = false;\n if (this.rearm) {\n this.rearm = false;\n void this.drain();\n }\n }\n }\n}\n\n/**\n * New-ROWID classification. Edited/unsent rows mutate EXISTING ROWIDs and are\n * invisible to the high-water delta — they belong to the mutation-detection\n * backlog item (date_edited / is_read deltas), not this path.\n */\nfunction classify(message: Message): ChangeEvent {\n return message.isReaction ? { type: \"reaction\", message } : { type: \"message.new\", message };\n}\n"],"names":[],"mappings":"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"}
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { existsSync, realpathSync } from "node:fs";
3
3
  import { createInterface } from "node:readline";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { Command } from "commander";
6
- import { A as APP_VERSION, d as checkLocalAccess, f as formatAccessReport, I as IMPLEMENTED_TYPES, e as ANALYTIC_INFO, h as installShutdownHandlers, r as registerCleanup, l as looksLikeThreadSlug } from "./shutdown-Dnj088id.js";
6
+ import { A as APP_VERSION, d as checkLocalAccess, f as formatAccessReport, I as IMPLEMENTED_TYPES, e as ANALYTIC_INFO, h as installShutdownHandlers, r as registerCleanup, l as looksLikeThreadSlug } from "./shutdown-URaLJ94f.js";
7
7
  import { t as toYaml } from "./analytics-render-DC26KLXv.js";
8
8
  import { spawn } from "node:child_process";
9
9
  import { join, dirname } from "node:path";
@@ -129,6 +129,9 @@ class LocalMcpClient {
129
129
  });
130
130
  }
131
131
  }
132
+ function ensureProductionReactForTui() {
133
+ if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
134
+ }
132
135
  const color = {
133
136
  dim: (v) => `\x1B[2m${v}\x1B[0m`,
134
137
  cyan: (v) => `\x1B[36m${v}\x1B[0m`,
@@ -334,9 +337,9 @@ async function runConsoleCommand(cmd, args, client) {
334
337
  const seconds = Number(args[0] ?? 60);
335
338
  if (!Number.isFinite(seconds) || seconds <= 0) throw new Error("Usage: watch [seconds]");
336
339
  const { EventBus } = await import("./event-bus-DqXvbtEM.js");
337
- const { ChangeWatcher } = await import("./change-watcher-DNuxvzqL.js");
338
- const { IMessageDB } = await import("./imessage-db-Be6EywKE.js").then((n) => n.i);
339
- const { getContactsDbPaths, getImsgDbPath, getSlugsDbPath } = await import("./shutdown-Dnj088id.js").then((n) => n.af);
340
+ const { ChangeWatcher } = await import("./change-watcher-B9Uiotj3.js");
341
+ const { IMessageDB } = await import("./imessage-db-Dr3PzK-9.js").then((n) => n.i);
342
+ const { getContactsDbPaths, getImsgDbPath, getSlugsDbPath } = await import("./shutdown-URaLJ94f.js").then((n) => n.af);
340
343
  const db = new IMessageDB(getImsgDbPath(), getContactsDbPaths(), getSlugsDbPath());
341
344
  const bus = new EventBus();
342
345
  const watcher = new ChangeWatcher({ dbPath: getImsgDbPath(), db, bus });
@@ -379,6 +382,7 @@ async function runConsoleCommand(cmd, args, client) {
379
382
  }
380
383
  return;
381
384
  case "tui": {
385
+ ensureProductionReactForTui();
382
386
  const { runTui } = await import("./tui.js");
383
387
  await runTui();
384
388
  return;
@@ -482,9 +486,9 @@ async function runExportCommand(target, opts) {
482
486
  const { existsSync: existsSync2, mkdirSync, copyFileSync, statSync } = await import("node:fs");
483
487
  const { homedir } = await import("node:os");
484
488
  const { dirname: dirname2, join: join2, isAbsolute, resolve } = await import("node:path");
485
- const { getContactsDbPaths, getImsgDbPath, getSlugsDbPath } = await import("./shutdown-Dnj088id.js").then((n) => n.af);
486
- const { IMessageDB } = await import("./imessage-db-Be6EywKE.js").then((n) => n.i);
487
- const { streamExport } = await import("./exportStream-DjdPdE1S.js");
489
+ const { getContactsDbPaths, getImsgDbPath, getSlugsDbPath } = await import("./shutdown-URaLJ94f.js").then((n) => n.af);
490
+ const { IMessageDB } = await import("./imessage-db-Dr3PzK-9.js").then((n) => n.i);
491
+ const { streamExport } = await import("./exportStream-CtHxTI3_.js");
488
492
  const { parseUserDate } = await import("./date-parse-DJXMfq3a.js");
489
493
  const format = normalizeFormat(opts.format ?? "md");
490
494
  const ext = extForFormat(format);
@@ -539,13 +543,13 @@ async function runExportCommand(target, opts) {
539
543
  if (since) filters.sinceMs = since.getTime();
540
544
  if (until) filters.untilMs = until.getTime();
541
545
  const attachments = db.searchAttachments(filters);
542
- const { resolveInterpretConfig } = await import("./app-config-B_QqZFbl.js");
546
+ const { resolveInterpretConfig } = await import("./app-config-CWVJPYcD.js");
543
547
  const nudge = resolveInterpretConfig().nudge;
544
548
  const firstMissing = attachments.find(
545
549
  (a) => a.filename && !existsSync2(a.filename.replace(/^~/, homedir()))
546
550
  );
547
551
  if (nudge.enabled && firstMissing && chatIdentifier) {
548
- const { ensureAttachmentDownloaded } = await import("./attachment-sync-DqIvfxPv.js");
552
+ const { ensureAttachmentDownloaded } = await import("./attachment-sync-DfYAnRQG.js");
549
553
  log(` Nudging Messages to sync missing attachments…`, "dim");
550
554
  await ensureAttachmentDownloaded(
551
555
  { filePath: firstMissing.filename.replace(/^~/, homedir()), chatId: chatIdentifier },
@@ -743,6 +747,7 @@ program.command("raw").description("Send raw JSON-RPC params to tools/call").arg
743
747
  await withClient((c) => printToolResult(c, parsed.name, parsed.arguments ?? {}));
744
748
  });
745
749
  program.command("tui").description("Launch the read-only terminal UI").option("--theme <theme>", 'TUI theme: "safe" or "powerline"').option("--accent <hex>", "TUI accent color as #RRGGBB").action(async () => {
750
+ ensureProductionReactForTui();
746
751
  const { runTui } = await import("./tui.js");
747
752
  await runTui();
748
753
  });
@@ -754,10 +759,10 @@ program.command("interpret <rowId>").description("Interpret one attachment (tran
754
759
  process.exitCode = 1;
755
760
  return;
756
761
  }
757
- const { getContactsDbPaths, getImsgDbPath, getSlugsDbPath } = await import("./shutdown-Dnj088id.js").then((n) => n.af);
758
- const { IMessageDB } = await import("./imessage-db-Be6EywKE.js").then((n) => n.i);
759
- const { getInterpretRuntime, refForAttachment } = await import("./media-intel-runtime-DbJ3jXLG.js").then((n) => n.c);
760
- const { deleteMediaIntel } = await import("./media-intel-cache-JJZzGCs4.js");
762
+ const { getContactsDbPaths, getImsgDbPath, getSlugsDbPath } = await import("./shutdown-URaLJ94f.js").then((n) => n.af);
763
+ const { IMessageDB } = await import("./imessage-db-Dr3PzK-9.js").then((n) => n.i);
764
+ const { getInterpretRuntime, refForAttachment } = await import("./media-intel-runtime-Bm9GkUdR.js").then((n) => n.c);
765
+ const { deleteMediaIntel } = await import("./media-intel-cache-B0_D_c45.js");
761
766
  const db = new IMessageDB(getImsgDbPath(), getContactsDbPaths(), getSlugsDbPath());
762
767
  try {
763
768
  const rec = db.getAttachmentByRowId(rowId);
@@ -792,7 +797,7 @@ program.command("interpret <rowId>").description("Interpret one attachment (tran
792
797
  program.command("setup").description("Autodetect DB paths and emit an MCP host config snippet").option("-w, --write <host>", 'Write into a host config: "claude" or "cursor"').option("-r, --runtime <runtime>", 'Runtime command: "npx" (default), "bunx", or "global"').option("--print-only", "Just print the snippet (default behaviour)").option("-i, --interactive", "Configure media interpretation (providers, chains, keys)").action(
793
798
  async (opts) => {
794
799
  if (opts.interactive) {
795
- const { runSetupWizard } = await import("./setup-wizard-D70bqgzB.js");
800
+ const { runSetupWizard } = await import("./setup-wizard-H2ox9qT0.js");
796
801
  try {
797
802
  await runSetupWizard();
798
803
  } catch (err) {
@@ -804,7 +809,7 @@ program.command("setup").description("Autodetect DB paths and emit an MCP host c
804
809
  }
805
810
  return;
806
811
  }
807
- const { probeMachine, buildMcpSnippet, writeHostConfig } = await import("./setup-DxbCKuOU.js");
812
+ const { probeMachine, buildMcpSnippet, writeHostConfig } = await import("./setup-e0Lmkl96.js");
808
813
  const report = probeMachine();
809
814
  if (!report.imsgDb.readable) {
810
815
  log(`✗ Messages DB is not readable: ${report.imsgDb.path}`, "err");
@@ -847,7 +852,7 @@ program.command("setup").description("Autodetect DB paths and emit an MCP host c
847
852
  );
848
853
  const configCmd = program.command("config").description("Manage TUI settings (theme, accent color)");
849
854
  configCmd.command("show").description("Print resolved TUI settings and where each value came from").action(async () => {
850
- const { resolveTuiConfig, defaultTuiConfigPath } = await import("./tui-config-Bt1yixQj.js");
855
+ const { resolveTuiConfig, defaultTuiConfigPath } = await import("./tui-config-CJdN9rGh.js");
851
856
  const cfg = resolveTuiConfig();
852
857
  log(
853
858
  `config file : ${cfg.configPath ?? `(none — defaults; would write to ${defaultTuiConfigPath()})`}`
@@ -860,7 +865,7 @@ configCmd.command("show").description("Print resolved TUI settings and where eac
860
865
  for (const w of cfg.warnings) log(w, "warn");
861
866
  });
862
867
  configCmd.command("edit").description("Open the TUI config file in $EDITOR (creates it if missing)").action(async () => {
863
- const { defaultTuiConfigPath, findTuiConfigPath, writeTuiConfig, DEFAULT_TUI_CONFIG } = await import("./tui-config-Bt1yixQj.js");
868
+ const { defaultTuiConfigPath, findTuiConfigPath, writeTuiConfig, DEFAULT_TUI_CONFIG } = await import("./tui-config-CJdN9rGh.js");
864
869
  const path = findTuiConfigPath() ?? defaultTuiConfigPath();
865
870
  const { existsSync: existsSync2 } = await import("node:fs");
866
871
  if (!existsSync2(path)) {