ingest-cache.ts 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. import { readFile, writeFile, fileExists } from "@/commands/fs"
  2. import { normalizePath, isAbsolutePath } from "@/lib/path-utils"
  3. /**
  4. * SHA256-based ingest cache.
  5. * Stores hash of source file content → skips re-ingest if unchanged.
  6. * Cache file: .qmai/ingest-cache.json
  7. */
  8. interface CacheEntry {
  9. hash: string
  10. timestamp: number
  11. filesWritten: string[]
  12. }
  13. interface CacheData {
  14. entries: Record<string, CacheEntry> // keyed by source filename
  15. }
  16. async function sha256(content: string): Promise<string> {
  17. const encoder = new TextEncoder()
  18. const data = encoder.encode(content)
  19. const hashBuffer = await crypto.subtle.digest("SHA-256", data)
  20. const hashArray = Array.from(new Uint8Array(hashBuffer))
  21. return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
  22. }
  23. function cachePath(projectPath: string): string {
  24. return `${normalizePath(projectPath)}/.qmai/ingest-cache.json`
  25. }
  26. async function loadCache(projectPath: string): Promise<CacheData> {
  27. try {
  28. const raw = await readFile(cachePath(projectPath))
  29. return JSON.parse(raw) as CacheData
  30. } catch {
  31. return { entries: {} }
  32. }
  33. }
  34. async function saveCache(projectPath: string, cache: CacheData): Promise<void> {
  35. try {
  36. await writeFile(cachePath(projectPath), JSON.stringify(cache, null, 2))
  37. } catch {
  38. // non-critical
  39. }
  40. }
  41. /**
  42. * Check if a source file has already been ingested with the same content.
  43. * Returns the list of previously written files if cached, or null if ingest
  44. * is needed.
  45. *
  46. * IMPORTANT: a cache hit is only returned if every previously-written file
  47. * still exists on disk. Otherwise we treat the cache as stale and fall
  48. * through to a full re-ingest. Historically we returned the cached list
  49. * blindly, which surfaced ghost entries in the activity panel — clicking
  50. * them gave the preview panel a missing file, and the auto-save path then
  51. * materialized a `[Binary file: ...]` stub at the now-empty location.
  52. */
  53. export async function checkIngestCache(
  54. projectPath: string,
  55. sourceFileName: string,
  56. sourceContent: string,
  57. ): Promise<string[] | null> {
  58. const cache = await loadCache(projectPath)
  59. const entry = cache.entries[sourceFileName]
  60. if (!entry) return null
  61. const currentHash = await sha256(sourceContent)
  62. if (entry.hash !== currentHash) return null
  63. const pp = normalizePath(projectPath)
  64. for (const filePath of entry.filesWritten) {
  65. const fullPath = isAbsolutePath(filePath)
  66. ? normalizePath(filePath)
  67. : `${pp}/${filePath}`
  68. try {
  69. if (!(await fileExists(fullPath))) {
  70. console.log(
  71. `[ingest-cache] cache miss for ${sourceFileName}: ${filePath} no longer on disk`,
  72. )
  73. return null
  74. }
  75. } catch {
  76. // If the existence check itself fails, fall back to re-ingest —
  77. // safer than trusting a stale cache entry.
  78. return null
  79. }
  80. }
  81. return entry.filesWritten
  82. }
  83. /**
  84. * Save ingest result to cache after successful ingest.
  85. */
  86. export async function saveIngestCache(
  87. projectPath: string,
  88. sourceFileName: string,
  89. sourceContent: string,
  90. filesWritten: string[],
  91. ): Promise<void> {
  92. const cache = await loadCache(projectPath)
  93. const hash = await sha256(sourceContent)
  94. const newEntries = { ...cache.entries }
  95. newEntries[sourceFileName] = {
  96. hash,
  97. timestamp: Date.now(),
  98. filesWritten,
  99. }
  100. await saveCache(projectPath, { entries: newEntries })
  101. }
  102. /**
  103. * Remove a source file entry from cache (e.g., when source is deleted).
  104. */
  105. export async function removeFromIngestCache(
  106. projectPath: string,
  107. sourceFileName: string,
  108. ): Promise<void> {
  109. const cache = await loadCache(projectPath)
  110. const newEntries = { ...cache.entries }
  111. delete newEntries[sourceFileName]
  112. await saveCache(projectPath, { entries: newEntries })
  113. }