@oscharko-dev/keiko-server 0.2.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 (509) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/assistant-response.d.ts +6 -0
  3. package/dist/assistant-response.d.ts.map +1 -0
  4. package/dist/assistant-response.js +12 -0
  5. package/dist/browser.d.ts +11 -0
  6. package/dist/browser.d.ts.map +1 -0
  7. package/dist/browser.js +245 -0
  8. package/dist/chat-handlers.d.ts +48 -0
  9. package/dist/chat-handlers.d.ts.map +1 -0
  10. package/dist/chat-handlers.js +821 -0
  11. package/dist/chat-stream-handlers.d.ts +4 -0
  12. package/dist/chat-stream-handlers.d.ts.map +1 -0
  13. package/dist/chat-stream-handlers.js +136 -0
  14. package/dist/conversation-prompt.d.ts +8 -0
  15. package/dist/conversation-prompt.d.ts.map +1 -0
  16. package/dist/conversation-prompt.js +36 -0
  17. package/dist/conversation-validation.d.ts +26 -0
  18. package/dist/conversation-validation.d.ts.map +1 -0
  19. package/dist/conversation-validation.js +125 -0
  20. package/dist/credentialPersistence.d.ts +23 -0
  21. package/dist/credentialPersistence.d.ts.map +1 -0
  22. package/dist/credentialPersistence.js +93 -0
  23. package/dist/credentialVault.d.ts +30 -0
  24. package/dist/credentialVault.d.ts.map +1 -0
  25. package/dist/credentialVault.js +206 -0
  26. package/dist/csp.d.ts +3 -0
  27. package/dist/csp.d.ts.map +1 -0
  28. package/dist/csp.js +75 -0
  29. package/dist/deps.d.ts +78 -0
  30. package/dist/deps.d.ts.map +1 -0
  31. package/dist/deps.js +457 -0
  32. package/dist/editor/agentRoutes.d.ts +7 -0
  33. package/dist/editor/agentRoutes.d.ts.map +1 -0
  34. package/dist/editor/agentRoutes.js +197 -0
  35. package/dist/editor/assuredGateRunner.d.ts +36 -0
  36. package/dist/editor/assuredGateRunner.d.ts.map +1 -0
  37. package/dist/editor/assuredGateRunner.js +100 -0
  38. package/dist/editor/assuredPreFilter.d.ts +34 -0
  39. package/dist/editor/assuredPreFilter.d.ts.map +1 -0
  40. package/dist/editor/assuredPreFilter.js +134 -0
  41. package/dist/editor/assuredPreFilterRunner.d.ts +31 -0
  42. package/dist/editor/assuredPreFilterRunner.d.ts.map +1 -0
  43. package/dist/editor/assuredPreFilterRunner.js +312 -0
  44. package/dist/editor/builtinLanguageProviders.d.ts +6 -0
  45. package/dist/editor/builtinLanguageProviders.d.ts.map +1 -0
  46. package/dist/editor/builtinLanguageProviders.js +221 -0
  47. package/dist/editor/codingContext.d.ts +12 -0
  48. package/dist/editor/codingContext.d.ts.map +1 -0
  49. package/dist/editor/codingContext.js +121 -0
  50. package/dist/editor/codingContextEvidence.d.ts +7 -0
  51. package/dist/editor/codingContextEvidence.d.ts.map +1 -0
  52. package/dist/editor/codingContextEvidence.js +52 -0
  53. package/dist/editor/codingContextProviders.d.ts +36 -0
  54. package/dist/editor/codingContextProviders.d.ts.map +1 -0
  55. package/dist/editor/codingContextProviders.js +348 -0
  56. package/dist/editor/completionModelEvidence.d.ts +16 -0
  57. package/dist/editor/completionModelEvidence.d.ts.map +1 -0
  58. package/dist/editor/completionModelEvidence.js +50 -0
  59. package/dist/editor/completionRoutes.d.ts +37 -0
  60. package/dist/editor/completionRoutes.d.ts.map +1 -0
  61. package/dist/editor/completionRoutes.js +411 -0
  62. package/dist/editor/contextRoutes.d.ts +6 -0
  63. package/dist/editor/contextRoutes.d.ts.map +1 -0
  64. package/dist/editor/contextRoutes.js +411 -0
  65. package/dist/editor/disposableAssuredExecution.d.ts +22 -0
  66. package/dist/editor/disposableAssuredExecution.d.ts.map +1 -0
  67. package/dist/editor/disposableAssuredExecution.js +57 -0
  68. package/dist/editor/editorCompletionModel.d.ts +47 -0
  69. package/dist/editor/editorCompletionModel.d.ts.map +1 -0
  70. package/dist/editor/editorCompletionModel.js +156 -0
  71. package/dist/editor/editorInlineCompletionModel.d.ts +34 -0
  72. package/dist/editor/editorInlineCompletionModel.d.ts.map +1 -0
  73. package/dist/editor/editorInlineCompletionModel.js +112 -0
  74. package/dist/editor/editorModelTokenBudget.d.ts +46 -0
  75. package/dist/editor/editorModelTokenBudget.d.ts.map +1 -0
  76. package/dist/editor/editorModelTokenBudget.js +121 -0
  77. package/dist/editor/inlineCompletionRateLimiter.d.ts +19 -0
  78. package/dist/editor/inlineCompletionRateLimiter.d.ts.map +1 -0
  79. package/dist/editor/inlineCompletionRateLimiter.js +46 -0
  80. package/dist/editor/inlineCompletionRoutes.d.ts +26 -0
  81. package/dist/editor/inlineCompletionRoutes.d.ts.map +1 -0
  82. package/dist/editor/inlineCompletionRoutes.js +404 -0
  83. package/dist/editor/inlineCompletionTelemetryEvidence.d.ts +5 -0
  84. package/dist/editor/inlineCompletionTelemetryEvidence.d.ts.map +1 -0
  85. package/dist/editor/inlineCompletionTelemetryEvidence.js +42 -0
  86. package/dist/editor/languageCancellation.d.ts +19 -0
  87. package/dist/editor/languageCancellation.d.ts.map +1 -0
  88. package/dist/editor/languageCancellation.js +48 -0
  89. package/dist/editor/languageProvider.d.ts +39 -0
  90. package/dist/editor/languageProvider.d.ts.map +1 -0
  91. package/dist/editor/languageProvider.js +11 -0
  92. package/dist/editor/languageRoutes.d.ts +15 -0
  93. package/dist/editor/languageRoutes.d.ts.map +1 -0
  94. package/dist/editor/languageRoutes.js +106 -0
  95. package/dist/editor/languageSanitize.d.ts +8 -0
  96. package/dist/editor/languageSanitize.d.ts.map +1 -0
  97. package/dist/editor/languageSanitize.js +101 -0
  98. package/dist/editor/languageService.d.ts +36 -0
  99. package/dist/editor/languageService.d.ts.map +1 -0
  100. package/dist/editor/languageService.js +93 -0
  101. package/dist/editor/languageServiceHost.d.ts +14 -0
  102. package/dist/editor/languageServiceHost.d.ts.map +1 -0
  103. package/dist/editor/languageServiceHost.js +242 -0
  104. package/dist/editor/localKnowledgeRetrieval.d.ts +21 -0
  105. package/dist/editor/localKnowledgeRetrieval.d.ts.map +1 -0
  106. package/dist/editor/localKnowledgeRetrieval.js +44 -0
  107. package/dist/editor/patchApplyEvidence.d.ts +21 -0
  108. package/dist/editor/patchApplyEvidence.d.ts.map +1 -0
  109. package/dist/editor/patchApplyEvidence.js +87 -0
  110. package/dist/editor/patchApplyRoutes.d.ts +16 -0
  111. package/dist/editor/patchApplyRoutes.d.ts.map +1 -0
  112. package/dist/editor/patchApplyRoutes.js +307 -0
  113. package/dist/editor/postApplyVerification.d.ts +42 -0
  114. package/dist/editor/postApplyVerification.d.ts.map +1 -0
  115. package/dist/editor/postApplyVerification.js +177 -0
  116. package/dist/editor/testGenerationEvidence.d.ts +6 -0
  117. package/dist/editor/testGenerationEvidence.d.ts.map +1 -0
  118. package/dist/editor/testGenerationEvidence.js +72 -0
  119. package/dist/editor/testGenerationPatch.d.ts +10 -0
  120. package/dist/editor/testGenerationPatch.d.ts.map +1 -0
  121. package/dist/editor/testGenerationPatch.js +66 -0
  122. package/dist/editor/testGenerationRoutes.d.ts +21 -0
  123. package/dist/editor/testGenerationRoutes.d.ts.map +1 -0
  124. package/dist/editor/testGenerationRoutes.js +254 -0
  125. package/dist/editor/testGenerationRunner.d.ts +23 -0
  126. package/dist/editor/testGenerationRunner.d.ts.map +1 -0
  127. package/dist/editor/testGenerationRunner.js +120 -0
  128. package/dist/editor/textOffsets.d.ts +6 -0
  129. package/dist/editor/textOffsets.d.ts.map +1 -0
  130. package/dist/editor/textOffsets.js +82 -0
  131. package/dist/editor/typescriptLanguageProvider.d.ts +3 -0
  132. package/dist/editor/typescriptLanguageProvider.d.ts.map +1 -0
  133. package/dist/editor/typescriptLanguageProvider.js +217 -0
  134. package/dist/evidence.d.ts +28 -0
  135. package/dist/evidence.d.ts.map +1 -0
  136. package/dist/evidence.js +145 -0
  137. package/dist/files-deny.d.ts +3 -0
  138. package/dist/files-deny.d.ts.map +1 -0
  139. package/dist/files-deny.js +12 -0
  140. package/dist/files.d.ts +97 -0
  141. package/dist/files.d.ts.map +1 -0
  142. package/dist/files.js +733 -0
  143. package/dist/gateway-setup.d.ts +10 -0
  144. package/dist/gateway-setup.d.ts.map +1 -0
  145. package/dist/gateway-setup.js +896 -0
  146. package/dist/governed-workflow.d.ts +17 -0
  147. package/dist/governed-workflow.d.ts.map +1 -0
  148. package/dist/governed-workflow.js +147 -0
  149. package/dist/grounded-answer.d.ts +12 -0
  150. package/dist/grounded-answer.d.ts.map +1 -0
  151. package/dist/grounded-answer.js +69 -0
  152. package/dist/grounded-context-index.d.ts +25 -0
  153. package/dist/grounded-context-index.d.ts.map +1 -0
  154. package/dist/grounded-context-index.js +169 -0
  155. package/dist/grounded-document-evidence.d.ts +28 -0
  156. package/dist/grounded-document-evidence.d.ts.map +1 -0
  157. package/dist/grounded-document-evidence.js +430 -0
  158. package/dist/grounded-handoff.d.ts +4 -0
  159. package/dist/grounded-handoff.d.ts.map +1 -0
  160. package/dist/grounded-handoff.js +445 -0
  161. package/dist/grounded-orchestrator.d.ts +43 -0
  162. package/dist/grounded-orchestrator.d.ts.map +1 -0
  163. package/dist/grounded-orchestrator.js +1445 -0
  164. package/dist/grounded-prompt.d.ts +2 -0
  165. package/dist/grounded-prompt.d.ts.map +1 -0
  166. package/dist/grounded-prompt.js +17 -0
  167. package/dist/grounded-qa-hybrid.d.ts +36 -0
  168. package/dist/grounded-qa-hybrid.d.ts.map +1 -0
  169. package/dist/grounded-qa-hybrid.js +762 -0
  170. package/dist/grounded-qa-multi-source.d.ts +38 -0
  171. package/dist/grounded-qa-multi-source.d.ts.map +1 -0
  172. package/dist/grounded-qa-multi-source.js +461 -0
  173. package/dist/grounded-qa.d.ts +45 -0
  174. package/dist/grounded-qa.d.ts.map +1 -0
  175. package/dist/grounded-qa.js +877 -0
  176. package/dist/grounded-rerank.d.ts +26 -0
  177. package/dist/grounded-rerank.d.ts.map +1 -0
  178. package/dist/grounded-rerank.js +72 -0
  179. package/dist/grounded-turn-registry.d.ts +23 -0
  180. package/dist/grounded-turn-registry.d.ts.map +1 -0
  181. package/dist/grounded-turn-registry.js +102 -0
  182. package/dist/headers.d.ts +3 -0
  183. package/dist/headers.d.ts.map +1 -0
  184. package/dist/headers.js +22 -0
  185. package/dist/host-check.d.ts +3 -0
  186. package/dist/host-check.d.ts.map +1 -0
  187. package/dist/host-check.js +58 -0
  188. package/dist/index.d.ts +26 -0
  189. package/dist/index.d.ts.map +1 -0
  190. package/dist/index.js +33 -0
  191. package/dist/load-csp.d.ts +3 -0
  192. package/dist/load-csp.d.ts.map +1 -0
  193. package/dist/load-csp.js +100 -0
  194. package/dist/local-knowledge-grounded-qa.d.ts +42 -0
  195. package/dist/local-knowledge-grounded-qa.d.ts.map +1 -0
  196. package/dist/local-knowledge-grounded-qa.js +678 -0
  197. package/dist/local-knowledge-handlers.d.ts +24 -0
  198. package/dist/local-knowledge-handlers.d.ts.map +1 -0
  199. package/dist/local-knowledge-handlers.js +1285 -0
  200. package/dist/local-knowledge-indexing-registry.d.ts +13 -0
  201. package/dist/local-knowledge-indexing-registry.d.ts.map +1 -0
  202. package/dist/local-knowledge-indexing-registry.js +53 -0
  203. package/dist/localKnowledgeKeyProvider.d.ts +11 -0
  204. package/dist/localKnowledgeKeyProvider.d.ts.map +1 -0
  205. package/dist/localKnowledgeKeyProvider.js +48 -0
  206. package/dist/memory-audit-event-builders.d.ts +21 -0
  207. package/dist/memory-audit-event-builders.d.ts.map +1 -0
  208. package/dist/memory-audit-event-builders.js +187 -0
  209. package/dist/memory-audit-handler.d.ts +23 -0
  210. package/dist/memory-audit-handler.d.ts.map +1 -0
  211. package/dist/memory-audit-handler.js +191 -0
  212. package/dist/memory-capture-policy.d.ts +10 -0
  213. package/dist/memory-capture-policy.d.ts.map +1 -0
  214. package/dist/memory-capture-policy.js +44 -0
  215. package/dist/memory-consolidation-handlers.d.ts +6 -0
  216. package/dist/memory-consolidation-handlers.d.ts.map +1 -0
  217. package/dist/memory-consolidation-handlers.js +491 -0
  218. package/dist/memory-consolidation-registry.d.ts +47 -0
  219. package/dist/memory-consolidation-registry.d.ts.map +1 -0
  220. package/dist/memory-consolidation-registry.js +106 -0
  221. package/dist/memory-conv-handlers.d.ts +8 -0
  222. package/dist/memory-conv-handlers.d.ts.map +1 -0
  223. package/dist/memory-conv-handlers.js +369 -0
  224. package/dist/memory-conversation-context.d.ts +13 -0
  225. package/dist/memory-conversation-context.d.ts.map +1 -0
  226. package/dist/memory-conversation-context.js +22 -0
  227. package/dist/memory-diagnostics.d.ts +29 -0
  228. package/dist/memory-diagnostics.d.ts.map +1 -0
  229. package/dist/memory-diagnostics.js +122 -0
  230. package/dist/memory-embedding.d.ts +21 -0
  231. package/dist/memory-embedding.d.ts.map +1 -0
  232. package/dist/memory-embedding.js +264 -0
  233. package/dist/memory-handlers.d.ts +19 -0
  234. package/dist/memory-handlers.d.ts.map +1 -0
  235. package/dist/memory-handlers.js +1204 -0
  236. package/dist/memory-maintenance-handlers.d.ts +35 -0
  237. package/dist/memory-maintenance-handlers.d.ts.map +1 -0
  238. package/dist/memory-maintenance-handlers.js +219 -0
  239. package/dist/memory-record-builders.d.ts +4 -0
  240. package/dist/memory-record-builders.d.ts.map +1 -0
  241. package/dist/memory-record-builders.js +19 -0
  242. package/dist/memory-retention.d.ts +31 -0
  243. package/dist/memory-retention.d.ts.map +1 -0
  244. package/dist/memory-retention.js +151 -0
  245. package/dist/memory-retrieval-signals.d.ts +12 -0
  246. package/dist/memory-retrieval-signals.d.ts.map +1 -0
  247. package/dist/memory-retrieval-signals.js +100 -0
  248. package/dist/memory-salience.d.ts +12 -0
  249. package/dist/memory-salience.d.ts.map +1 -0
  250. package/dist/memory-salience.js +154 -0
  251. package/dist/memory-scope-sanitizer.d.ts +6 -0
  252. package/dist/memory-scope-sanitizer.d.ts.map +1 -0
  253. package/dist/memory-scope-sanitizer.js +106 -0
  254. package/dist/memory-target-resolver.d.ts +4 -0
  255. package/dist/memory-target-resolver.d.ts.map +1 -0
  256. package/dist/memory-target-resolver.js +73 -0
  257. package/dist/memory-workflow-port.d.ts +14 -0
  258. package/dist/memory-workflow-port.d.ts.map +1 -0
  259. package/dist/memory-workflow-port.js +186 -0
  260. package/dist/private-json.d.ts +3 -0
  261. package/dist/private-json.d.ts.map +1 -0
  262. package/dist/private-json.js +62 -0
  263. package/dist/promptEnhancer/index.d.ts +3 -0
  264. package/dist/promptEnhancer/index.d.ts.map +1 -0
  265. package/dist/promptEnhancer/index.js +5 -0
  266. package/dist/promptEnhancer/orchestrate.d.ts +2 -0
  267. package/dist/promptEnhancer/orchestrate.d.ts.map +1 -0
  268. package/dist/promptEnhancer/orchestrate.js +5 -0
  269. package/dist/promptEnhancer/routes.d.ts +9 -0
  270. package/dist/promptEnhancer/routes.d.ts.map +1 -0
  271. package/dist/promptEnhancer/routes.js +205 -0
  272. package/dist/qualityIntelligence/capsuleAdapter.d.ts +27 -0
  273. package/dist/qualityIntelligence/capsuleAdapter.d.ts.map +1 -0
  274. package/dist/qualityIntelligence/capsuleAdapter.js +57 -0
  275. package/dist/qualityIntelligence/connectorAuthorization.d.ts +22 -0
  276. package/dist/qualityIntelligence/connectorAuthorization.d.ts.map +1 -0
  277. package/dist/qualityIntelligence/connectorAuthorization.js +35 -0
  278. package/dist/qualityIntelligence/connectorErrors.d.ts +16 -0
  279. package/dist/qualityIntelligence/connectorErrors.d.ts.map +1 -0
  280. package/dist/qualityIntelligence/connectorErrors.js +56 -0
  281. package/dist/qualityIntelligence/connectorRoutes.d.ts +7 -0
  282. package/dist/qualityIntelligence/connectorRoutes.d.ts.map +1 -0
  283. package/dist/qualityIntelligence/connectorRoutes.js +167 -0
  284. package/dist/qualityIntelligence/editRoutes.d.ts +5 -0
  285. package/dist/qualityIntelligence/editRoutes.d.ts.map +1 -0
  286. package/dist/qualityIntelligence/editRoutes.js +293 -0
  287. package/dist/qualityIntelligence/exportAssembly.d.ts +22 -0
  288. package/dist/qualityIntelligence/exportAssembly.d.ts.map +1 -0
  289. package/dist/qualityIntelligence/exportAssembly.js +352 -0
  290. package/dist/qualityIntelligence/exportRoutes.d.ts +5 -0
  291. package/dist/qualityIntelligence/exportRoutes.d.ts.map +1 -0
  292. package/dist/qualityIntelligence/exportRoutes.js +320 -0
  293. package/dist/qualityIntelligence/figma/figmaConcurrency.d.ts +8 -0
  294. package/dist/qualityIntelligence/figma/figmaConcurrency.d.ts.map +1 -0
  295. package/dist/qualityIntelligence/figma/figmaConcurrency.js +34 -0
  296. package/dist/qualityIntelligence/figma/figmaConnector.d.ts +65 -0
  297. package/dist/qualityIntelligence/figma/figmaConnector.d.ts.map +1 -0
  298. package/dist/qualityIntelligence/figma/figmaConnector.js +184 -0
  299. package/dist/qualityIntelligence/figma/figmaConnectorAudit.d.ts +52 -0
  300. package/dist/qualityIntelligence/figma/figmaConnectorAudit.d.ts.map +1 -0
  301. package/dist/qualityIntelligence/figma/figmaConnectorAudit.js +63 -0
  302. package/dist/qualityIntelligence/figma/figmaConnectorErrors.d.ts +31 -0
  303. package/dist/qualityIntelligence/figma/figmaConnectorErrors.d.ts.map +1 -0
  304. package/dist/qualityIntelligence/figma/figmaConnectorErrors.js +220 -0
  305. package/dist/qualityIntelligence/figma/figmaConnectorMetrics.d.ts +44 -0
  306. package/dist/qualityIntelligence/figma/figmaConnectorMetrics.d.ts.map +1 -0
  307. package/dist/qualityIntelligence/figma/figmaConnectorMetrics.js +49 -0
  308. package/dist/qualityIntelligence/figma/figmaConsent.d.ts +39 -0
  309. package/dist/qualityIntelligence/figma/figmaConsent.d.ts.map +1 -0
  310. package/dist/qualityIntelligence/figma/figmaConsent.js +62 -0
  311. package/dist/qualityIntelligence/figma/figmaHttpPort.d.ts +28 -0
  312. package/dist/qualityIntelligence/figma/figmaHttpPort.d.ts.map +1 -0
  313. package/dist/qualityIntelligence/figma/figmaHttpPort.js +70 -0
  314. package/dist/qualityIntelligence/figma/figmaObservedActions.d.ts +49 -0
  315. package/dist/qualityIntelligence/figma/figmaObservedActions.d.ts.map +1 -0
  316. package/dist/qualityIntelligence/figma/figmaObservedActions.js +89 -0
  317. package/dist/qualityIntelligence/figma/figmaReadiness.d.ts +32 -0
  318. package/dist/qualityIntelligence/figma/figmaReadiness.d.ts.map +1 -0
  319. package/dist/qualityIntelligence/figma/figmaReadiness.js +67 -0
  320. package/dist/qualityIntelligence/figma/figmaRenderPort.d.ts +29 -0
  321. package/dist/qualityIntelligence/figma/figmaRenderPort.d.ts.map +1 -0
  322. package/dist/qualityIntelligence/figma/figmaRenderPort.js +93 -0
  323. package/dist/qualityIntelligence/figma/figmaResnapshot.d.ts +28 -0
  324. package/dist/qualityIntelligence/figma/figmaResnapshot.d.ts.map +1 -0
  325. package/dist/qualityIntelligence/figma/figmaResnapshot.js +38 -0
  326. package/dist/qualityIntelligence/figma/figmaRetry.d.ts +31 -0
  327. package/dist/qualityIntelligence/figma/figmaRetry.d.ts.map +1 -0
  328. package/dist/qualityIntelligence/figma/figmaRetry.js +62 -0
  329. package/dist/qualityIntelligence/figma/figmaScopeRef.d.ts +9 -0
  330. package/dist/qualityIntelligence/figma/figmaScopeRef.d.ts.map +1 -0
  331. package/dist/qualityIntelligence/figma/figmaScopeRef.js +18 -0
  332. package/dist/qualityIntelligence/figma/figmaScopedPagination.d.ts +86 -0
  333. package/dist/qualityIntelligence/figma/figmaScopedPagination.d.ts.map +1 -0
  334. package/dist/qualityIntelligence/figma/figmaScopedPagination.js +308 -0
  335. package/dist/qualityIntelligence/figma/figmaSnapshotBuilder.d.ts +31 -0
  336. package/dist/qualityIntelligence/figma/figmaSnapshotBuilder.d.ts.map +1 -0
  337. package/dist/qualityIntelligence/figma/figmaSnapshotBuilder.js +314 -0
  338. package/dist/qualityIntelligence/figma/figmaSnapshotHash.d.ts +18 -0
  339. package/dist/qualityIntelligence/figma/figmaSnapshotHash.d.ts.map +1 -0
  340. package/dist/qualityIntelligence/figma/figmaSnapshotHash.js +63 -0
  341. package/dist/qualityIntelligence/figma/figmaSnapshotTypes.d.ts +65 -0
  342. package/dist/qualityIntelligence/figma/figmaSnapshotTypes.d.ts.map +1 -0
  343. package/dist/qualityIntelligence/figma/figmaSnapshotTypes.js +13 -0
  344. package/dist/qualityIntelligence/figma/figmaTokenSource.d.ts +9 -0
  345. package/dist/qualityIntelligence/figma/figmaTokenSource.d.ts.map +1 -0
  346. package/dist/qualityIntelligence/figma/figmaTokenSource.js +61 -0
  347. package/dist/qualityIntelligence/figma/figmaTokenStore.d.ts +19 -0
  348. package/dist/qualityIntelligence/figma/figmaTokenStore.d.ts.map +1 -0
  349. package/dist/qualityIntelligence/figma/figmaTokenStore.js +156 -0
  350. package/dist/qualityIntelligence/figma/figmaUrl.d.ts +6 -0
  351. package/dist/qualityIntelligence/figma/figmaUrl.d.ts.map +1 -0
  352. package/dist/qualityIntelligence/figma/figmaUrl.js +36 -0
  353. package/dist/qualityIntelligence/figma/index.d.ts +20 -0
  354. package/dist/qualityIntelligence/figma/index.d.ts.map +1 -0
  355. package/dist/qualityIntelligence/figma/index.js +26 -0
  356. package/dist/qualityIntelligence/figmaCodegenRoutes.d.ts +28 -0
  357. package/dist/qualityIntelligence/figmaCodegenRoutes.d.ts.map +1 -0
  358. package/dist/qualityIntelligence/figmaCodegenRoutes.js +165 -0
  359. package/dist/qualityIntelligence/figmaSnapshotAdapter.d.ts +55 -0
  360. package/dist/qualityIntelligence/figmaSnapshotAdapter.d.ts.map +1 -0
  361. package/dist/qualityIntelligence/figmaSnapshotAdapter.js +219 -0
  362. package/dist/qualityIntelligence/figmaSnapshotOrchestration.d.ts +64 -0
  363. package/dist/qualityIntelligence/figmaSnapshotOrchestration.d.ts.map +1 -0
  364. package/dist/qualityIntelligence/figmaSnapshotOrchestration.js +203 -0
  365. package/dist/qualityIntelligence/figmaSnapshotRoutes.d.ts +112 -0
  366. package/dist/qualityIntelligence/figmaSnapshotRoutes.d.ts.map +1 -0
  367. package/dist/qualityIntelligence/figmaSnapshotRoutes.js +1063 -0
  368. package/dist/qualityIntelligence/figmaSnapshotScreenIds.d.ts +19 -0
  369. package/dist/qualityIntelligence/figmaSnapshotScreenIds.d.ts.map +1 -0
  370. package/dist/qualityIntelligence/figmaSnapshotScreenIds.js +75 -0
  371. package/dist/qualityIntelligence/generationPort.d.ts +15 -0
  372. package/dist/qualityIntelligence/generationPort.d.ts.map +1 -0
  373. package/dist/qualityIntelligence/generationPort.js +185 -0
  374. package/dist/qualityIntelligence/handoffErrors.d.ts +9 -0
  375. package/dist/qualityIntelligence/handoffErrors.d.ts.map +1 -0
  376. package/dist/qualityIntelligence/handoffErrors.js +21 -0
  377. package/dist/qualityIntelligence/handoffRoutes.d.ts +15 -0
  378. package/dist/qualityIntelligence/handoffRoutes.d.ts.map +1 -0
  379. package/dist/qualityIntelligence/handoffRoutes.js +341 -0
  380. package/dist/qualityIntelligence/index.d.ts +17 -0
  381. package/dist/qualityIntelligence/index.d.ts.map +1 -0
  382. package/dist/qualityIntelligence/index.js +36 -0
  383. package/dist/qualityIntelligence/judgePort.d.ts +30 -0
  384. package/dist/qualityIntelligence/judgePort.d.ts.map +1 -0
  385. package/dist/qualityIntelligence/judgePort.js +326 -0
  386. package/dist/qualityIntelligence/modelSelection.d.ts +58 -0
  387. package/dist/qualityIntelligence/modelSelection.d.ts.map +1 -0
  388. package/dist/qualityIntelligence/modelSelection.js +148 -0
  389. package/dist/qualityIntelligence/reCheckRoutes.d.ts +6 -0
  390. package/dist/qualityIntelligence/reCheckRoutes.d.ts.map +1 -0
  391. package/dist/qualityIntelligence/reCheckRoutes.js +1157 -0
  392. package/dist/qualityIntelligence/retentionEnforcement.d.ts +13 -0
  393. package/dist/qualityIntelligence/retentionEnforcement.d.ts.map +1 -0
  394. package/dist/qualityIntelligence/retentionEnforcement.js +47 -0
  395. package/dist/qualityIntelligence/retentionRoutes.d.ts +8 -0
  396. package/dist/qualityIntelligence/retentionRoutes.d.ts.map +1 -0
  397. package/dist/qualityIntelligence/retentionRoutes.js +74 -0
  398. package/dist/qualityIntelligence/reviewRoutes.d.ts +5 -0
  399. package/dist/qualityIntelligence/reviewRoutes.d.ts.map +1 -0
  400. package/dist/qualityIntelligence/reviewRoutes.js +145 -0
  401. package/dist/qualityIntelligence/reviewStore.d.ts +75 -0
  402. package/dist/qualityIntelligence/reviewStore.d.ts.map +1 -0
  403. package/dist/qualityIntelligence/reviewStore.js +170 -0
  404. package/dist/qualityIntelligence/runExecution.d.ts +36 -0
  405. package/dist/qualityIntelligence/runExecution.d.ts.map +1 -0
  406. package/dist/qualityIntelligence/runExecution.js +180 -0
  407. package/dist/qualityIntelligence/runIngestion.d.ts +70 -0
  408. package/dist/qualityIntelligence/runIngestion.d.ts.map +1 -0
  409. package/dist/qualityIntelligence/runIngestion.js +1235 -0
  410. package/dist/qualityIntelligence/runRegistry.d.ts +31 -0
  411. package/dist/qualityIntelligence/runRegistry.d.ts.map +1 -0
  412. package/dist/qualityIntelligence/runRegistry.js +66 -0
  413. package/dist/qualityIntelligence/runRoutes.d.ts +16 -0
  414. package/dist/qualityIntelligence/runRoutes.d.ts.map +1 -0
  415. package/dist/qualityIntelligence/runRoutes.js +357 -0
  416. package/dist/qualityIntelligence/traceabilityRoutes.d.ts +5 -0
  417. package/dist/qualityIntelligence/traceabilityRoutes.d.ts.map +1 -0
  418. package/dist/qualityIntelligence/traceabilityRoutes.js +173 -0
  419. package/dist/qualityIntelligence/uiRoutes.d.ts +7 -0
  420. package/dist/qualityIntelligence/uiRoutes.d.ts.map +1 -0
  421. package/dist/qualityIntelligence/uiRoutes.js +336 -0
  422. package/dist/read-handlers.d.ts +9 -0
  423. package/dist/read-handlers.d.ts.map +1 -0
  424. package/dist/read-handlers.js +265 -0
  425. package/dist/relationship-handlers.d.ts +191 -0
  426. package/dist/relationship-handlers.d.ts.map +1 -0
  427. package/dist/relationship-handlers.js +0 -0
  428. package/dist/routes.d.ts +37 -0
  429. package/dist/routes.d.ts.map +1 -0
  430. package/dist/routes.js +507 -0
  431. package/dist/run-engine.d.ts +25 -0
  432. package/dist/run-engine.d.ts.map +1 -0
  433. package/dist/run-engine.js +385 -0
  434. package/dist/run-handlers.d.ts +9 -0
  435. package/dist/run-handlers.d.ts.map +1 -0
  436. package/dist/run-handlers.js +465 -0
  437. package/dist/run-request.d.ts +17 -0
  438. package/dist/run-request.d.ts.map +1 -0
  439. package/dist/run-request.js +219 -0
  440. package/dist/runs.d.ts +47 -0
  441. package/dist/runs.d.ts.map +1 -0
  442. package/dist/runs.js +100 -0
  443. package/dist/server.d.ts +13 -0
  444. package/dist/server.d.ts.map +1 -0
  445. package/dist/server.js +152 -0
  446. package/dist/sink.d.ts +28 -0
  447. package/dist/sink.d.ts.map +1 -0
  448. package/dist/sink.js +80 -0
  449. package/dist/sse-write.d.ts +9 -0
  450. package/dist/sse-write.d.ts.map +1 -0
  451. package/dist/sse-write.js +26 -0
  452. package/dist/sse.d.ts +8 -0
  453. package/dist/sse.d.ts.map +1 -0
  454. package/dist/sse.js +27 -0
  455. package/dist/static.d.ts +5 -0
  456. package/dist/static.d.ts.map +1 -0
  457. package/dist/static.js +76 -0
  458. package/dist/store/chats.d.ts +17 -0
  459. package/dist/store/chats.d.ts.map +1 -0
  460. package/dist/store/chats.js +624 -0
  461. package/dist/store/db.d.ts +11 -0
  462. package/dist/store/db.d.ts.map +1 -0
  463. package/dist/store/db.js +203 -0
  464. package/dist/store/errors.d.ts +13 -0
  465. package/dist/store/errors.d.ts.map +1 -0
  466. package/dist/store/errors.js +30 -0
  467. package/dist/store/index.d.ts +7 -0
  468. package/dist/store/index.d.ts.map +1 -0
  469. package/dist/store/index.js +6 -0
  470. package/dist/store/messages.d.ts +8 -0
  471. package/dist/store/messages.d.ts.map +1 -0
  472. package/dist/store/messages.js +149 -0
  473. package/dist/store/paths.d.ts +5 -0
  474. package/dist/store/paths.d.ts.map +1 -0
  475. package/dist/store/paths.js +84 -0
  476. package/dist/store/projects.d.ts +8 -0
  477. package/dist/store/projects.d.ts.map +1 -0
  478. package/dist/store/projects.js +59 -0
  479. package/dist/store/relationship-audit.d.ts +42 -0
  480. package/dist/store/relationship-audit.d.ts.map +1 -0
  481. package/dist/store/relationship-audit.js +155 -0
  482. package/dist/store/relationships.d.ts +191 -0
  483. package/dist/store/relationships.d.ts.map +1 -0
  484. package/dist/store/relationships.js +724 -0
  485. package/dist/store/schema.d.ts +4 -0
  486. package/dist/store/schema.d.ts.map +1 -0
  487. package/dist/store/schema.js +220 -0
  488. package/dist/store/types.d.ts +29 -0
  489. package/dist/store/types.d.ts.map +1 -0
  490. package/dist/store/types.js +8 -0
  491. package/dist/store/validation.d.ts +7 -0
  492. package/dist/store/validation.d.ts.map +1 -0
  493. package/dist/store/validation.js +117 -0
  494. package/dist/store-handlers.d.ts +17 -0
  495. package/dist/store-handlers.d.ts.map +1 -0
  496. package/dist/store-handlers.js +872 -0
  497. package/dist/terminal-errors.d.ts +22 -0
  498. package/dist/terminal-errors.d.ts.map +1 -0
  499. package/dist/terminal-errors.js +45 -0
  500. package/dist/terminal-evidence.d.ts +21 -0
  501. package/dist/terminal-evidence.d.ts.map +1 -0
  502. package/dist/terminal-evidence.js +65 -0
  503. package/dist/terminal-routes.d.ts +10 -0
  504. package/dist/terminal-routes.d.ts.map +1 -0
  505. package/dist/terminal-routes.js +219 -0
  506. package/dist/terminal.d.ts +68 -0
  507. package/dist/terminal.d.ts.map +1 -0
  508. package/dist/terminal.js +855 -0
  509. package/package.json +52 -0
