egaki 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/dist/cli/cache-utils.d.ts +33 -0
  3. package/dist/cli/cache-utils.d.ts.map +1 -0
  4. package/dist/cli/cache-utils.js +157 -0
  5. package/dist/cli/cached-generate.d.ts +105 -0
  6. package/dist/cli/cached-generate.d.ts.map +1 -0
  7. package/dist/cli/cached-generate.js +208 -0
  8. package/dist/cli/cached-generate.test.d.ts +11 -0
  9. package/dist/cli/cached-generate.test.d.ts.map +1 -0
  10. package/dist/cli/cached-generate.test.js +431 -0
  11. package/dist/cli/cartesia-provider.d.ts +16 -0
  12. package/dist/cli/cartesia-provider.d.ts.map +1 -0
  13. package/dist/cli/cartesia-provider.js +184 -0
  14. package/dist/cli/chatgpt-auth.d.ts.map +1 -0
  15. package/dist/cli/cli.d.ts +4 -0
  16. package/dist/cli/cli.d.ts.map +1 -0
  17. package/dist/cli/cli.js +1329 -0
  18. package/dist/cli/clone-generate.d.ts +26 -0
  19. package/dist/cli/clone-generate.d.ts.map +1 -0
  20. package/dist/cli/clone-generate.js +148 -0
  21. package/dist/{credentials.d.ts → cli/credentials.d.ts} +11 -2
  22. package/dist/cli/credentials.d.ts.map +1 -0
  23. package/dist/{credentials.js → cli/credentials.js} +88 -5
  24. package/dist/cli/elevenlabs-speech-provider.d.ts +26 -0
  25. package/dist/cli/elevenlabs-speech-provider.d.ts.map +1 -0
  26. package/dist/cli/elevenlabs-speech-provider.js +199 -0
  27. package/dist/cli/generate.d.ts +140 -0
  28. package/dist/cli/generate.d.ts.map +1 -0
  29. package/dist/cli/generate.js +604 -0
  30. package/dist/cli/generate.test.d.ts +2 -0
  31. package/dist/cli/generate.test.d.ts.map +1 -0
  32. package/dist/cli/generate.test.js +131 -0
  33. package/dist/cli/login.d.ts.map +1 -0
  34. package/dist/{login.js → cli/login.js} +45 -4
  35. package/dist/cli/main.d.ts +3 -0
  36. package/dist/cli/main.d.ts.map +1 -0
  37. package/dist/cli/main.js +7 -0
  38. package/dist/cli/model-catalog.d.ts +141 -0
  39. package/dist/cli/model-catalog.d.ts.map +1 -0
  40. package/dist/cli/model-catalog.js +2135 -0
  41. package/dist/{models.d.ts → cli/models.d.ts} +16 -9
  42. package/dist/cli/models.d.ts.map +1 -0
  43. package/dist/cli/models.js +313 -0
  44. package/dist/cli/open-browser.d.ts.map +1 -0
  45. package/dist/cli/openai-speech-provider.d.ts +3 -0
  46. package/dist/cli/openai-speech-provider.d.ts.map +1 -0
  47. package/dist/cli/openai-speech-provider.js +28 -0
  48. package/dist/cli/separation-generate.d.ts +34 -0
  49. package/dist/cli/separation-generate.d.ts.map +1 -0
  50. package/dist/cli/separation-generate.js +117 -0
  51. package/dist/cli/speech-catalog.d.ts +35 -0
  52. package/dist/cli/speech-catalog.d.ts.map +1 -0
  53. package/dist/cli/speech-catalog.js +195 -0
  54. package/dist/cli/speech-generate.d.ts +86 -0
  55. package/dist/cli/speech-generate.d.ts.map +1 -0
  56. package/dist/cli/speech-generate.js +156 -0
  57. package/dist/cli/speech-models.d.ts +8 -0
  58. package/dist/cli/speech-models.d.ts.map +1 -0
  59. package/dist/cli/speech-models.js +109 -0
  60. package/dist/cli/speech-provider.test.d.ts +2 -0
  61. package/dist/cli/speech-provider.test.d.ts.map +1 -0
  62. package/dist/cli/speech-provider.test.js +197 -0
  63. package/dist/cli/subscription.d.ts.map +1 -0
  64. package/dist/cli/transcription-catalog.d.ts +35 -0
  65. package/dist/cli/transcription-catalog.d.ts.map +1 -0
  66. package/dist/cli/transcription-catalog.js +192 -0
  67. package/dist/cli/transcription-generate.d.ts +62 -0
  68. package/dist/cli/transcription-generate.d.ts.map +1 -0
  69. package/dist/cli/transcription-generate.js +144 -0
  70. package/dist/cli/transcription-models.d.ts +9 -0
  71. package/dist/cli/transcription-models.d.ts.map +1 -0
  72. package/dist/cli/transcription-models.js +100 -0
  73. package/dist/cli/transcription-providers.d.ts +22 -0
  74. package/dist/cli/transcription-providers.d.ts.map +1 -0
  75. package/dist/cli/transcription-providers.js +290 -0
  76. package/dist/cli/video-model-catalog.d.ts +2 -0
  77. package/dist/cli/video-model-catalog.d.ts.map +1 -0
  78. package/dist/cli/video-model-catalog.js +5 -0
  79. package/dist/cli/xai-auth.d.ts +29 -0
  80. package/dist/cli/xai-auth.d.ts.map +1 -0
  81. package/dist/cli/xai-auth.js +325 -0
  82. package/dist/vite/angled-screen.d.ts +57 -0
  83. package/dist/vite/angled-screen.d.ts.map +1 -0
  84. package/dist/vite/angled-screen.js +115 -0
  85. package/dist/vite/app.d.ts +51 -0
  86. package/dist/vite/app.d.ts.map +1 -0
  87. package/dist/vite/app.js +150 -0
  88. package/dist/vite/bands-shader.d.ts +47 -0
  89. package/dist/vite/bands-shader.d.ts.map +1 -0
  90. package/dist/vite/bands-shader.js +267 -0
  91. package/dist/vite/code-block.d.ts +107 -0
  92. package/dist/vite/code-block.d.ts.map +1 -0
  93. package/dist/vite/code-block.js +641 -0
  94. package/dist/vite/components.d.ts +36 -0
  95. package/dist/vite/components.d.ts.map +1 -0
  96. package/dist/vite/components.js +113 -0
  97. package/dist/vite/dispersion-rings-shader.d.ts +44 -0
  98. package/dist/vite/dispersion-rings-shader.d.ts.map +1 -0
  99. package/dist/vite/dispersion-rings-shader.js +282 -0
  100. package/dist/vite/easing-curves.d.ts +129 -0
  101. package/dist/vite/easing-curves.d.ts.map +1 -0
  102. package/dist/vite/easing-curves.js +584 -0
  103. package/dist/vite/easing-curves.test.d.ts +2 -0
  104. package/dist/vite/easing-curves.test.d.ts.map +1 -0
  105. package/dist/vite/easing-curves.test.js +200 -0
  106. package/dist/vite/generated-media-client.d.ts +35 -0
  107. package/dist/vite/generated-media-client.d.ts.map +1 -0
  108. package/dist/vite/generated-media-client.js +76 -0
  109. package/dist/vite/keyframes.d.ts +129 -0
  110. package/dist/vite/keyframes.d.ts.map +1 -0
  111. package/dist/vite/keyframes.js +194 -0
  112. package/dist/vite/layout-editor.d.ts +45 -0
  113. package/dist/vite/layout-editor.d.ts.map +1 -0
  114. package/dist/vite/layout-editor.js +587 -0
  115. package/dist/vite/layout-transition.d.ts +136 -0
  116. package/dist/vite/layout-transition.d.ts.map +1 -0
  117. package/dist/vite/layout-transition.js +581 -0
  118. package/dist/vite/liquid-gradient-shader.d.ts +62 -0
  119. package/dist/vite/liquid-gradient-shader.d.ts.map +1 -0
  120. package/dist/vite/liquid-gradient-shader.js +413 -0
  121. package/dist/vite/mdx-client.d.ts +15 -0
  122. package/dist/vite/mdx-client.d.ts.map +1 -0
  123. package/dist/vite/mdx-client.js +265 -0
  124. package/dist/vite/mdx-parse.d.ts +102 -0
  125. package/dist/vite/mdx-parse.d.ts.map +1 -0
  126. package/dist/vite/mdx-parse.js +251 -0
  127. package/dist/vite/mdx-provided-components.d.ts +18 -0
  128. package/dist/vite/mdx-provided-components.d.ts.map +1 -0
  129. package/dist/vite/mdx-provided-components.js +14 -0
  130. package/dist/vite/mdx-video.d.ts +327 -0
  131. package/dist/vite/mdx-video.d.ts.map +1 -0
  132. package/dist/vite/mdx-video.js +596 -0
  133. package/dist/vite/mdx-video.test.d.ts +9 -0
  134. package/dist/vite/mdx-video.test.d.ts.map +1 -0
  135. package/dist/vite/mdx-video.test.js +1945 -0
  136. package/dist/vite/media-components.d.ts +45 -0
  137. package/dist/vite/media-components.d.ts.map +1 -0
  138. package/dist/vite/media-components.js +355 -0
  139. package/dist/vite/media-duration-store.d.ts +66 -0
  140. package/dist/vite/media-duration-store.d.ts.map +1 -0
  141. package/dist/vite/media-duration-store.js +204 -0
  142. package/dist/vite/player-page.d.ts +35 -0
  143. package/dist/vite/player-page.d.ts.map +1 -0
  144. package/dist/vite/player-page.js +720 -0
  145. package/dist/vite/render-client.d.ts +18 -0
  146. package/dist/vite/render-client.d.ts.map +1 -0
  147. package/dist/vite/render-client.js +67 -0
  148. package/dist/vite/sdk.d.ts +221 -0
  149. package/dist/vite/sdk.d.ts.map +1 -0
  150. package/dist/vite/sdk.js +355 -0
  151. package/dist/vite/server-components.d.ts +55 -0
  152. package/dist/vite/server-components.d.ts.map +1 -0
  153. package/dist/vite/server-components.js +156 -0
  154. package/dist/vite/server-mdx.d.ts +74 -0
  155. package/dist/vite/server-mdx.d.ts.map +1 -0
  156. package/dist/vite/server-mdx.js +259 -0
  157. package/dist/vite/shader-renderer.d.ts +48 -0
  158. package/dist/vite/shader-renderer.d.ts.map +1 -0
  159. package/dist/vite/shader-renderer.js +411 -0
  160. package/dist/vite/store-hooks.d.ts +12 -0
  161. package/dist/vite/store-hooks.d.ts.map +1 -0
  162. package/dist/vite/store-hooks.js +93 -0
  163. package/dist/vite/store.d.ts +75 -0
  164. package/dist/vite/store.d.ts.map +1 -0
  165. package/dist/vite/store.js +52 -0
  166. package/dist/vite/store.test.d.ts +6 -0
  167. package/dist/vite/store.test.d.ts.map +1 -0
  168. package/dist/vite/store.test.js +172 -0
  169. package/dist/vite/tweakpane-hook.d.ts +94 -0
  170. package/dist/vite/tweakpane-hook.d.ts.map +1 -0
  171. package/dist/vite/tweakpane-hook.js +450 -0
  172. package/dist/vite/vite-plugin.d.ts +28 -0
  173. package/dist/vite/vite-plugin.d.ts.map +1 -0
  174. package/dist/vite/vite-plugin.js +538 -0
  175. package/dist/vite/wave-gradient-shader.d.ts +41 -0
  176. package/dist/vite/wave-gradient-shader.d.ts.map +1 -0
  177. package/dist/vite/wave-gradient-shader.js +207 -0
  178. package/package.json +103 -25
  179. package/skills/egaki/SKILL.md +162 -1
  180. package/src/cli/cache-utils.ts +169 -0
  181. package/src/cli/cached-generate.test.ts +531 -0
  182. package/src/cli/cached-generate.ts +348 -0
  183. package/src/cli/cartesia-provider.ts +249 -0
  184. package/src/cli/cli.ts +1818 -0
  185. package/src/cli/clone-generate.ts +202 -0
  186. package/src/{credentials.ts → cli/credentials.ts} +90 -7
  187. package/src/cli/elevenlabs-speech-provider.ts +243 -0
  188. package/src/cli/generate.test.ts +166 -0
  189. package/src/cli/generate.ts +912 -0
  190. package/src/{login.ts → cli/login.ts} +48 -3
  191. package/src/cli/main.ts +8 -0
  192. package/src/cli/model-catalog.ts +2336 -0
  193. package/src/cli/models.ts +408 -0
  194. package/src/cli/openai-speech-provider.ts +34 -0
  195. package/src/cli/separation-generate.ts +187 -0
  196. package/src/cli/speech-catalog.ts +253 -0
  197. package/src/cli/speech-generate.ts +275 -0
  198. package/src/cli/speech-models.ts +146 -0
  199. package/src/cli/speech-provider.test.ts +226 -0
  200. package/src/cli/transcription-catalog.ts +250 -0
  201. package/src/cli/transcription-generate.ts +214 -0
  202. package/src/cli/transcription-models.ts +136 -0
  203. package/src/cli/transcription-providers.ts +427 -0
  204. package/src/cli/video-model-catalog.ts +14 -0
  205. package/src/cli/xai-auth.ts +396 -0
  206. package/src/vite/angled-screen.tsx +214 -0
  207. package/src/vite/app.tsx +197 -0
  208. package/src/vite/bands-shader.tsx +319 -0
  209. package/src/vite/code-block.tsx +939 -0
  210. package/src/vite/components.tsx +278 -0
  211. package/src/vite/dispersion-rings-shader.tsx +331 -0
  212. package/src/vite/easing-curves.test.ts +232 -0
  213. package/src/vite/easing-curves.ts +743 -0
  214. package/src/vite/generated-media-client.tsx +121 -0
  215. package/src/vite/keyframes.tsx +347 -0
  216. package/src/vite/layout-editor.tsx +709 -0
  217. package/src/vite/layout-transition.tsx +706 -0
  218. package/src/vite/liquid-gradient-shader.tsx +474 -0
  219. package/src/vite/mdx-client.tsx +420 -0
  220. package/src/vite/mdx-parse.ts +343 -0
  221. package/src/vite/mdx-provided-components.ts +19 -0
  222. package/src/vite/mdx-video.test.tsx +2219 -0
  223. package/src/vite/mdx-video.tsx +976 -0
  224. package/src/vite/media-components.tsx +473 -0
  225. package/src/vite/media-duration-store.ts +225 -0
  226. package/src/vite/motion-timing.ts +194 -0
  227. package/src/vite/player-page.tsx +1107 -0
  228. package/src/vite/render-client.ts +84 -0
  229. package/src/vite/sdk.ts +598 -0
  230. package/src/vite/server-components.tsx +241 -0
  231. package/src/vite/server-mdx.ts +285 -0
  232. package/src/vite/shader-renderer.tsx +535 -0
  233. package/src/vite/store-hooks.ts +117 -0
  234. package/src/vite/store.test.ts +202 -0
  235. package/src/vite/store.ts +115 -0
  236. package/src/vite/styles.css +21 -0
  237. package/src/vite/tweakpane-hook.tsx +593 -0
  238. package/src/vite/virtual-modules.d.ts +30 -0
  239. package/src/vite/vite-plugin.ts +584 -0
  240. package/src/vite/wave-gradient-shader.tsx +253 -0
  241. package/README.md +0 -266
  242. package/dist/antigravity-auth.d.ts +0 -15
  243. package/dist/antigravity-auth.d.ts.map +0 -1
  244. package/dist/antigravity-auth.js +0 -544
  245. package/dist/antigravity-image.d.ts +0 -11
  246. package/dist/antigravity-image.d.ts.map +0 -1
  247. package/dist/antigravity-image.js +0 -285
  248. package/dist/chatgpt-auth.d.ts.map +0 -1
  249. package/dist/cli.d.ts +0 -3
  250. package/dist/cli.d.ts.map +0 -1
  251. package/dist/cli.js +0 -968
  252. package/dist/credentials.d.ts.map +0 -1
  253. package/dist/gateway-model-candidates.generated.d.ts +0 -11
  254. package/dist/gateway-model-candidates.generated.d.ts.map +0 -1
  255. package/dist/gateway-model-candidates.generated.js +0 -64
  256. package/dist/login.d.ts.map +0 -1
  257. package/dist/model-catalog.d.ts +0 -42
  258. package/dist/model-catalog.d.ts.map +0 -1
  259. package/dist/model-catalog.js +0 -1001
  260. package/dist/models.d.ts.map +0 -1
  261. package/dist/models.js +0 -246
  262. package/dist/open-browser.d.ts.map +0 -1
  263. package/dist/subscription.d.ts.map +0 -1
  264. package/dist/video-model-catalog.d.ts +0 -52
  265. package/dist/video-model-catalog.d.ts.map +0 -1
  266. package/dist/video-model-catalog.js +0 -306
  267. package/src/cli.ts +0 -1378
  268. package/src/model-catalog.ts +0 -1077
  269. package/src/models.ts +0 -309
  270. package/src/video-model-catalog.ts +0 -363
  271. /package/dist/{chatgpt-auth.d.ts → cli/chatgpt-auth.d.ts} +0 -0
  272. /package/dist/{chatgpt-auth.js → cli/chatgpt-auth.js} +0 -0
  273. /package/dist/{login.d.ts → cli/login.d.ts} +0 -0
  274. /package/dist/{open-browser.d.ts → cli/open-browser.d.ts} +0 -0
  275. /package/dist/{open-browser.js → cli/open-browser.js} +0 -0
  276. /package/dist/{subscription.d.ts → cli/subscription.d.ts} +0 -0
  277. /package/dist/{subscription.js → cli/subscription.js} +0 -0
  278. /package/src/{chatgpt-auth.ts → cli/chatgpt-auth.ts} +0 -0
  279. /package/src/{open-browser.ts → cli/open-browser.ts} +0 -0
  280. /package/src/{subscription.ts → cli/subscription.ts} +0 -0
