claude-brain 0.30.2 → 0.30.3

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 (236) hide show
  1. package/README.md +241 -191
  2. package/VERSION +1 -1
  3. package/assets/CLAUDE-unified.md +11 -11
  4. package/assets/CLAUDE.md +29 -29
  5. package/package.json +7 -3
  6. package/packs/backend/node.json +173 -173
  7. package/packs/core/javascript.json +176 -176
  8. package/packs/core/typescript.json +222 -222
  9. package/packs/frontend/react.json +254 -254
  10. package/packs/meta/testing.json +172 -172
  11. package/scripts/postinstall.mjs +531 -531
  12. package/src/automation/decision-detector.ts +452 -452
  13. package/src/automation/phase12-manager.ts +456 -456
  14. package/src/automation/proactive-recall.ts +373 -373
  15. package/src/automation/project-detector.ts +310 -310
  16. package/src/automation/repo-scanner.ts +210 -205
  17. package/src/cli/auto-setup.ts +75 -75
  18. package/src/cli/auto-start.ts +266 -266
  19. package/src/cli/bin.ts +264 -264
  20. package/src/cli/commands/autostart.ts +90 -90
  21. package/src/cli/commands/chroma.ts +578 -577
  22. package/src/cli/commands/export-training.ts +70 -70
  23. package/src/cli/commands/export.ts +130 -130
  24. package/src/cli/commands/git-hook.ts +183 -183
  25. package/src/cli/commands/hooks.ts +217 -217
  26. package/src/cli/commands/init.ts +123 -123
  27. package/src/cli/commands/install-mcp.ts +122 -111
  28. package/src/cli/commands/models.ts +979 -979
  29. package/src/cli/commands/pack.ts +200 -200
  30. package/src/cli/commands/refresh.ts +344 -339
  31. package/src/cli/commands/reindex.ts +120 -120
  32. package/src/cli/commands/serve.ts +466 -463
  33. package/src/cli/commands/start.ts +44 -44
  34. package/src/cli/commands/status.ts +220 -203
  35. package/src/cli/commands/uninstall-mcp.ts +45 -41
  36. package/src/cli/commands/update.ts +130 -124
  37. package/src/cli/migrate-chroma.ts +106 -106
  38. package/src/cli/ui/animations.ts +80 -80
  39. package/src/cli/ui/components.ts +82 -82
  40. package/src/cli/ui/index.ts +4 -4
  41. package/src/cli/ui/logo.ts +36 -36
  42. package/src/cli/ui/theme.ts +55 -55
  43. package/src/code-intelligence/indexer.ts +352 -352
  44. package/src/code-intelligence/linker.ts +178 -178
  45. package/src/code-intelligence/parser.ts +484 -484
  46. package/src/code-intelligence/query.ts +291 -291
  47. package/src/code-intelligence/schema.ts +83 -83
  48. package/src/code-intelligence/types.ts +95 -95
  49. package/src/config/defaults.ts +52 -52
  50. package/src/config/home.ts +56 -56
  51. package/src/config/index.ts +5 -5
  52. package/src/config/loader.ts +192 -192
  53. package/src/config/schema.ts +446 -415
  54. package/src/config/validator.ts +182 -182
  55. package/src/context/assembler.ts +407 -400
  56. package/src/context/index.ts +79 -79
  57. package/src/context/progress-tracker.ts +174 -174
  58. package/src/context/standards-manager.ts +287 -287
  59. package/src/context/validator.ts +58 -58
  60. package/src/diagnostics/index.ts +122 -121
  61. package/src/health/index.ts +233 -232
  62. package/src/hooks/brain-hook.ts +134 -131
  63. package/src/hooks/capture.ts +168 -168
  64. package/src/hooks/claude-code-mastery.md +112 -112
  65. package/src/hooks/context-hook.ts +260 -245
  66. package/src/hooks/deduplicator.ts +72 -72
  67. package/src/hooks/git-capture.ts +109 -109
  68. package/src/hooks/git-hook-installer.ts +211 -207
  69. package/src/hooks/index.ts +20 -20
  70. package/src/hooks/installer.ts +306 -288
  71. package/src/hooks/interceptor-hook.ts +204 -201
  72. package/src/hooks/passive-classifier.ts +397 -397
  73. package/src/hooks/queue.ts +160 -129
  74. package/src/hooks/session-tracker.ts +312 -312
  75. package/src/hooks/types.ts +52 -52
  76. package/src/index.ts +7 -7
  77. package/src/intelligence/cross-project/generalizer.ts +283 -283
  78. package/src/intelligence/cross-project/index.ts +7 -7
  79. package/src/intelligence/hf-downloader.ts +222 -222
  80. package/src/intelligence/hf-manifest.json +78 -78
  81. package/src/intelligence/index.ts +24 -24
  82. package/src/intelligence/inference-router.ts +762 -762
  83. package/src/intelligence/model-manager.ts +263 -245
  84. package/src/intelligence/optimization/index.ts +10 -10
  85. package/src/intelligence/optimization/precompute.ts +202 -202
  86. package/src/intelligence/optimization/semantic-cache.ts +213 -207
  87. package/src/intelligence/prediction/index.ts +7 -7
  88. package/src/intelligence/prediction/recommender.ts +276 -268
  89. package/src/intelligence/reasoning/chain-retrieval.ts +243 -247
  90. package/src/intelligence/reasoning/index.ts +7 -7
  91. package/src/intelligence/temporal/evolution.ts +193 -197
  92. package/src/intelligence/temporal/index.ts +16 -16
  93. package/src/intelligence/temporal/query-processor.ts +190 -190
  94. package/src/intelligence/temporal/timeline.ts +272 -259
  95. package/src/intelligence/temporal/trends.ts +263 -263
  96. package/src/intelligence/tokenizer.ts +118 -118
  97. package/src/knowledge/entity-extractor.ts +447 -443
  98. package/src/knowledge/graph/builder.ts +185 -185
  99. package/src/knowledge/graph/linker.ts +201 -201
  100. package/src/knowledge/graph/memory-graph.ts +359 -359
  101. package/src/knowledge/graph/schema.ts +99 -99
  102. package/src/knowledge/graph/search.ts +166 -166
  103. package/src/knowledge/relationship-extractor.ts +108 -108
  104. package/src/memory/chroma/client.ts +211 -192
  105. package/src/memory/chroma/collection-manager.ts +92 -92
  106. package/src/memory/chroma/config.ts +57 -57
  107. package/src/memory/chroma/embeddings.ts +177 -175
  108. package/src/memory/chroma/index.ts +82 -82
  109. package/src/memory/chroma/migration.ts +270 -270
  110. package/src/memory/chroma/schemas.ts +69 -69
  111. package/src/memory/chroma/search.ts +319 -315
  112. package/src/memory/chroma/store.ts +755 -747
  113. package/src/memory/compression.ts +121 -121
  114. package/src/memory/consolidation/archiver.ts +162 -165
  115. package/src/memory/consolidation/merger.ts +182 -186
  116. package/src/memory/consolidation/scorer.ts +136 -136
  117. package/src/memory/database.ts +9 -0
  118. package/src/memory/dual-write.ts +145 -0
  119. package/src/memory/embeddings.ts +226 -226
  120. package/src/memory/episodic/detector.ts +108 -108
  121. package/src/memory/episodic/manager.ts +347 -351
  122. package/src/memory/episodic/summarizer.ts +179 -179
  123. package/src/memory/episodic/types.ts +52 -52
  124. package/src/memory/fts5-search.ts +692 -633
  125. package/src/memory/index.ts +943 -1060
  126. package/src/memory/migrations/add-fts5.ts +118 -108
  127. package/src/memory/patterns.ts +438 -438
  128. package/src/memory/pruning.ts +60 -60
  129. package/src/memory/schema.ts +88 -88
  130. package/src/memory/store.ts +911 -787
  131. package/src/orchestrator/handlers/decision-handler.ts +204 -204
  132. package/src/packs/index.ts +9 -9
  133. package/src/packs/loader.ts +134 -134
  134. package/src/packs/manager.ts +204 -204
  135. package/src/packs/ranker.ts +78 -78
  136. package/src/packs/types.ts +81 -81
  137. package/src/phase12/index.ts +5 -5
  138. package/src/retrieval/bm25/index.ts +300 -297
  139. package/src/retrieval/bm25/tokenizer.ts +184 -184
  140. package/src/retrieval/feedback/adaptive.ts +221 -221
  141. package/src/retrieval/feedback/index.ts +16 -16
  142. package/src/retrieval/feedback/metrics.ts +221 -221
  143. package/src/retrieval/feedback/store.ts +283 -283
  144. package/src/retrieval/fusion/index.ts +194 -194
  145. package/src/retrieval/fusion/rrf.ts +165 -165
  146. package/src/retrieval/index.ts +12 -12
  147. package/src/retrieval/pipeline.ts +375 -375
  148. package/src/retrieval/query/expander.ts +203 -203
  149. package/src/retrieval/query/index.ts +27 -27
  150. package/src/retrieval/query/intent-classifier.ts +252 -252
  151. package/src/retrieval/query/temporal-parser.ts +295 -295
  152. package/src/retrieval/reranker/index.ts +189 -188
  153. package/src/retrieval/reranker/model.ts +99 -95
  154. package/src/retrieval/service.ts +125 -125
  155. package/src/retrieval/types.ts +162 -162
  156. package/src/routing/entity-extractor.ts +454 -454
  157. package/src/routing/handlers/exploration-handler.ts +369 -0
  158. package/src/routing/handlers/index.ts +19 -0
  159. package/src/routing/handlers/memory-handler.ts +273 -0
  160. package/src/routing/handlers/mutation-handler.ts +241 -0
  161. package/src/routing/handlers/recall-handler.ts +642 -0
  162. package/src/routing/handlers/shared.ts +515 -0
  163. package/src/routing/handlers/types.ts +48 -0
  164. package/src/routing/intent-classifier.ts +552 -552
  165. package/src/routing/response-filter.ts +399 -391
  166. package/src/routing/router.ts +245 -2193
  167. package/src/routing/search-engine.ts +521 -514
  168. package/src/routing/types.ts +104 -94
  169. package/src/scripts/health-check.ts +118 -118
  170. package/src/scripts/setup.ts +122 -122
  171. package/src/server/auto-updater.ts +283 -276
  172. package/src/server/handlers/call-tool.ts +159 -159
  173. package/src/server/handlers/list-tools.ts +35 -35
  174. package/src/server/handlers/tools/auto-remember.ts +165 -165
  175. package/src/server/handlers/tools/brain.ts +86 -86
  176. package/src/server/handlers/tools/create-project.ts +135 -135
  177. package/src/server/handlers/tools/get-code-standards.ts +123 -123
  178. package/src/server/handlers/tools/get-corrections.ts +152 -152
  179. package/src/server/handlers/tools/get-patterns.ts +156 -156
  180. package/src/server/handlers/tools/get-project-context.ts +75 -75
  181. package/src/server/handlers/tools/index.ts +30 -30
  182. package/src/server/handlers/tools/init-project.ts +756 -756
  183. package/src/server/handlers/tools/list-projects.ts +126 -126
  184. package/src/server/handlers/tools/recall-similar.ts +87 -87
  185. package/src/server/handlers/tools/recognize-pattern.ts +132 -132
  186. package/src/server/handlers/tools/record-correction.ts +131 -131
  187. package/src/server/handlers/tools/remember-decision.ts +168 -168
  188. package/src/server/handlers/tools/schemas.ts +179 -179
  189. package/src/server/handlers/tools/search-code.ts +122 -122
  190. package/src/server/handlers/tools/smart-context.ts +146 -146
  191. package/src/server/handlers/tools/update-progress.ts +131 -131
  192. package/src/server/http-api.ts +215 -1229
  193. package/src/server/mcp-proxy.ts +85 -84
  194. package/src/server/mcp-server.ts +285 -284
  195. package/src/server/middleware/auth.ts +39 -0
  196. package/src/server/middleware/error-handler.ts +37 -0
  197. package/src/server/middleware/rate-limit.ts +53 -0
  198. package/src/server/middleware/validate.ts +42 -0
  199. package/src/server/pid-manager.ts +137 -136
  200. package/src/server/providers/resources.ts +581 -581
  201. package/src/server/routes/code.ts +228 -0
  202. package/src/server/routes/context.ts +26 -0
  203. package/src/server/routes/health.ts +19 -0
  204. package/src/server/routes/helpers.ts +100 -0
  205. package/src/server/routes/hooks.ts +197 -0
  206. package/src/server/routes/mcp.ts +47 -0
  207. package/src/server/routes/memory.ts +397 -0
  208. package/src/server/routes/models.ts +96 -0
  209. package/src/server/routes/projects.ts +89 -0
  210. package/src/server/routes/types.ts +21 -0
  211. package/src/server/schemas/api-schemas.ts +202 -0
  212. package/src/server/services.ts +720 -720
  213. package/src/server/utils/memory-indicator.ts +84 -84
  214. package/src/server/utils/response-formatter.ts +129 -129
  215. package/src/server/web-viewer.ts +1145 -1115
  216. package/src/setup/index.ts +38 -38
  217. package/src/tools/registry.ts +115 -115
  218. package/src/tools/schemas.ts +666 -666
  219. package/src/tools/types.ts +412 -412
  220. package/src/training/data-store.ts +320 -298
  221. package/src/training/retrain-pipeline.ts +399 -394
  222. package/src/utils/error-handler.ts +136 -136
  223. package/src/utils/index.ts +58 -58
  224. package/src/utils/kill-port.ts +55 -53
  225. package/src/utils/phase12-helper.ts +56 -56
  226. package/src/utils/safe-path.ts +43 -0
  227. package/src/utils/timing.ts +47 -47
  228. package/src/utils/transaction.ts +63 -63
  229. package/src/vault/index.ts +4 -3
  230. package/src/vault/paths.ts +106 -106
  231. package/src/vault/query.ts +4 -1
  232. package/src/vault/reader.ts +44 -1
  233. package/src/vault/watcher.ts +24 -1
  234. package/src/vault/writer.ts +487 -413
  235. package/skills/persistent-memory/SKILL.md +0 -148
  236. package/skills/persistent-memory/references/tool-reference.md +0 -90
