@neuralis/brain-core 0.3.2 → 0.4.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 (311) hide show
  1. package/README.md +79 -1409
  2. package/app/README.md +1 -1
  3. package/app/cards/FilesystemCardShell.tsx +42 -4
  4. package/app/cards/FilesystemChangeCard.tsx +34 -11
  5. package/app/cards/README.md +6 -5
  6. package/app/cards/diffUtils.ts +99 -1
  7. package/app/filesystem/FilesystemHost.tsx +17 -2
  8. package/app/filesystem/api/README.md +3 -3
  9. package/app/filesystem/api/index.ts +35 -11
  10. package/app/filesystem/api/projectPackages.ts +13 -102
  11. package/app/filesystem/domain/types.ts +30 -10
  12. package/app/filesystem/hooks/README.md +3 -3
  13. package/app/filesystem/hooks/useDiscoverableSources.ts +19 -30
  14. package/app/filesystem/hooks/useFilesystemSSE.ts +1 -1
  15. package/app/filesystem/host/README.md +5 -3
  16. package/app/filesystem/host/buildFilesystemHostValue.ts +19 -0
  17. package/app/filesystem/host/installFilesystemHostComponents.tsx +1 -1
  18. package/app/filesystem/lib/sourceKindForm.ts +118 -39
  19. package/app/filesystem/lib/treeVisualState.tsx +98 -4
  20. package/app/filesystem/store/README.md +1 -0
  21. package/app/filesystem/store/filesystemStore.ts +86 -5
  22. package/app/filesystem/store/gitSlice.ts +5 -0
  23. package/app/filesystem/store/helpers.ts +17 -0
  24. package/app/filesystem/store/listSlice.ts +8 -0
  25. package/app/filesystem/ui/CodeEditor.tsx +18 -3
  26. package/app/filesystem/ui/FileEditor.tsx +15 -10
  27. package/app/filesystem/ui/FileTree.tsx +41 -16
  28. package/app/filesystem/ui/FolderPicker.tsx +251 -0
  29. package/app/filesystem/ui/GitControlPanel.tsx +5 -0
  30. package/app/filesystem/ui/PackagesPanel.tsx +52 -374
  31. package/app/filesystem/ui/PolicyEditor.tsx +109 -24
  32. package/app/filesystem/ui/README.md +17 -11
  33. package/app/filesystem/ui/RawMediaPreview.tsx +58 -0
  34. package/app/filesystem/ui/SourceBranchHeader.tsx +4 -4
  35. package/app/filesystem/ui/SourcesPanel.tsx +420 -360
  36. package/app/filesystem/ui/Toolbar.tsx +1 -2
  37. package/app/shared/filesystem.png +0 -0
  38. package/dist/app/host.js +57 -49
  39. package/dist/scripts/sourceRootPairV1.d.ts +47 -0
  40. package/dist/scripts/sourceRootPairV1.d.ts.map +1 -0
  41. package/dist/scripts/sourceRootPairV1.js +147 -0
  42. package/dist/scripts/sourceRootPairV1.js.map +1 -0
  43. package/dist/scripts/sourceToFolderV1.d.ts +56 -0
  44. package/dist/scripts/sourceToFolderV1.d.ts.map +1 -0
  45. package/dist/scripts/sourceToFolderV1.js +152 -0
  46. package/dist/scripts/sourceToFolderV1.js.map +1 -0
  47. package/dist/scripts/sourceToHostV1.d.ts +56 -0
  48. package/dist/scripts/sourceToHostV1.d.ts.map +1 -0
  49. package/dist/scripts/sourceToHostV1.js +132 -0
  50. package/dist/scripts/sourceToHostV1.js.map +1 -0
  51. package/dist/src/connectors/AppSourceConfigStore.d.ts +17 -3
  52. package/dist/src/connectors/AppSourceConfigStore.d.ts.map +1 -1
  53. package/dist/src/connectors/AppSourceConfigStore.js +46 -6
  54. package/dist/src/connectors/AppSourceConfigStore.js.map +1 -1
  55. package/dist/src/connectors/HostConnector.d.ts +201 -0
  56. package/dist/src/connectors/HostConnector.d.ts.map +1 -0
  57. package/dist/src/connectors/HostConnector.js +903 -0
  58. package/dist/src/connectors/HostConnector.js.map +1 -0
  59. package/dist/src/connectors/inProcessTransport.d.ts +131 -0
  60. package/dist/src/connectors/inProcessTransport.d.ts.map +1 -0
  61. package/dist/src/connectors/inProcessTransport.js +447 -0
  62. package/dist/src/connectors/inProcessTransport.js.map +1 -0
  63. package/dist/src/domain/access/callerPermissions.d.ts +29 -0
  64. package/dist/src/domain/access/callerPermissions.d.ts.map +1 -1
  65. package/dist/src/domain/access/callerPermissions.js +85 -5
  66. package/dist/src/domain/access/callerPermissions.js.map +1 -1
  67. package/dist/src/domain/access/nativeLocalFence.d.ts +65 -0
  68. package/dist/src/domain/access/nativeLocalFence.d.ts.map +1 -0
  69. package/dist/src/domain/access/nativeLocalFence.js +171 -0
  70. package/dist/src/domain/access/nativeLocalFence.js.map +1 -0
  71. package/dist/src/domain/access/uriPermission.js +3 -3
  72. package/dist/src/domain/access/uriPermission.js.map +1 -1
  73. package/dist/src/domain/byteRange.d.ts +15 -0
  74. package/dist/src/domain/byteRange.d.ts.map +1 -0
  75. package/dist/src/domain/byteRange.js +30 -0
  76. package/dist/src/domain/byteRange.js.map +1 -0
  77. package/dist/src/domain/defaultPermissions.d.ts.map +1 -1
  78. package/dist/src/domain/defaultPermissions.js +29 -0
  79. package/dist/src/domain/defaultPermissions.js.map +1 -1
  80. package/dist/src/domain/mime.d.ts +8 -16
  81. package/dist/src/domain/mime.d.ts.map +1 -1
  82. package/dist/src/domain/mime.js +10 -99
  83. package/dist/src/domain/mime.js.map +1 -1
  84. package/dist/src/domain/paths.d.ts.map +1 -1
  85. package/dist/src/domain/paths.js +5 -3
  86. package/dist/src/domain/paths.js.map +1 -1
  87. package/dist/src/domain/sourceConfig.d.ts +52 -19
  88. package/dist/src/domain/sourceConfig.d.ts.map +1 -1
  89. package/dist/src/domain/sourceConfig.js +103 -19
  90. package/dist/src/domain/sourceConfig.js.map +1 -1
  91. package/dist/src/domain/sourcePackages.d.ts +8 -8
  92. package/dist/src/domain/sourcePackages.d.ts.map +1 -1
  93. package/dist/src/domain/sourceRootPair.d.ts +88 -0
  94. package/dist/src/domain/sourceRootPair.d.ts.map +1 -0
  95. package/dist/src/domain/sourceRootPair.js +108 -0
  96. package/dist/src/domain/sourceRootPair.js.map +1 -0
  97. package/dist/src/domain/syncTypes.d.ts +8 -1
  98. package/dist/src/domain/syncTypes.d.ts.map +1 -1
  99. package/dist/src/domain/syncTypes.js +1 -0
  100. package/dist/src/domain/syncTypes.js.map +1 -1
  101. package/dist/src/domain/types.d.ts +24 -1
  102. package/dist/src/domain/types.d.ts.map +1 -1
  103. package/dist/src/domain/uri.d.ts.map +1 -1
  104. package/dist/src/domain/uri.js +3 -2
  105. package/dist/src/domain/uri.js.map +1 -1
  106. package/dist/src/embeddings/clients/GeminiEmbedder.d.ts.map +1 -1
  107. package/dist/src/embeddings/clients/GeminiEmbedder.js +2 -5
  108. package/dist/src/embeddings/clients/GeminiEmbedder.js.map +1 -1
  109. package/dist/src/embeddings/clients/OpenAIEmbedder.d.ts.map +1 -1
  110. package/dist/src/embeddings/clients/OpenAIEmbedder.js +2 -3
  111. package/dist/src/embeddings/clients/OpenAIEmbedder.js.map +1 -1
  112. package/dist/src/embeddings/clients/QwenEmbedder.d.ts +2 -0
  113. package/dist/src/embeddings/clients/QwenEmbedder.d.ts.map +1 -1
  114. package/dist/src/embeddings/clients/QwenEmbedder.js +10 -2
  115. package/dist/src/embeddings/clients/QwenEmbedder.js.map +1 -1
  116. package/dist/src/embeddings/clients/VoyageEmbedder.d.ts +2 -0
  117. package/dist/src/embeddings/clients/VoyageEmbedder.d.ts.map +1 -1
  118. package/dist/src/embeddings/clients/VoyageEmbedder.js +8 -1
  119. package/dist/src/embeddings/clients/VoyageEmbedder.js.map +1 -1
  120. package/dist/src/embeddings/factory.d.ts +5 -3
  121. package/dist/src/embeddings/factory.d.ts.map +1 -1
  122. package/dist/src/embeddings/factory.js +7 -3
  123. package/dist/src/embeddings/factory.js.map +1 -1
  124. package/dist/src/embeddings/models.d.ts +1 -1
  125. package/dist/src/embeddings/models.d.ts.map +1 -1
  126. package/dist/src/embeddings/retry.js +1 -1
  127. package/dist/src/embeddings/retry.js.map +1 -1
  128. package/dist/src/embeddings/types.d.ts +6 -0
  129. package/dist/src/embeddings/types.d.ts.map +1 -1
  130. package/dist/src/embeddings/types.js +11 -0
  131. package/dist/src/embeddings/types.js.map +1 -1
  132. package/dist/src/git/GitRunner.d.ts +110 -30
  133. package/dist/src/git/GitRunner.d.ts.map +1 -1
  134. package/dist/src/git/GitRunner.js +511 -199
  135. package/dist/src/git/GitRunner.js.map +1 -1
  136. package/dist/src/git/GitWatchRegistry.d.ts +41 -35
  137. package/dist/src/git/GitWatchRegistry.d.ts.map +1 -1
  138. package/dist/src/git/GitWatchRegistry.js +61 -79
  139. package/dist/src/git/GitWatchRegistry.js.map +1 -1
  140. package/dist/src/git/gitPlace.d.ts +61 -0
  141. package/dist/src/git/gitPlace.d.ts.map +1 -0
  142. package/dist/src/git/gitPlace.js +74 -0
  143. package/dist/src/git/gitPlace.js.map +1 -0
  144. package/dist/src/git/sourceGitControl.d.ts +31 -19
  145. package/dist/src/git/sourceGitControl.d.ts.map +1 -1
  146. package/dist/src/git/sourceGitControl.js +208 -92
  147. package/dist/src/git/sourceGitControl.js.map +1 -1
  148. package/dist/src/git/sourceGitStatus.js +1 -1
  149. package/dist/src/git/sourceGitStatus.js.map +1 -1
  150. package/dist/src/git/untrackedStats.js +1 -1
  151. package/dist/src/git/untrackedStats.js.map +1 -1
  152. package/dist/src/index.d.ts +2 -1
  153. package/dist/src/index.d.ts.map +1 -1
  154. package/dist/src/index.js +2 -1
  155. package/dist/src/index.js.map +1 -1
  156. package/dist/src/lifecycle.d.ts +8 -11
  157. package/dist/src/lifecycle.d.ts.map +1 -1
  158. package/dist/src/lifecycle.js +2 -1
  159. package/dist/src/lifecycle.js.map +1 -1
  160. package/dist/src/routes/git.d.ts.map +1 -1
  161. package/dist/src/routes/git.js +183 -54
  162. package/dist/src/routes/git.js.map +1 -1
  163. package/dist/src/routes/helpers/uriGuard.d.ts +1 -1
  164. package/dist/src/routes/helpers/uriGuard.d.ts.map +1 -1
  165. package/dist/src/routes/helpers/uriGuard.js +3 -2
  166. package/dist/src/routes/helpers/uriGuard.js.map +1 -1
  167. package/dist/src/routes/helpers.d.ts +10 -3
  168. package/dist/src/routes/helpers.d.ts.map +1 -1
  169. package/dist/src/routes/helpers.js +12 -5
  170. package/dist/src/routes/helpers.js.map +1 -1
  171. package/dist/src/routes/list.d.ts.map +1 -1
  172. package/dist/src/routes/list.js +2 -3
  173. package/dist/src/routes/list.js.map +1 -1
  174. package/dist/src/routes/policies.d.ts +6 -4
  175. package/dist/src/routes/policies.d.ts.map +1 -1
  176. package/dist/src/routes/policies.js +22 -16
  177. package/dist/src/routes/policies.js.map +1 -1
  178. package/dist/src/routes/raw.d.ts.map +1 -1
  179. package/dist/src/routes/raw.js +65 -11
  180. package/dist/src/routes/raw.js.map +1 -1
  181. package/dist/src/routes/sources.d.ts +60 -18
  182. package/dist/src/routes/sources.d.ts.map +1 -1
  183. package/dist/src/routes/sources.js +411 -299
  184. package/dist/src/routes/sources.js.map +1 -1
  185. package/dist/src/services/BridgeService.d.ts +15 -35
  186. package/dist/src/services/BridgeService.d.ts.map +1 -1
  187. package/dist/src/services/BridgeService.js +30 -88
  188. package/dist/src/services/BridgeService.js.map +1 -1
  189. package/dist/src/services/FilesystemService.d.ts +3 -2
  190. package/dist/src/services/FilesystemService.d.ts.map +1 -1
  191. package/dist/src/services/FilesystemService.js +110 -32
  192. package/dist/src/services/FilesystemService.js.map +1 -1
  193. package/dist/src/services/FilesystemUiService.d.ts +4 -1
  194. package/dist/src/services/FilesystemUiService.d.ts.map +1 -1
  195. package/dist/src/services/FilesystemUiService.js +66 -25
  196. package/dist/src/services/FilesystemUiService.js.map +1 -1
  197. package/dist/src/services/SyncScheduler.d.ts +12 -4
  198. package/dist/src/services/SyncScheduler.d.ts.map +1 -1
  199. package/dist/src/services/SyncScheduler.js +37 -8
  200. package/dist/src/services/SyncScheduler.js.map +1 -1
  201. package/dist/src/services/baselineRepair.d.ts +13 -3
  202. package/dist/src/services/baselineRepair.d.ts.map +1 -1
  203. package/dist/src/services/baselineRepair.js +13 -3
  204. package/dist/src/services/baselineRepair.js.map +1 -1
  205. package/dist/src/services/declaredSources.d.ts +125 -42
  206. package/dist/src/services/declaredSources.d.ts.map +1 -1
  207. package/dist/src/services/declaredSources.js +117 -45
  208. package/dist/src/services/declaredSources.js.map +1 -1
  209. package/dist/src/store/dataFormats.d.ts +17 -2
  210. package/dist/src/store/dataFormats.d.ts.map +1 -1
  211. package/dist/src/store/dataFormats.js +18 -3
  212. package/dist/src/store/dataFormats.js.map +1 -1
  213. package/dist/src/sync/DeltaSyncRunner.d.ts.map +1 -1
  214. package/dist/src/sync/DeltaSyncRunner.js +144 -4
  215. package/dist/src/sync/DeltaSyncRunner.js.map +1 -1
  216. package/dist/src/sync/SyncJobQueue.d.ts +7 -2
  217. package/dist/src/sync/SyncJobQueue.d.ts.map +1 -1
  218. package/dist/src/sync/SyncJobQueue.js +16 -10
  219. package/dist/src/sync/SyncJobQueue.js.map +1 -1
  220. package/dist/src/sync/SyncWatchService.d.ts +21 -4
  221. package/dist/src/sync/SyncWatchService.d.ts.map +1 -1
  222. package/dist/src/sync/SyncWatchService.js +115 -10
  223. package/dist/src/sync/SyncWatchService.js.map +1 -1
  224. package/dist/src/sync/categoryInference.d.ts +1 -1
  225. package/dist/src/sync/categoryInference.js +1 -1
  226. package/dist/src/usecases/admitSourceRoot.d.ts +65 -0
  227. package/dist/src/usecases/admitSourceRoot.d.ts.map +1 -0
  228. package/dist/src/usecases/admitSourceRoot.js +85 -0
  229. package/dist/src/usecases/admitSourceRoot.js.map +1 -0
  230. package/dist/src/usecases/browseSourceFolders.d.ts +85 -0
  231. package/dist/src/usecases/browseSourceFolders.d.ts.map +1 -0
  232. package/dist/src/usecases/browseSourceFolders.js +210 -0
  233. package/dist/src/usecases/browseSourceFolders.js.map +1 -0
  234. package/dist/src/usecases/createDeclaredSources.d.ts +13 -2
  235. package/dist/src/usecases/createDeclaredSources.d.ts.map +1 -1
  236. package/dist/src/usecases/createDeclaredSources.js +39 -0
  237. package/dist/src/usecases/createDeclaredSources.js.map +1 -1
  238. package/dist/src/usecases/list.d.ts.map +1 -1
  239. package/dist/src/usecases/list.js +83 -26
  240. package/dist/src/usecases/list.js.map +1 -1
  241. package/dist/src/usecases/listAttachableDevices.d.ts +13 -0
  242. package/dist/src/usecases/listAttachableDevices.d.ts.map +1 -0
  243. package/dist/src/usecases/listAttachableDevices.js +10 -0
  244. package/dist/src/usecases/listAttachableDevices.js.map +1 -0
  245. package/dist/src/usecases/listSourcePackages.d.ts.map +1 -1
  246. package/dist/src/usecases/listSourcePackages.js +2 -5
  247. package/dist/src/usecases/listSourcePackages.js.map +1 -1
  248. package/dist/src/usecases/purgeExcludedPoints.d.ts.map +1 -1
  249. package/dist/src/usecases/purgeExcludedPoints.js +3 -2
  250. package/dist/src/usecases/purgeExcludedPoints.js.map +1 -1
  251. package/dist/src/usecases/readFiles.d.ts.map +1 -1
  252. package/dist/src/usecases/readFiles.js +15 -0
  253. package/dist/src/usecases/readFiles.js.map +1 -1
  254. package/dist/src/usecases/reconcileSourcePackageRoots.d.ts +1 -1
  255. package/dist/src/usecases/reconcileSourcePackageRoots.d.ts.map +1 -1
  256. package/dist/src/usecases/reconcileSourcePackageRoots.js +2 -1
  257. package/dist/src/usecases/reconcileSourcePackageRoots.js.map +1 -1
  258. package/dist/src/usecases/revokePrincipal.d.ts +7 -5
  259. package/dist/src/usecases/revokePrincipal.d.ts.map +1 -1
  260. package/dist/src/usecases/revokePrincipal.js +29 -7
  261. package/dist/src/usecases/revokePrincipal.js.map +1 -1
  262. package/dist/src/usecases/scheduleSourceSync.d.ts +7 -0
  263. package/dist/src/usecases/scheduleSourceSync.d.ts.map +1 -1
  264. package/dist/src/usecases/scheduleSourceSync.js +6 -5
  265. package/dist/src/usecases/scheduleSourceSync.js.map +1 -1
  266. package/dist/src/usecases/triggerSourceSync.d.ts +46 -0
  267. package/dist/src/usecases/triggerSourceSync.d.ts.map +1 -0
  268. package/dist/src/usecases/triggerSourceSync.js +75 -0
  269. package/dist/src/usecases/triggerSourceSync.js.map +1 -0
  270. package/dist/src/usecases/uploadBinary.d.ts +2 -1
  271. package/dist/src/usecases/uploadBinary.d.ts.map +1 -1
  272. package/dist/src/usecases/uploadBinary.js +2 -2
  273. package/dist/src/usecases/uploadBinary.js.map +1 -1
  274. package/dist/src/usecases/vector/endpointGuard.d.ts +1 -1
  275. package/dist/src/usecases/vector/endpointGuard.js +1 -1
  276. package/dist/src/vector/qdrantClient.d.ts +1 -1
  277. package/dist/src/vector/qdrantClient.js +1 -1
  278. package/dist/src/wiring.d.ts +3 -0
  279. package/dist/src/wiring.d.ts.map +1 -1
  280. package/dist/src/wiring.js +72 -6
  281. package/dist/src/wiring.js.map +1 -1
  282. package/package.json +145 -54
  283. package/skills/connect-business-data/SKILL.md +2 -2
  284. package/skills/create-package/SKILL.md +18 -18
  285. package/skills/create-package/references/config-and-credentials.md +16 -13
  286. package/skills/create-package/references/connectors-and-uri-policies.md +36 -27
  287. package/skills/create-package/references/manifest-and-discovery.md +12 -12
  288. package/skills/create-package/references/runtime-and-build.md +18 -18
  289. package/skills/create-package/references/skills-and-context.md +35 -19
  290. package/skills/create-package/references/surfaces-and-design.md +16 -16
  291. package/skills/create-package/references/team-and-workflows.md +2 -2
  292. package/skills/create-package/references/test-and-ship.md +17 -17
  293. package/skills/create-package/references/tools-and-schemas.md +5 -5
  294. package/skills/create-package/scripts/{check-package.sh → check-package.mjs} +78 -64
  295. package/skills/create-package/scripts/{verify-loaded.sh → verify-loaded.mjs} +156 -162
  296. package/skills/git/SKILL.md +31 -29
  297. package/skills/manage-files/SKILL.md +11 -14
  298. package/skills/manage-sources/SKILL.md +85 -45
  299. package/skills/manage-uri-policy/SKILL.md +9 -6
  300. package/skills/sync/SKILL.md +1 -1
  301. package/dist/src/connectors/LocalDiskConnector.d.ts +0 -171
  302. package/dist/src/connectors/LocalDiskConnector.d.ts.map +0 -1
  303. package/dist/src/connectors/LocalDiskConnector.js +0 -1233
  304. package/dist/src/connectors/LocalDiskConnector.js.map +0 -1
  305. package/skills/manage-files/scripts/copy.sh +0 -7
  306. package/skills/manage-files/scripts/create.sh +0 -7
  307. package/skills/manage-files/scripts/mkdir.sh +0 -7
  308. package/skills/manage-files/scripts/modify.sh +0 -7
  309. package/skills/manage-files/scripts/upload.sh +0 -16
  310. package/skills/manage-files/scripts/write.sh +0 -29
  311. package/skills/manage-sources/scripts/reembed.sh +0 -23