@@ -0,0 +1,1235 @@
1
+ // Quality Intelligence run-source ingestion (Epic #270, Issue #278).
2
+ //
3
+ // Converts the inline sources of a start-run request into content-bearing evidence atoms +
4
+ // browser-safe source envelopes, reusing the pure-domain ingestion + hardening helpers from
5
+ // `@oscharko-dev/keiko-quality-intelligence`. The server tier owns IO (it is the only layer that
6
+ // may touch the filesystem); the pure domain owns splitting + hashing. Oversize and unsupported
7
+ // inputs fail with user-actionable errors (#278 AC) before any model prompt is built.
8
+ import { realpathSync } from "node:fs";
9
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
10
+ import { QualityIntelligence } from "@oscharko-dev/keiko-contracts";
11
+ import { redact, sha256Hex } from "@oscharko-dev/keiko-security";
12
+ import { QualityIntelligenceGeneration, QualityIntelligenceHardening, QualityIntelligenceFigma, isUnsafeFormatCodePoint, stripUnsafeFormatChars, } from "@oscharko-dev/keiko-quality-intelligence";
13
+ import { detectWorkspaceAt, discoverWithStats, buildContextPackFromFiles, readWorkspaceFile, isDenied, DEFAULT_CONTEXT_REQUEST, DEFAULT_READ_OPTIONS, WorkspaceError, FileTooLargeError, PathDeniedError, PathEscapeError, WorkspaceReadError, } from "@oscharko-dev/keiko-workspace";
14
+ const MAX_TOTAL_ATOMS = 120;
15
+ const MAX_LABEL_CHARS = 120;
16
+ // Mirrors the Chat multi-source cap (MAX_CONNECTED_SOURCES / MAX_SCOPES = 16). Sources beyond this
17
+ // are dropped before ingestion with a user-actionable coverage notice (Epic #729, Issue #730).
18
+ const MAX_QI_SOURCES = 16;
19
+ /**
20
+ * Fair per-source atom budget — mirrors Chat's splitExplorationBudget floor-division semantics
21
+ * (grounded-qa-multi-source.ts): the global budget is shared evenly so no single source starves the
22
+ * others. A single source keeps the whole budget.
23
+ */
24
+ function perSourceAtomBudget(total, sourceCount) {
25
+ if (sourceCount <= 1)
26
+ return total;
27
+ return Math.max(1, Math.floor(total / sourceCount));
28
+ }
29
+ // Global per-run evidence byte budget. Each source kind (workspace / file / capsule /
30
+ // figma-snapshot) was previously allowed ~192KB INDEPENDENTLY, so N large sources summed to N×192KB
31
+ // and blew the model prompt cap (MAX_PROMPT_BYTES = 256KB) — failing the entire N+1 run with
32
+ // QI_PROMPT_TOO_LARGE (Epic #729 headline). The byte budget is now a single global pool split fairly
33
+ // across sources, mirroring the atom-budget split, so the merged evidence text stays bounded
34
+ // regardless of N. A single source keeps the full budget (identical to the prior single-source
35
+ // behaviour). figma-snapshot uses the same split via figmaScreenDocs (mirrors processCapsuleDocs).
36
+ const EVIDENCE_BUDGET_BYTES = 196_608;
37
+ // Never starve a source below this many bytes — a tiny share is still usable context.
38
+ const MIN_SOURCE_BUDGET_BYTES = 4_096;
39
+ // Bound productive vision calls for one Figma snapshot source. The deterministic structural
40
+ // baseline still covers every parseable screen; vision hints are additive and sampled first-in-order.
41
+ const MAX_FIGMA_VISION_AUGMENTED_SCREENS = 12;
42
+ /**
43
+ * Fair per-source UTF-8 byte budget — the byte analogue of {@link perSourceAtomBudget}. Floor-divides
44
+ * the global evidence byte pool across sources (Chat byte-split parity) with a non-zero floor so the
45
+ * summed canonical text of all sources stays under the model prompt ceiling and no single large
46
+ * source can exhaust the budget for the others (Epic #729 / #730 multi-source containment).
47
+ */
48
+ function perSourceByteBudget(sourceCount) {
49
+ if (sourceCount <= 1)
50
+ return EVIDENCE_BUDGET_BYTES;
51
+ return Math.max(MIN_SOURCE_BUDGET_BYTES, Math.floor(EVIDENCE_BUDGET_BYTES / sourceCount));
52
+ }
53
+ export class QiIngestionError extends Error {
54
+ code;
55
+ constructor(code, message) {
56
+ super(message);
57
+ this.code = code;
58
+ this.name = "QiIngestionError";
59
+ }
60
+ }
61
+ // Credential token shapes mirrored from keiko-contracts `fieldLooksUnsafe` so a connected
62
+ // source's display label can never echo a secret back to the browser-surfaced envelope
63
+ // (#277/#278 envelope display-surface invariant — the label is the only user-derived envelope
64
+ // field; localRef/origin/integrityHash are server-built and hash-derived).
65
+ const CREDENTIAL_LABEL_SHAPES = [
66
+ /AKIA[0-9A-Z]{12,}/gu,
67
+ /(?:ghp_|gho_|github_pat_)[A-Za-z0-9_]{20,}/gu,
68
+ /xox[baprs]-[A-Za-z0-9-]{10,}/gu,
69
+ /sk-[A-Za-z0-9]{16,}/gu,
70
+ /\bBearer\s+\S+/giu,
71
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/gu,
72
+ ];
73
+ // Make a source label single-line AND spoof-safe with one code-point scan (the `no-control-regex`
74
+ // lint rule forbids a control-range regex literal, and a scan is the established in-package idiom —
75
+ // mirrors generationPort.scrubEvidenceText):
76
+ // - C0 controls (incl. tab/newline/CR) and DEL become a SPACE, so a multi-line or control-laden
77
+ // label can never glue a second line of content into the streamed displayLabel (#277/#278).
78
+ // - Bidi overrides/isolates, zero-width/BOM, LRM/RLM, the Arabic letter mark, and C1 controls are
79
+ // DROPPED outright. These are invisible or reorder surrounding text, so a source filename /
80
+ // capsule id cannot smuggle a right-to-left or zero-width spoof into the browser-streamed
81
+ // envelope display surface. The drop set is the SHARED `isUnsafeFormatCodePoint` predicate used
82
+ // by the candidate-text scrubber (keiko-quality-intelligence stripUnsafeFormatChars), so the
83
+ // source-label path is symmetric with the persisted/exported candidate-text path (Epic #729;
84
+ // the bidi/zero-width display-hygiene class of #280/#284). C0/DEL are handled first (→ space)
85
+ // because a single-line label spaces line breaks rather than gluing them.
86
+ function stripUnsafeLabelChars(value) {
87
+ let out = "";
88
+ for (const ch of value) {
89
+ const cp = ch.codePointAt(0) ?? 0;
90
+ if (cp <= 0x1f || cp === 0x7f) {
91
+ out += " ";
92
+ continue;
93
+ }
94
+ if (isUnsafeFormatCodePoint(cp)) {
95
+ continue;
96
+ }
97
+ out += ch;
98
+ }
99
+ return out;
100
+ }
101
+ const sanitiseLabel = (label) => {
102
+ // Strip any URL authority — ANY scheme (http, file, s3, ftp, …), not just http(s) — plus the
103
+ // well-known credential token shapes, so a browser-supplied label never carries a URL or secret
104
+ // into the envelope display surface that is streamed back to the client (#277/#278).
105
+ let cleaned = label.replace(/[a-z][a-z0-9+.-]*:\/\/\S+/giu, " ");
106
+ for (const shape of CREDENTIAL_LABEL_SHAPES)
107
+ cleaned = cleaned.replace(shape, " ");
108
+ // Map control characters (newline, CR, tab, NUL, DEL, …) to spaces and DROP bidi-override,
109
+ // zero-width, BOM, and C1 spoofing code points so a multi-line, control-laden, or
110
+ // visually-reordered label can never carry a second line of content into — or spoof the reading
111
+ // order of — the browser-streamed envelope displayLabel. Without the control→space step the
112
+ // absolute-path basename-collapse below (which splits on "/" only) would keep a trailing
113
+ // "\n<more content>" glued inside the final path segment, defeating the basename defence; without
114
+ // the bidi/zero-width drop a crafted filename could reorder the displayed label (#277/#278
115
+ // envelope display-surface invariant; Epic #729 symmetry with the candidate-text scrubber).
116
+ cleaned = stripUnsafeLabelChars(cleaned);
117
+ cleaned = cleaned.trim();
118
+ // Collapse an absolute POSIX / Windows-drive / UNC path label to its final segment so the
119
+ // display label never leaks the filesystem layout (the basename is the useful display token).
120
+ if (/^(?:\/|[A-Za-z]:[\\/]|\\\\)/u.test(cleaned)) {
121
+ const segments = cleaned.split(/[\\/]/u).filter((s) => s.length > 0);
122
+ cleaned = (segments[segments.length - 1] ?? "").trim();
123
+ }
124
+ const safe = cleaned.length === 0 ? "Untitled source" : cleaned;
125
+ return safe.length > MAX_LABEL_CHARS ? `${safe.slice(0, MAX_LABEL_CHARS - 1)}…` : safe;
126
+ };
127
+ // Reject a source whose absolute path (any segment) names a denied credential location. isDenied
128
+ // inspects EVERY path segment, so a denied ancestor cannot be hidden by rooting a read deeper. Shared
129
+ // by the folder and single-file paths so both honour the same containment guard (Epic #729 security).
130
+ // Also rejects the symlink variant (assertRealPathNotDenied) so a benign-named link cannot resolve
131
+ // into a protected location.
132
+ function assertNotDenied(absPath, label, noun) {
133
+ if (isDenied(absPath)) {
134
+ throw new QiIngestionError("QI_SOURCE_DENIED", `${noun} "${label}" is in a protected location.`);
135
+ }
136
+ assertRealPathNotDenied(absPath, label, noun);
137
+ }
138
+ // Defense-in-depth against a symlinked workspace root. The keiko-workspace deny gate (readWorkspaceFile)
139
+ // inspects only the path RELATIVE to the realpath'd root, so a denied segment AT or ABOVE the connected
140
+ // root is invisible to it: a benign-named "~/docs" symlink whose real target is "~/.aws" lets a
141
+ // supported file inside it read through to the model, even though the lexical assertNotDenied above sees
142
+ // only "docs". Re-running the deny gate over the REAL (symlink-resolved) absolute path rejects it. The
143
+ // lexical check above already covers the no-symlink case, so this only ADDS denials when realpath
144
+ // diverges into a protected location; a non-existent target surfaces later as NOT_FOUND, so a failed
145
+ // realpath is a deliberate no-op here. (#713 single-file security review: "deny-list still applies";
146
+ // #729 folder-root parity — both ingest paths share this boundary blind spot.)
147
+ function assertRealPathNotDenied(absPath, label, noun) {
148
+ let realPath;
149
+ try {
150
+ realPath = realpathSync(absPath);
151
+ }
152
+ catch {
153
+ return;
154
+ }
155
+ if (realPath !== absPath && isDenied(realPath)) {
156
+ throw new QiIngestionError("QI_SOURCE_DENIED", `${noun} "${label}" is in a protected location.`);
157
+ }
158
+ }
159
+ // Escape the field delimiter ("|") and the escape character ("\") in each user/path-controlled
160
+ // field so a label or content value can never inject a raw delimiter and forge another source's
161
+ // envelope id. The strictly-increasing loop index already disambiguates sources today; escaping
162
+ // makes the pre-image injective on its own — robust even if the fields were ever reordered —
163
+ // closing the latent cross-source provenance-spoofing surface flagged by the #732 composition
164
+ // security audit. A value with no "\" or "|" encodes to itself, so clean labels/paths keep their
165
+ // existing envelope id (and the atom ids derived from it), preserving re-check stability.
166
+ const escapeEnvelopeField = (value) => value.split("\\").join("\\\\").split("|").join("\\|");
167
+ export const envelopeIdFor = (index, label, content) => {
168
+ const digest = sha256Hex(`qi-src-v1|${String(index)}|${escapeEnvelopeField(label)}|${escapeEnvelopeField(content)}`).slice(0, 24);
169
+ return QualityIntelligence.asQualityIntelligenceSourceEnvelopeId(`qi-src-${digest}`);
170
+ };
171
+ const REQUIREMENTS_ENVELOPE_PREFIX = "qi-src-req-";
172
+ const requirementsEnvelopeIdFor = (index) => {
173
+ const digest = sha256Hex(`qi-src-req-v1|${String(index)}`).slice(0, 24);
174
+ return QualityIntelligence.asQualityIntelligenceSourceEnvelopeId(`${REQUIREMENTS_ENVELOPE_PREFIX}${digest}`);
175
+ };
176
+ const stableLocalRef = (prefix, value) => `${prefix}:${sha256Hex(value).slice(0, 24)}`;
177
+ const replacementGroupIdFor = (envelopeId, stableKey) => sha256Hex(`qi-replace-v1|${String(envelopeId)}|${stableKey}`);
178
+ const auditSummaryIdFor = (runId) => QualityIntelligence.asQualityIntelligenceAuditSummaryId(`qi-audit-${sha256Hex(runId).slice(0, 24)}`);
179
+ function ingestRequirements(source, index, registeredAt) {
180
+ const text = typeof source.text === "string" ? source.text : "";
181
+ const oversize = QualityIntelligenceHardening.assertSourceSize(text);
182
+ if (!oversize.ok) {
183
+ throw new QiIngestionError("QI_SOURCE_TOO_LARGE", "A requirements source exceeds the size limit.");
184
+ }
185
+ const label = sanitiseLabel(source.label);
186
+ const envelopeId = requirementsEnvelopeIdFor(index);
187
+ const atoms = QualityIntelligenceGeneration.splitRequirementsIntoAtoms(text, { envelopeId });
188
+ if (atoms.length === 0) {
189
+ throw new QiIngestionError("QI_SOURCE_EMPTY", `Source "${label}" produced no usable requirement statements.`);
190
+ }
191
+ const envelope = {
192
+ id: envelopeId,
193
+ kind: "human-context",
194
+ displayLabel: label,
195
+ provenance: {
196
+ origin: "requirements",
197
+ registeredAt,
198
+ integrityHashSha256Hex: sha256Hex(text),
199
+ },
200
+ localRef: `req:${String(index)}`,
201
+ };
202
+ return {
203
+ envelope,
204
+ atoms: atoms.map((entry, ordinal) => Object.freeze({
205
+ ...entry,
206
+ replacementGroupId: replacementGroupIdFor(envelopeId, `requirements:${String(index)}`),
207
+ replacementOrdinal: ordinal,
208
+ })),
209
+ };
210
+ }
211
+ const WORKSPACE_BUDGET_BYTES = 196_608;
212
+ const WORKSPACE_MAX_BYTES_PER_FILE = 16_384;
213
+ const CODE_EXTENSION = /\.(?:ts|tsx|js|jsx|mjs|cjs|py|java|go|rb|rs|cs|cpp|cc|c|h|hpp|kt|swift|php|scala|sql)$/iu;
214
+ const REQUIREMENT_TEXT_EXTENSION = /\.(?:md|markdown|txt|text|rst|adoc|asciidoc|org)$/iu;
215
+ const atomKindForPath = (path) => CODE_EXTENSION.test(path) ? "code-fragment" : "document-excerpt";
216
+ const workspaceAtom = (entry, envelopeId) => {
217
+ // entry.excerpt is already redacted by keiko-workspace; prefix the path so the model can
218
+ // attribute generated cases to a file.
219
+ const canonicalText = `${entry.path}\n${entry.excerpt}`;
220
+ // The atom id is derived from the file PATH only — never the file's position in the discovery
221
+ // order (Epic #735 drift correctness). A path is unique within a workspace envelope, so the id is
222
+ // stable when other files are added/removed/reordered; an unchanged file keeps its id (and its
223
+ // canonicalHash), so drift detection never falsely orphans it. Content changes are caught by the
224
+ // canonicalHash diff, not the id. (v1 folded in the array index, which shifted on add/remove and
225
+ // false-orphaned every candidate of an otherwise-unchanged folder — see reCheckRoutes drift.)
226
+ const digest = sha256Hex(`qi-atom-ws-v2|${String(envelopeId)}|${entry.path}`).slice(0, 32);
227
+ const atom = {
228
+ kind: atomKindForPath(entry.path),
229
+ id: QualityIntelligence.asQualityIntelligenceEvidenceAtomId(`qi-atom-${digest}`),
230
+ sourceEnvelopeId: envelopeId,
231
+ canonicalHashSha256Hex: sha256Hex(canonicalText),
232
+ redactionStatus: "redacted",
233
+ lifecycleStatus: "draft",
234
+ };
235
+ return { atom, canonicalText };
236
+ };
237
+ const requirementAtomIdFor = (envelopeId, path, statement) => {
238
+ const digest = sha256Hex(`qi-atom-doc-req-v1|${String(envelopeId)}|${path}|${statement}`).slice(0, 32);
239
+ return QualityIntelligence.asQualityIntelligenceEvidenceAtomId(`qi-atom-${digest}`);
240
+ };
241
+ const stripRequirementDocumentStructure = (text) => text
242
+ .split(/\r?\n/u)
243
+ .filter((line) => !/^\s{0,3}#{1,6}\s+\S/u.test(line))
244
+ .join("\n");
245
+ function documentRequirementAtoms(entry, envelopeId) {
246
+ if (!REQUIREMENT_TEXT_EXTENSION.test(entry.path))
247
+ return Object.freeze([]);
248
+ const split = QualityIntelligenceGeneration.splitRequirementsIntoAtoms(stripRequirementDocumentStructure(entry.excerpt), {
249
+ envelopeId,
250
+ maxAtoms: MAX_TOTAL_ATOMS,
251
+ });
252
+ if (split.length <= 1)
253
+ return Object.freeze([]);
254
+ return Object.freeze(split.map((requirement, ordinal) => {
255
+ const canonicalText = `${entry.path}\n${requirement.canonicalText}`;
256
+ const atom = {
257
+ kind: "requirement",
258
+ id: requirementAtomIdFor(envelopeId, entry.path, requirement.canonicalText),
259
+ sourceEnvelopeId: envelopeId,
260
+ canonicalHashSha256Hex: sha256Hex(canonicalText),
261
+ redactionStatus: "redacted",
262
+ lifecycleStatus: "draft",
263
+ };
264
+ return Object.freeze({
265
+ atom: Object.freeze(atom),
266
+ canonicalText,
267
+ replacementGroupId: replacementGroupIdFor(envelopeId, `document:${entry.path}`),
268
+ replacementOrdinal: ordinal,
269
+ });
270
+ }));
271
+ }
272
+ function atomsForWorkspaceEntry(entry, envelopeId) {
273
+ const requirementAtoms = documentRequirementAtoms(entry, envelopeId);
274
+ return requirementAtoms.length > 0
275
+ ? requirementAtoms
276
+ : Object.freeze([workspaceAtom(entry, envelopeId)]);
277
+ }
278
+ // Ingest a local folder by REUSING keiko-workspace traversal + redaction (no independent
279
+ // repository traversal — Issue #278 stop condition). Each selected, already-redacted context
280
+ // entry becomes one content-bearing atom under a single repository-context envelope.
281
+ function ingestWorkspace(source, index, registeredAt, byteBudget) {
282
+ const label = sanitiseLabel(source.label);
283
+ // Reject a folder whose ROOT names a denied credential location (lexically or via a symlinked root):
284
+ // connecting e.g. ~/.aws AS A FOLDER would otherwise ingest credential files whose RELATIVE paths
285
+ // ("credentials", "config.json") never trip the per-file deny check (#729 security).
286
+ assertNotDenied(resolve(source.path), label, "Folder");
287
+ let workspace;
288
+ try {
289
+ workspace = detectWorkspaceAt(source.path);
290
+ }
291
+ catch (error) {
292
+ if (error instanceof WorkspaceError) {
293
+ throw new QiIngestionError("QI_WORKSPACE_NOT_FOUND", "The selected folder could not be opened as a workspace.");
294
+ }
295
+ throw error;
296
+ }
297
+ const { files } = discoverWithStats(workspace, DEFAULT_CONTEXT_REQUEST.discovery);
298
+ const pack = buildContextPackFromFiles(workspace, {
299
+ ...DEFAULT_CONTEXT_REQUEST,
300
+ budgetBytes: Math.min(WORKSPACE_BUDGET_BYTES, byteBudget),
301
+ maxBytesPerFile: WORKSPACE_MAX_BYTES_PER_FILE,
302
+ }, files);
303
+ if (pack.selected.length === 0) {
304
+ throw new QiIngestionError("QI_SOURCE_EMPTY", `No readable files were found in "${label}".`);
305
+ }
306
+ const envelopeId = envelopeIdFor(index, label, pack.workspaceRoot);
307
+ const envelope = {
308
+ id: envelopeId,
309
+ kind: "repository-context",
310
+ displayLabel: label,
311
+ provenance: {
312
+ origin: "workspace",
313
+ registeredAt,
314
+ integrityHashSha256Hex: sha256Hex(`${pack.workspaceRoot}|${pack.selected.map((e) => e.path).join(",")}`),
315
+ },
316
+ localRef: stableLocalRef("workspace", pack.workspaceRoot),
317
+ };
318
+ const atoms = pack.selected.flatMap((entry) => atomsForWorkspaceEntry(entry, envelopeId));
319
+ return { envelope, atoms };
320
+ }
321
+ // A single Fachkonzept document may use the full per-run workspace byte budget — it is the only
322
+ // file — bounded so an oversized file fails with a user-actionable error instead of silently
323
+ // dominating the prompt budget.
324
+ const SINGLE_FILE_MAX_BYTES = WORKSPACE_BUDGET_BYTES;
325
+ // Text-like single-file documents share the strict NUL-byte check because they are expected to be
326
+ // ordinary UTF-8-ish text. Code files reuse the shared CODE_EXTENSION set above.
327
+ const DOC_TEXT_EXTENSION = /\.(?:md|markdown|txt|text|rst|adoc|asciidoc|json|ya?ml|xml|html?|csv|tsv|ini|toml|cfg|conf|properties|tex|org)$/iu;
328
+ // PDF and DOCX are accepted in single-file mode for parity with folder-backed QI: the read path
329
+ // stays keiko-workspace only (best-effort UTF-8/redaction reads, no dedicated document parser). A
330
+ // genuinely text-based PDF/DOCX decodes to usable prose and is ingested; a compressed/binary one
331
+ // decodes to NUL bytes + control-character noise (the real text lives in DEFLATE streams) and is
332
+ // rejected with a user-actionable error rather than silently feeding the model garbage (#713).
333
+ const BEST_EFFORT_DOCUMENT_EXTENSION = /\.(?:pdf|docx)$/iu;
334
+ const isSupportedFilePath = (path) => CODE_EXTENSION.test(path) ||
335
+ DOC_TEXT_EXTENSION.test(path) ||
336
+ BEST_EFFORT_DOCUMENT_EXTENSION.test(path);
337
+ const requiresStrictTextGuard = (path) => CODE_EXTENSION.test(path) || DOC_TEXT_EXTENSION.test(path);
338
+ // A best-effort document whose decoded text is dominated by binary noise carries none of the
339
+ // document's actual prose. Reject above this control-character density so the model never receives
340
+ // unusable content. Kept low (10%) but non-zero so that prose with stray control bytes still reads.
341
+ const DOCUMENT_CONTROL_CHAR_RATIO_LIMIT = 0.1;
342
+ // A control character (excluding ordinary tab/newline/CR), DEL, or the Unicode replacement char —
343
+ // the residue of decoding compressed/binary bytes as UTF-8. Printable prose (incl. umlauts/ß) is
344
+ // never counted.
345
+ function isControlChar(code) {
346
+ const allowedWhitespace = code === 0x09 || code === 0x0a || code === 0x0d;
347
+ return (code < 0x20 && !allowedWhitespace) || code === 0x7f || code === 0xfffd;
348
+ }
349
+ // Detect a best-effort PDF/DOCX read that produced binary noise instead of extractable text. A NUL
350
+ // byte is the canonical binary marker; a high control-character ratio catches mojibake without one.
351
+ // German prose (umlauts, ß) is fully printable, so it never trips this guard.
352
+ function looksBinaryDocument(text) {
353
+ if (text.includes("\u0000"))
354
+ return true;
355
+ let control = 0;
356
+ let total = 0;
357
+ for (const ch of text) {
358
+ total += 1;
359
+ if (isControlChar(ch.codePointAt(0) ?? 0))
360
+ control += 1;
361
+ }
362
+ return total > 0 && control / total > DOCUMENT_CONTROL_CHAR_RATIO_LIMIT;
363
+ }
364
+ const documentFormatLabel = (path) => (/\.pdf$/iu.test(path) ? "PDF" : "Word");
365
+ // A best-effort PDF/DOCX whose decoded text is binary noise (compressed streams, ZIP members)
366
+ // carries none of the document's prose. Reject with actionable guidance instead of ingesting
367
+ // garbage the model cannot use — a text-based PDF/DOCX still ingests normally (#713).
368
+ function assertBestEffortDocumentText(absFile, label, text) {
369
+ if (!BEST_EFFORT_DOCUMENT_EXTENSION.test(absFile) || !looksBinaryDocument(text))
370
+ return;
371
+ throw new QiIngestionError("QI_SOURCE_UNSUPPORTED", `File "${label}" is a ${documentFormatLabel(absFile)} document whose text could not be ` +
372
+ `extracted. Export it to Markdown or plain text and connect that instead.`);
373
+ }
374
+ // Resolve a single file's workspace root and read it through the keiko-workspace boundary-checked
375
+ // read path (`readWorkspaceFile`: lexical containment -> deny rules -> symlink realpath gate -> size
376
+ // cap -> redaction). Every workspace failure is mapped to a safe, user-actionable QiIngestionError.
377
+ function readSingleFileContent(absFile, label) {
378
+ let workspace;
379
+ try {
380
+ workspace = detectWorkspaceAt(dirname(absFile));
381
+ }
382
+ catch (error) {
383
+ if (error instanceof WorkspaceError) {
384
+ throw new QiIngestionError("QI_WORKSPACE_NOT_FOUND", "The selected file could not be opened.");
385
+ }
386
+ throw error;
387
+ }
388
+ try {
389
+ return readWorkspaceFile(workspace, relative(workspace.root, absFile), {
390
+ ...DEFAULT_READ_OPTIONS,
391
+ maxBytes: SINGLE_FILE_MAX_BYTES,
392
+ });
393
+ }
394
+ catch (error) {
395
+ if (error instanceof FileTooLargeError) {
396
+ throw new QiIngestionError("QI_SOURCE_TOO_LARGE", `File "${label}" exceeds the single-file size limit.`);
397
+ }
398
+ if (error instanceof PathDeniedError || error instanceof PathEscapeError) {
399
+ throw new QiIngestionError("QI_SOURCE_DENIED", `File "${label}" is in a protected location.`);
400
+ }
401
+ if (error instanceof WorkspaceReadError) {
402
+ throw new QiIngestionError("QI_WORKSPACE_NOT_FOUND", "The selected file could not be read.");
403
+ }
404
+ throw error;
405
+ }
406
+ }
407
+ // Ingest a single local file by REUSING the keiko-workspace boundary-checked read path. No
408
+ // independent file reader is added (Issue #713 stop condition). The same protections that guard the
409
+ // folder path apply identically to the single file; binary / unsupported / oversized / denied /
410
+ // empty inputs each fail with a safe, user-actionable code before any model prompt.
411
+ function ingestFile(source, index, registeredAt, byteBudget) {
412
+ const label = sanitiseLabel(source.label);
413
+ if (!isAbsolute(source.path)) {
414
+ throw new QiIngestionError("QI_BAD_SOURCE", "File source paths must be absolute local paths.");
415
+ }
416
+ const absFile = resolve(source.path);
417
+ if (!isSupportedFilePath(absFile)) {
418
+ throw new QiIngestionError("QI_SOURCE_UNSUPPORTED", `File "${label}" is not a supported single-file document.`);
419
+ }
420
+ // Reject any path whose segments name a denied credential directory or file (.ssh, .aws, .env,
421
+ // *.pem, id_rsa, …) — lexically or after symlink resolution — regardless of the workspace root below.
422
+ assertNotDenied(absFile, label, "File");
423
+ const content = readSingleFileContent(absFile, label);
424
+ // keiko-workspace decodes as UTF-8; a NUL byte is the canonical binary marker. A binary file that
425
+ // slipped past the strict text/code gate (e.g. a mis-named ".txt") is rejected here, never
426
+ // partially ingested. PDF/DOCX get a dedicated binary-noise check below (their own format).
427
+ if (requiresStrictTextGuard(absFile) && content.text.includes("\u0000")) {
428
+ throw new QiIngestionError("QI_SOURCE_UNSUPPORTED", `File "${label}" appears to be binary, not text.`);
429
+ }
430
+ assertBestEffortDocumentText(absFile, label, content.text);
431
+ if (content.text.trim().length === 0) {
432
+ throw new QiIngestionError("QI_SOURCE_EMPTY", `File "${label}" produced no usable content.`);
433
+ }
434
+ // Bound the single file's contributed text to this source's fair share of the global evidence byte
435
+ // budget so a large file cannot, alongside other connected sources, blow the model prompt cap and
436
+ // fail the whole N+1 run (Epic #729). A lone connected file keeps the full budget unchanged
437
+ // (byteBudget === EVIDENCE_BUDGET_BYTES, so boundedText === content.text).
438
+ const boundedText = truncateToUtf8Bytes(content.text, byteBudget);
439
+ const envelopeId = envelopeIdFor(index, label, content.relativePath);
440
+ const envelope = {
441
+ id: envelopeId,
442
+ kind: "repository-context",
443
+ displayLabel: label,
444
+ provenance: {
445
+ origin: "file",
446
+ registeredAt,
447
+ integrityHashSha256Hex: sha256Hex(`${content.relativePath}|${boundedText}`),
448
+ },
449
+ localRef: stableLocalRef("file", absFile),
450
+ };
451
+ const atoms = atomsForWorkspaceEntry({ path: content.relativePath, excerpt: boundedText }, envelopeId);
452
+ return { envelope, atoms };
453
+ }
454
+ // A single capsule document may use the per-document byte budget; the whole capsule corpus is
455
+ // bounded by CAPSULE_BUDGET_BYTES. These mirror the folder path's per-file and per-run budgets so a
456
+ // large capsule degrades gracefully (a bounded prompt) instead of failing the entire run with
457
+ // QI_PROMPT_TOO_LARGE (Epic #710, Issue #717 — resilience parity with workspace/file sources).
458
+ const CAPSULE_MAX_BYTES_PER_DOCUMENT = WORKSPACE_MAX_BYTES_PER_FILE;
459
+ const CAPSULE_BUDGET_BYTES = WORKSPACE_BUDGET_BYTES;
460
+ const utf8Encoder = new TextEncoder();
461
+ const utf8ByteLength = (text) => utf8Encoder.encode(text).length;
462
+ // Truncate to at most maxBytes UTF-8 bytes without splitting a multi-byte code point.
463
+ function truncateToUtf8Bytes(text, maxBytes) {
464
+ if (utf8ByteLength(text) <= maxBytes)
465
+ return text;
466
+ let out = "";
467
+ let bytes = 0;
468
+ for (const cp of text) {
469
+ const cpBytes = utf8ByteLength(cp);
470
+ if (bytes + cpBytes > maxBytes)
471
+ break;
472
+ out += cp;
473
+ bytes += cpBytes;
474
+ }
475
+ return out;
476
+ }
477
+ // Redact every member document and cap it. The LK corpus text is NOT redacted at index time — the
478
+ // workspace/file paths redact at read time via keiko-workspace, so the capsule path MUST redact
479
+ // here to honour the atom's redactionStatus:"redacted" and the epic's no-credential-leakage DoD
480
+ // (Epic #710, Issue #717). Each document is capped to the per-document budget and ingestion stops
481
+ // once the cumulative corpus reaches the per-run budget so an oversized capsule degrades gracefully.
482
+ function processCapsuleDocs(docs, byteBudget) {
483
+ // The per-run corpus budget is the smaller of the capsule's own ceiling and this source's fair
484
+ // share of the global evidence byte budget (Epic #729 N+1 split). The per-document cap is likewise
485
+ // never larger than the per-run budget so the always-included first document cannot exceed it.
486
+ const perRunBudget = Math.min(CAPSULE_BUDGET_BYTES, byteBudget);
487
+ const perDocBudget = Math.min(CAPSULE_MAX_BYTES_PER_DOCUMENT, perRunBudget);
488
+ const processed = [];
489
+ let totalBytes = 0;
490
+ for (const doc of docs) {
491
+ const capped = truncateToUtf8Bytes(redact(stripUnsafeFormatChars(doc.text)), perDocBudget);
492
+ if (capped.trim().length === 0)
493
+ continue;
494
+ const bytes = utf8ByteLength(capped);
495
+ // Always include the first usable document (capped to ≤ the per-document budget); thereafter
496
+ // stop before a document would push the corpus past the per-run budget so the total stays
497
+ // bounded (never the raw corpus) and the run never hard-fails on QI_PROMPT_TOO_LARGE.
498
+ if (processed.length > 0 && totalBytes + bytes > perRunBudget)
499
+ break;
500
+ processed.push({ documentId: doc.documentId, text: capped });
501
+ totalBytes += bytes;
502
+ }
503
+ return processed;
504
+ }
505
+ // Build one evidence atom per capsule document. Reuses the workspace atom shape so the model sees
506
+ // structured text (documentId prefix + body), consistent with folder/file sources. The text is
507
+ // already redacted + capped by processCapsuleDocs, so redactionStatus:"redacted" is truthful.
508
+ function capsuleDocAtom(docId, text, envelopeId, fingerprintText = text) {
509
+ const canonicalText = `${docId}\n${text}`;
510
+ const fingerprintCanonicalText = `${docId}\n${fingerprintText}`;
511
+ // Derive the atom id from the stable document id only — never its position in the corpus order
512
+ // (Epic #735 drift correctness, mirrors workspaceAtom). A capsule document id (and a Figma
513
+ // screen id) is unique within its envelope, so adding/removing a sibling document never shifts an
514
+ // unchanged document's atom id and never false-orphans its candidates. Content edits are caught by
515
+ // the canonicalHash diff.
516
+ const digest = sha256Hex(`qi-atom-cap-v2|${String(envelopeId)}|${docId}`).slice(0, 32);
517
+ const atom = {
518
+ kind: "document-excerpt",
519
+ id: QualityIntelligence.asQualityIntelligenceEvidenceAtomId(`qi-atom-${digest}`),
520
+ sourceEnvelopeId: envelopeId,
521
+ canonicalHashSha256Hex: sha256Hex(fingerprintCanonicalText),
522
+ redactionStatus: "redacted",
523
+ lifecycleStatus: "draft",
524
+ };
525
+ return { atom, canonicalText };
526
+ }
527
+ // Shared builder for capsule and capsule-set sources: both resolve to a flat list of corpus
528
+ // documents that are redacted, budget-capped, and mapped to one local-knowledge-capsule envelope
529
+ // plus per-document atoms (Epic #710, Issue #716/#717).
530
+ function buildCapsuleSource(build, byteBudget) {
531
+ if (build.rawDocs.length === 0) {
532
+ throw new QiIngestionError("QI_CAPSULE_UNAVAILABLE", build.emptyError);
533
+ }
534
+ const docs = processCapsuleDocs(build.rawDocs, byteBudget);
535
+ if (docs.length === 0) {
536
+ throw new QiIngestionError("QI_SOURCE_EMPTY", `Source "${build.label}" produced no usable content.`);
537
+ }
538
+ const joinedText = docs.map((d) => d.text).join("\n");
539
+ const envelopeId = envelopeIdFor(build.index, build.label, build.envelopeKey);
540
+ const envelope = {
541
+ id: envelopeId,
542
+ kind: "local-knowledge-capsule",
543
+ displayLabel: build.label,
544
+ provenance: {
545
+ origin: build.origin,
546
+ registeredAt: build.registeredAt,
547
+ integrityHashSha256Hex: sha256Hex(joinedText),
548
+ },
549
+ localRef: build.scopeRef,
550
+ };
551
+ const atoms = docs.map((d) => capsuleDocAtom(d.documentId, d.text, envelopeId));
552
+ return { envelope, atoms };
553
+ }
554
+ function ingestCapsule(source, index, registeredAt, resolver, byteBudget) {
555
+ const label = sanitiseLabel(source.label);
556
+ return buildCapsuleSource({
557
+ label,
558
+ index,
559
+ registeredAt,
560
+ envelopeKey: source.capsuleId,
561
+ scopeRef: stableLocalRef("capsule", source.capsuleId),
562
+ origin: `local-knowledge-capsule:${source.capsuleId}`,
563
+ rawDocs: resolver.capsule(source.capsuleId),
564
+ emptyError: `Capsule "${label}" has no indexed content or could not be opened.`,
565
+ }, byteBudget);
566
+ }
567
+ function ingestCapsuleSet(source, index, registeredAt, resolver, byteBudget) {
568
+ const label = sanitiseLabel(source.label);
569
+ return buildCapsuleSource({
570
+ label,
571
+ index,
572
+ registeredAt,
573
+ envelopeKey: source.capsuleSetId,
574
+ scopeRef: stableLocalRef("capsule-set", source.capsuleSetId),
575
+ origin: `local-knowledge-capsule-set:${source.capsuleSetId}`,
576
+ rawDocs: resolver.capsuleSet(source.capsuleSetId),
577
+ emptyError: `Capsule set "${label}" has no indexed content or could not be opened.`,
578
+ }, byteBudget);
579
+ }
580
+ function isPromiseLike(value) {
581
+ return typeof value.then === "function";
582
+ }
583
+ function collectIrStats(node, stats) {
584
+ const next = {
585
+ imageFillCount: stats.imageFillCount + node.imageFills.length,
586
+ textNodeCount: stats.textNodeCount + (node.text !== undefined && node.text.trim().length > 0 ? 1 : 0),
587
+ semanticNodeCount: stats.semanticNodeCount +
588
+ (node.interactionHint === "button" ||
589
+ node.interactionHint === "input" ||
590
+ node.interactionHint === "link"
591
+ ? 1
592
+ : 0),
593
+ };
594
+ return node.children.reduce((acc, child) => collectIrStats(child, acc), next);
595
+ }
596
+ function screenNeedsVisionAugmentation(ir, baseline) {
597
+ const stats = collectIrStats(ir.root, {
598
+ imageFillCount: 0,
599
+ textNodeCount: 0,
600
+ semanticNodeCount: 0,
601
+ });
602
+ const structuralItems = baseline.items.filter((item) => item.category !== "screen-render").length;
603
+ return (stats.imageFillCount > 0 ||
604
+ stats.textNodeCount === 0 ||
605
+ stats.semanticNodeCount === 0 ||
606
+ structuralItems === 0);
607
+ }
608
+ function figmaVisionRequest(record, screen, baselineText) {
609
+ return {
610
+ snapshotRunId: record.runId,
611
+ screenId: screen.screenId,
612
+ image: screen.image,
613
+ imageRelativePath: screen.image.relativePath,
614
+ baselineText,
615
+ };
616
+ }
617
+ function mergeFigmaVisionHints(baselineText, hints) {
618
+ return {
619
+ text: QualityIntelligenceFigma.mergeVisionHints(baselineText, hints).text,
620
+ fingerprintText: baselineText,
621
+ };
622
+ }
623
+ /** Vision-augment one screen's baseline text without ever overriding it (additive only). */
624
+ function visionAugmentedScreenText(baseline, ir, record, screen, vision) {
625
+ const baselineText = QualityIntelligenceFigma.renderBaselineText(baseline);
626
+ if (vision === undefined ||
627
+ !isRenderedScreenRow(screen) ||
628
+ !screenNeedsVisionAugmentation(ir, baseline)) {
629
+ return { text: baselineText, fingerprintText: baselineText };
630
+ }
631
+ const hints = vision(figmaVisionRequest(record, screen, baselineText));
632
+ return isPromiseLike(hints)
633
+ ? { text: baselineText, fingerprintText: baselineText }
634
+ : mergeFigmaVisionHints(baselineText, hints);
635
+ }
636
+ async function visionAugmentedScreenTextAsync(baseline, ir, record, screen, vision) {
637
+ const baselineText = QualityIntelligenceFigma.renderBaselineText(baseline);
638
+ if (vision === undefined ||
639
+ !isRenderedScreenRow(screen) ||
640
+ !screenNeedsVisionAugmentation(ir, baseline)) {
641
+ return { text: baselineText, fingerprintText: baselineText };
642
+ }
643
+ const hints = await vision(figmaVisionRequest(record, screen, baselineText));
644
+ return mergeFigmaVisionHints(baselineText, hints);
645
+ }
646
+ function isRenderedScreenRow(row) {
647
+ return "image" in row;
648
+ }
649
+ // Parse every screen's opaque irJson once; an unparseable screen is dropped (never crashes the run).
650
+ // Rendered screens win if a corrupt record ever duplicates an id; structural-only rows then fill
651
+ // the non-rendered/capped coverage gap for QI.
652
+ function parseScreens(record) {
653
+ const parsed = [];
654
+ const seen = new Set();
655
+ for (const row of record.screens) {
656
+ const ir = QualityIntelligenceFigma.parseScreenIr(row.irJson);
657
+ if (ir !== undefined) {
658
+ parsed.push({ row, ir });
659
+ seen.add(row.screenId);
660
+ }
661
+ }
662
+ for (const row of record.structuralScreens ?? []) {
663
+ if (seen.has(row.screenId))
664
+ continue;
665
+ const ir = QualityIntelligenceFigma.parseScreenIr(row.irJson);
666
+ if (ir !== undefined) {
667
+ parsed.push({ row, ir });
668
+ seen.add(row.screenId);
669
+ }
670
+ }
671
+ return parsed;
672
+ }
673
+ function scopedParsedScreens(record, screenIds) {
674
+ const parsed = parseScreens(record);
675
+ // Absent screenIds → whole snapshot (the contract's "absent = whole"). An explicitly EMPTY scope
676
+ // matches NOTHING — it must NEVER widen to the whole board. The route layer already rejects an empty
677
+ // array; this is the defense-in-depth invariant guaranteeing a scoped request can never silently fall
678
+ // back to every screen even if an internal caller passes an empty list.
679
+ if (screenIds === undefined)
680
+ return parsed;
681
+ if (screenIds.length === 0)
682
+ return [];
683
+ const wanted = new Set(screenIds);
684
+ return parsed.filter((screen) => wanted.has(screen.row.screenId));
685
+ }
686
+ // Canonicalise a scoped screen-id list (trim → drop empties → dedupe → sort) so the derived envelope
687
+ // id / provenance ref is stable regardless of the order or duplicates a caller passed. The route layer
688
+ // already canonicalises incoming requests; this keeps direct/internal callers consistent too.
689
+ function canonicalFigmaScreenIds(screenIds) {
690
+ return [...new Set(screenIds.map((id) => id.trim()).filter((id) => id.length > 0))].sort();
691
+ }
692
+ function figmaSnapshotSourceRef(source) {
693
+ if (source.screenIds === undefined)
694
+ return source.snapshotRunId;
695
+ const canonical = canonicalFigmaScreenIds(source.screenIds);
696
+ return canonical.length === 0
697
+ ? source.snapshotRunId
698
+ : `${source.snapshotRunId}#${canonical.join(",")}`;
699
+ }
700
+ function imageSourceRef(source) {
701
+ return `${source.sourceKind}:${source.snapshotRunId}#${source.screenId}`;
702
+ }
703
+ function imageDescriptionBaseline(screenId, screenName) {
704
+ return ("No structural JSON is attached to this source. Describe the visible UI screenshot for " +
705
+ "test generation. Focus on user-visible purpose, controls, form fields, labels, states, " +
706
+ `layout groups, and likely validation affordances.\nScreen id: ${screenId}\nScreen name: ${screenName}`);
707
+ }
708
+ function imageDescriptionText(screenId, screenName, hints) {
709
+ const cleaned = hints
710
+ .map((hint) => redactFigmaAtomText(hint).trim())
711
+ .filter((hint) => hint.length > 0);
712
+ if (cleaned.length === 0)
713
+ return undefined;
714
+ return [
715
+ `Image description for ${screenName} (${screenId})`,
716
+ "",
717
+ ...cleaned.map((hint) => `- ${hint}`),
718
+ ].join("\n");
719
+ }
720
+ function parsedRenderedScreenForImageSource(record, source, label) {
721
+ const parsed = parseScreens(record).find((screen) => screen.row.screenId === source.screenId);
722
+ if (parsed === undefined || !isRenderedScreenRow(parsed.row)) {
723
+ throw new QiIngestionError("QI_IMAGE_UNAVAILABLE", `Image "${label}" could not be found in the stored Figma snapshot.`);
724
+ }
725
+ return { row: parsed.row, ir: parsed.ir };
726
+ }
727
+ function imageDescriptionUnavailable(label) {
728
+ return new QiIngestionError("QI_IMAGE_DESCRIPTION_UNAVAILABLE", `Image "${label}" could not be described. Configure an image-input capable model for Quality Intelligence image sources.`);
729
+ }
730
+ function imageDescriptionDocFromHints(screenId, screenName, hints, byteBudget) {
731
+ const text = imageDescriptionText(screenId, screenName, hints);
732
+ if (text === undefined)
733
+ return undefined;
734
+ const perDocBudget = Math.min(CAPSULE_MAX_BYTES_PER_DOCUMENT, byteBudget);
735
+ const capped = truncateToUtf8Bytes(redactFigmaAtomText(text), perDocBudget);
736
+ if (capped.trim().length === 0)
737
+ return undefined;
738
+ return {
739
+ documentId: `Image description: ${figmaDocumentId(screenId, screenName)}`,
740
+ text: capped,
741
+ fingerprintText: capped,
742
+ };
743
+ }
744
+ // Distinguish "the selected screens are not present in this snapshot" from "the snapshot produced no
745
+ // usable baseline" so a scoped run surfaces a precise, user-actionable reason (audit: clear
746
+ // screen-not-found error) in the streamed skippedSources notice. The code stays QI_SOURCE_EMPTY so the
747
+ // N+1-resilience skip and the drift "all orphaned" allowance keep their existing control flow — only
748
+ // the message is sharpened.
749
+ function figmaSnapshotEmptyError(record, source, label) {
750
+ if (source.screenIds !== undefined) {
751
+ const requested = canonicalFigmaScreenIds(source.screenIds);
752
+ const present = new Set(parseScreens(record).map((screen) => screen.row.screenId));
753
+ const missing = requested.filter((id) => !present.has(id));
754
+ if (requested.length > 0 && missing.length === requested.length) {
755
+ return new QiIngestionError("QI_SOURCE_EMPTY", `Figma snapshot "${label}": none of the selected screens (${missing.join(", ")}) exist in this snapshot.`);
756
+ }
757
+ }
758
+ return new QiIngestionError("QI_SOURCE_EMPTY", `Figma snapshot "${label}" produced no usable screen baseline.`);
759
+ }
760
+ // Derive the deterministic navigation/flow/coverage test items per screen from the parsed screens +
761
+ // the snapshot's raw inter-screen links (#811). Composes into the baseline below through #754's
762
+ // `extraItems` seam. When the snapshot carries no `links` (an older record), every screen maps to no
763
+ // nav items and the baseline is identical to the IR-only path — purely additive.
764
+ function navItemsByScreen(parsed, links) {
765
+ const irResult = {
766
+ screens: parsed.map((p) => p.ir),
767
+ links,
768
+ tokens: { colors: [], typography: [], spacing: [], radius: [] },
769
+ reduction: { inputNodeCount: 0, keptNodeCount: 0, removedNodeCount: 0, removedRatio: 0 },
770
+ };
771
+ return QualityIntelligenceFigma.deriveNavTestItemsByScreen(QualityIntelligenceFigma.deriveNavGraph(irResult));
772
+ }
773
+ // Derive the deterministic accessibility test items per screen from the parsed screens (#812).
774
+ // Composes into the baseline below through #754's `extraItems` seam, ALONGSIDE the navigation items
775
+ // (concatenated, never replacing them). Model-free: a screen with no colour/box/interactive nodes of
776
+ // interest maps to no a11y items, so the baseline is identical to the IR-only path — purely additive.
777
+ function a11yItemsByScreen(parsed) {
778
+ return QualityIntelligenceFigma.deriveA11yTestItemsByScreen(parsed.map((p) => p.ir));
779
+ }
780
+ function figmaDocumentId(screenId, screenName) {
781
+ // Correct order (the #734 strip-before-redact rule): strip format chars first to
782
+ // de-obfuscate any zero-width-split secret, then redact (now catches raw AND ZW-split
783
+ // secrets → emits "[REDACTED]"), then sanitiseLabel for display safety (collapses
784
+ // newlines, URLs, paths). Mirrors redactFigmaAtomText = redact(stripUnsafeFormatChars)
785
+ // below, with sanitiseLabel as the final display-safe step.
786
+ const safeName = sanitiseLabel(redact(stripUnsafeFormatChars(screenName)));
787
+ return `${screenId} (${truncateToUtf8Bytes(safeName, MAX_LABEL_CHARS)})`;
788
+ }
789
+ // Strip Unicode bidi-override / zero-width / C1 spoofing code points from the untrusted Figma-derived
790
+ // atom text BEFORE secret redaction. Two reasons for this order (the #734 strip-before-redact rule):
791
+ // stripping first DE-OBFUSCATES a zero-width-split secret so the redactor can still match it, and it
792
+ // removes the bidi/zero-width chars that would otherwise ride a Figma screen name or prototype trigger
793
+ // verbatim into the QI atom text and every downstream export (bidi-spoofing of generated test titles).
794
+ // Symmetric with the candidate-text path (buildRequirementExcerpt) and the source-label path
795
+ // (sanitiseLabel / stripUnsafeLabelChars). TAB/LF/CR are preserved so the multi-line baseline structure
796
+ // stays intact; clean inputs are byte-identical, so the atom hash and budget accounting are unchanged.
797
+ const redactFigmaAtomText = (text) => redact(stripUnsafeFormatChars(text));
798
+ function consumeVisionProviderForScreen(vision, remainingVisionScreens, ir, baseline) {
799
+ if (vision === undefined ||
800
+ remainingVisionScreens <= 0 ||
801
+ !screenNeedsVisionAugmentation(ir, baseline)) {
802
+ return { provider: undefined, remaining: remainingVisionScreens };
803
+ }
804
+ return { provider: vision, remaining: remainingVisionScreens - 1 };
805
+ }
806
+ function corpusDocFromFigmaScreen(row, ir, augmented, perDocBudget) {
807
+ const capped = truncateToUtf8Bytes(redactFigmaAtomText(augmented.text), perDocBudget);
808
+ if (capped.trim().length === 0)
809
+ return undefined;
810
+ const fingerprintText = truncateToUtf8Bytes(redactFigmaAtomText(augmented.fingerprintText), perDocBudget);
811
+ return {
812
+ doc: {
813
+ documentId: figmaDocumentId(row.screenId, ir.name),
814
+ text: capped,
815
+ fingerprintText,
816
+ },
817
+ bytes: utf8ByteLength(capped),
818
+ };
819
+ }
820
+ function figmaDocFitsBudget(docs, totalBytes, nextBytes, perRunBudget) {
821
+ return docs.length === 0 || totalBytes + nextBytes <= perRunBudget;
822
+ }
823
+ // Derive the redacted, budget-capped canonical text for every parseable screen. Each screen's
824
+ // deterministic structural baseline (#754) is augmented additively with its navigation/flow test
825
+ // items (#811) AND its accessibility test items (#812) — concatenated, neither replacing the other —
826
+ // through the `extraItems` seam, then optionally with vision hints. The per-run byte budget bounds
827
+ // the cumulative corpus so an oversized board never hard-fails on QI_PROMPT_TOO_LARGE.
828
+ // The byteBudget is the caller's fair share of the global evidence pool (Epic #729 N+1 split)
829
+ // so a figma-snapshot source never consumes more than its fair slice alongside other sources.
830
+ function figmaScreenDocs(record, vision, byteBudget, screenIds) {
831
+ // Mirror processCapsuleDocs (:558-563): the per-run corpus budget is the smaller of the capsule's
832
+ // own ceiling and this source's fair share of the global evidence byte budget (Epic #729 N+1
833
+ // split). The per-document cap is likewise never larger than the per-run budget.
834
+ const perRunBudget = Math.min(CAPSULE_BUDGET_BYTES, byteBudget);
835
+ const perDocBudget = Math.min(CAPSULE_MAX_BYTES_PER_DOCUMENT, perRunBudget);
836
+ const parsed = scopedParsedScreens(record, screenIds);
837
+ const navItems = navItemsByScreen(parsed, record.links ?? []);
838
+ const a11yItems = a11yItemsByScreen(parsed);
839
+ const docs = [];
840
+ let totalBytes = 0;
841
+ let remainingVisionScreens = MAX_FIGMA_VISION_AUGMENTED_SCREENS;
842
+ for (const { row, ir } of parsed) {
843
+ const extraItems = [...(navItems.get(ir.id) ?? []), ...(a11yItems.get(ir.id) ?? [])];
844
+ const baseline = QualityIntelligenceFigma.deriveScreenTestBaseline(ir, extraItems);
845
+ const visionSlot = isRenderedScreenRow(row)
846
+ ? consumeVisionProviderForScreen(vision, remainingVisionScreens, ir, baseline)
847
+ : { provider: undefined, remaining: remainingVisionScreens };
848
+ remainingVisionScreens = visionSlot.remaining;
849
+ const augmented = visionAugmentedScreenText(baseline, ir, record, row, visionSlot.provider);
850
+ const nextDoc = corpusDocFromFigmaScreen(row, ir, augmented, perDocBudget);
851
+ if (nextDoc === undefined)
852
+ continue;
853
+ if (!figmaDocFitsBudget(docs, totalBytes, nextDoc.bytes, perRunBudget))
854
+ break;
855
+ docs.push(nextDoc.doc);
856
+ totalBytes += nextDoc.bytes;
857
+ }
858
+ return docs;
859
+ }
860
+ async function figmaScreenDocsAsync(record, vision, byteBudget, screenIds) {
861
+ const perRunBudget = Math.min(CAPSULE_BUDGET_BYTES, byteBudget);
862
+ const perDocBudget = Math.min(CAPSULE_MAX_BYTES_PER_DOCUMENT, perRunBudget);
863
+ const parsed = scopedParsedScreens(record, screenIds);
864
+ const navItems = navItemsByScreen(parsed, record.links ?? []);
865
+ const a11yItems = a11yItemsByScreen(parsed);
866
+ const docs = [];
867
+ let totalBytes = 0;
868
+ let remainingVisionScreens = MAX_FIGMA_VISION_AUGMENTED_SCREENS;
869
+ for (const { row, ir } of parsed) {
870
+ const extraItems = [...(navItems.get(ir.id) ?? []), ...(a11yItems.get(ir.id) ?? [])];
871
+ const baseline = QualityIntelligenceFigma.deriveScreenTestBaseline(ir, extraItems);
872
+ const visionSlot = isRenderedScreenRow(row)
873
+ ? consumeVisionProviderForScreen(vision, remainingVisionScreens, ir, baseline)
874
+ : { provider: undefined, remaining: remainingVisionScreens };
875
+ remainingVisionScreens = visionSlot.remaining;
876
+ const augmented = await visionAugmentedScreenTextAsync(baseline, ir, record, row, visionSlot.provider);
877
+ const nextDoc = corpusDocFromFigmaScreen(row, ir, augmented, perDocBudget);
878
+ if (nextDoc === undefined)
879
+ continue;
880
+ if (!figmaDocFitsBudget(docs, totalBytes, nextDoc.bytes, perRunBudget))
881
+ break;
882
+ docs.push(nextDoc.doc);
883
+ totalBytes += nextDoc.bytes;
884
+ }
885
+ return docs;
886
+ }
887
+ function ingestFigmaSnapshot(source, index, registeredAt, loader, vision, byteBudget) {
888
+ const label = sanitiseLabel(source.label);
889
+ const record = loader(source.snapshotRunId);
890
+ if (record === undefined) {
891
+ throw new QiIngestionError("QI_FIGMA_SNAPSHOT_UNAVAILABLE", `Figma snapshot "${label}" could not be found or read. Build the snapshot first.`);
892
+ }
893
+ if (record.screens.length === 0 && (record.structuralScreens?.length ?? 0) === 0) {
894
+ throw new QiIngestionError("QI_SOURCE_EMPTY", `Figma snapshot "${label}" has no screens.`);
895
+ }
896
+ const docs = figmaScreenDocs(record, vision, byteBudget, source.screenIds);
897
+ if (docs.length === 0) {
898
+ throw figmaSnapshotEmptyError(record, source, label);
899
+ }
900
+ const joinedFingerprintText = docs.map((d) => d.fingerprintText ?? d.text).join("\n");
901
+ const sourceRef = figmaSnapshotSourceRef(source);
902
+ const envelopeId = envelopeIdFor(index, label, sourceRef);
903
+ // A stored Figma Snapshot is figma evidence, not repository context. Use the dedicated
904
+ // `figma-evidence` envelope kind (#278 AC2 "represented as an explicit connector-backed source"
905
+ // + AC4 citation/audit attribution) so the persisted envelope, source-mix priority, and any
906
+ // kind-grouped audit rollup classify it correctly instead of folding it into repo context.
907
+ const envelope = {
908
+ id: envelopeId,
909
+ kind: "figma-evidence",
910
+ displayLabel: label,
911
+ provenance: {
912
+ origin: `figma-snapshot:${sourceRef}`,
913
+ registeredAt,
914
+ integrityHashSha256Hex: sha256Hex(joinedFingerprintText),
915
+ },
916
+ localRef: stableLocalRef("figma-snapshot", sourceRef),
917
+ };
918
+ const atoms = docs.map((d) => capsuleDocAtom(d.documentId, d.text, envelopeId, d.fingerprintText ?? d.text));
919
+ return { envelope, atoms };
920
+ }
921
+ async function ingestFigmaSnapshotAsync(source, index, registeredAt, loader, vision, byteBudget) {
922
+ const label = sanitiseLabel(source.label);
923
+ const record = loader(source.snapshotRunId);
924
+ if (record === undefined) {
925
+ throw new QiIngestionError("QI_FIGMA_SNAPSHOT_UNAVAILABLE", `Figma snapshot "${label}" could not be found or read. Build the snapshot first.`);
926
+ }
927
+ if (record.screens.length === 0 && (record.structuralScreens?.length ?? 0) === 0) {
928
+ throw new QiIngestionError("QI_SOURCE_EMPTY", `Figma snapshot "${label}" has no screens.`);
929
+ }
930
+ const docs = await figmaScreenDocsAsync(record, vision, byteBudget, source.screenIds);
931
+ if (docs.length === 0) {
932
+ throw figmaSnapshotEmptyError(record, source, label);
933
+ }
934
+ const joinedFingerprintText = docs.map((d) => d.fingerprintText ?? d.text).join("\n");
935
+ const sourceRef = figmaSnapshotSourceRef(source);
936
+ const envelopeId = envelopeIdFor(index, label, sourceRef);
937
+ const envelope = {
938
+ id: envelopeId,
939
+ kind: "figma-evidence",
940
+ displayLabel: label,
941
+ provenance: {
942
+ origin: `figma-snapshot:${sourceRef}`,
943
+ registeredAt,
944
+ integrityHashSha256Hex: sha256Hex(joinedFingerprintText),
945
+ },
946
+ localRef: stableLocalRef("figma-snapshot", sourceRef),
947
+ };
948
+ const atoms = docs.map((d) => capsuleDocAtom(d.documentId, d.text, envelopeId, d.fingerprintText ?? d.text));
949
+ return { envelope, atoms };
950
+ }
951
+ function buildImageSourceEnvelope(source, label, index, registeredAt, doc) {
952
+ const sourceRef = imageSourceRef(source);
953
+ const envelopeId = envelopeIdFor(index, label, sourceRef);
954
+ const envelope = {
955
+ id: envelopeId,
956
+ kind: "figma-evidence",
957
+ displayLabel: label,
958
+ provenance: {
959
+ origin: `image:${sourceRef}`,
960
+ registeredAt,
961
+ integrityHashSha256Hex: sha256Hex(doc.fingerprintText ?? doc.text),
962
+ },
963
+ localRef: stableLocalRef("image", sourceRef),
964
+ };
965
+ return {
966
+ envelope,
967
+ atoms: [capsuleDocAtom(doc.documentId, doc.text, envelopeId, doc.fingerprintText ?? doc.text)],
968
+ };
969
+ }
970
+ function imageDescriptionDoc(record, source, label, vision, byteBudget) {
971
+ if (vision === undefined)
972
+ return undefined;
973
+ const { row, ir } = parsedRenderedScreenForImageSource(record, source, label);
974
+ const hints = vision(figmaVisionRequest(record, row, imageDescriptionBaseline(row.screenId, ir.name)));
975
+ if (isPromiseLike(hints))
976
+ return undefined;
977
+ return imageDescriptionDocFromHints(row.screenId, ir.name, hints, byteBudget);
978
+ }
979
+ async function imageDescriptionDocAsync(record, source, label, vision, byteBudget) {
980
+ if (vision === undefined)
981
+ return undefined;
982
+ const { row, ir } = parsedRenderedScreenForImageSource(record, source, label);
983
+ const hints = await vision(figmaVisionRequest(record, row, imageDescriptionBaseline(row.screenId, ir.name)));
984
+ return imageDescriptionDocFromHints(row.screenId, ir.name, hints, byteBudget);
985
+ }
986
+ function ingestImageSource(source, index, registeredAt, loader, vision, byteBudget) {
987
+ const label = sanitiseLabel(source.label);
988
+ const record = loader(source.snapshotRunId);
989
+ if (record === undefined) {
990
+ throw new QiIngestionError("QI_IMAGE_UNAVAILABLE", `Image "${label}" could not be found or read. Build the Figma snapshot first.`);
991
+ }
992
+ const doc = imageDescriptionDoc(record, source, label, vision, byteBudget);
993
+ if (doc === undefined)
994
+ throw imageDescriptionUnavailable(label);
995
+ return buildImageSourceEnvelope(source, label, index, registeredAt, doc);
996
+ }
997
+ async function ingestImageSourceAsync(source, index, registeredAt, loader, vision, byteBudget) {
998
+ const label = sanitiseLabel(source.label);
999
+ const record = loader(source.snapshotRunId);
1000
+ if (record === undefined) {
1001
+ throw new QiIngestionError("QI_IMAGE_UNAVAILABLE", `Image "${label}" could not be found or read. Build the Figma snapshot first.`);
1002
+ }
1003
+ const doc = await imageDescriptionDocAsync(record, source, label, vision, byteBudget);
1004
+ if (doc === undefined)
1005
+ throw imageDescriptionUnavailable(label);
1006
+ return buildImageSourceEnvelope(source, label, index, registeredAt, doc);
1007
+ }
1008
+ function ingestCapsuleSource(source, index, registeredAt, capsuleResolver, byteBudget) {
1009
+ if (capsuleResolver === undefined) {
1010
+ throw new QiIngestionError("QI_CAPSULE_UNAVAILABLE", source.kind === "capsule"
1011
+ ? "Capsule sources are unavailable: the Local Knowledge store is not configured."
1012
+ : "Capsule-set sources are unavailable: the Local Knowledge store is not configured.");
1013
+ }
1014
+ return source.kind === "capsule"
1015
+ ? ingestCapsule(source, index, registeredAt, capsuleResolver, byteBudget)
1016
+ : ingestCapsuleSet(source, index, registeredAt, capsuleResolver, byteBudget);
1017
+ }
1018
+ function ingestStoredFigmaSource(source, index, registeredAt, figmaSnapshotLoader, figmaVision, byteBudget) {
1019
+ if (figmaSnapshotLoader === undefined) {
1020
+ throw new QiIngestionError(source.kind === "image" ? "QI_IMAGE_UNAVAILABLE" : "QI_FIGMA_SNAPSHOT_UNAVAILABLE", source.kind === "image"
1021
+ ? "Image sources are unavailable: the evidence directory is not configured."
1022
+ : "Figma-snapshot sources are unavailable: the evidence directory is not configured.");
1023
+ }
1024
+ return source.kind === "image"
1025
+ ? ingestImageSource(source, index, registeredAt, figmaSnapshotLoader, figmaVision, byteBudget)
1026
+ : ingestFigmaSnapshot(source, index, registeredAt, figmaSnapshotLoader, figmaVision, byteBudget);
1027
+ }
1028
+ function ingestOne(source, index, registeredAt, capsuleResolver, figmaSnapshotLoader, figmaVision, byteBudget) {
1029
+ switch (source.kind) {
1030
+ case "requirements":
1031
+ return ingestRequirements(source, index, registeredAt);
1032
+ case "workspace":
1033
+ return ingestWorkspace(source, index, registeredAt, byteBudget);
1034
+ case "file":
1035
+ return ingestFile(source, index, registeredAt, byteBudget);
1036
+ case "capsule":
1037
+ case "capsule-set":
1038
+ return ingestCapsuleSource(source, index, registeredAt, capsuleResolver, byteBudget);
1039
+ case "figma-snapshot":
1040
+ case "image":
1041
+ return ingestStoredFigmaSource(source, index, registeredAt, figmaSnapshotLoader, figmaVision, byteBudget);
1042
+ }
1043
+ }
1044
+ async function ingestOneAsync(source, index, registeredAt, capsuleResolver, figmaSnapshotLoader, figmaVision, byteBudget) {
1045
+ if (source.kind !== "figma-snapshot" && source.kind !== "image") {
1046
+ return ingestOne(source, index, registeredAt, capsuleResolver, figmaSnapshotLoader, figmaVision, byteBudget);
1047
+ }
1048
+ if (figmaSnapshotLoader === undefined) {
1049
+ throw new QiIngestionError(source.kind === "image" ? "QI_IMAGE_UNAVAILABLE" : "QI_FIGMA_SNAPSHOT_UNAVAILABLE", source.kind === "image"
1050
+ ? "Image sources are unavailable: the evidence directory is not configured."
1051
+ : "Figma-snapshot sources are unavailable: the evidence directory is not configured.");
1052
+ }
1053
+ if (source.kind === "image") {
1054
+ return ingestImageSourceAsync(source, index, registeredAt, figmaSnapshotLoader, figmaVision, byteBudget);
1055
+ }
1056
+ return ingestFigmaSnapshotAsync(source, index, registeredAt, figmaSnapshotLoader, figmaVision, byteBudget);
1057
+ }
1058
+ /**
1059
+ * Ingest one source into the accumulator (Epic #729 N+1 resilience). On a per-source QiIngestionError
1060
+ * the source is recorded as skipped and ingestion continues with the rest; a genuine (non-coded) bug
1061
+ * still throws so it is never silently swallowed. Each successful source takes its fair atom share,
1062
+ * bounded by the global cap so the total never exceeds it.
1063
+ */
1064
+ function ingestSourceInto(acc, source, index, input, budgets) {
1065
+ let ingested;
1066
+ try {
1067
+ ingested = ingestOne(source, index, input.registeredAt, input.capsuleResolver, input.figmaSnapshotLoader, input.figmaVision, budgets.byteBudget);
1068
+ }
1069
+ catch (error) {
1070
+ if (!(error instanceof QiIngestionError))
1071
+ throw error;
1072
+ acc.firstSkipError ??= error;
1073
+ acc.skippedSources.push({
1074
+ label: sanitiseLabel(source.label),
1075
+ kind: source.kind,
1076
+ code: error.code,
1077
+ message: error.message,
1078
+ });
1079
+ return;
1080
+ }
1081
+ const { envelope, atoms } = ingested;
1082
+ const take = Math.min(budgets.atomBudget, MAX_TOTAL_ATOMS - acc.ingestedAtoms.length);
1083
+ const taken = take <= 0 ? [] : atoms.slice(0, take);
1084
+ acc.envelopes.push(envelope);
1085
+ acc.ingestedAtoms.push(...taken);
1086
+ acc.sourceSummaries.push({
1087
+ label: envelope.displayLabel,
1088
+ kind: source.kind,
1089
+ atomCount: taken.length,
1090
+ });
1091
+ }
1092
+ async function ingestSourceIntoAsync(acc, source, index, input, budgets) {
1093
+ let ingested;
1094
+ try {
1095
+ ingested = await ingestOneAsync(source, index, input.registeredAt, input.capsuleResolver, input.figmaSnapshotLoader, input.figmaVision, budgets.byteBudget);
1096
+ }
1097
+ catch (error) {
1098
+ if (!(error instanceof QiIngestionError))
1099
+ throw error;
1100
+ acc.firstSkipError ??= error;
1101
+ acc.skippedSources.push({
1102
+ label: sanitiseLabel(source.label),
1103
+ kind: source.kind,
1104
+ code: error.code,
1105
+ message: error.message,
1106
+ });
1107
+ return;
1108
+ }
1109
+ const { envelope, atoms } = ingested;
1110
+ const take = Math.min(budgets.atomBudget, MAX_TOTAL_ATOMS - acc.ingestedAtoms.length);
1111
+ const taken = take <= 0 ? [] : atoms.slice(0, take);
1112
+ acc.envelopes.push(envelope);
1113
+ acc.ingestedAtoms.push(...taken);
1114
+ acc.sourceSummaries.push({
1115
+ label: envelope.displayLabel,
1116
+ kind: source.kind,
1117
+ atomCount: taken.length,
1118
+ });
1119
+ }
1120
+ function emptyDriftIngestionResult(input, droppedSourceCount, skippedSources) {
1121
+ return {
1122
+ envelopes: [],
1123
+ ingestedAtoms: [],
1124
+ provenanceRefs: {
1125
+ envelopeIds: [],
1126
+ auditSummaryId: auditSummaryIdFor(input.runId),
1127
+ },
1128
+ sourceSummaries: [],
1129
+ droppedSourceCount,
1130
+ skippedSources,
1131
+ };
1132
+ }
1133
+ function allowEmptyDriftIngestion(input, acc, droppedSourceCount) {
1134
+ if (input.allowEmpty !== true)
1135
+ return undefined;
1136
+ const blockingSkip = acc.skippedSources.find((source) => source.code !== "QI_SOURCE_EMPTY");
1137
+ if (blockingSkip !== undefined) {
1138
+ throw new QiIngestionError(blockingSkip.code, blockingSkip.message);
1139
+ }
1140
+ return emptyDriftIngestionResult(input, droppedSourceCount, acc.skippedSources);
1141
+ }
1142
+ export function ingestInlineSources(input) {
1143
+ // Read through the typed property in the loop: `Array.isArray` would widen a local binding of the
1144
+ // readonly union array to `any[]`, so the guard checks length on the typed property directly.
1145
+ const allSources = input.request.sources;
1146
+ if (allSources.length === 0) {
1147
+ throw new QiIngestionError("QI_NO_SOURCES", "At least one source is required to start a run.");
1148
+ }
1149
+ // Cap the source count BEFORE ingestion (no partial work for dropped sources), then split BOTH the
1150
+ // global atom budget and the global evidence BYTE budget fairly so no single source starves the
1151
+ // others and the merged prompt stays under the model ceiling regardless of N (Chat N+1 parity,
1152
+ // #730) — preventing one large source from hard-failing the whole run with QI_PROMPT_TOO_LARGE.
1153
+ const sources = allSources.slice(0, MAX_QI_SOURCES);
1154
+ const droppedSourceCount = allSources.length - sources.length;
1155
+ const budgets = {
1156
+ atomBudget: perSourceAtomBudget(MAX_TOTAL_ATOMS, sources.length),
1157
+ byteBudget: perSourceByteBudget(sources.length),
1158
+ };
1159
+ // N+1 resilience (Chat parity): each source is ingested independently; a source that produces
1160
+ // nothing usable is skipped + recorded so the healthy ones still produce the run. The run fails only
1161
+ // when EVERY source fails — re-raising the FIRST coded error so a single bad source keeps its
1162
+ // specific, user-actionable code + message (unchanged single-source UX).
1163
+ const acc = {
1164
+ envelopes: [],
1165
+ ingestedAtoms: [],
1166
+ sourceSummaries: [],
1167
+ skippedSources: [],
1168
+ };
1169
+ for (let i = 0; i < sources.length; i += 1) {
1170
+ const source = sources[i];
1171
+ if (source === undefined)
1172
+ continue;
1173
+ ingestSourceInto(acc, source, i, input, budgets);
1174
+ }
1175
+ if (acc.ingestedAtoms.length === 0) {
1176
+ const emptyDrift = allowEmptyDriftIngestion(input, acc, droppedSourceCount);
1177
+ if (emptyDrift !== undefined)
1178
+ return emptyDrift;
1179
+ throw (acc.firstSkipError ??
1180
+ new QiIngestionError("QI_SOURCE_EMPTY", "No usable evidence was produced from the sources."));
1181
+ }
1182
+ return {
1183
+ envelopes: acc.envelopes,
1184
+ ingestedAtoms: acc.ingestedAtoms,
1185
+ provenanceRefs: {
1186
+ envelopeIds: acc.envelopes.map((e) => String(e.id)),
1187
+ auditSummaryId: auditSummaryIdFor(input.runId),
1188
+ },
1189
+ sourceSummaries: acc.sourceSummaries,
1190
+ droppedSourceCount,
1191
+ skippedSources: acc.skippedSources,
1192
+ };
1193
+ }
1194
+ export async function ingestInlineSourcesAsync(input) {
1195
+ const allSources = input.request.sources;
1196
+ if (allSources.length === 0) {
1197
+ throw new QiIngestionError("QI_NO_SOURCES", "At least one source is required to start a run.");
1198
+ }
1199
+ const sources = allSources.slice(0, MAX_QI_SOURCES);
1200
+ const droppedSourceCount = allSources.length - sources.length;
1201
+ const budgets = {
1202
+ atomBudget: perSourceAtomBudget(MAX_TOTAL_ATOMS, sources.length),
1203
+ byteBudget: perSourceByteBudget(sources.length),
1204
+ };
1205
+ const acc = {
1206
+ envelopes: [],
1207
+ ingestedAtoms: [],
1208
+ sourceSummaries: [],
1209
+ skippedSources: [],
1210
+ };
1211
+ for (let i = 0; i < sources.length; i += 1) {
1212
+ const source = sources[i];
1213
+ if (source === undefined)
1214
+ continue;
1215
+ await ingestSourceIntoAsync(acc, source, i, input, budgets);
1216
+ }
1217
+ if (acc.ingestedAtoms.length === 0) {
1218
+ const emptyDrift = allowEmptyDriftIngestion(input, acc, droppedSourceCount);
1219
+ if (emptyDrift !== undefined)
1220
+ return emptyDrift;
1221
+ throw (acc.firstSkipError ??
1222
+ new QiIngestionError("QI_SOURCE_EMPTY", "No usable evidence was produced from the sources."));
1223
+ }
1224
+ return {
1225
+ envelopes: acc.envelopes,
1226
+ ingestedAtoms: acc.ingestedAtoms,
1227
+ provenanceRefs: {
1228
+ envelopeIds: acc.envelopes.map((e) => String(e.id)),
1229
+ auditSummaryId: auditSummaryIdFor(input.runId),
1230
+ },
1231
+ sourceSummaries: acc.sourceSummaries,
1232
+ droppedSourceCount,
1233
+ skippedSources: acc.skippedSources,
1234
+ };
1235
+ }