@@ -1,413 +1,487 @@
1
- /**
2
- * Vault File Writer
3
- * Utilities for safely updating vault content with atomic writes and backups
4
- */
5
-
6
- import fs from 'fs/promises'
7
- import path from 'path'
8
- import matter from 'gray-matter'
9
- import type { Logger } from 'pino'
10
- import type { Frontmatter } from './types'
11
- import { TEMPLATES, renderTemplate, createProjectVariables, getTodayDate } from './templates'
12
- import { RetryManager } from '@/utils'
13
-
14
- export interface WriteOptions {
15
- createBackup?: boolean
16
- ensureDirectory?: boolean
17
- }
18
-
19
- export class VaultWriter {
20
- private logger: Logger
21
- private backupDir: string
22
- private retryManager: RetryManager
23
-
24
- constructor(logger: Logger, backupDir: string) {
25
- this.logger = logger.child({ component: 'vault-writer' })
26
- this.backupDir = backupDir
27
- this.retryManager = new RetryManager(logger)
28
- }
29
-
30
- /**
31
- * Write markdown file with frontmatter atomically
32
- * Falls back to direct write if rename fails (e.g., Obsidian has file open)
33
- */
34
- async writeMarkdownFile(
35
- filePath: string,
36
- frontmatter: Frontmatter,
37
- content: string,
38
- options: WriteOptions = {}
39
- ): Promise<void> {
40
- return this.retryManager.execute(
41
- async () => {
42
- const { createBackup = true, ensureDirectory = true } = options
43
-
44
- try {
45
- if (createBackup && (await this.fileExists(filePath))) {
46
- await this.createBackup(filePath)
47
- }
48
-
49
- const fileContent = matter.stringify(content, frontmatter)
50
-
51
- if (ensureDirectory) {
52
- await fs.mkdir(path.dirname(filePath), { recursive: true })
53
- }
54
-
55
- // Try atomic write first (temp file + rename)
56
- const tempPath = `${filePath}.tmp`
57
- try {
58
- await fs.writeFile(tempPath, fileContent, 'utf-8')
59
- await fs.rename(tempPath, filePath)
60
- } catch (renameError) {
61
- // If rename fails (EPERM - file locked by Obsidian), write directly
62
- const isPermError = (renameError as NodeJS.ErrnoException).code === 'EPERM'
63
- if (isPermError) {
64
- this.logger.warn({ filePath }, 'Atomic write failed (file locked), using direct write')
65
- await fs.writeFile(filePath, fileContent, 'utf-8')
66
- // Clean up temp file
67
- try {
68
- await fs.unlink(tempPath)
69
- } catch {}
70
- } else {
71
- throw renameError
72
- }
73
- }
74
-
75
- this.logger.info({ filePath }, 'File written successfully')
76
- } catch (error) {
77
- try {
78
- await fs.unlink(`${filePath}.tmp`)
79
- } catch {
80
- }
81
-
82
- this.logger.error({ error, filePath }, 'Failed to write file')
83
- throw new Error(`Failed to write file ${filePath}: ${error}`)
84
- }
85
- },
86
- {
87
- maxRetries: 3,
88
- retryCondition: RetryManager.isRetryableError,
89
- onRetry: (error, attempt) => {
90
- this.logger.warn(
91
- { error: error.message, attempt, filePath },
92
- 'Retrying file write'
93
- )
94
- }
95
- }
96
- )
97
- }
98
-
99
- /**
100
- * Update only the frontmatter of a file
101
- */
102
- async updateFrontmatter(
103
- filePath: string,
104
- updates: Partial<Frontmatter>
105
- ): Promise<void> {
106
- try {
107
- // Read existing file
108
- const rawContent = await fs.readFile(filePath, 'utf-8')
109
- const { data, content } = matter(rawContent)
110
-
111
- // Merge updates and update timestamp
112
- const newFrontmatter: Frontmatter = {
113
- ...(data as Frontmatter),
114
- ...updates,
115
- updated: getTodayDate()
116
- }
117
-
118
- // Write back
119
- await this.writeMarkdownFile(filePath, newFrontmatter, content, {
120
- createBackup: true
121
- })
122
-
123
- this.logger.info({ filePath, updates }, 'Frontmatter updated')
124
- } catch (error) {
125
- this.logger.error({ error, filePath }, 'Failed to update frontmatter')
126
- throw error
127
- }
128
- }
129
-
130
- /**
131
- * Append content to a file
132
- * Creates the file (and parent directories) if it doesn't exist
133
- */
134
- async appendContent(
135
- filePath: string,
136
- newContent: string,
137
- separator: string = '\n\n'
138
- ): Promise<void> {
139
- try {
140
- let rawContent: string
141
- try {
142
- rawContent = await fs.readFile(filePath, 'utf-8')
143
- } catch (readError: any) {
144
- if (readError.code === 'ENOENT') {
145
- // File doesn't exist — create it with the new content
146
- const today = getTodayDate()
147
- const frontmatter: Frontmatter = {
148
- created: today,
149
- updated: today
150
- }
151
- await this.writeMarkdownFile(filePath, frontmatter, newContent, {
152
- createBackup: false,
153
- ensureDirectory: true
154
- })
155
- this.logger.info({ filePath }, 'File created with initial content')
156
- return
157
- }
158
- throw readError
159
- }
160
-
161
- const { data, content } = matter(rawContent)
162
-
163
- // Append new content
164
- const updatedContent = content.trim() + separator + newContent
165
-
166
- // Update timestamp in frontmatter
167
- const today = getTodayDate()
168
- const updatedFrontmatter: Frontmatter = {
169
- ...(data as Frontmatter),
170
- updated: today,
171
- last_updated: today
172
- }
173
-
174
- // Write back
175
- await this.writeMarkdownFile(filePath, updatedFrontmatter, updatedContent, {
176
- createBackup: true
177
- })
178
-
179
- this.logger.info({ filePath }, 'Content appended')
180
- } catch (error) {
181
- this.logger.error({ error, filePath }, 'Failed to append content')
182
- throw error
183
- }
184
- }
185
-
186
- /**
187
- * Prepend content to a file (after frontmatter)
188
- */
189
- async prependContent(
190
- filePath: string,
191
- newContent: string,
192
- separator: string = '\n\n'
193
- ): Promise<void> {
194
- try {
195
- // Read existing file
196
- const rawContent = await fs.readFile(filePath, 'utf-8')
197
- const { data, content } = matter(rawContent)
198
-
199
- // Prepend new content
200
- const updatedContent = newContent + separator + content.trim()
201
-
202
- // Update timestamp in frontmatter
203
- const updatedFrontmatter: Frontmatter = {
204
- ...(data as Frontmatter),
205
- updated: getTodayDate()
206
- }
207
-
208
- // Write back
209
- await this.writeMarkdownFile(filePath, updatedFrontmatter, updatedContent, {
210
- createBackup: true
211
- })
212
-
213
- this.logger.info({ filePath }, 'Content prepended')
214
- } catch (error) {
215
- this.logger.error({ error, filePath }, 'Failed to prepend content')
216
- throw error
217
- }
218
- }
219
-
220
- /**
221
- * Replace a section in a file by header
222
- */
223
- async replaceSection(
224
- filePath: string,
225
- sectionHeader: string,
226
- newSectionContent: string
227
- ): Promise<boolean> {
228
- try {
229
- const rawContent = await fs.readFile(filePath, 'utf-8')
230
- const { data, content } = matter(rawContent)
231
-
232
- // Find the section using regex
233
- const headerPattern = new RegExp(
234
- `(^|\\n)(#{1,6}\\s*${this.escapeRegex(sectionHeader)}\\s*\\n)`,
235
- 'i'
236
- )
237
- const match = content.match(headerPattern)
238
-
239
- if (!match || !match[1] || !match[2]) {
240
- this.logger.warn({ filePath, sectionHeader }, 'Section not found')
241
- return false
242
- }
243
-
244
- // Find the end of the section (next header of same or higher level)
245
- const headerMatch = match[2].match(/^#+/)
246
- const headerLevel = headerMatch?.[0]?.length ?? 1
247
- const startIndex = (match.index ?? 0) + match[0].length
248
- const remainingContent = content.substring(startIndex)
249
-
250
- const nextHeaderPattern = new RegExp(`\\n#{1,${headerLevel}}\\s+`)
251
- const nextMatch = remainingContent.match(nextHeaderPattern)
252
- const endIndex = nextMatch
253
- ? startIndex + (nextMatch.index ?? 0)
254
- : content.length
255
-
256
- // Build new content
257
- const before = content.substring(0, (match.index ?? 0) + match[1].length)
258
- const sectionTitle = match[2]
259
- const after = content.substring(endIndex)
260
- const updatedContent = before + sectionTitle + newSectionContent + after
261
-
262
- // Update frontmatter timestamp
263
- const updatedFrontmatter: Frontmatter = {
264
- ...(data as Frontmatter),
265
- updated: getTodayDate()
266
- }
267
-
268
- await this.writeMarkdownFile(filePath, updatedFrontmatter, updatedContent, {
269
- createBackup: true
270
- })
271
-
272
- this.logger.info({ filePath, sectionHeader }, 'Section replaced')
273
- return true
274
- } catch (error) {
275
- this.logger.error({ error, filePath }, 'Failed to replace section')
276
- throw error
277
- }
278
- }
279
-
280
- /**
281
- * Create a backup of a file
282
- */
283
- private async createBackup(filePath: string): Promise<string | null> {
284
- try {
285
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
286
- const basename = path.basename(filePath)
287
- const backupPath = path.join(this.backupDir, `${basename}.${timestamp}.bak`)
288
-
289
- await fs.mkdir(this.backupDir, { recursive: true })
290
- await fs.copyFile(filePath, backupPath)
291
-
292
- this.logger.debug({ filePath, backupPath }, 'Backup created')
293
- return backupPath
294
- } catch (error) {
295
- this.logger.warn({ error, filePath }, 'Failed to create backup')
296
- // Don't throw - backup failure shouldn't prevent write
297
- return null
298
- }
299
- }
300
-
301
- /**
302
- * Check if file exists
303
- */
304
- private async fileExists(filePath: string): Promise<boolean> {
305
- try {
306
- await fs.access(filePath)
307
- return true
308
- } catch {
309
- return false
310
- }
311
- }
312
-
313
- /**
314
- * Delete a file
315
- */
316
- async deleteFile(
317
- filePath: string,
318
- createBackup: boolean = true
319
- ): Promise<void> {
320
- try {
321
- if (createBackup) {
322
- await this.createBackup(filePath)
323
- }
324
-
325
- await fs.unlink(filePath)
326
- this.logger.info({ filePath }, 'File deleted')
327
- } catch (error) {
328
- this.logger.error({ error, filePath }, 'Failed to delete file')
329
- throw error
330
- }
331
- }
332
-
333
- /**
334
- * Create directory structure for a new project
335
- */
336
- async createProjectStructure(
337
- projectPath: string,
338
- projectName: string
339
- ): Promise<void> {
340
- try {
341
- await fs.mkdir(projectPath, { recursive: true })
342
-
343
- const variables = createProjectVariables(projectName)
344
-
345
- // Create each file from template
346
- const files = [
347
- { name: 'context.md', template: TEMPLATES.PROJECT_CONTEXT },
348
- { name: 'decisions.md', template: TEMPLATES.DECISIONS_LOG },
349
- { name: 'progress.md', template: TEMPLATES.PROGRESS_TRACKER },
350
- { name: 'standards.md', template: TEMPLATES.CODING_STANDARDS },
351
- { name: 'patterns.md', template: TEMPLATES.PATTERNS_LOG },
352
- { name: 'corrections.md', template: TEMPLATES.CORRECTIONS_LOG }
353
- ]
354
-
355
- for (const file of files) {
356
- const content = renderTemplate(file.template, variables)
357
- const filePath = path.join(projectPath, file.name)
358
-
359
- // Parse the rendered template to separate frontmatter and content
360
- const { data, content: body } = matter(content)
361
-
362
- await this.writeMarkdownFile(filePath, data as Frontmatter, body, {
363
- createBackup: false,
364
- ensureDirectory: false
365
- })
366
- }
367
-
368
- this.logger.info({ projectPath, projectName }, 'Project structure created')
369
- } catch (error) {
370
- this.logger.error({ error, projectPath }, 'Failed to create project')
371
- throw error
372
- }
373
- }
374
-
375
- /**
376
- * Clean old backups (keep only recent ones)
377
- */
378
- async cleanOldBackups(maxAge: number = 7 * 24 * 60 * 60 * 1000): Promise<number> {
379
- try {
380
- const entries = await fs.readdir(this.backupDir, { withFileTypes: true })
381
- const now = Date.now()
382
- let cleaned = 0
383
-
384
- for (const entry of entries) {
385
- if (!entry.isFile() || !entry.name.endsWith('.bak')) continue
386
-
387
- const filePath = path.join(this.backupDir, entry.name)
388
- const stats = await fs.stat(filePath)
389
-
390
- if (now - stats.mtimeMs > maxAge) {
391
- await fs.unlink(filePath)
392
- cleaned++
393
- }
394
- }
395
-
396
- if (cleaned > 0) {
397
- this.logger.info({ cleaned }, 'Old backups cleaned')
398
- }
399
-
400
- return cleaned
401
- } catch (error) {
402
- this.logger.warn({ error }, 'Failed to clean old backups')
403
- return 0
404
- }
405
- }
406
-
407
- /**
408
- * Escape special regex characters
409
- */
410
- private escapeRegex(str: string): string {
411
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
412
- }
413
- }
1
+ /**
2
+ * Vault File Writer
3
+ * Utilities for safely updating vault content with atomic writes and backups
4
+ */
5
+
6
+ import fs from 'fs/promises'
7
+ import path from 'path'
8
+ import matter from 'gray-matter'
9
+ import type { Logger } from 'pino'
10
+ import type { Frontmatter } from './types'
11
+ import { TEMPLATES, renderTemplate, createProjectVariables, getTodayDate } from './templates'
12
+ import { RetryManager } from '@/utils'
13
+ import { validatePath } from '@/utils/safe-path'
14
+
15
+ export interface WriteOptions {
16
+ createBackup?: boolean
17
+ ensureDirectory?: boolean
18
+ }
19
+
20
+ export class VaultWriter {
21
+ private logger: Logger
22
+ private backupDir: string
23
+ private retryManager: RetryManager
24
+ private baseDir: string
25
+
26
+ constructor(logger: Logger, backupDir: string, baseDir: string = '') {
27
+ this.logger = logger.child({ component: 'vault-writer' })
28
+ this.backupDir = backupDir
29
+ this.baseDir = baseDir
30
+ this.retryManager = new RetryManager(logger)
31
+ }
32
+
33
+ /**
34
+ * Write markdown file with frontmatter atomically
35
+ * Falls back to direct write if rename fails (e.g., Obsidian has file open)
36
+ */
37
+ async writeMarkdownFile(
38
+ filePath: string,
39
+ frontmatter: Frontmatter,
40
+ content: string,
41
+ options: WriteOptions = {}
42
+ ): Promise<void> {
43
+ // Validate path against base directory
44
+ if (this.baseDir) {
45
+ try {
46
+ validatePath(filePath, this.baseDir)
47
+ } catch (error) {
48
+ this.logger.error({ error, filePath, baseDir: this.baseDir }, 'Path traversal blocked in writeMarkdownFile')
49
+ throw new Error(`Path validation failed for ${filePath}: ${error}`)
50
+ }
51
+ }
52
+
53
+ return this.retryManager.execute(
54
+ async () => {
55
+ const { createBackup = true, ensureDirectory = true } = options
56
+
57
+ try {
58
+ if (createBackup && (await this.fileExists(filePath))) {
59
+ await this.createBackup(filePath)
60
+ }
61
+
62
+ const fileContent = matter.stringify(content, frontmatter)
63
+
64
+ if (ensureDirectory) {
65
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
66
+ }
67
+
68
+ // Try atomic write first (temp file + rename)
69
+ const tempPath = `${filePath}.tmp`
70
+ try {
71
+ await fs.writeFile(tempPath, fileContent, 'utf-8')
72
+ await fs.rename(tempPath, filePath)
73
+ } catch (renameError) {
74
+ // If rename fails (EPERM - file locked by Obsidian), write directly
75
+ const isPermError = (renameError as NodeJS.ErrnoException).code === 'EPERM'
76
+ if (isPermError) {
77
+ this.logger.warn({ filePath }, 'Atomic write failed (file locked), using direct write')
78
+ await fs.writeFile(filePath, fileContent, 'utf-8')
79
+ // Clean up temp file
80
+ try {
81
+ await fs.unlink(tempPath)
82
+ } catch {}
83
+ } else {
84
+ throw renameError
85
+ }
86
+ }
87
+
88
+ this.logger.info({ filePath }, 'File written successfully')
89
+ } catch (error) {
90
+ try {
91
+ await fs.unlink(`${filePath}.tmp`)
92
+ } catch {
93
+ // Temp file cleanup — safe to ignore if already removed
94
+ }
95
+
96
+ this.logger.error({ error, filePath }, 'Failed to write file')
97
+ throw new Error(`Failed to write file ${filePath}: ${error}`)
98
+ }
99
+ },
100
+ {
101
+ maxRetries: 3,
102
+ retryCondition: RetryManager.isRetryableError,
103
+ onRetry: (error, attempt) => {
104
+ this.logger.warn(
105
+ { error: error.message, attempt, filePath },
106
+ 'Retrying file write'
107
+ )
108
+ }
109
+ }
110
+ )
111
+ }
112
+
113
+ /**
114
+ * Update only the frontmatter of a file
115
+ */
116
+ async updateFrontmatter(
117
+ filePath: string,
118
+ updates: Partial<Frontmatter>
119
+ ): Promise<void> {
120
+ // Validate path against base directory
121
+ if (this.baseDir) {
122
+ try {
123
+ validatePath(filePath, this.baseDir)
124
+ } catch (error) {
125
+ this.logger.error({ error, filePath, baseDir: this.baseDir }, 'Path traversal blocked in updateFrontmatter')
126
+ throw new Error(`Path validation failed for ${filePath}: ${error}`)
127
+ }
128
+ }
129
+
130
+ try {
131
+ // Read existing file
132
+ const rawContent = await fs.readFile(filePath, 'utf-8')
133
+ const { data, content } = matter(rawContent)
134
+
135
+ // Merge updates and update timestamp
136
+ const newFrontmatter: Frontmatter = {
137
+ ...(data as Frontmatter),
138
+ ...updates,
139
+ updated: getTodayDate()
140
+ }
141
+
142
+ // Write back
143
+ await this.writeMarkdownFile(filePath, newFrontmatter, content, {
144
+ createBackup: true
145
+ })
146
+
147
+ this.logger.info({ filePath, updates }, 'Frontmatter updated')
148
+ } catch (error) {
149
+ this.logger.error({ error, filePath }, 'Failed to update frontmatter')
150
+ throw error
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Append content to a file
156
+ * Creates the file (and parent directories) if it doesn't exist
157
+ */
158
+ async appendContent(
159
+ filePath: string,
160
+ newContent: string,
161
+ separator: string = '\n\n'
162
+ ): Promise<void> {
163
+ // Validate path against base directory
164
+ if (this.baseDir) {
165
+ try {
166
+ validatePath(filePath, this.baseDir)
167
+ } catch (error) {
168
+ this.logger.error({ error, filePath, baseDir: this.baseDir }, 'Path traversal blocked in appendContent')
169
+ throw new Error(`Path validation failed for ${filePath}: ${error}`)
170
+ }
171
+ }
172
+
173
+ try {
174
+ let rawContent: string
175
+ try {
176
+ rawContent = await fs.readFile(filePath, 'utf-8')
177
+ } catch (readError: unknown) {
178
+ if ((readError as Record<string, unknown>)?.code === 'ENOENT') {
179
+ // File doesn't exist — create it with the new content
180
+ const today = getTodayDate()
181
+ const frontmatter: Frontmatter = {
182
+ created: today,
183
+ updated: today
184
+ }
185
+ await this.writeMarkdownFile(filePath, frontmatter, newContent, {
186
+ createBackup: false,
187
+ ensureDirectory: true
188
+ })
189
+ this.logger.info({ filePath }, 'File created with initial content')
190
+ return
191
+ }
192
+ throw readError
193
+ }
194
+
195
+ const { data, content } = matter(rawContent)
196
+
197
+ // Append new content
198
+ const updatedContent = content.trim() + separator + newContent
199
+
200
+ // Update timestamp in frontmatter
201
+ const today = getTodayDate()
202
+ const updatedFrontmatter: Frontmatter = {
203
+ ...(data as Frontmatter),
204
+ updated: today,
205
+ last_updated: today
206
+ }
207
+
208
+ // Write back
209
+ await this.writeMarkdownFile(filePath, updatedFrontmatter, updatedContent, {
210
+ createBackup: true
211
+ })
212
+
213
+ this.logger.info({ filePath }, 'Content appended')
214
+ } catch (error) {
215
+ this.logger.error({ error, filePath }, 'Failed to append content')
216
+ throw error
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Prepend content to a file (after frontmatter)
222
+ */
223
+ async prependContent(
224
+ filePath: string,
225
+ newContent: string,
226
+ separator: string = '\n\n'
227
+ ): Promise<void> {
228
+ // Validate path against base directory
229
+ if (this.baseDir) {
230
+ try {
231
+ validatePath(filePath, this.baseDir)
232
+ } catch (error) {
233
+ this.logger.error({ error, filePath, baseDir: this.baseDir }, 'Path traversal blocked in prependContent')
234
+ throw new Error(`Path validation failed for ${filePath}: ${error}`)
235
+ }
236
+ }
237
+
238
+ try {
239
+ // Read existing file
240
+ const rawContent = await fs.readFile(filePath, 'utf-8')
241
+ const { data, content } = matter(rawContent)
242
+
243
+ // Prepend new content
244
+ const updatedContent = newContent + separator + content.trim()
245
+
246
+ // Update timestamp in frontmatter
247
+ const updatedFrontmatter: Frontmatter = {
248
+ ...(data as Frontmatter),
249
+ updated: getTodayDate()
250
+ }
251
+
252
+ // Write back
253
+ await this.writeMarkdownFile(filePath, updatedFrontmatter, updatedContent, {
254
+ createBackup: true
255
+ })
256
+
257
+ this.logger.info({ filePath }, 'Content prepended')
258
+ } catch (error) {
259
+ this.logger.error({ error, filePath }, 'Failed to prepend content')
260
+ throw error
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Replace a section in a file by header
266
+ */
267
+ async replaceSection(
268
+ filePath: string,
269
+ sectionHeader: string,
270
+ newSectionContent: string
271
+ ): Promise<boolean> {
272
+ // Validate path against base directory
273
+ if (this.baseDir) {
274
+ try {
275
+ validatePath(filePath, this.baseDir)
276
+ } catch (error) {
277
+ this.logger.error({ error, filePath, baseDir: this.baseDir }, 'Path traversal blocked in replaceSection')
278
+ throw new Error(`Path validation failed for ${filePath}: ${error}`)
279
+ }
280
+ }
281
+
282
+ try {
283
+ const rawContent = await fs.readFile(filePath, 'utf-8')
284
+ const { data, content } = matter(rawContent)
285
+
286
+ // Find the section using regex
287
+ const headerPattern = new RegExp(
288
+ `(^|\\n)(#{1,6}\\s*${this.escapeRegex(sectionHeader)}\\s*\\n)`,
289
+ 'i'
290
+ )
291
+ const match = content.match(headerPattern)
292
+
293
+ if (!match || !match[1] || !match[2]) {
294
+ this.logger.warn({ filePath, sectionHeader }, 'Section not found')
295
+ return false
296
+ }
297
+
298
+ // Find the end of the section (next header of same or higher level)
299
+ const headerMatch = match[2].match(/^#+/)
300
+ const headerLevel = headerMatch?.[0]?.length ?? 1
301
+ const startIndex = (match.index ?? 0) + match[0].length
302
+ const remainingContent = content.substring(startIndex)
303
+
304
+ const nextHeaderPattern = new RegExp(`\\n#{1,${headerLevel}}\\s+`)
305
+ const nextMatch = remainingContent.match(nextHeaderPattern)
306
+ const endIndex = nextMatch
307
+ ? startIndex + (nextMatch.index ?? 0)
308
+ : content.length
309
+
310
+ // Build new content
311
+ const before = content.substring(0, (match.index ?? 0) + match[1].length)
312
+ const sectionTitle = match[2]
313
+ const after = content.substring(endIndex)
314
+ const updatedContent = before + sectionTitle + newSectionContent + after
315
+
316
+ // Update frontmatter timestamp
317
+ const updatedFrontmatter: Frontmatter = {
318
+ ...(data as Frontmatter),
319
+ updated: getTodayDate()
320
+ }
321
+
322
+ await this.writeMarkdownFile(filePath, updatedFrontmatter, updatedContent, {
323
+ createBackup: true
324
+ })
325
+
326
+ this.logger.info({ filePath, sectionHeader }, 'Section replaced')
327
+ return true
328
+ } catch (error) {
329
+ this.logger.error({ error, filePath }, 'Failed to replace section')
330
+ throw error
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Create a backup of a file
336
+ */
337
+ private async createBackup(filePath: string): Promise<string | null> {
338
+ try {
339
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
340
+ const basename = path.basename(filePath)
341
+ const backupPath = path.join(this.backupDir, `${basename}.${timestamp}.bak`)
342
+
343
+ await fs.mkdir(this.backupDir, { recursive: true })
344
+ await fs.copyFile(filePath, backupPath)
345
+
346
+ this.logger.debug({ filePath, backupPath }, 'Backup created')
347
+ return backupPath
348
+ } catch (error) {
349
+ this.logger.warn({ error, filePath }, 'Failed to create backup')
350
+ // Don't throw - backup failure shouldn't prevent write
351
+ return null
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Check if file exists
357
+ */
358
+ private async fileExists(filePath: string): Promise<boolean> {
359
+ try {
360
+ await fs.access(filePath)
361
+ return true
362
+ } catch {
363
+ return false
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Delete a file
369
+ */
370
+ async deleteFile(
371
+ filePath: string,
372
+ createBackup: boolean = true
373
+ ): Promise<void> {
374
+ // Validate path against base directory
375
+ if (this.baseDir) {
376
+ try {
377
+ validatePath(filePath, this.baseDir)
378
+ } catch (error) {
379
+ this.logger.error({ error, filePath, baseDir: this.baseDir }, 'Path traversal blocked in deleteFile')
380
+ throw new Error(`Path validation failed for ${filePath}: ${error}`)
381
+ }
382
+ }
383
+
384
+ try {
385
+ if (createBackup) {
386
+ await this.createBackup(filePath)
387
+ }
388
+
389
+ await fs.unlink(filePath)
390
+ this.logger.info({ filePath }, 'File deleted')
391
+ } catch (error) {
392
+ this.logger.error({ error, filePath }, 'Failed to delete file')
393
+ throw error
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Create directory structure for a new project
399
+ */
400
+ async createProjectStructure(
401
+ projectPath: string,
402
+ projectName: string
403
+ ): Promise<void> {
404
+ // Validate path against base directory
405
+ if (this.baseDir) {
406
+ try {
407
+ validatePath(projectPath, this.baseDir)
408
+ } catch (error) {
409
+ this.logger.error({ error, projectPath, baseDir: this.baseDir }, 'Path traversal blocked in createProjectStructure')
410
+ throw new Error(`Path validation failed for ${projectPath}: ${error}`)
411
+ }
412
+ }
413
+
414
+ try {
415
+ await fs.mkdir(projectPath, { recursive: true })
416
+
417
+ const variables = createProjectVariables(projectName)
418
+
419
+ // Create each file from template
420
+ const files = [
421
+ { name: 'context.md', template: TEMPLATES.PROJECT_CONTEXT },
422
+ { name: 'decisions.md', template: TEMPLATES.DECISIONS_LOG },
423
+ { name: 'progress.md', template: TEMPLATES.PROGRESS_TRACKER },
424
+ { name: 'standards.md', template: TEMPLATES.CODING_STANDARDS },
425
+ { name: 'patterns.md', template: TEMPLATES.PATTERNS_LOG },
426
+ { name: 'corrections.md', template: TEMPLATES.CORRECTIONS_LOG }
427
+ ]
428
+
429
+ for (const file of files) {
430
+ const content = renderTemplate(file.template, variables)
431
+ const filePath = path.join(projectPath, file.name)
432
+
433
+ // Parse the rendered template to separate frontmatter and content
434
+ const { data, content: body } = matter(content)
435
+
436
+ await this.writeMarkdownFile(filePath, data as Frontmatter, body, {
437
+ createBackup: false,
438
+ ensureDirectory: false
439
+ })
440
+ }
441
+
442
+ this.logger.info({ projectPath, projectName }, 'Project structure created')
443
+ } catch (error) {
444
+ this.logger.error({ error, projectPath }, 'Failed to create project')
445
+ throw error
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Clean old backups (keep only recent ones)
451
+ */
452
+ async cleanOldBackups(maxAge: number = 7 * 24 * 60 * 60 * 1000): Promise<number> {
453
+ try {
454
+ const entries = await fs.readdir(this.backupDir, { withFileTypes: true })
455
+ const now = Date.now()
456
+ let cleaned = 0
457
+
458
+ for (const entry of entries) {
459
+ if (!entry.isFile() || !entry.name.endsWith('.bak')) continue
460
+
461
+ const filePath = path.join(this.backupDir, entry.name)
462
+ const stats = await fs.stat(filePath)
463
+
464
+ if (now - stats.mtimeMs > maxAge) {
465
+ await fs.unlink(filePath)
466
+ cleaned++
467
+ }
468
+ }
469
+
470
+ if (cleaned > 0) {
471
+ this.logger.info({ cleaned }, 'Old backups cleaned')
472
+ }
473
+
474
+ return cleaned
475
+ } catch (error) {
476
+ this.logger.warn({ error }, 'Failed to clean old backups')
477
+ return 0
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Escape special regex characters
483
+ */
484
+ private escapeRegex(str: string): string {
485
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
486
+ }
487
+ }