@@ -0,0 +1,169 @@
1
+ // Shared caching utilities for egaki generate functions.
2
+ //
3
+ // Provides deterministic cache keys, file lookup, deduplication queues,
4
+ // and stale file management. Used by the cached variants of generateImage,
5
+ // generateSpeech, generateVideo, and transcribeAudio.
6
+ //
7
+ // The project root (for locating public/generated/) is set via setProjectRoot()
8
+ // at startup. In Vite context, server-components.tsx sets it from the virtual
9
+ // module. Falls back to process.cwd() if not set.
10
+
11
+ import { createHash } from 'node:crypto'
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Project root
17
+ // ---------------------------------------------------------------------------
18
+
19
+ let _projectRoot: string | undefined
20
+
21
+ /** Set the project root directory. Called by the Vite plugin on startup. */
22
+ export function setProjectRoot(root: string) {
23
+ _projectRoot = root
24
+ }
25
+
26
+ /** Get the project root directory. Falls back to cwd if not set. */
27
+ export function getProjectRoot(): string {
28
+ return _projectRoot ?? process.cwd()
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Deterministic cache keys
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /** Deterministic JSON from a value: keys sorted recursively, undefined
36
+ * values stripped. Safe for nested objects and arrays. */
37
+ export function stableJsonKey(value: unknown): string {
38
+ return JSON.stringify(sortValue(value))
39
+ }
40
+
41
+ function sortValue(value: unknown): unknown {
42
+ if (Array.isArray(value)) return value.map(sortValue)
43
+ if (!value || typeof value !== 'object') return value
44
+ return Object.fromEntries(
45
+ Object.entries(value as Record<string, unknown>)
46
+ .filter(([, v]) => v !== undefined)
47
+ .sort(([a], [b]) => a.localeCompare(b))
48
+ .map(([k, v]) => [k, sortValue(v)]),
49
+ )
50
+ }
51
+
52
+ /** First 8 hex chars of sha256. */
53
+ export function hashKey(input: string | Uint8Array): string {
54
+ return createHash('sha256').update(input).digest('hex').slice(0, 8)
55
+ }
56
+
57
+ /** First ~40 chars of text, kebab-cased, filesystem-safe. */
58
+ export function promptPrefix(text: string): string {
59
+ const slug = text
60
+ .toLowerCase()
61
+ .replace(/[^a-z0-9\s-]/g, '')
62
+ .trim()
63
+ .replace(/\s+/g, '-')
64
+ .slice(0, 40)
65
+ .replace(/-$/, '')
66
+ return slug || 'generated'
67
+ }
68
+
69
+ export function extensionFromMediaType(mediaType: string): string {
70
+ const map: Record<string, string> = {
71
+ 'image/png': '.png',
72
+ 'image/jpeg': '.jpg',
73
+ 'image/webp': '.webp',
74
+ 'video/mp4': '.mp4',
75
+ 'video/webm': '.webm',
76
+ 'audio/mpeg': '.mp3',
77
+ 'audio/wav': '.wav',
78
+ 'audio/opus': '.opus',
79
+ 'audio/ogg': '.ogg',
80
+ 'audio/aac': '.aac',
81
+ 'audio/flac': '.flac',
82
+ }
83
+ return map[mediaType] || '.bin'
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Generated directory management
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /** Returns the generated files directory for a given namespace.
91
+ * Creates the directory if it doesn't exist. */
92
+ export function generatedDir(namespace: string): string {
93
+ const root = getProjectRoot()
94
+ const dir = path.join(root, 'public', 'generated', namespace)
95
+ fs.mkdirSync(dir, { recursive: true })
96
+ return dir
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // File lookup
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /** Find an existing cached file by hash in the generated directory.
104
+ * Also checks the stale/ subfolder — if found there, restores it. */
105
+ export function findCachedFile(dir: string, hash: string): string | undefined {
106
+ try {
107
+ const found = fs.readdirSync(dir).find((f) => f.includes(hash))
108
+ if (found) return found
109
+ } catch { /* empty */ }
110
+ try {
111
+ const staleDir = path.join(dir, 'stale')
112
+ const inStale = fs.readdirSync(staleDir).find((f) => f.includes(hash))
113
+ if (inStale) {
114
+ fs.renameSync(path.join(staleDir, inStale), path.join(dir, inStale))
115
+ console.log(`[egaki] restored from stale: ${inStale}`)
116
+ return inStale
117
+ }
118
+ } catch { /* empty */ }
119
+ return undefined
120
+ }
121
+
122
+ /** Find a previous generation with the same prompt prefix (different hash)
123
+ * that can serve as fallback while a new generation is in progress. */
124
+ export function findFallbackFile(dir: string, prefix: string, currentHash: string): { filename: string; inStale: boolean } | undefined {
125
+ const match = (f: string) => f.startsWith(prefix + '-') && !f.includes(currentHash)
126
+ try {
127
+ const inDir = fs.readdirSync(dir).find(match)
128
+ if (inDir) return { filename: inDir, inStale: false }
129
+ } catch { /* empty */ }
130
+ try {
131
+ const staleDir = path.join(dir, 'stale')
132
+ const inStale = fs.readdirSync(staleDir).find(match)
133
+ if (inStale) return { filename: inStale, inStale: true }
134
+ } catch { /* empty */ }
135
+ return undefined
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Stale file management
140
+ // ---------------------------------------------------------------------------
141
+
142
+ /** Move a specific file into the stale/ subfolder of its directory. */
143
+ export function moveFileToStale(dir: string, fallback: { filename: string; inStale: boolean }) {
144
+ if (fallback.inStale) return
145
+ const src = path.join(dir, fallback.filename)
146
+ const staleDir = path.join(dir, 'stale')
147
+ try {
148
+ if (!fs.existsSync(src)) return
149
+ fs.mkdirSync(staleDir, { recursive: true })
150
+ fs.renameSync(src, path.join(staleDir, fallback.filename))
151
+ console.log(`[egaki] moved stale: ${fallback.filename} → stale/`)
152
+ } catch { /* race with another rename, safe to ignore */ }
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Generation queue — deduplicates concurrent calls with the same cache key.
157
+ // Stored on globalThis so in-flight promises survive Vite HMR module reloads.
158
+ // ---------------------------------------------------------------------------
159
+
160
+ export const generationQueue: Map<string, Promise<any>> =
161
+ (globalThis as any).__egakiGenerationQueue ??= new Map<string, Promise<any>>()
162
+
163
+ /** Format key params for logging, omitting undefined values and the _type field. */
164
+ export function formatKeyParams(params: Record<string, unknown>): string {
165
+ const entries = Object.entries(params)
166
+ .filter(([k, v]) => v !== undefined && k !== '_type')
167
+ .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
168
+ return entries.join(', ')
169
+ }
@@ -0,0 +1,531 @@
1
+ /**
2
+ * Tests for cachedGenerate — the general-purpose persisted memoization HOF.
3
+ *
4
+ * Uses a temp directory as the project root so tests are isolated and don't
5
+ * touch the real public/generated/ folder. Tests verify: cache hit/miss,
6
+ * dedup of concurrent calls, stale fallback management, auto-hashing of
7
+ * Uint8Array params, JSON serialization, progress tracking, error handling,
8
+ * and the getCacheInfo helper.
9
+ */
10
+
11
+ import { describe, test, expect, beforeEach, afterEach } from 'vitest'
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import os from 'node:os'
15
+ import {
16
+ cachedGenerate,
17
+ getCacheInfo,
18
+ getGenerationProgress,
19
+ type CachedGenerateConfig,
20
+ } from './cached-generate.ts'
21
+ import { setProjectRoot } from './cache-utils.ts'
22
+
23
+ let tmpDir: string
24
+
25
+ beforeEach(() => {
26
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'egaki-cached-generate-test-'))
27
+ setProjectRoot(tmpDir)
28
+ })
29
+
30
+ afterEach(() => {
31
+ fs.rmSync(tmpDir, { recursive: true, force: true })
32
+ })
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Helpers
36
+ // ---------------------------------------------------------------------------
37
+
38
+ interface TestParams {
39
+ prompt: string
40
+ seed?: number
41
+ inputData?: Uint8Array
42
+ }
43
+
44
+ function makeTestConfig(overrides?: Partial<CachedGenerateConfig<TestParams, { data: string }, { src: string }>>) {
45
+ let callCount = 0
46
+ const config: CachedGenerateConfig<TestParams, { data: string }, { src: string }> = {
47
+ namespace: 'test',
48
+ prefixFrom: (p) => p.prompt,
49
+ generate: async (params) => {
50
+ callCount++
51
+ return { data: `generated-${params.prompt}` }
52
+ },
53
+ serialize: (result) => ({
54
+ bytes: new Uint8Array(Buffer.from(result.data)),
55
+ extension: '.txt',
56
+ }),
57
+ ...overrides,
58
+ }
59
+ return { config, getCallCount: () => callCount }
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Cache hit / miss
64
+ // ---------------------------------------------------------------------------
65
+
66
+ describe('cache hit and miss', () => {
67
+ test('first call generates and writes file', async () => {
68
+ const { config, getCallCount } = makeTestConfig()
69
+ const fn = cachedGenerate(config)
70
+
71
+ const result = await fn({ prompt: 'hello world' })
72
+ expect(result).not.toBeInstanceOf(Error)
73
+ const r = result as { src: string }
74
+ expect(r.src).toMatch(/^\/generated\/test\/hello-world-[a-f0-9]{8}\.txt$/)
75
+ expect(getCallCount()).toBe(1)
76
+
77
+ // File exists on disk
78
+ const filePath = path.join(tmpDir, 'public', r.src)
79
+ expect(fs.existsSync(filePath)).toBe(true)
80
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe('generated-hello world')
81
+ })
82
+
83
+ test('second call with same params returns cached (no generation)', async () => {
84
+ const { config, getCallCount } = makeTestConfig()
85
+ const fn = cachedGenerate(config)
86
+
87
+ const r1 = await fn({ prompt: 'hello world' })
88
+ const r2 = await fn({ prompt: 'hello world' })
89
+
90
+ expect(r1).not.toBeInstanceOf(Error)
91
+ expect(r2).not.toBeInstanceOf(Error)
92
+ expect((r1 as { src: string }).src).toBe((r2 as { src: string }).src)
93
+ expect(getCallCount()).toBe(1) // only called once
94
+ })
95
+
96
+ test('different params generate different files', async () => {
97
+ const { config, getCallCount } = makeTestConfig()
98
+ const fn = cachedGenerate(config)
99
+
100
+ const r1 = await fn({ prompt: 'alpha' })
101
+ const r2 = await fn({ prompt: 'beta' })
102
+
103
+ expect(r1).not.toBeInstanceOf(Error)
104
+ expect(r2).not.toBeInstanceOf(Error)
105
+ expect((r1 as { src: string }).src).not.toBe((r2 as { src: string }).src)
106
+ expect(getCallCount()).toBe(2)
107
+ })
108
+ })
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Concurrent dedup
112
+ // ---------------------------------------------------------------------------
113
+
114
+ describe('deduplication', () => {
115
+ test('concurrent calls with same params return same promise', async () => {
116
+ let resolveGenerate!: (v: { data: string }) => void
117
+ const { config } = makeTestConfig({
118
+ generate: () => new Promise((resolve) => { resolveGenerate = resolve }),
119
+ })
120
+ const fn = cachedGenerate(config)
121
+
122
+ const p1 = fn({ prompt: 'dedup test' })
123
+ const p2 = fn({ prompt: 'dedup test' })
124
+
125
+ // Resolve the single generation
126
+ resolveGenerate({ data: 'result' })
127
+
128
+ const [r1, r2] = await Promise.all([p1, p2])
129
+ expect(r1).not.toBeInstanceOf(Error)
130
+ expect(r2).not.toBeInstanceOf(Error)
131
+ expect((r1 as { src: string }).src).toBe((r2 as { src: string }).src)
132
+ })
133
+ })
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Stale fallback management
137
+ // ---------------------------------------------------------------------------
138
+
139
+ describe('stale management', () => {
140
+ test('changing params moves old file to stale/', async () => {
141
+ const { config } = makeTestConfig()
142
+ const fn = cachedGenerate(config)
143
+
144
+ // Generate first version
145
+ const r1 = await fn({ prompt: 'hello world', seed: 1 })
146
+ expect(r1).not.toBeInstanceOf(Error)
147
+
148
+ const firstFile = path.basename((r1 as { src: string }).src)
149
+
150
+ // Generate with different seed but same prompt prefix
151
+ const r2 = await fn({ prompt: 'hello world', seed: 2 })
152
+ expect(r2).not.toBeInstanceOf(Error)
153
+
154
+ // Old file should be in stale/
155
+ const staleDir = path.join(tmpDir, 'public', 'generated', 'test', 'stale')
156
+ const staleFiles = fs.existsSync(staleDir) ? fs.readdirSync(staleDir) : []
157
+ expect(staleFiles).toContain(firstFile)
158
+ })
159
+ })
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Auto-hashing Uint8Array params
163
+ // ---------------------------------------------------------------------------
164
+
165
+ describe('Uint8Array auto-hashing', () => {
166
+ test('same bytes produce same cache key', async () => {
167
+ const { config, getCallCount } = makeTestConfig()
168
+ const fn = cachedGenerate(config)
169
+
170
+ const data = new Uint8Array([1, 2, 3])
171
+ const r1 = await fn({ prompt: 'with data', inputData: data })
172
+ const r2 = await fn({ prompt: 'with data', inputData: new Uint8Array([1, 2, 3]) })
173
+
174
+ expect(getCallCount()).toBe(1) // same key → cached
175
+ expect((r1 as { src: string }).src).toBe((r2 as { src: string }).src)
176
+ })
177
+
178
+ test('different bytes produce different cache keys', async () => {
179
+ const { config, getCallCount } = makeTestConfig()
180
+ const fn = cachedGenerate(config)
181
+
182
+ await fn({ prompt: 'with data', inputData: new Uint8Array([1, 2, 3]) })
183
+ await fn({ prompt: 'with data', inputData: new Uint8Array([4, 5, 6]) })
184
+
185
+ expect(getCallCount()).toBe(2)
186
+ })
187
+ })
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // JSON serialization
191
+ // ---------------------------------------------------------------------------
192
+
193
+ describe('JSON serialization', () => {
194
+ test('json serialize writes .json and deserialize reads it back', async () => {
195
+ interface JsonResult { items: string[] }
196
+ const fn = cachedGenerate<{ query: string }, JsonResult, JsonResult>({
197
+ namespace: 'json-test',
198
+ prefixFrom: (p) => p.query,
199
+ generate: async (params) => ({ items: [params.query, 'b', 'c'] }),
200
+ serialize: (result) => ({
201
+ json: result,
202
+ extension: '.json' as const,
203
+ }),
204
+ deserialize: ({ filePath }) => JSON.parse(fs.readFileSync(filePath, 'utf-8')),
205
+ })
206
+
207
+ const r1 = await fn({ query: 'search term' })
208
+ expect(r1).not.toBeInstanceOf(Error)
209
+ expect((r1 as JsonResult).items).toEqual(['search term', 'b', 'c'])
210
+
211
+ // Second call reads from cache
212
+ const r2 = await fn({ query: 'search term' })
213
+ expect(r2).not.toBeInstanceOf(Error)
214
+ expect((r2 as JsonResult).items).toEqual(['search term', 'b', 'c'])
215
+ })
216
+ })
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Prefix override from serialize
220
+ // ---------------------------------------------------------------------------
221
+
222
+ describe('prefix override', () => {
223
+ test('serialize can return a prefix to override the filename', async () => {
224
+ const fn = cachedGenerate<{ id: string }, { text: string }, { src: string }>({
225
+ namespace: 'prefix-test',
226
+ prefixFrom: () => 'initial',
227
+ generate: async () => ({ text: 'the actual content is here' }),
228
+ serialize: (result) => ({
229
+ bytes: new Uint8Array(Buffer.from(result.text)),
230
+ extension: '.txt',
231
+ prefix: 'the actual content is here',
232
+ }),
233
+ })
234
+
235
+ const r = await fn({ id: '123' })
236
+ expect(r).not.toBeInstanceOf(Error)
237
+ // The filename should use the override prefix, not 'initial'
238
+ expect((r as { src: string }).src).toMatch(/the-actual-content-is-here/)
239
+ expect((r as { src: string }).src).not.toMatch(/initial/)
240
+ })
241
+ })
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Progress tracking
245
+ // ---------------------------------------------------------------------------
246
+
247
+ describe('progress tracking', () => {
248
+ test('generation is registered and unregistered', async () => {
249
+ let resolveGenerate!: (v: { data: string }) => void
250
+ const fn = cachedGenerate<TestParams, { data: string }>({
251
+ namespace: 'progress-test',
252
+ prefixFrom: (p) => p.prompt,
253
+ modelFrom: (p) => `model-${p.seed}`,
254
+ generate: () => new Promise((resolve) => { resolveGenerate = resolve }),
255
+ serialize: (result) => ({
256
+ bytes: new Uint8Array(Buffer.from(result.data)),
257
+ extension: '.txt',
258
+ }),
259
+ })
260
+
261
+ const promise = fn({ prompt: 'tracking test', seed: 42 })
262
+
263
+ // While generating, progress should show the entry
264
+ const progress = getGenerationProgress()
265
+ expect(progress.summary.total).toBe(1)
266
+ expect(progress.summary.counts['progress-test']).toBe(1)
267
+ expect(progress.generations[0]?.namespace).toBe('progress-test')
268
+ expect(progress.generations[0]?.label).toBe('tracking test')
269
+ expect(progress.generations[0]?.model).toBe('model-42')
270
+
271
+ // Resolve the generation
272
+ resolveGenerate({ data: 'done' })
273
+ await promise
274
+
275
+ // After completion, progress should be empty
276
+ const after = getGenerationProgress()
277
+ expect(after.summary.total).toBe(0)
278
+ })
279
+
280
+ test('failed generation reports error in progress', async () => {
281
+ const fn = cachedGenerate<TestParams, { data: string }>({
282
+ namespace: 'error-progress',
283
+ prefixFrom: (p) => p.prompt,
284
+ generate: async () => { throw new Error('API failed') },
285
+ serialize: (result) => ({
286
+ bytes: new Uint8Array(Buffer.from(result.data)),
287
+ extension: '.txt',
288
+ }),
289
+ })
290
+
291
+ const result = await fn({ prompt: 'fail test' })
292
+ expect(result).toBeInstanceOf(Error)
293
+ expect((result as Error).message).toBe('API failed')
294
+
295
+ // Error should be in the progress drain
296
+ const progress = getGenerationProgress()
297
+ expect(progress.errors.length).toBe(1)
298
+ expect(progress.errors[0]?.error).toBe('API failed')
299
+ expect(progress.errors[0]?.namespace).toBe('error-progress')
300
+
301
+ // Errors drain on read — second call should have none
302
+ const progress2 = getGenerationProgress()
303
+ expect(progress2.errors.length).toBe(0)
304
+ })
305
+ })
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Error handling
309
+ // ---------------------------------------------------------------------------
310
+
311
+ describe('error handling', () => {
312
+ test('generate throwing returns Error', async () => {
313
+ const fn = cachedGenerate<TestParams, { data: string }>({
314
+ namespace: 'error-test',
315
+ prefixFrom: (p) => p.prompt,
316
+ generate: async () => { throw new Error('boom') },
317
+ serialize: (result) => ({
318
+ bytes: new Uint8Array(Buffer.from(result.data)),
319
+ extension: '.txt',
320
+ }),
321
+ })
322
+
323
+ const result = await fn({ prompt: 'error test' })
324
+ expect(result).toBeInstanceOf(Error)
325
+ expect((result as Error).message).toBe('boom')
326
+ })
327
+
328
+ test('non-Error throw is wrapped in Error', async () => {
329
+ const fn = cachedGenerate<TestParams, { data: string }>({
330
+ namespace: 'error-test-2',
331
+ prefixFrom: (p) => p.prompt,
332
+ generate: async () => { throw 'string error' },
333
+ serialize: (result) => ({
334
+ bytes: new Uint8Array(Buffer.from(result.data)),
335
+ extension: '.txt',
336
+ }),
337
+ })
338
+
339
+ const result = await fn({ prompt: 'string error test' })
340
+ expect(result).toBeInstanceOf(Error)
341
+ expect((result as Error).message).toBe('string error')
342
+ })
343
+
344
+ test('failed call does not cache — retry regenerates', async () => {
345
+ let shouldFail = true
346
+ const fn = cachedGenerate<TestParams, { data: string }>({
347
+ namespace: 'retry-test',
348
+ prefixFrom: (p) => p.prompt,
349
+ generate: async (params) => {
350
+ if (shouldFail) throw new Error('temporary failure')
351
+ return { data: `ok-${params.prompt}` }
352
+ },
353
+ serialize: (result) => ({
354
+ bytes: new Uint8Array(Buffer.from(result.data)),
355
+ extension: '.txt',
356
+ }),
357
+ })
358
+
359
+ const r1 = await fn({ prompt: 'retry' })
360
+ expect(r1).toBeInstanceOf(Error)
361
+
362
+ shouldFail = false
363
+ const r2 = await fn({ prompt: 'retry' })
364
+ expect(r2).not.toBeInstanceOf(Error)
365
+ expect((r2 as { src: string }).src).toMatch(/\/generated\/retry-test\//)
366
+ })
367
+ })
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Namespace isolation in dedup queue
371
+ // ---------------------------------------------------------------------------
372
+
373
+ describe('namespace isolation', () => {
374
+ test('same params in different namespaces generate independently', async () => {
375
+ let imageCallCount = 0
376
+ let videoCallCount = 0
377
+
378
+ const imageFn = cachedGenerate<{ prompt: string }, { data: string }>({
379
+ namespace: 'ns-image',
380
+ prefixFrom: (p) => p.prompt,
381
+ generate: async (params) => {
382
+ imageCallCount++
383
+ return { data: `image-${params.prompt}` }
384
+ },
385
+ serialize: (result) => ({
386
+ bytes: new Uint8Array(Buffer.from(result.data)),
387
+ extension: '.png',
388
+ }),
389
+ })
390
+
391
+ const videoFn = cachedGenerate<{ prompt: string }, { data: string }>({
392
+ namespace: 'ns-video',
393
+ prefixFrom: (p) => p.prompt,
394
+ generate: async (params) => {
395
+ videoCallCount++
396
+ return { data: `video-${params.prompt}` }
397
+ },
398
+ serialize: (result) => ({
399
+ bytes: new Uint8Array(Buffer.from(result.data)),
400
+ extension: '.mp4',
401
+ }),
402
+ })
403
+
404
+ const [imgResult, vidResult] = await Promise.all([
405
+ imageFn({ prompt: 'same params' }),
406
+ videoFn({ prompt: 'same params' }),
407
+ ])
408
+
409
+ expect(imgResult).not.toBeInstanceOf(Error)
410
+ expect(vidResult).not.toBeInstanceOf(Error)
411
+ expect(imageCallCount).toBe(1)
412
+ expect(videoCallCount).toBe(1)
413
+ // Different src paths
414
+ expect((imgResult as { src: string }).src).toMatch(/ns-image/)
415
+ expect((vidResult as { src: string }).src).toMatch(/ns-video/)
416
+ })
417
+ })
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // Recursive Uint8Array hashing
421
+ // ---------------------------------------------------------------------------
422
+
423
+ describe('recursive binary hashing', () => {
424
+ test('nested Uint8Array values produce stable cache keys', async () => {
425
+ const { config, getCallCount } = makeTestConfig()
426
+ const fn = cachedGenerate(config)
427
+
428
+ // Pass nested binary data inside an object
429
+ const r1 = await fn({ prompt: 'nested', inputData: new Uint8Array([10, 20, 30]) } as any)
430
+ const r2 = await fn({ prompt: 'nested', inputData: new Uint8Array([10, 20, 30]) } as any)
431
+
432
+ expect(getCallCount()).toBe(1) // same nested bytes → cached
433
+ })
434
+ })
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // Serialize failure handling
438
+ // ---------------------------------------------------------------------------
439
+
440
+ describe('serialize failure', () => {
441
+ test('serialize throwing unregisters progress and returns Error', async () => {
442
+ // Drain any leftover errors from previous tests
443
+ getGenerationProgress()
444
+
445
+ const fn = cachedGenerate<TestParams, { data: string }>({
446
+ namespace: 'serialize-fail',
447
+ prefixFrom: (p) => p.prompt,
448
+ generate: async () => ({ data: 'ok' }),
449
+ serialize: () => { throw new Error('serialize exploded') },
450
+ })
451
+
452
+ const result = await fn({ prompt: 'serialize test' })
453
+ expect(result).toBeInstanceOf(Error)
454
+ expect((result as Error).message).toBe('serialize exploded')
455
+
456
+ // Progress should be clean — no stuck entries
457
+ const progress = getGenerationProgress()
458
+ expect(progress.summary.total).toBe(0)
459
+ // Error should be in the drain
460
+ expect(progress.errors.length).toBe(1)
461
+ expect(progress.errors[0]?.error).toBe('serialize exploded')
462
+ })
463
+ })
464
+
465
+ // ---------------------------------------------------------------------------
466
+ // cacheKey config excludes non-identity fields
467
+ // ---------------------------------------------------------------------------
468
+
469
+ describe('cacheKey config', () => {
470
+ test('excluded fields do not affect cache identity', async () => {
471
+ let callCount = 0
472
+ const fn = cachedGenerate<{ query: string; label?: string }, { data: string }>({
473
+ namespace: 'cachekey-test',
474
+ prefixFrom: (p) => p.label ?? p.query,
475
+ cacheKey: ({ label, ...rest }) => rest,
476
+ generate: async (params) => {
477
+ callCount++
478
+ return { data: `result-${params.query}` }
479
+ },
480
+ serialize: (result) => ({
481
+ bytes: new Uint8Array(Buffer.from(result.data)),
482
+ extension: '.txt',
483
+ }),
484
+ })
485
+
486
+ await fn({ query: 'search', label: 'label-a' })
487
+ await fn({ query: 'search', label: 'label-b' })
488
+
489
+ // Same query, different labels → should be same cache entry
490
+ expect(callCount).toBe(1)
491
+ })
492
+ })
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // getCacheInfo helper
496
+ // ---------------------------------------------------------------------------
497
+
498
+ describe('getCacheInfo', () => {
499
+ test('returns cache miss before any generation', () => {
500
+ const info = getCacheInfo('test-ns', { prompt: 'hello' }, 'hello')
501
+ expect(info.cached).toBeUndefined()
502
+ expect(info.hash).toMatch(/^[a-f0-9]{8}$/)
503
+ expect(info.prefix).toBe('hello')
504
+ })
505
+
506
+ test('returns cache hit after generation', async () => {
507
+ const { config } = makeTestConfig()
508
+ const fn = cachedGenerate(config)
509
+
510
+ await fn({ prompt: 'cached info test' })
511
+
512
+ const info = getCacheInfo('test', { prompt: 'cached info test' }, 'cached info test')
513
+ expect(info.cached).toBeDefined()
514
+ expect(info.cached).toMatch(/cached-info-test-[a-f0-9]{8}\.txt/)
515
+ })
516
+
517
+ test('returns fallbackSrc when a previous generation exists', async () => {
518
+ const { config } = makeTestConfig()
519
+ const fn = cachedGenerate(config)
520
+
521
+ // Generate first version
522
+ await fn({ prompt: 'fallback test', seed: 1 })
523
+
524
+ // Query for a different version that doesn't exist yet
525
+ // Version 1 is in the main dir and should be found as fallback
526
+ const info = getCacheInfo('test', { prompt: 'fallback test', seed: 2 }, 'fallback test')
527
+ expect(info.cached).toBeUndefined()
528
+ expect(info.fallbackSrc).toBeDefined()
529
+ expect(info.fallbackSrc).toMatch(/\/generated\/test\/fallback-test-/)
530
+ })
531
+ })