@@ -1,1233 +0,0 @@
1
- import fs from 'node:fs/promises';
2
- import { binaryMimeTypeForPath, inferMimeTypeFromFilename } from '../domain/mime.js';
3
- import { looksBinaryContent } from '../domain/text.js';
4
- import { existsSync } from 'node:fs';
5
- import os from 'node:os';
6
- import path from 'node:path';
7
- import { createHash } from 'node:crypto';
8
- import { execFile } from 'node:child_process';
9
- import { resolveSearchBinaries } from '../search/binaryResolver.js';
10
- import { getInlineVideoCapBytes, getInlineImageCapBytes, getInlineAudioCapBytes, getInlinePdfCapBytes, getBrainConfigBoolean, getBrainConfigNumber, resolveWatchRecovery, } from '../config/env.js';
11
- import { CONNECTOR_PORT_VERSION } from '@neuralis/package-system/contracts';
12
- import { ConnectorRawReadTooLarge, ConnectorRootAccessError, } from '@neuralis/package-system/contracts';
13
- import { discoverOsMounts } from '@neuralis/package-system/paths';
14
- import { getFsWatchHub } from '@neuralis/package-system/data';
15
- import { compileGlobPatterns } from '../domain/syncignore.js';
16
- import { DEFAULT_MAX_SCAN_FILES } from '../domain/scanCap.js';
17
- // Hard read cap — `brainMaxReadFileMb` platform key (default 20 MB), read
18
- // LIVE per call. Larger files throw `File too large` for every read-path
19
- // consumer (Inc 5).
20
- const maxReadBytes = () => getBrainConfigNumber('brainMaxReadFileMb', undefined, 20) * 1024 * 1024;
21
- /** One `readRaw` syscall's buffer slice — bounds a single read, not the file. */
22
- const RAW_READ_CHUNK = 1024 * 1024;
23
- // Per-MIME inline caps for binary `bytes` emission (Sidequest §2.2). Above
24
- // these limits the connector returns a `[binary file too large…]` text marker
25
- // instead of base64 bytes — provider context windows + tool-result caps would
26
- // otherwise drown. ALL four MIME caps are LIVE platform settings now
27
- // (`inlineImageMaxMb` / `inlineAudioMaxMb` / `inlinePdfMaxMb` joined
28
- // `inlineVideoMaxMb` in Inc 5 — same env.ts cap-fn shape, media Inc 3′c
29
- // pattern). Audio default ~8 min of 128kbps mp3 (media-that-works Inc 7).
30
- // PDF text-extract cap — `brainPdfTextExcerptBytes` (default 200 KB), LIVE:
31
- // enough to brief the model without exploding context. Larger PDFs get
32
- // truncated with a tail marker.
33
- const pdfTextExcerptBytes = () => getBrainConfigNumber('brainPdfTextExcerptBytes', undefined, 200 * 1024);
34
- // First-page render output cap — `pdftoppm -r 100` on a typical page comes in
35
- // well under 4 MB. Opt-in via the `fsReadPdfRender` platform key (env
36
- // NEURALIS_FS_READ_PDF_RENDER fallback), read LIVE per call (Inc 4).
37
- const pdfRenderFirstPage = () => getBrainConfigBoolean('fsReadPdfRender', 'NEURALIS_FS_READ_PDF_RENDER', false);
38
- function formatBytes(n) {
39
- if (!Number.isFinite(n) || n <= 0)
40
- return '0 B';
41
- if (n < 1024)
42
- return `${n} B`;
43
- if (n < 1024 * 1024)
44
- return `${(n / 1024).toFixed(1)} KB`;
45
- return `${(n / (1024 * 1024)).toFixed(1)} MB`;
46
- }
47
- function inlineCapForMime(mimeType) {
48
- if (mimeType === 'application/pdf')
49
- return getInlinePdfCapBytes();
50
- if (mimeType.startsWith('image/'))
51
- return getInlineImageCapBytes();
52
- if (mimeType.startsWith('video/'))
53
- return getInlineVideoCapBytes();
54
- if (mimeType.startsWith('audio/'))
55
- return getInlineAudioCapBytes();
56
- return null;
57
- }
58
- let pdftotextProbed = null;
59
- let pdftoppmProbed = null;
60
- /** PATH-only child env — never the host process.env (frameSample pattern):
61
- * these are member-reachable spawns (`fs_read`/`fs_search`), and the app
62
- * process env carries infra secrets (QDRANT_API_KEY et al.) that no helper
63
- * child needs. */
64
- const CHILD_ENV = { PATH: process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin' };
65
- async function whichBinary(name) {
66
- return new Promise((resolve) => {
67
- const child = execFile('which', [name], { env: CHILD_ENV }, (err, stdout) => {
68
- if (err)
69
- return resolve(false);
70
- resolve(typeof stdout === 'string' && stdout.trim().length > 0);
71
- });
72
- child.on('error', () => resolve(false));
73
- });
74
- }
75
- async function hasPdftotext() {
76
- if (pdftotextProbed === null)
77
- pdftotextProbed = await whichBinary('pdftotext');
78
- return pdftotextProbed;
79
- }
80
- async function hasPdftoppm() {
81
- if (pdftoppmProbed === null)
82
- pdftoppmProbed = await whichBinary('pdftoppm');
83
- return pdftoppmProbed;
84
- }
85
- async function pdftotextExcerpt(absPath) {
86
- const excerptBytes = pdfTextExcerptBytes();
87
- return new Promise((resolve) => {
88
- const child = execFile('pdftotext', [absPath, '-'], { timeout: 10_000, maxBuffer: excerptBytes * 4, env: CHILD_ENV }, (err, stdout) => {
89
- if (err)
90
- return resolve(null);
91
- const out = String(stdout ?? '');
92
- const trimmed = out.length > excerptBytes
93
- ? `${out.slice(0, excerptBytes)}\n\n…(pdftotext output truncated at ${excerptBytes} bytes)`
94
- : out;
95
- resolve(trimmed);
96
- });
97
- child.on('error', () => resolve(null));
98
- });
99
- }
100
- /**
101
- * THE PDF→PNG renderer (`pdftoppm -r 100`): pages `first..last` of `absPath`
102
- * (clamped to the document), in page order. One private temp directory per call (`mkdtemp`), removed
103
- * afterwards — concurrent renders never read each other's output. A failed or
104
- * timed-out render answers `[]`. Two callers: `read` (the first page, opt-in
105
- * `fsReadPdfRender`) and the sync's media lane (`renderPdfPages`).
106
- */
107
- async function pdftoppmPages(absPath, first, last) {
108
- const from = Math.max(1, Math.floor(first));
109
- const to = Math.max(from, Math.floor(last));
110
- let dir;
111
- try {
112
- dir = await fs.mkdtemp(path.join(os.tmpdir(), 'neuralis-pdfrender-'));
113
- }
114
- catch {
115
- return [];
116
- }
117
- try {
118
- const ok = await new Promise((resolve) => {
119
- const child = execFile('pdftoppm', ['-f', String(from), '-l', String(to), '-r', '100', '-png', absPath, path.join(dir, 'page')],
120
- // 15 s per page, like the single-page render always had.
121
- { timeout: 15_000 * (to - from + 1), env: CHILD_ENV }, (err) => resolve(!err));
122
- child.on('error', () => resolve(false));
123
- });
124
- if (!ok)
125
- return [];
126
- // `page-1.png`, or zero-padded (`page-01.png`) once the document has ≥ 10 pages.
127
- const files = (await fs.readdir(dir))
128
- .map((name) => ({ name, page: Number(/-(\d+)\.png$/.exec(name)?.[1]) }))
129
- .filter((f) => Number.isFinite(f.page))
130
- .sort((a, b) => a.page - b.page);
131
- const pages = [];
132
- for (const f of files)
133
- pages.push({ page: f.page, png: await fs.readFile(path.join(dir, f.name)) });
134
- return pages;
135
- }
136
- catch {
137
- return [];
138
- }
139
- finally {
140
- await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
141
- }
142
- }
143
- /**
144
- * Concrete root-access error thrown by the local-disk connector. Routes +
145
- * services catch the abstract base (`ConnectorRootAccessError`), not this
146
- * class specifically — so every future connector that touches a real FS
147
- * surface can share the same structured-warning pathway.
148
- */
149
- export class LocalDiskRootAccessError extends ConnectorRootAccessError {
150
- constructor(code, rootPath, uri, cause) {
151
- super(code, rootPath, uri, code === 'ROOT_MISSING'
152
- ? `Local source root "${rootPath}" does not exist`
153
- : `Local source root "${rootPath}" is not readable (permission denied)`, cause);
154
- this.name = 'LocalDiskRootAccessError';
155
- }
156
- }
157
- function isSkippableFsError(error) {
158
- const code = error?.code;
159
- return code === 'EACCES' || code === 'EPERM' || code === 'ENOENT';
160
- }
161
- function compileGlobMatchers(patterns) {
162
- const compile = (value) => {
163
- const escaped = value.replace(/[.+^${}()|[\]]/g, '\\$&');
164
- const regex = escaped
165
- .replace(/\*\*/g, '§§')
166
- .replace(/\*/g, '[^/]*')
167
- .replace(/§§/g, '.*')
168
- .replace(/\?/g, '[^/]');
169
- return new RegExp(`^${regex}$`);
170
- };
171
- const out = [];
172
- for (const pat of patterns) {
173
- const normalized = pat.replace(/\\/g, '/');
174
- out.push(compile(normalized));
175
- if (normalized.endsWith('/**')) {
176
- out.push(compile(normalized.slice(0, -3)));
177
- }
178
- }
179
- return out;
180
- }
181
- function matchesAny(relPath, matchers) {
182
- const candidate = relPath.replace(/\\/g, '/');
183
- for (const m of matchers) {
184
- if (m.test(candidate))
185
- return true;
186
- const parts = candidate.split('/');
187
- for (let i = 0; i < parts.length; i++) {
188
- const sub = parts.slice(i).join('/');
189
- if (m.test(sub))
190
- return true;
191
- }
192
- }
193
- return false;
194
- }
195
- const SCAN_TIMEOUT_MS = 30_000;
196
- const SCAN_MAX_BUFFER = 10 * 1024 * 1024;
197
- const COUNT_CACHE_TTL_MS = 60_000;
198
- function runBinary(bin, args, cwd, _maxMatches) {
199
- // Live `brainScanTimeoutMs` (default SCAN_TIMEOUT_MS) — grep/rg kill timeout.
200
- const scanTimeoutMs = getBrainConfigNumber('brainScanTimeoutMs', undefined, SCAN_TIMEOUT_MS);
201
- return new Promise((resolve, reject) => {
202
- execFile(bin, args, {
203
- cwd,
204
- timeout: scanTimeoutMs,
205
- maxBuffer: SCAN_MAX_BUFFER,
206
- env: CHILD_ENV,
207
- }, (error, stdout, stderr) => {
208
- const code = error && 'code' in error && typeof error.code === 'number' ? error.code : 0;
209
- if (error && code !== 1 && code !== 0) {
210
- const killed = 'killed' in error && error.killed;
211
- if (killed)
212
- return reject(new Error(`Search timed out after ${scanTimeoutMs}ms`));
213
- return reject(new Error(`Search binary failed (${bin}): ${String(stderr || error.message)}`));
214
- }
215
- resolve(String(stdout ?? ''));
216
- });
217
- });
218
- }
219
- function accessErrorCode(error) {
220
- const code = error?.code;
221
- if (code === 'ENOENT')
222
- return 'ROOT_MISSING';
223
- if (code === 'EACCES' || code === 'EPERM')
224
- return 'ROOT_PERMISSION_DENIED';
225
- return null;
226
- }
227
- async function resolveAndGuard(basePath, target, options) {
228
- const normalizedTarget = path.isAbsolute(target) ? `.${target}` : target;
229
- const normalizedBase = path.resolve(basePath);
230
- const rootReal = await fs.realpath(normalizedBase).catch(() => normalizedBase);
231
- const resolved = path.resolve(normalizedBase, normalizedTarget);
232
- const relative = path.relative(normalizedBase, resolved);
233
- if (relative.startsWith('..') || path.isAbsolute(relative)) {
234
- throw new Error(`Path traversal blocked: "${target}" escapes base "${basePath}"`);
235
- }
236
- const segments = relative.split(path.sep).filter(Boolean);
237
- let current = rootReal;
238
- for (let index = 0; index < segments.length; index += 1) {
239
- const candidate = path.join(current, segments[index]);
240
- try {
241
- const stat = await fs.lstat(candidate);
242
- if (stat.isSymbolicLink()) {
243
- throw new Error(`Symlink traversal blocked: "${target}"`);
244
- }
245
- const real = await fs.realpath(candidate).catch(() => candidate);
246
- if (!real.startsWith(rootReal + path.sep) && real !== rootReal) {
247
- throw new Error(`Path traversal blocked: "${target}" escapes base "${basePath}"`);
248
- }
249
- current = real;
250
- }
251
- catch (error) {
252
- if (error.code === 'ENOENT' && options?.allowMissingTail) {
253
- return path.join(current, ...segments.slice(index));
254
- }
255
- throw error;
256
- }
257
- }
258
- return current;
259
- }
260
- /**
261
- * The capability set the local on-disk connector implements. This MUST stay
262
- * byte-equal to the `local` connector's `capabilities` array in
263
- * `package.json#neuralis.connectors` — `SourceLoader.instantiateConnector`
264
- * fails closed (`ConnectorCapabilityMismatch`) on any divergence, and for a
265
- * first-party connector `wiring.ts` now re-throws that (boot-fatal) instead of
266
- * silently degrading the kind. `localDiskCapabilities.test.ts` is the
267
- * derive-and-verify drift guard that catches a mismatch at TEST time.
268
- */
269
- export const LOCAL_DISK_CAPABILITIES = [
270
- 'list', 'read', 'stat', 'write', 'delete', 'move', 'mkdir', 'rmdir',
271
- 'scanDelta', 'walk', 'count', 'peekCount', 'scanContent', 'subscribeEvents', 'resolveOsUri',
272
- 'rawRead',
273
- ];
274
- /**
275
- * Structured failure thrown by `LocalDiskConnector.resolveOsUri` when the
276
- * connector is running inside a Docker container but no host-pair root has
277
- * been configured. Returning a container-internal path here would corrupt
278
- * vector payloads + filesystem-node `os_uri` fields with paths that do not
279
- * exist on the host. Callers (sync runner, the write engine) catch this
280
- * and either skip the upsert or persist `os_uri: undefined` instead.
281
- */
282
- export class OsUriUnresolvableError extends Error {
283
- code;
284
- source;
285
- uri;
286
- constructor(params) {
287
- super(`os_uri unresolvable for "${params.uri}" on source "${params.source}": ` +
288
- `running in Docker, but the source config has no \`hostRoot\` and no ` +
289
- `\`NEURALIS_MOUNT_*\` os-mount covers its root` +
290
- (params.root ? ` ("${params.root}")` : '') +
291
- `. Refusing to emit container-internal paths into vectors/os_uri. ` +
292
- `Add the path via \`neuralis mount add\` (which registers the ` +
293
- `host-pair env), or set \`hostRoot\` on the source config.`);
294
- this.name = 'OsUriUnresolvableError';
295
- this.code = 'os_uri_host_pair_missing';
296
- this.source = params.source;
297
- this.uri = params.uri;
298
- }
299
- }
300
- /**
301
- * Connector for local filesystem access — URI-native.
302
- *
303
- * Contributed by brain-core via `neuralis.connectors[]` manifest entry
304
- * `{ kind: 'local', configSchema: { root, hostRoot? } }`. The package loader
305
- * instantiates it via `createLocalDiskConnector(ctx)` for each source binding.
306
- */
307
- export class LocalDiskConnector {
308
- kind = 'local';
309
- source;
310
- portVersion = CONNECTOR_PORT_VERSION;
311
- #root;
312
- #hostRoot;
313
- #isDocker;
314
- #capabilities;
315
- #countCache = new Map();
316
- constructor(root, source, hostRoot, isDocker) {
317
- this.#root = path.resolve(root);
318
- this.source = source;
319
- this.#hostRoot = hostRoot ? path.resolve(hostRoot) : undefined;
320
- this.#isDocker = isDocker ?? detectDockerRuntime();
321
- this.#capabilities = Object.freeze(new Set(LOCAL_DISK_CAPABILITIES));
322
- }
323
- capabilities() {
324
- return this.#capabilities;
325
- }
326
- /** Expose the resolved root path for diagnostics / wiring logs. */
327
- get root() {
328
- return this.#root;
329
- }
330
- async probeRoot() {
331
- try {
332
- await fs.access(this.#root, (await import('node:fs')).constants.R_OK);
333
- return { rootPath: this.#root, exists: true, readable: true };
334
- }
335
- catch (error) {
336
- const code = accessErrorCode(error);
337
- return {
338
- rootPath: this.#root,
339
- exists: code !== 'ROOT_MISSING',
340
- readable: false,
341
- error: error instanceof Error ? error.message : String(error),
342
- };
343
- }
344
- }
345
- /**
346
- * Connector-owned liveness — whether the backing host directory is reachable.
347
- * Wraps {@link probeRoot} but maps to the fixed `SourceLiveReason` enum and
348
- * DISCARDS the raw `rootPath`/`error` (those carry the absolute host-mount
349
- * path — leaking them to a member via the tone-title is a path-disclosure
350
- * regression). UI-tone + `<runtime_stack>` signal only; never gates access.
351
- */
352
- async probeConnection() {
353
- const probe = await this.probeRoot();
354
- if (probe.exists && probe.readable)
355
- return { connected: true };
356
- return { connected: false, reason: probe.exists ? 'fs_permission' : 'fs_unreachable' };
357
- }
358
- #extractPath(uri) {
359
- const prefix = `${this.source}://`;
360
- if (!uri.startsWith(prefix)) {
361
- throw new Error(`URI "${uri}" does not belong to source "${this.source}"`);
362
- }
363
- return uri.slice(prefix.length) || '/';
364
- }
365
- #toUri(relativePath) {
366
- const clean = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
367
- return clean ? `${this.source}://${clean}` : `${this.source}://`;
368
- }
369
- async list(uri) {
370
- const dirPath = this.#extractPath(uri);
371
- const isRoot = dirPath === '/' || dirPath === '' || dirPath === '.';
372
- let absPath;
373
- try {
374
- absPath = await resolveAndGuard(this.#root, isRoot ? '.' : dirPath);
375
- }
376
- catch (error) {
377
- const code = accessErrorCode(error);
378
- if (isRoot && code) {
379
- throw new LocalDiskRootAccessError(code, this.#root, uri, error);
380
- }
381
- if (isSkippableFsError(error))
382
- return [];
383
- throw error;
384
- }
385
- let entries;
386
- try {
387
- entries = await fs.readdir(absPath, { withFileTypes: true });
388
- }
389
- catch (error) {
390
- const code = accessErrorCode(error);
391
- if (isRoot && code) {
392
- throw new LocalDiskRootAccessError(code, this.#root, uri, error);
393
- }
394
- if (isSkippableFsError(error))
395
- return [];
396
- throw error;
397
- }
398
- const items = [];
399
- for (const entry of entries) {
400
- if (entry.isSymbolicLink())
401
- continue;
402
- const entryRelPath = path.join(dirPath === '/' ? '/' : dirPath, entry.name);
403
- const entryUri = this.#toUri(entryRelPath);
404
- const isDir = entry.isDirectory();
405
- let size;
406
- let modifiedAt;
407
- if (!isDir) {
408
- try {
409
- const stat = await fs.stat(path.join(absPath, entry.name));
410
- size = stat.size;
411
- modifiedAt = stat.mtime.toISOString();
412
- }
413
- catch { /* stat failure is non-fatal for listing */ }
414
- }
415
- items.push({
416
- uri: entryUri,
417
- name: entry.name,
418
- isDirectory: isDir,
419
- ...(size != null ? { size } : {}),
420
- ...(modifiedAt ? { modifiedAt } : {}),
421
- });
422
- }
423
- return items.sort((a, b) => {
424
- if (a.isDirectory !== b.isDirectory)
425
- return a.isDirectory ? -1 : 1;
426
- return a.name.localeCompare(b.name);
427
- });
428
- }
429
- /**
430
- * Existence + metadata, on the SAME guarded path as `read` and without
431
- * transferring a byte of content. `null` = the path resolved but nothing is
432
- * there (`ENOENT`, from either the guard's per-segment walk or the `lstat`);
433
- * every other failure THROWS exactly as `read` would — a traversal/symlink
434
- * refusal, an EACCES, a root-access error — so the caller's existing
435
- * "gone vs. could-not-ask" split keeps working unchanged.
436
- *
437
- * `lstat`, never `stat`: `resolveAndGuard` REFUSES a symlink rather than
438
- * resolving through it, so following one here would answer for a target the
439
- * read path would never have served.
440
- */
441
- async stat(uri) {
442
- const ref = this.#extractPath(uri);
443
- let absPath;
444
- try {
445
- absPath = await resolveAndGuard(this.#root, ref);
446
- }
447
- catch (error) {
448
- if (error?.code === 'ENOENT')
449
- return null;
450
- throw error;
451
- }
452
- let info;
453
- try {
454
- info = await fs.lstat(absPath);
455
- }
456
- catch (error) {
457
- if (error?.code === 'ENOENT')
458
- return null;
459
- throw error;
460
- }
461
- return {
462
- isDirectory: info.isDirectory(),
463
- size: info.size,
464
- modifiedAt: info.mtime.toISOString(),
465
- };
466
- }
467
- /**
468
- * The sync media lane's PDF read — NOT a kernel `ConnectorPort` member (only
469
- * a connector that holds the file on its own disk can render it; the lane
470
- * treats a connector without it as "unavailable"). The raw bytes' sha256
471
- * (the point's `content_hash`) and pages `first..last` rendered by the ONE
472
- * renderer — none when the hash equals `unlessHash`, so an unchanged PDF is
473
- * never re-rendered. `null` when `pdftoppm` is not installed; the same
474
- * containment and read cap as `read`.
475
- */
476
- async renderPdfPages(uri, opts) {
477
- const absPath = await resolveAndGuard(this.#root, this.#extractPath(uri));
478
- const stat = await fs.stat(absPath);
479
- if (stat.isDirectory())
480
- throw new Error(`Cannot read directory as file: "${uri}"`);
481
- const readCap = maxReadBytes();
482
- if (stat.size > readCap)
483
- throw new Error(`File too large (${stat.size} bytes, max ${readCap}): "${uri}"`);
484
- if (!(await hasPdftoppm()))
485
- return null;
486
- const contentHash = createHash('sha256').update(await fs.readFile(absPath)).digest('hex');
487
- if (opts.unlessHash !== undefined && opts.unlessHash === contentHash)
488
- return { contentHash, pages: [] };
489
- return { contentHash, pages: await pdftoppmPages(absPath, opts.first, opts.last) };
490
- }
491
- /**
492
- * The byte-exact read. Same guarded resolver as `read`; the cap is
493
- * `min(opts.maxBytes, brainMaxReadFileMb)`, checked on the OPENED
494
- * descriptor's size before any byte is buffered, and the read itself pulls
495
- * at most that size+1 bytes — a file that grew after the size check is
496
- * refused (over the cap: the too-large refusal), never cut.
497
- */
498
- async readRaw(uri, opts) {
499
- const absPath = await resolveAndGuard(this.#root, this.#extractPath(uri));
500
- const planeCap = maxReadBytes();
501
- const cap = Math.max(0, Math.min(Math.floor(opts.maxBytes), planeCap));
502
- const tooLarge = (size) => new ConnectorRawReadTooLarge(uri, cap, {
503
- ...(size === undefined ? {} : { size }),
504
- ...(planeCap < opts.maxBytes ? { planeLimit: planeCap } : {}),
505
- });
506
- const handle = await fs.open(absPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
507
- try {
508
- const info = await handle.stat();
509
- if (info.isDirectory())
510
- throw new Error(`Cannot read directory as file: "${uri}"`);
511
- if (!info.isFile())
512
- throw new Error(`Not a regular file: "${uri}"`);
513
- if (info.size > cap)
514
- throw tooLarge(info.size);
515
- // Sized by the fstat answer, not the cap; the one extra byte detects growth.
516
- const limit = info.size;
517
- const buffer = Buffer.allocUnsafe(limit + 1);
518
- let filled = 0;
519
- while (filled <= limit) {
520
- const { bytesRead } = await handle.read(buffer, filled, Math.min(RAW_READ_CHUNK, limit + 1 - filled), filled);
521
- if (bytesRead === 0)
522
- break;
523
- filled += bytesRead;
524
- }
525
- if (filled > limit)
526
- throw filled > cap ? tooLarge() : new Error(`File changed while reading: "${uri}"`);
527
- return {
528
- bytes: new Uint8Array(buffer.buffer, buffer.byteOffset, filled),
529
- size: filled,
530
- mimeType: binaryMimeTypeForPath(absPath) ?? inferMimeTypeFromFilename(absPath),
531
- };
532
- }
533
- finally {
534
- await handle.close();
535
- }
536
- }
537
- async read(uri) {
538
- const ref = this.#extractPath(uri);
539
- const absPath = await resolveAndGuard(this.#root, ref);
540
- const stat = await fs.stat(absPath);
541
- if (stat.isDirectory()) {
542
- throw new Error(`Cannot read directory as file: "${uri}"`);
543
- }
544
- const readCap = maxReadBytes();
545
- if (stat.size > readCap) {
546
- throw new Error(`File too large (${stat.size} bytes, max ${readCap}): "${uri}"`);
547
- }
548
- const binaryMimeType = binaryMimeTypeForPath(absPath);
549
- if (binaryMimeType) {
550
- // PDFs get a special path: extract text by default (pdftotext), and
551
- // optionally render the first page when NEURALIS_FS_READ_PDF_RENDER=1.
552
- // The bytes emission for the rendered page goes through the same cap as
553
- // images.
554
- if (binaryMimeType === 'application/pdf') {
555
- const haveText = await hasPdftotext();
556
- const textExcerpt = haveText ? await pdftotextExcerpt(absPath) : null;
557
- if (pdfRenderFirstPage() && (await hasPdftoppm())) {
558
- const rendered = (await pdftoppmPages(absPath, 1, 1))[0]?.png ?? null;
559
- if (rendered && rendered.length <= getInlineImageCapBytes()) {
560
- return {
561
- text: textExcerpt ?? '',
562
- mimeType: 'image/png',
563
- // Signal the rendered-PDF case: the bytes are a png rendering of
564
- // a .pdf source. The journal must persist these INLINE (re-reading
565
- // the .pdf uri would re-render non-deterministically / 400). dynamic-brook MM2.
566
- sourceMimeType: 'application/pdf',
567
- size: stat.size,
568
- bytes: rendered.toString('base64'),
569
- };
570
- }
571
- }
572
- if (textExcerpt !== null) {
573
- return { text: textExcerpt, mimeType: binaryMimeType, size: stat.size };
574
- }
575
- return {
576
- text: `[application/pdf — pdftotext unavailable; install poppler-utils to enable PDF text extraction]`,
577
- mimeType: binaryMimeType,
578
- size: stat.size,
579
- };
580
- }
581
- // Image / video / other binaries — emit base64 when under the per-MIME
582
- // inline cap, or a text marker otherwise.
583
- const cap = inlineCapForMime(binaryMimeType);
584
- if (cap !== null && stat.size <= cap) {
585
- const buffer = await fs.readFile(absPath);
586
- return {
587
- text: '',
588
- mimeType: binaryMimeType,
589
- size: stat.size,
590
- bytes: buffer.toString('base64'),
591
- };
592
- }
593
- if (cap !== null) {
594
- return {
595
- text: `[binary file too large to inline: ${binaryMimeType} ${formatBytes(stat.size)} (cap ${formatBytes(cap)})]`,
596
- mimeType: binaryMimeType,
597
- size: stat.size,
598
- };
599
- }
600
- // Other binaries (zip, exe, font, …) — leave bytes unset, stay
601
- // text-empty as before so the use-case downgrades to a marker.
602
- return {
603
- text: '',
604
- mimeType: binaryMimeType,
605
- size: stat.size,
606
- };
607
- }
608
- const buffer = await fs.readFile(absPath);
609
- try {
610
- const text = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
611
- if (!looksBinaryContent(text))
612
- return { text, size: stat.size };
613
- }
614
- catch (error) {
615
- if (!(error instanceof TypeError))
616
- throw error;
617
- }
618
- return { text: `[binary or invalid UTF-8 @ ${uri}: ${stat.size} bytes]`,
619
- mimeType: 'application/octet-stream', size: stat.size };
620
- }
621
- async write(uri, content) {
622
- const filePath = this.#extractPath(uri);
623
- const absPath = await resolveAndGuard(this.#root, filePath, { allowMissingTail: true });
624
- await fs.mkdir(path.dirname(absPath), { recursive: true });
625
- if (typeof content === 'string') {
626
- await fs.writeFile(absPath, content, 'utf-8');
627
- }
628
- else {
629
- await fs.writeFile(absPath, Buffer.from(content));
630
- }
631
- return { uri };
632
- }
633
- async delete(uri) {
634
- const ref = this.#extractPath(uri);
635
- const absPath = await resolveAndGuard(this.#root, ref);
636
- await fs.unlink(absPath);
637
- }
638
- async move(fromUri, toUri) {
639
- const fromRef = this.#extractPath(fromUri);
640
- const toRef = this.#extractPath(toUri);
641
- const fromAbs = await resolveAndGuard(this.#root, fromRef);
642
- const toAbs = await resolveAndGuard(this.#root, toRef, { allowMissingTail: true });
643
- await fs.mkdir(path.dirname(toAbs), { recursive: true });
644
- await fs.rename(fromAbs, toAbs);
645
- return { uri: toUri, previousUri: fromUri };
646
- }
647
- async mkdir(uri) {
648
- const ref = this.#extractPath(uri);
649
- const absPath = await resolveAndGuard(this.#root, ref, { allowMissingTail: true });
650
- await fs.mkdir(absPath, { recursive: true });
651
- }
652
- async rmdir(uri) {
653
- const ref = this.#extractPath(uri);
654
- const absPath = await resolveAndGuard(this.#root, ref);
655
- // Empty-dir removal only — the recursive folder-delete usecase unlinks /
656
- // tombstones every contained file first, then drops the empty shells. A
657
- // populated dir throws ENOTEMPTY here (fail-safe, never a silent blind erase).
658
- await fs.rmdir(absPath);
659
- }
660
- /**
661
- * OS-level change events under `rootUri`, fed by the shared FsWatchHub
662
- * (D6: ONE watcher substrate). Catches EVERY background change — shell
663
- * writes, host edits, git checkouts, external programs — not just app
664
- * writes. URI mapping happens here (the hub only knows absolute paths);
665
- * exclude filtering and debounce are the SUBSCRIBER's job. An `overflow`
666
- * event means "event delivery can no longer be trusted — full reconcile".
667
- */
668
- subscribeEvents(rootUri, listener, opts) {
669
- const subPath = this.#extractPath(rootUri);
670
- const watchRoot = path.resolve(this.#root, subPath === '/' ? '.' : subPath);
671
- if (watchRoot !== this.#root && !watchRoot.startsWith(`${this.#root}${path.sep}`)) {
672
- throw new Error(`subscribeEvents root "${rootUri}" escapes source "${this.source}".`);
673
- }
674
- // Push the exclude set down to the watcher: excluded trees never consume
675
- // OS watch descriptors (inotify budgets matter on repo-sized sources).
676
- const excludeRegexes = compileGlobPatterns(opts?.excludeGlobs ?? []);
677
- const ignored = excludeRegexes.length > 0
678
- ? (absPath) => {
679
- const rel = path.relative(this.#root, absPath);
680
- if (!rel || rel.startsWith('..'))
681
- return false;
682
- const relPosix = rel.split(path.sep).join('/');
683
- return excludeRegexes.some((rx) => rx.test(relPosix));
684
- }
685
- : undefined;
686
- const handle = getFsWatchHub().acquire(watchRoot, (event) => {
687
- if (event.kind === 'overflow') {
688
- // Carry the hub's classification through 1:1 — a fixed reason class
689
- // and an errno NAME, never a path or an error message (invariant 9).
690
- // The consumer still ACTS the same way whatever the value (reconcile
691
- // the whole source); the cause exists so an incident can be attributed
692
- // from a log line instead of being one undifferentiated signal.
693
- listener({
694
- kind: 'overflow',
695
- uri: `${this.source}://`,
696
- modifiedAt: event.at,
697
- ...(event.reason ? { reason: event.reason } : {}),
698
- ...(event.code ? { code: event.code } : {}),
699
- });
700
- return;
701
- }
702
- const rel = path.relative(this.#root, event.absPath);
703
- if (!rel || rel.startsWith('..'))
704
- return;
705
- listener({
706
- kind: event.kind,
707
- uri: this.#toUri(rel.split(path.sep).join('/')),
708
- modifiedAt: event.at,
709
- });
710
- }, {
711
- ...(ignored ? { ignored } : {}),
712
- // Declared per listener: the hub shares one watcher per root and takes
713
- // the conservative maximum across everything live on it.
714
- recovery: resolveWatchRecovery(),
715
- });
716
- return {
717
- unsubscribe: () => { void handle.release(); },
718
- };
719
- }
720
- async scanDelta(request) {
721
- const rootUri = request.rootUri ?? `${this.source}://`;
722
- const rootPath = this.#extractPath(rootUri) || '/';
723
- // The checkpoint is stamped BEFORE the walk, never after it. A walk of a
724
- // repo-sized tree takes seconds to minutes; a file edited AFTER the walk
725
- // visited it but BEFORE the walk ended carries an mtime earlier than an
726
- // end-stamped cursor, so the next delta would never report it and the edit
727
- // would sit unindexed until something else touched the file. Stamping first
728
- // makes the overlap conservative: the window re-reports, it never skips.
729
- const checkpoint = new Date().toISOString();
730
- const { items: files, truncated } = await this.#walkFiles(rootPath, request.signal, request.excludeGlobs, request.maxScanFiles);
731
- // An unparseable cursor yields NaN, and every `>=` against NaN is false —
732
- // which would report NOTHING changed and silently strand the whole source.
733
- // Treat it as "no cursor" and return the full set: this scan re-reports,
734
- // it never skips. (HEAD relied on NaN being falsy for the same outcome;
735
- // the explicit test survives the `>= 0` boundary the inclusive bound has.)
736
- const parsedSince = request.since ? new Date(request.since).getTime() : undefined;
737
- const sinceMs = parsedSince !== undefined && Number.isFinite(parsedSince) ? parsedSince : undefined;
738
- // INCLUSIVE (`>=`) on purpose. Filesystem mtime resolution is coarser than
739
- // the cursor (whole seconds on several filesystems), so a change landing in
740
- // the same tick as the previous checkpoint is indistinguishable from the
741
- // state that checkpoint described. An exclusive bound drops it silently;
742
- // the inclusive one re-reports the boundary file and the content-hash skip
743
- // in the sync runner absorbs the repeat at no embedding cost.
744
- const changed = sinceMs === undefined ? files : files.filter((item) => {
745
- if (!item.modifiedAt)
746
- return true;
747
- const modifiedAtMs = new Date(item.modifiedAt).getTime();
748
- return Number.isFinite(modifiedAtMs) && modifiedAtMs >= sinceMs;
749
- });
750
- return {
751
- files: changed,
752
- checkpoint,
753
- truncated,
754
- // ONE walk answers both questions: `changed` is the delta, `files` is the
755
- // complete set the same traversal saw, and `checkpoint`/`truncated`
756
- // describe both. Answered only when asked — an unasked inventory would
757
- // double the response for every caller that does not need it.
758
- ...(request.includeInventory ? { inventory: files } : {}),
759
- };
760
- }
761
- resolveOsUri(uri) {
762
- const relativePath = this.#extractPath(uri);
763
- const absPath = path.resolve(this.#root, relativePath.startsWith('/') ? `.${relativePath}` : relativePath);
764
- if (this.#hostRoot) {
765
- return absPath.replace(this.#root, this.#hostRoot);
766
- }
767
- if (this.#isDocker) {
768
- // Running in Docker without a host-pair root → returning the
769
- // container-internal absPath would write a path that does not exist
770
- // on the host into the vector payload and the filesystem-node
771
- // os_uri field. Fail loudly so the caller can skip / null it out.
772
- throw new OsUriUnresolvableError({ source: this.source, uri, root: this.#root });
773
- }
774
- return absPath;
775
- }
776
- #countCacheKey(rootUri, opts) {
777
- // No default excludes — physical count MUST reflect the real filesystem.
778
- // Callers that want filtered counts pass opts.excludeGlobs explicitly.
779
- const excludes = opts?.excludeGlobs ?? [];
780
- const cap = typeof opts?.maxScanFiles === 'number' && opts.maxScanFiles > 0 ? opts.maxScanFiles : DEFAULT_MAX_SCAN_FILES;
781
- return `${rootUri}::${excludes.join('|')}::${cap}`;
782
- }
783
- peekCount(rootUri, opts) {
784
- const cached = this.#countCache.get(this.#countCacheKey(rootUri, opts));
785
- if (!cached)
786
- return undefined;
787
- if (Date.now() - cached.ts >= getBrainConfigNumber('brainCountCacheTtlMs', undefined, COUNT_CACHE_TTL_MS))
788
- return undefined;
789
- return cached.value;
790
- }
791
- async walk(rootUri, opts) {
792
- return (await this.#walk(rootUri, opts, false)).items;
793
- }
794
- async walkWithStatus(rootUri, opts) {
795
- return this.#walk(rootUri, opts, true);
796
- }
797
- async #walk(rootUri, opts, diagnostic) {
798
- // Strip a trailing slash on the walk root so a caller-supplied
799
- // `packages://x/` does not seed `current.virtual = "x/"` and produce
800
- // mid-URI "//" children via the string-concat below. `list()`/`#walkFiles()`
801
- // use `path.join` and are already safe; this keeps `walk()` robust too.
802
- const rootPath = (this.#extractPath(rootUri) || '/').replace(/\/+$/, '') || '/';
803
- let absRoot;
804
- try {
805
- absRoot = await resolveAndGuard(this.#root, rootPath === '/' ? '.' : rootPath);
806
- }
807
- catch (error) {
808
- if (diagnostic || isSkippableFsError(error))
809
- return { items: [], complete: false, truncated: false };
810
- throw error;
811
- }
812
- const maxItems = typeof opts?.maxItems === 'number' && opts.maxItems > 0
813
- ? Math.min(opts.maxItems, DEFAULT_MAX_SCAN_FILES)
814
- : diagnostic ? DEFAULT_MAX_SCAN_FILES : 500;
815
- const maxEntries = typeof opts?.maxEntries === 'number' && Number.isFinite(opts.maxEntries) && opts.maxEntries > 0
816
- ? Math.min(Math.floor(opts.maxEntries), DEFAULT_MAX_SCAN_FILES)
817
- : typeof opts?.maxItems === 'number' && Number.isFinite(opts.maxItems) && opts.maxItems > 0
818
- ? Math.min(Math.floor(opts.maxItems), DEFAULT_MAX_SCAN_FILES) : 500;
819
- let visited = 1; // The traversal root is a visited directory too.
820
- let complete = true;
821
- let truncated = false;
822
- // No default excludes — walk returns the full on-disk tree.
823
- const excludes = opts?.excludeGlobs ?? [];
824
- const excludeMatchers = compileGlobMatchers(excludes);
825
- const items = [];
826
- const queue = [
827
- { abs: absRoot, virtual: rootPath === '/' ? '/' : rootPath },
828
- ];
829
- let nextDirectory = 0;
830
- while (nextDirectory < queue.length) {
831
- if (items.length >= maxItems || (diagnostic && visited >= maxEntries)) {
832
- truncated = true;
833
- break;
834
- }
835
- const current = queue[nextDirectory++];
836
- let entries;
837
- try {
838
- if (diagnostic) {
839
- // One-entry native buffer and a charged application buffer: no full
840
- // readdir allocation before the caller's budget has been applied.
841
- const directory = await fs.opendir(current.abs, { bufferSize: 1 });
842
- entries = [];
843
- try {
844
- while (visited < maxEntries) {
845
- const entry = await directory.read();
846
- if (entry === null)
847
- break;
848
- visited += 1;
849
- entries.push(entry);
850
- }
851
- if (visited >= maxEntries)
852
- truncated = true;
853
- }
854
- finally {
855
- await directory.close();
856
- }
857
- }
858
- else {
859
- entries = await fs.readdir(current.abs, { withFileTypes: true });
860
- }
861
- }
862
- catch (error) {
863
- if (diagnostic || isSkippableFsError(error)) {
864
- complete = false;
865
- continue;
866
- }
867
- throw error;
868
- }
869
- entries.sort((a, b) => {
870
- const aDir = a.isDirectory() ? 0 : 1;
871
- const bDir = b.isDirectory() ? 0 : 1;
872
- if (aDir !== bDir)
873
- return aDir - bDir;
874
- return a.name.localeCompare(b.name);
875
- });
876
- for (const entry of entries) {
877
- if (items.length >= maxItems) {
878
- truncated = true;
879
- break;
880
- }
881
- if (entry.isSymbolicLink?.()) {
882
- complete = false;
883
- continue;
884
- }
885
- const entryAbs = path.join(current.abs, entry.name);
886
- const relFromRoot = path.relative(absRoot, entryAbs).replace(/\\/g, '/');
887
- if (matchesAny(relFromRoot, excludeMatchers)) {
888
- complete = false;
889
- continue;
890
- }
891
- const virtualPath = current.virtual === '/'
892
- ? `/${entry.name}`
893
- : `${current.virtual.replace(/\/+$/, '')}/${entry.name}`;
894
- const isDir = entry.isDirectory();
895
- let size;
896
- let modifiedAt;
897
- if (!isDir) {
898
- try {
899
- const stat = await fs.stat(entryAbs);
900
- size = stat.size;
901
- modifiedAt = stat.mtime.toISOString();
902
- }
903
- catch {
904
- complete = false; /* transient stat failure — emit without metadata */
905
- }
906
- }
907
- items.push({
908
- uri: this.#toUri(virtualPath),
909
- name: entry.name,
910
- isDirectory: isDir,
911
- ...(size != null ? { size } : {}),
912
- ...(modifiedAt ? { modifiedAt } : {}),
913
- });
914
- if (isDir)
915
- queue.push({ abs: entryAbs, virtual: virtualPath });
916
- }
917
- }
918
- return { items, complete: complete && !truncated, truncated };
919
- }
920
- async count(rootUri, opts) {
921
- // No default excludes — physical count must match walk() cardinality.
922
- const excludes = opts?.excludeGlobs ?? [];
923
- const cacheKey = this.#countCacheKey(rootUri, opts);
924
- const cached = this.#countCache.get(cacheKey);
925
- const now = Date.now();
926
- if (cached && now - cached.ts < getBrainConfigNumber('brainCountCacheTtlMs', undefined, COUNT_CACHE_TTL_MS))
927
- return cached.value;
928
- const rootPath = this.#extractPath(rootUri) || '/';
929
- let absRoot;
930
- try {
931
- absRoot = await resolveAndGuard(this.#root, rootPath === '/' ? '.' : rootPath);
932
- }
933
- catch (error) {
934
- if (isSkippableFsError(error))
935
- return 0;
936
- throw error;
937
- }
938
- const excludeMatchers = compileGlobMatchers(excludes);
939
- const cap = typeof opts?.maxScanFiles === 'number' && opts.maxScanFiles > 0
940
- ? opts.maxScanFiles
941
- : DEFAULT_MAX_SCAN_FILES;
942
- let total = 0;
943
- const stack = [absRoot];
944
- while (stack.length > 0) {
945
- if (total >= cap)
946
- break;
947
- const current = stack.pop();
948
- let entries;
949
- try {
950
- entries = await fs.readdir(current, { withFileTypes: true });
951
- }
952
- catch (error) {
953
- if (isSkippableFsError(error))
954
- continue;
955
- throw error;
956
- }
957
- for (const entry of entries) {
958
- if (entry.isSymbolicLink?.())
959
- continue;
960
- const entryAbs = path.join(current, entry.name);
961
- const relFromRoot = path.relative(absRoot, entryAbs);
962
- if (matchesAny(relFromRoot, excludeMatchers))
963
- continue;
964
- if (entry.isDirectory()) {
965
- stack.push(entryAbs);
966
- }
967
- else if (entry.isFile()) {
968
- total += 1;
969
- if (total >= cap)
970
- break;
971
- }
972
- }
973
- }
974
- this.#countCache.set(cacheKey, { value: total, ts: now });
975
- return total;
976
- }
977
- async scanContent(rootUri, query, opts) {
978
- if (!query || query.length === 0)
979
- return [];
980
- const rootPath = this.#extractPath(rootUri) || '/';
981
- let absRoot;
982
- try {
983
- absRoot = await resolveAndGuard(this.#root, rootPath === '/' ? '.' : rootPath);
984
- }
985
- catch (error) {
986
- if (isSkippableFsError(error))
987
- return [];
988
- throw error;
989
- }
990
- const bins = await resolveSearchBinaries();
991
- const caseSensitive = opts?.caseSensitive === true;
992
- const maxMatches = typeof opts?.maxMatches === 'number' && opts.maxMatches > 0 ? opts.maxMatches : 500;
993
- let hasGit = false;
994
- try {
995
- const s = await fs.stat(path.join(absRoot, '.git'));
996
- hasGit = s.isDirectory();
997
- }
998
- catch { /* no .git — fall through */ }
999
- const excludeArgs = [];
1000
- for (const pattern of opts?.excludeGlobs ?? []) {
1001
- excludeArgs.push('--exclude-dir', pattern.replace(/\/\*\*$/, '').replace(/^\*\*\//, ''));
1002
- }
1003
- // The tool contract (tools/fs_search.json) promises LITERAL fixed-string
1004
- // grep semantics — every engine branch passes -F so a query with regex
1005
- // metacharacters (`${`, `[slug]`) neither hard-fails nor varies by which
1006
- // binary wins the ladder. Fuzzy needs are semantic mode's job.
1007
- if (hasGit && bins.gitGrep) {
1008
- const args = ['grep', '-n', '--column', '--no-color', '-F'];
1009
- if (!caseSensitive)
1010
- args.push('-i');
1011
- args.push('-e', query, '--');
1012
- // Include/exclude ride the pathspec tail. Git pathspecs are
1013
- // case-SENSITIVE by default (`:(icase)` restores parity with -i), and
1014
- // plain pathspec `*` crosses `/` (wildmatch without pathname mode) —
1015
- // semantics differ slightly from rg's gitignore-style globs; documented
1016
- // best-effort. Before this, the git-grep branch (the primary branch for
1017
- // repo checkouts) silently ignored every glob option.
1018
- for (const pat of opts?.includeGlobs ?? []) {
1019
- args.push(caseSensitive ? pat : `:(icase)${pat}`);
1020
- }
1021
- for (const pat of opts?.excludeGlobs ?? []) {
1022
- args.push(`:(exclude)${pat}`);
1023
- }
1024
- return this.#parseGrepOutput(rootUri, absRoot, await runBinary(bins.gitGrep, args, absRoot, maxMatches), maxMatches);
1025
- }
1026
- if (bins.rg) {
1027
- const args = ['-n', '--column', '--no-heading', '--color=never', '-F'];
1028
- if (!caseSensitive)
1029
- args.push('-i');
1030
- for (const pat of opts?.includeGlobs ?? [])
1031
- args.push('--glob', pat);
1032
- for (const pat of opts?.excludeGlobs ?? [])
1033
- args.push('--glob', `!${pat}`);
1034
- args.push('-e', query, absRoot);
1035
- return this.#parseGrepOutput(rootUri, absRoot, await runBinary(bins.rg, args, absRoot, maxMatches), maxMatches);
1036
- }
1037
- if (bins.grep) {
1038
- const args = ['-R', '-n', '--color=never', '-F'];
1039
- if (!caseSensitive)
1040
- args.push('-i');
1041
- // GNU grep --include matches the BASENAME only — the narrowest of the
1042
- // three glob dialects; documented best-effort.
1043
- for (const pat of opts?.includeGlobs ?? [])
1044
- args.push(`--include=${pat}`);
1045
- args.push(...excludeArgs, '-e', query, absRoot);
1046
- return this.#parseGrepOutput(rootUri, absRoot, await runBinary(bins.grep, args, absRoot, maxMatches), maxMatches);
1047
- }
1048
- throw new Error('No text-search binary available on the host (tried rg, git grep, grep)');
1049
- }
1050
- #parseGrepOutput(rootUri, absRoot, output, maxMatches) {
1051
- const lines = output.split('\n');
1052
- const results = [];
1053
- const isSourceRoot = rootUri.endsWith('://');
1054
- const joinBase = isSourceRoot ? rootUri : rootUri.replace(/\/+$/, '');
1055
- for (const line of lines) {
1056
- if (results.length >= maxMatches)
1057
- break;
1058
- if (!line)
1059
- continue;
1060
- const m = line.match(/^(.+?):(\d+)(?::(\d+))?:(.*)$/);
1061
- if (!m)
1062
- continue;
1063
- const filePath = m[1];
1064
- const lineNo = parseInt(m[2], 10);
1065
- const col = m[3] ? parseInt(m[3], 10) : undefined;
1066
- const preview = m[4];
1067
- const rel = path.relative(absRoot, filePath).replace(/\\/g, '/');
1068
- const hasRel = rel && rel !== '.' && !rel.startsWith('..');
1069
- const uri = !hasRel
1070
- ? (isSourceRoot ? rootUri : joinBase)
1071
- : isSourceRoot ? `${joinBase}${rel}` : `${joinBase}/${rel}`;
1072
- results.push({
1073
- uri,
1074
- line: lineNo,
1075
- ...(typeof col === 'number' && Number.isFinite(col) ? { column: col } : {}),
1076
- preview,
1077
- });
1078
- }
1079
- return results;
1080
- }
1081
- async #walkFiles(rootPath, signal, excludeGlobs, maxScanFiles) {
1082
- const rootAbs = await resolveAndGuard(this.#root, rootPath === '/' ? '.' : rootPath);
1083
- const stack = [{ abs: rootAbs, virtual: rootPath === '/' ? '/' : rootPath }];
1084
- const out = [];
1085
- const cap = typeof maxScanFiles === 'number' && maxScanFiles > 0 ? maxScanFiles : DEFAULT_MAX_SCAN_FILES;
1086
- // Prune excluded trees DURING the walk — never descend into node_modules /
1087
- // .git / dist etc. Without this a repo-sized source blows the MAX_SCAN_FILES
1088
- // cap on excluded files and starves alphabetically-late included trees
1089
- // (e.g. `.claude`, which sorts first → pushed first → popped LAST in DFS).
1090
- // A directory is pruned when an exclude matches `<rel>/` (so `node_modules/**`
1091
- // catches the bare dir); files match their plain relative path.
1092
- const excludeRegexes = compileGlobPatterns(excludeGlobs ?? []);
1093
- const relOf = (virtual) => (virtual.startsWith('/') ? virtual.slice(1) : virtual);
1094
- const isExcludedDir = (virtual) => {
1095
- const rel = relOf(virtual);
1096
- return rel.length > 0 && excludeRegexes.some((rx) => rx.test(`${rel}/`));
1097
- };
1098
- const isExcludedFile = (virtual) => {
1099
- const rel = relOf(virtual);
1100
- return rel.length > 0 && excludeRegexes.some((rx) => rx.test(rel));
1101
- };
1102
- while (stack.length > 0) {
1103
- // Cooperative cancellation per directory pop — a repo-sized walk
1104
- // (~200k file cap) must stop within one directory of an abort.
1105
- signal?.throwIfAborted();
1106
- if (out.length >= cap)
1107
- break;
1108
- const current = stack.pop();
1109
- if (!current)
1110
- continue;
1111
- let entries;
1112
- try {
1113
- entries = await fs.readdir(current.abs, { withFileTypes: true });
1114
- }
1115
- catch (error) {
1116
- if (isSkippableFsError(error))
1117
- continue;
1118
- throw error;
1119
- }
1120
- for (const entry of entries) {
1121
- if (out.length >= cap)
1122
- break;
1123
- if (entry.isSymbolicLink?.())
1124
- continue;
1125
- const entryAbs = path.join(current.abs, entry.name);
1126
- const entryVirtual = path.join(current.virtual === '/' ? '/' : current.virtual, entry.name);
1127
- if (entry.isDirectory()) {
1128
- if (!isExcludedDir(entryVirtual))
1129
- stack.push({ abs: entryAbs, virtual: entryVirtual });
1130
- continue;
1131
- }
1132
- if (isExcludedFile(entryVirtual))
1133
- continue;
1134
- try {
1135
- const stat = await fs.stat(entryAbs);
1136
- out.push({
1137
- uri: this.#toUri(entryVirtual),
1138
- name: entry.name,
1139
- size: stat.size,
1140
- modifiedAt: stat.mtime.toISOString(),
1141
- });
1142
- }
1143
- catch {
1144
- /* transient IO errors skip the entry, not the sync */
1145
- }
1146
- }
1147
- }
1148
- out.sort((a, b) => a.uri.localeCompare(b.uri));
1149
- return { items: out, truncated: out.length >= cap };
1150
- }
1151
- }
1152
- /**
1153
- * Factory: builds a LocalDiskConnector instance for a specific source binding.
1154
- *
1155
- * Resolves `config.root` against the current projectRoot
1156
- * (`resolveLocalRoot`): a relative path joins onto `ctx.projectRoot`, an
1157
- * absolute one is the container path as written.
1158
- */
1159
- export function createLocalDiskConnector(ctx) {
1160
- const cfg = ctx.config;
1161
- if (typeof cfg.root !== 'string' || cfg.root.length === 0) {
1162
- throw new Error(`LocalDiskConnector: config.root is required for source "${ctx.source}".`);
1163
- }
1164
- const { resolvedRoot } = resolveLocalRoot({
1165
- configured: cfg.root,
1166
- projectRoot: ctx.projectRoot ?? '',
1167
- source: ctx.source,
1168
- });
1169
- // Host-pair precedence: explicit config wins; then derive from the
1170
- // `NEURALIS_MOUNT_*` os-mount table so a UI-added source whose root sits under a `pnpm
1171
- // neuralis:mount`-created bind (e.g. `/mounts/repo`) resolves its host path
1172
- // without the user having to hand-edit `hostRoot` into the config.
1173
- const hostRoot = cfg.hostRoot ??
1174
- deriveHostRootFromOsMounts(resolvedRoot, discoverOsMounts(process.env));
1175
- return new LocalDiskConnector(resolvedRoot, ctx.source, hostRoot);
1176
- }
1177
- /**
1178
- * Derive a host-side root for `resolvedRoot` from the os-mount table. Returns
1179
- * the host path for the *longest* matching mount whose `containerPath` is the
1180
- * root itself or a path-boundary-correct ancestor (`/mounts/repo` matches
1181
- * `/mounts/repo` and `/mounts/repo/sub`, but NOT `/mounts/repo2`). Mounts
1182
- * without a `hostPath` (no `NEURALIS_MOUNT_HOST_*` pair) are skipped. Returns
1183
- * `undefined` when nothing matches, so the caller falls through to the
1184
- * fail-loud `OsUriUnresolvableError` path in Docker.
1185
- */
1186
- export function deriveHostRootFromOsMounts(resolvedRoot, mounts) {
1187
- const target = path.resolve(resolvedRoot);
1188
- let best;
1189
- for (const mount of mounts) {
1190
- if (!mount.hostPath)
1191
- continue;
1192
- const container = path.resolve(mount.containerPath);
1193
- const isMatch = target === container || target.startsWith(`${container}${path.sep}`);
1194
- if (!isMatch)
1195
- continue;
1196
- if (!best || container.length > best.containerPath.length) {
1197
- const tail = target.slice(container.length); // '' when target === container
1198
- best = { containerPath: container, hostRoot: path.join(path.resolve(mount.hostPath), tail) };
1199
- }
1200
- }
1201
- return best?.hostRoot;
1202
- }
1203
- /**
1204
- * Canonical container-root resolution for a local source's configured root.
1205
- * Exported so the brain-core API can hand agent-core's shell uri-policy gate
1206
- * the SAME resolved roots the connector itself mounts — the two branches
1207
- * (relative under the project root, absolute as written) are never re-typed.
1208
- */
1209
- export function resolveLocalRoot(params) {
1210
- const { configured, projectRoot } = params;
1211
- if (!path.isAbsolute(configured)) {
1212
- return { resolvedRoot: projectRoot ? path.join(projectRoot, configured) : configured };
1213
- }
1214
- // An absolute root is a container path as written — never re-targeted by a
1215
- // segment of it that happens to spell the project id.
1216
- return { resolvedRoot: path.resolve(configured) };
1217
- }
1218
- function detectDockerRuntime() {
1219
- const flag = process.env.NEURALIS_DOCKER;
1220
- if (flag === 'true' || flag === '1')
1221
- return true;
1222
- try {
1223
- if (existsSync('/.dockerenv'))
1224
- return true;
1225
- if (existsSync('/run/.containerenv'))
1226
- return true;
1227
- }
1228
- catch {
1229
- /* no fs access — assume non-docker */
1230
- }
1231
- return false;
1232
- }
1233
- //# sourceMappingURL=LocalDiskConnector.js.map