redmine-context 1.0.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 (229) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +449 -0
  3. package/dist/bundle/index.d.ts +5 -0
  4. package/dist/bundle/index.js +5 -0
  5. package/dist/bundle/json.d.ts +90 -0
  6. package/dist/bundle/json.js +266 -0
  7. package/dist/bundle/markdown.d.ts +75 -0
  8. package/dist/bundle/markdown.js +294 -0
  9. package/dist/bundle/search-list.d.ts +43 -0
  10. package/dist/bundle/search-list.js +53 -0
  11. package/dist/bundle/stable-stringify.d.ts +26 -0
  12. package/dist/bundle/stable-stringify.js +50 -0
  13. package/dist/cache/contract.d.ts +157 -0
  14. package/dist/cache/contract.js +0 -0
  15. package/dist/cache/disk-index.d.ts +82 -0
  16. package/dist/cache/disk-index.js +220 -0
  17. package/dist/cache/disk.d.ts +133 -0
  18. package/dist/cache/disk.js +313 -0
  19. package/dist/cache/gc.d.ts +78 -0
  20. package/dist/cache/gc.js +123 -0
  21. package/dist/cache/get-or-compute.d.ts +36 -0
  22. package/dist/cache/get-or-compute.js +52 -0
  23. package/dist/cache/index.d.ts +9 -0
  24. package/dist/cache/index.js +8 -0
  25. package/dist/cache/keys.d.ts +76 -0
  26. package/dist/cache/keys.js +78 -0
  27. package/dist/cache/memory.d.ts +48 -0
  28. package/dist/cache/memory.js +110 -0
  29. package/dist/cache-first.d.ts +127 -0
  30. package/dist/cache-first.js +227 -0
  31. package/dist/client/errors.d.ts +33 -0
  32. package/dist/client/errors.js +49 -0
  33. package/dist/client/http.d.ts +110 -0
  34. package/dist/client/http.js +207 -0
  35. package/dist/client/index.d.ts +5 -0
  36. package/dist/client/index.js +5 -0
  37. package/dist/client/issues.d.ts +71 -0
  38. package/dist/client/issues.js +100 -0
  39. package/dist/client/search.d.ts +58 -0
  40. package/dist/client/search.js +81 -0
  41. package/dist/config/credentials.d.ts +247 -0
  42. package/dist/config/credentials.js +427 -0
  43. package/dist/config/doctor.d.ts +123 -0
  44. package/dist/config/doctor.js +260 -0
  45. package/dist/config/index.d.ts +6 -0
  46. package/dist/config/index.js +6 -0
  47. package/dist/config/keyring.d.ts +96 -0
  48. package/dist/config/keyring.js +158 -0
  49. package/dist/config/login.d.ts +97 -0
  50. package/dist/config/login.js +189 -0
  51. package/dist/config/settings.d.ts +94 -0
  52. package/dist/config/settings.js +140 -0
  53. package/dist/contract.d.ts +173 -0
  54. package/dist/contract.js +27 -0
  55. package/dist/core.d.ts +1 -0
  56. package/dist/core.js +8 -0
  57. package/dist/extract/audio-extractor.d.ts +105 -0
  58. package/dist/extract/audio-extractor.js +156 -0
  59. package/dist/extract/audio.d.ts +126 -0
  60. package/dist/extract/audio.js +184 -0
  61. package/dist/extract/dispatcher.d.ts +132 -0
  62. package/dist/extract/dispatcher.js +115 -0
  63. package/dist/extract/download.d.ts +111 -0
  64. package/dist/extract/download.js +261 -0
  65. package/dist/extract/duration.d.ts +106 -0
  66. package/dist/extract/duration.js +148 -0
  67. package/dist/extract/ffmpeg.d.ts +56 -0
  68. package/dist/extract/ffmpeg.js +95 -0
  69. package/dist/extract/gguf.d.ts +137 -0
  70. package/dist/extract/gguf.js +215 -0
  71. package/dist/extract/index.d.ts +19 -0
  72. package/dist/extract/index.js +19 -0
  73. package/dist/extract/magic.d.ts +80 -0
  74. package/dist/extract/magic.js +282 -0
  75. package/dist/extract/ooxml.d.ts +131 -0
  76. package/dist/extract/ooxml.js +336 -0
  77. package/dist/extract/pdf.d.ts +147 -0
  78. package/dist/extract/pdf.js +322 -0
  79. package/dist/extract/queue.d.ts +167 -0
  80. package/dist/extract/queue.js +217 -0
  81. package/dist/extract/subprocess.d.ts +145 -0
  82. package/dist/extract/subprocess.js +181 -0
  83. package/dist/extract/tesseract.d.ts +153 -0
  84. package/dist/extract/tesseract.js +321 -0
  85. package/dist/extract/video-extractor.d.ts +84 -0
  86. package/dist/extract/video-extractor.js +89 -0
  87. package/dist/extract/video.d.ts +198 -0
  88. package/dist/extract/video.js +418 -0
  89. package/dist/extract/which.d.ts +63 -0
  90. package/dist/extract/which.js +72 -0
  91. package/dist/extract/whisper-extract.d.ts +211 -0
  92. package/dist/extract/whisper-extract.js +323 -0
  93. package/dist/extract/whisper.d.ts +50 -0
  94. package/dist/extract/whisper.js +67 -0
  95. package/dist/extract/zip.d.ts +50 -0
  96. package/dist/extract/zip.js +165 -0
  97. package/dist/extract-issue-attachments.d.ts +82 -0
  98. package/dist/extract-issue-attachments.js +156 -0
  99. package/dist/fetch-attachment-text.d.ts +113 -0
  100. package/dist/fetch-attachment-text.js +155 -0
  101. package/dist/fetch-issue-bundle.d.ts +81 -0
  102. package/dist/fetch-issue-bundle.js +93 -0
  103. package/dist/fetch-issue-search.d.ts +74 -0
  104. package/dist/fetch-issue-search.js +120 -0
  105. package/dist/index.d.ts +21 -0
  106. package/dist/index.js +67 -0
  107. package/dist/normalize/collections.d.ts +52 -0
  108. package/dist/normalize/collections.js +170 -0
  109. package/dist/normalize/helpers.d.ts +35 -0
  110. package/dist/normalize/helpers.js +59 -0
  111. package/dist/normalize/index.d.ts +2 -0
  112. package/dist/normalize/index.js +2 -0
  113. package/dist/normalize/issue.d.ts +34 -0
  114. package/dist/normalize/issue.js +154 -0
  115. package/dist/surfaces/cli/commands.d.ts +61 -0
  116. package/dist/surfaces/cli/commands.js +262 -0
  117. package/dist/surfaces/cli/main.d.ts +32 -0
  118. package/dist/surfaces/cli/main.js +210 -0
  119. package/dist/surfaces/cli/prompts.d.ts +66 -0
  120. package/dist/surfaces/cli/prompts.js +147 -0
  121. package/dist/surfaces/cli/tty.d.ts +27 -0
  122. package/dist/surfaces/cli/tty.js +35 -0
  123. package/dist/surfaces/cli/types.d.ts +39 -0
  124. package/dist/surfaces/cli/types.js +7 -0
  125. package/dist/surfaces/mcp/server.d.ts +171 -0
  126. package/dist/surfaces/mcp/server.js +427 -0
  127. package/dist/surfaces/tui/app.d.ts +55 -0
  128. package/dist/surfaces/tui/app.js +180 -0
  129. package/dist/surfaces/tui/attachment-status.d.ts +79 -0
  130. package/dist/surfaces/tui/attachment-status.js +113 -0
  131. package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
  132. package/dist/surfaces/tui/components/breadcrumb.js +31 -0
  133. package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
  134. package/dist/surfaces/tui/components/gradient-text.js +75 -0
  135. package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
  136. package/dist/surfaces/tui/components/scroll-view.js +77 -0
  137. package/dist/surfaces/tui/components/spinner.d.ts +12 -0
  138. package/dist/surfaces/tui/components/spinner.js +39 -0
  139. package/dist/surfaces/tui/components/text-input.d.ts +45 -0
  140. package/dist/surfaces/tui/components/text-input.js +114 -0
  141. package/dist/surfaces/tui/format-file-size.d.ts +24 -0
  142. package/dist/surfaces/tui/format-file-size.js +43 -0
  143. package/dist/surfaces/tui/glyphs.d.ts +47 -0
  144. package/dist/surfaces/tui/glyphs.js +84 -0
  145. package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
  146. package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
  147. package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
  148. package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
  149. package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
  150. package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
  151. package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
  152. package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
  153. package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
  154. package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
  155. package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
  156. package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
  157. package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
  158. package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
  159. package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
  160. package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
  161. package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
  162. package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
  163. package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
  164. package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
  165. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
  166. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
  167. package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
  168. package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
  169. package/dist/surfaces/tui/index.d.ts +50 -0
  170. package/dist/surfaces/tui/index.js +158 -0
  171. package/dist/surfaces/tui/instance.d.ts +37 -0
  172. package/dist/surfaces/tui/instance.js +36 -0
  173. package/dist/surfaces/tui/job-registry.d.ts +113 -0
  174. package/dist/surfaces/tui/job-registry.js +123 -0
  175. package/dist/surfaces/tui/job-status.d.ts +45 -0
  176. package/dist/surfaces/tui/job-status.js +81 -0
  177. package/dist/surfaces/tui/navigation.d.ts +74 -0
  178. package/dist/surfaces/tui/navigation.js +87 -0
  179. package/dist/surfaces/tui/palettes.d.ts +38 -0
  180. package/dist/surfaces/tui/palettes.js +244 -0
  181. package/dist/surfaces/tui/screen.d.ts +30 -0
  182. package/dist/surfaces/tui/screen.js +51 -0
  183. package/dist/surfaces/tui/screens/about.d.ts +2 -0
  184. package/dist/surfaces/tui/screens/about.js +35 -0
  185. package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
  186. package/dist/surfaces/tui/screens/appearance.js +74 -0
  187. package/dist/surfaces/tui/screens/config.d.ts +2 -0
  188. package/dist/surfaces/tui/screens/config.js +82 -0
  189. package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
  190. package/dist/surfaces/tui/screens/doctor.js +109 -0
  191. package/dist/surfaces/tui/screens/export.d.ts +2 -0
  192. package/dist/surfaces/tui/screens/export.js +168 -0
  193. package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
  194. package/dist/surfaces/tui/screens/home-selection.js +80 -0
  195. package/dist/surfaces/tui/screens/home.d.ts +2 -0
  196. package/dist/surfaces/tui/screens/home.js +200 -0
  197. package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
  198. package/dist/surfaces/tui/screens/issue-detail.js +182 -0
  199. package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
  200. package/dist/surfaces/tui/screens/jobs.js +89 -0
  201. package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
  202. package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
  203. package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
  204. package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
  205. package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
  206. package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
  207. package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
  208. package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
  209. package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
  210. package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
  211. package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
  212. package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
  213. package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
  214. package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
  215. package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
  216. package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
  217. package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
  218. package/dist/surfaces/tui/screens/welcome.js +95 -0
  219. package/dist/surfaces/tui/status-color.d.ts +21 -0
  220. package/dist/surfaces/tui/status-color.js +23 -0
  221. package/dist/surfaces/tui/symbols.d.ts +231 -0
  222. package/dist/surfaces/tui/symbols.js +14 -0
  223. package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
  224. package/dist/surfaces/tui/terminal-colors.js +39 -0
  225. package/dist/surfaces/tui/theme.d.ts +156 -0
  226. package/dist/surfaces/tui/theme.js +86 -0
  227. package/dist/surfaces/tui/truncate.d.ts +31 -0
  228. package/dist/surfaces/tui/truncate.js +81 -0
  229. package/package.json +93 -0
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Localização do binário whisper.cpp e diretório canônico do modelo GGUF
3
+ * (M4-01, #57, ADR-002).
4
+ *
5
+ * A milestone M4 transcreve áudio localmente com whisper.cpp (ADR-002). O binário
6
+ * teve VÁRIOS nomes ao longo do tempo — hoje é `whisper-cli`; o brew empacota como
7
+ * `whisper-cpp`; o legado era simplesmente `main`. O `doctor` (#57) detecta
8
+ * QUALQUER um e reporta o caminho encontrado (que revela qual binário é).
9
+ *
10
+ * DECISÕES (exercitadas por testes):
11
+ * - SEM FLAG DE VERSÃO ESTÁVEL: diferente de ffmpeg/tesseract, o whisper.cpp não
12
+ * tem um `--version` estável entre releases — o PATH resolvido é a evidência de
13
+ * presença; o `doctor` não reporta versão para ele (documentado no diagnóstico).
14
+ * - MODELO SEPARADO DO BINÁRIO: o binário e o modelo `.gguf` são artefatos
15
+ * independentes; {@link whisperModelDir} define AGORA o path canônico do cache
16
+ * de modelos (`env-paths` cache + `/models`) que a #58 (download do modelo)
17
+ * consumirá — um único ponto de verdade para ambas as issues.
18
+ */
19
+ import { findExecutable } from './which.js';
20
+ /** Resultado de {@link findWhisper}: caminho + qual binário casou. */
21
+ export interface WhisperLocation {
22
+ /** Caminho absoluto do executável encontrado. */
23
+ readonly path: string;
24
+ /** Nome do binário que casou (`whisper-cli` | `whisper-cpp` | `main`). */
25
+ readonly binaryName: string;
26
+ }
27
+ /**
28
+ * Localiza o binário do whisper.cpp no `PATH` e em locais convencionais,
29
+ * preferindo `whisper-cli` a `whisper-cpp` a `main`. Puro e reutilizável pelo
30
+ * `doctor` (#57). Não executa o binário.
31
+ *
32
+ * @param deps - Deps injetáveis (plataforma/PATH/executabilidade) — ver
33
+ * {@link findExecutable}. Default: ambiente real.
34
+ * @returns A localização encontrada (path + nome), ou `undefined` se ausente.
35
+ * @example
36
+ * const found = findWhisper();
37
+ * if (found !== undefined) logger.info(`whisper.cpp: ${found.binaryName}`);
38
+ */
39
+ export declare function findWhisper(deps?: Parameters<typeof findExecutable>[2]): WhisperLocation | undefined;
40
+ /**
41
+ * Diretório canônico do cache de modelos GGUF do whisper.cpp — `env-paths` cache
42
+ * do usuário (por SO) + `/models`. PONTO ÚNICO DE VERDADE compartilhado entre o
43
+ * `doctor` (#57, status do modelo) e o download do modelo (#58).
44
+ *
45
+ * @returns Caminho absoluto do diretório de modelos (ex.:
46
+ * `~/Library/Caches/redmine-context/models` no macOS).
47
+ * @example
48
+ * const dir = whisperModelDir();
49
+ */
50
+ export declare function whisperModelDir(): string;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Localização do binário whisper.cpp e diretório canônico do modelo GGUF
3
+ * (M4-01, #57, ADR-002).
4
+ *
5
+ * A milestone M4 transcreve áudio localmente com whisper.cpp (ADR-002). O binário
6
+ * teve VÁRIOS nomes ao longo do tempo — hoje é `whisper-cli`; o brew empacota como
7
+ * `whisper-cpp`; o legado era simplesmente `main`. O `doctor` (#57) detecta
8
+ * QUALQUER um e reporta o caminho encontrado (que revela qual binário é).
9
+ *
10
+ * DECISÕES (exercitadas por testes):
11
+ * - SEM FLAG DE VERSÃO ESTÁVEL: diferente de ffmpeg/tesseract, o whisper.cpp não
12
+ * tem um `--version` estável entre releases — o PATH resolvido é a evidência de
13
+ * presença; o `doctor` não reporta versão para ele (documentado no diagnóstico).
14
+ * - MODELO SEPARADO DO BINÁRIO: o binário e o modelo `.gguf` são artefatos
15
+ * independentes; {@link whisperModelDir} define AGORA o path canônico do cache
16
+ * de modelos (`env-paths` cache + `/models`) que a #58 (download do modelo)
17
+ * consumirá — um único ponto de verdade para ambas as issues.
18
+ */
19
+ import { join } from 'node:path';
20
+ import envPaths from 'env-paths';
21
+ import { findExecutable } from './which.js';
22
+ /** Nome da aplicação para `env-paths` — em sincronia com o cache de anexos (ADR-004). */
23
+ const APP_NAME = 'redmine-context';
24
+ /** Subdiretório do cache que guarda os modelos GGUF do whisper.cpp. */
25
+ const MODELS_SUBDIR = 'models';
26
+ /**
27
+ * Nomes candidatos do binário whisper.cpp, EM ORDEM DE PREFERÊNCIA: o atual
28
+ * (`whisper-cli`), o empacotado pelo brew (`whisper-cpp`) e o legado (`main`).
29
+ *
30
+ * `main` é um nome genérico e é o ÚLTIMO da lista de propósito — só casa um
31
+ * executável exatamente chamado `main` no PATH/locais convencionais, e apenas
32
+ * quando nenhum nome mais específico existe (ADR-002; caveat de supply chain).
33
+ */
34
+ const WHISPER_BINARIES = ['whisper-cli', 'whisper-cpp', 'main'];
35
+ /** Locais convencionais do whisper.cpp por família de SO, após o `PATH`. */
36
+ const CONVENTIONAL = {
37
+ unix: ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'],
38
+ windows: ['C:\\whisper\\bin', 'C:\\Program Files\\whisper\\bin'],
39
+ };
40
+ /**
41
+ * Localiza o binário do whisper.cpp no `PATH` e em locais convencionais,
42
+ * preferindo `whisper-cli` a `whisper-cpp` a `main`. Puro e reutilizável pelo
43
+ * `doctor` (#57). Não executa o binário.
44
+ *
45
+ * @param deps - Deps injetáveis (plataforma/PATH/executabilidade) — ver
46
+ * {@link findExecutable}. Default: ambiente real.
47
+ * @returns A localização encontrada (path + nome), ou `undefined` se ausente.
48
+ * @example
49
+ * const found = findWhisper();
50
+ * if (found !== undefined) logger.info(`whisper.cpp: ${found.binaryName}`);
51
+ */
52
+ export function findWhisper(deps) {
53
+ return findExecutable(WHISPER_BINARIES, CONVENTIONAL, deps);
54
+ }
55
+ /**
56
+ * Diretório canônico do cache de modelos GGUF do whisper.cpp — `env-paths` cache
57
+ * do usuário (por SO) + `/models`. PONTO ÚNICO DE VERDADE compartilhado entre o
58
+ * `doctor` (#57, status do modelo) e o download do modelo (#58).
59
+ *
60
+ * @returns Caminho absoluto do diretório de modelos (ex.:
61
+ * `~/Library/Caches/redmine-context/models` no macOS).
62
+ * @example
63
+ * const dir = whisperModelDir();
64
+ */
65
+ export function whisperModelDir() {
66
+ return join(envPaths(APP_NAME).cache, MODELS_SUBDIR);
67
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Leitor de ZIP MÍNIMO e ZERO-DEPENDÊNCIA (#184) — só o necessário para ler
3
+ * entradas de contêineres OOXML (docx/pptx/xlsx). Fiel ao estilo do projeto
4
+ * ("sem dependência nova", como o `magic.ts`): usa apenas `node:zlib`.
5
+ *
6
+ * Lê o End Of Central Directory (EOCD) do fim do buffer, percorre o Central
7
+ * Directory (a fonte AUTORITATIVA dos tamanhos/offsets — funciona mesmo com data
8
+ * descriptors) e extrai cada entrada sob demanda: método STORED (0) devolve os
9
+ * bytes crus; DEFLATE (8) infla via `inflateRawSync`. Entradas de diretório
10
+ * (nome terminando em `/`) são ignoradas.
11
+ *
12
+ * SEGURANÇA (anti-zip-bomb): a inflação é limitada por `maxOutputLength`
13
+ * ({@link MAX_ENTRY_BYTES}); uma entrada que estoure o teto lança em vez de
14
+ * alocar memória sem limite. Não suporta ZIP64 (contêineres Office reais cabem
15
+ * folgadamente abaixo dos limites de 32 bits) — arquivos ZIP64 lançam, e o
16
+ * chamador degrada graciosamente.
17
+ */
18
+ /** Metadados de uma entrada do ZIP, lidos do Central Directory. */
19
+ export interface ZipEntry {
20
+ /** Nome (caminho) da entrada dentro do zip. */
21
+ readonly name: string;
22
+ /** Método de compressão (0 = stored, 8 = deflate). */
23
+ readonly method: number;
24
+ /** Tamanho comprimido em bytes. */
25
+ readonly compressedSize: number;
26
+ /** Tamanho descomprimido em bytes. */
27
+ readonly uncompressedSize: number;
28
+ /** Offset do Local file header desta entrada no buffer. */
29
+ readonly localHeaderOffset: number;
30
+ }
31
+ /** Arquivo ZIP parseado: consulta e leitura de entradas por nome. */
32
+ export interface ZipArchive {
33
+ /** Nomes de todas as entradas de arquivo (exclui diretórios). */
34
+ names(): string[];
35
+ /** `true` se existe uma entrada de arquivo com esse nome exato. */
36
+ has(name: string): boolean;
37
+ /** Lê e descomprime uma entrada; `undefined` se não existir. */
38
+ read(name: string): Buffer | undefined;
39
+ }
40
+ /**
41
+ * Parseia um buffer ZIP e devolve um {@link ZipArchive} para leitura sob demanda.
42
+ *
43
+ * @param buffer - Conteúdo completo do arquivo zip.
44
+ * @returns O arquivo parseado (índice de entradas + leitura preguiçosa).
45
+ * @throws {Error} Se o buffer não for um zip válido ou for ZIP64 (não suportado).
46
+ * @example
47
+ * const zip = parseZip(await readFile('a.docx'));
48
+ * const xml = zip.read('word/document.xml')?.toString('utf8');
49
+ */
50
+ export declare function parseZip(buffer: Buffer): ZipArchive;
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Leitor de ZIP MÍNIMO e ZERO-DEPENDÊNCIA (#184) — só o necessário para ler
3
+ * entradas de contêineres OOXML (docx/pptx/xlsx). Fiel ao estilo do projeto
4
+ * ("sem dependência nova", como o `magic.ts`): usa apenas `node:zlib`.
5
+ *
6
+ * Lê o End Of Central Directory (EOCD) do fim do buffer, percorre o Central
7
+ * Directory (a fonte AUTORITATIVA dos tamanhos/offsets — funciona mesmo com data
8
+ * descriptors) e extrai cada entrada sob demanda: método STORED (0) devolve os
9
+ * bytes crus; DEFLATE (8) infla via `inflateRawSync`. Entradas de diretório
10
+ * (nome terminando em `/`) são ignoradas.
11
+ *
12
+ * SEGURANÇA (anti-zip-bomb): a inflação é limitada por `maxOutputLength`
13
+ * ({@link MAX_ENTRY_BYTES}); uma entrada que estoure o teto lança em vez de
14
+ * alocar memória sem limite. Não suporta ZIP64 (contêineres Office reais cabem
15
+ * folgadamente abaixo dos limites de 32 bits) — arquivos ZIP64 lançam, e o
16
+ * chamador degrada graciosamente.
17
+ */
18
+ import { inflateRawSync } from 'node:zlib';
19
+ /** Assinatura do End Of Central Directory record (`PK\x05\x06`). */
20
+ const EOCD_SIG = 0x06054b50;
21
+ /** Assinatura de um Central Directory file header (`PK\x01\x02`). */
22
+ const CDH_SIG = 0x02014b50;
23
+ /** Assinatura de um Local file header (`PK\x03\x04`). */
24
+ const LFH_SIG = 0x04034b50;
25
+ /** Método de compressão: armazenado sem compressão. */
26
+ const METHOD_STORED = 0;
27
+ /** Método de compressão: DEFLATE. */
28
+ const METHOD_DEFLATE = 8;
29
+ /** Teto de bytes descomprimidos por entrada (anti-zip-bomb): 64 MiB. */
30
+ const MAX_ENTRY_BYTES = 64 * 1024 * 1024;
31
+ /** Tamanho fixo do EOCD record (sem comentário). */
32
+ const EOCD_MIN = 22;
33
+ /** Tamanho fixo do Central Directory file header (sem nome/extra/comentário). */
34
+ const CDH_FIXED = 46;
35
+ /** Tamanho fixo do Local file header (sem nome/extra). */
36
+ const LFH_FIXED = 30;
37
+ /** Comprimento máximo do comentário do EOCD (campo uint16). */
38
+ const MAX_COMMENT = 0xffff;
39
+ /**
40
+ * Localiza o offset do EOCD varrendo do fim do buffer para trás (o comentário
41
+ * final tem até 64 KiB). Retorna o offset do PRIMEIRO EOCD encontrado a partir do
42
+ * fim.
43
+ *
44
+ * @param buf - Buffer do arquivo zip inteiro.
45
+ * @returns Offset do EOCD.
46
+ * @throws {Error} Se o buffer for menor que o EOCD mínimo ou não tiver a assinatura.
47
+ */
48
+ function findEocd(buf) {
49
+ if (buf.length < EOCD_MIN) {
50
+ throw new Error('zip inválido: buffer menor que o EOCD mínimo');
51
+ }
52
+ const floor = Math.max(0, buf.length - EOCD_MIN - MAX_COMMENT);
53
+ for (let i = buf.length - EOCD_MIN; i >= floor; i -= 1) {
54
+ if (buf.readUInt32LE(i) === EOCD_SIG) {
55
+ // Anti-spoofing (dados anexados após o zip): um EOCD REAL termina EXATAMENTE
56
+ // no fim do arquivo (offset + 22 + tamanho-do-comentário === buf.length). Sem
57
+ // essa checagem, uma segunda assinatura `PK\x05\x06` forjada e anexada ao fim
58
+ // seria aceita, permitindo apontar o central directory para conteúdo diferente
59
+ // do que o Word abriria. Um Office real tem comentário vazio e EOCD no EOF.
60
+ const commentLen = buf.readUInt16LE(i + 20);
61
+ if (i + EOCD_MIN + commentLen === buf.length) {
62
+ return i;
63
+ }
64
+ }
65
+ }
66
+ throw new Error('zip inválido: EOCD ausente ou inconsistente (dados anexados?)');
67
+ }
68
+ /** Implementação de {@link ZipArchive} sobre um buffer + índice de entradas. */
69
+ class BufferZipArchive {
70
+ buf;
71
+ entries;
72
+ constructor(buf, entries) {
73
+ this.buf = buf;
74
+ this.entries = entries;
75
+ }
76
+ names() {
77
+ return [...this.entries.keys()];
78
+ }
79
+ has(name) {
80
+ return this.entries.has(name);
81
+ }
82
+ read(name) {
83
+ const entry = this.entries.get(name);
84
+ if (entry === undefined) {
85
+ return undefined;
86
+ }
87
+ return this.readEntry(entry);
88
+ }
89
+ /**
90
+ * Lê e descomprime uma entrada usando o Local file header (para o offset dos
91
+ * dados) e o `compressedSize` do Central Directory (autoritativo).
92
+ *
93
+ * @param entry - Entrada a ler.
94
+ * @returns Bytes descomprimidos.
95
+ * @throws {Error} Se o header local for inválido, os dados truncados, o método
96
+ * não suportado, ou a inflação estourar {@link MAX_ENTRY_BYTES}.
97
+ */
98
+ readEntry(entry) {
99
+ const buf = this.buf;
100
+ const off = entry.localHeaderOffset;
101
+ if (off + LFH_FIXED > buf.length || buf.readUInt32LE(off) !== LFH_SIG) {
102
+ throw new Error(`zip inválido: local header ausente para "${entry.name}"`);
103
+ }
104
+ const nameLen = buf.readUInt16LE(off + 26);
105
+ const extraLen = buf.readUInt16LE(off + 28);
106
+ const dataStart = off + LFH_FIXED + nameLen + extraLen;
107
+ const dataEnd = dataStart + entry.compressedSize;
108
+ if (dataEnd > buf.length) {
109
+ throw new Error(`zip inválido: dados truncados para "${entry.name}"`);
110
+ }
111
+ const raw = buf.subarray(dataStart, dataEnd);
112
+ if (entry.method === METHOD_STORED) {
113
+ if (raw.length > MAX_ENTRY_BYTES) {
114
+ throw new Error(`entrada "${entry.name}" excede o teto de ${MAX_ENTRY_BYTES} bytes`);
115
+ }
116
+ return Buffer.from(raw);
117
+ }
118
+ if (entry.method === METHOD_DEFLATE) {
119
+ return inflateRawSync(raw, { maxOutputLength: MAX_ENTRY_BYTES });
120
+ }
121
+ throw new Error(`método de compressão não suportado (${entry.method}) em "${entry.name}"`);
122
+ }
123
+ }
124
+ /**
125
+ * Parseia um buffer ZIP e devolve um {@link ZipArchive} para leitura sob demanda.
126
+ *
127
+ * @param buffer - Conteúdo completo do arquivo zip.
128
+ * @returns O arquivo parseado (índice de entradas + leitura preguiçosa).
129
+ * @throws {Error} Se o buffer não for um zip válido ou for ZIP64 (não suportado).
130
+ * @example
131
+ * const zip = parseZip(await readFile('a.docx'));
132
+ * const xml = zip.read('word/document.xml')?.toString('utf8');
133
+ */
134
+ export function parseZip(buffer) {
135
+ const eocd = findEocd(buffer);
136
+ const totalEntries = buffer.readUInt16LE(eocd + 10);
137
+ const cdOffset = buffer.readUInt32LE(eocd + 16);
138
+ if (totalEntries === 0xffff || cdOffset === 0xffffffff) {
139
+ throw new Error('zip64 não suportado');
140
+ }
141
+ const entries = new Map();
142
+ let p = cdOffset;
143
+ for (let i = 0; i < totalEntries; i += 1) {
144
+ if (p + CDH_FIXED > buffer.length || buffer.readUInt32LE(p) !== CDH_SIG) {
145
+ throw new Error('zip inválido: central directory corrompido');
146
+ }
147
+ const method = buffer.readUInt16LE(p + 10);
148
+ const compressedSize = buffer.readUInt32LE(p + 20);
149
+ const uncompressedSize = buffer.readUInt32LE(p + 24);
150
+ const nameLen = buffer.readUInt16LE(p + 28);
151
+ const extraLen = buffer.readUInt16LE(p + 30);
152
+ const commentLen = buffer.readUInt16LE(p + 32);
153
+ const localHeaderOffset = buffer.readUInt32LE(p + 42);
154
+ const name = buffer.toString('utf8', p + CDH_FIXED, p + CDH_FIXED + nameLen);
155
+ // Entradas de diretório (nome terminando em `/`) não têm conteúdo útil.
156
+ // Nomes DUPLICADOS: last-write-wins (o `Map` sobrescreve). Decisão consciente —
157
+ // não há escrita em disco (sem path traversal), apenas round-trip de texto; a
158
+ // última entrada com aquele nome é a lida, como na maioria dos leitores de zip.
159
+ if (!name.endsWith('/')) {
160
+ entries.set(name, { name, method, compressedSize, uncompressedSize, localHeaderOffset });
161
+ }
162
+ p += CDH_FIXED + nameLen + extraLen + commentLen;
163
+ }
164
+ return new BufferZipArchive(buffer, entries);
165
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Orquestração core da extração de anexos de uma issue (M3-10, ADR-002/ADR-005).
3
+ *
4
+ * É a AMARRAÇÃO do M3: liga download → dispatcher → extrator → cache → bundle.
5
+ * Para cada anexo de IMAGEM da issue, resolve o extrator no registry, calcula a
6
+ * chave attachment-level ({@link buildAttachmentKey}) e, sob {@link getOrCompute}
7
+ * (cache de 2 camadas), baixa o binário (respeitando limite/skip do ADR-002) e o
8
+ * roteia para o extrator via {@link dispatchExtraction} (magic byte vence). O
9
+ * resultado é um `Map<attachmentId, ExtractionResult>` consumido pelos bundles.
10
+ *
11
+ * DEGRADAÇÃO GRACIOSA (ADR-002), toda exercitada por testes:
12
+ * - tesseract AUSENTE não impede o bundle: o extrator devolve `failed` com motivo
13
+ * de instalação; o anexo apenas carrega esse status.
14
+ * - a falha de UM anexo (rede, IO) NÃO derruba os demais: cada anexo roda numa
15
+ * Promise com `catch` individual que a converte em `failed`.
16
+ * - cache-hit não re-extrai: `getOrCompute` serve do store sem baixar nem rodar o
17
+ * extrator caro de novo.
18
+ *
19
+ * Só anexos cujo MIME provável (extensão/`content_type`) tem extrator registrado
20
+ * são processados — os demais nem entram no mapa (não há trabalho a fazer). A
21
+ * ROTEAMENTO real dentro do `compute` ainda é decidido por magic bytes.
22
+ */
23
+ import { type CacheStore, type ExtractorConfig } from './cache/index.js';
24
+ import type { HttpClient, Logger } from './client/index.js';
25
+ import type { Attachment, ExtractionResult, Issue } from './contract.js';
26
+ import { type Extractor, type ExtractorRegistry } from './extract/index.js';
27
+ /** Opções de {@link extractIssueAttachments}. */
28
+ export interface ExtractIssueAttachmentsOptions {
29
+ /** URL base da instância Redmine — vira o `instance_hash` da chave e do path. */
30
+ instanceUrl: string;
31
+ /** Registry consultado para achar o extrator do MIME real de cada anexo. */
32
+ registry: ExtractorRegistry;
33
+ /** Store de cache attachment-level (memória ou disco) — ver {@link CacheStore}. */
34
+ store: CacheStore<ExtractionResult>;
35
+ /** Raiz do cache em disco p/ os downloads. Default: {@link defaultCacheDir}. */
36
+ cacheDir?: string;
37
+ /** Limite (bytes) por anexo; acima disso o download é pulado. Default do downloader. */
38
+ maxBytes?: number;
39
+ /**
40
+ * Sinal de CANCELAMENTO (#69/#73) fiado ao `dispatchExtraction` de cada anexo e,
41
+ * dali, ao `runWithWatchdog` dos extratores de mídia — ao abortar, o subprocesso
42
+ * (ffmpeg/whisper) é MORTO. Repassado pela fila de background do MCP via
43
+ * {@link JobContext.signal}. Opcional/aditivo (respeita `exactOptionalPropertyTypes`).
44
+ */
45
+ signal?: AbortSignal;
46
+ /** Logger para avisos (falha de anexo, mismatch); default no-op. Nunca `console.*`. */
47
+ logger?: Logger;
48
+ }
49
+ /**
50
+ * MIME provável de um anexo, para PRÉ-FILTRO e seleção do extrator da chave de
51
+ * cache: a extensão declarada e, em falta, o `content_type`. NÃO é a fonte de
52
+ * verdade do roteamento (isso é magic byte, dentro do `compute`).
53
+ *
54
+ * @param attachment - Anexo do contrato.
55
+ * @returns MIME provável, ou `undefined` se indeterminável.
56
+ */
57
+ export declare function probableMime(attachment: Attachment): string | undefined;
58
+ /**
59
+ * Deriva a {@link ExtractorConfig} da chave de cache a partir de um extrator,
60
+ * usando defaults estáveis quando `model`/`params` não são declarados.
61
+ *
62
+ * @param extractor - Extrator resolvido pelo registry.
63
+ * @returns Config `(version, model, params)` para {@link buildAttachmentKey}.
64
+ */
65
+ export declare function toExtractorConfig(extractor: Extractor): ExtractorConfig;
66
+ /**
67
+ * Extrai o texto de TODOS os anexos de imagem de uma issue, em paralelo e com
68
+ * degradação graciosa por anexo (ADR-002).
69
+ *
70
+ * @param http - Client HTTP autenticado da instância — ver {@link HttpClient}.
71
+ * @param issue - Issue normalizada cujos anexos serão processados.
72
+ * @param options - Ver {@link ExtractIssueAttachmentsOptions}.
73
+ * @returns `Map<attachmentId, ExtractionResult>` — só com anexos de imagem
74
+ * (extrator registrado). Um anexo que falhe entra como `failed`, nunca ausente.
75
+ * @example
76
+ * const registry = await createDefaultRegistry();
77
+ * const store = new DiskCacheStore<ExtractionResult>({ cacheDir });
78
+ * const map = await extractIssueAttachments(http, issue, {
79
+ * instanceUrl: 'https://redmine.example', cacheDir, store, registry,
80
+ * });
81
+ */
82
+ export declare function extractIssueAttachments(http: HttpClient, issue: Issue, options: ExtractIssueAttachmentsOptions): Promise<Map<number, ExtractionResult>>;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Orquestração core da extração de anexos de uma issue (M3-10, ADR-002/ADR-005).
3
+ *
4
+ * É a AMARRAÇÃO do M3: liga download → dispatcher → extrator → cache → bundle.
5
+ * Para cada anexo de IMAGEM da issue, resolve o extrator no registry, calcula a
6
+ * chave attachment-level ({@link buildAttachmentKey}) e, sob {@link getOrCompute}
7
+ * (cache de 2 camadas), baixa o binário (respeitando limite/skip do ADR-002) e o
8
+ * roteia para o extrator via {@link dispatchExtraction} (magic byte vence). O
9
+ * resultado é um `Map<attachmentId, ExtractionResult>` consumido pelos bundles.
10
+ *
11
+ * DEGRADAÇÃO GRACIOSA (ADR-002), toda exercitada por testes:
12
+ * - tesseract AUSENTE não impede o bundle: o extrator devolve `failed` com motivo
13
+ * de instalação; o anexo apenas carrega esse status.
14
+ * - a falha de UM anexo (rede, IO) NÃO derruba os demais: cada anexo roda numa
15
+ * Promise com `catch` individual que a converte em `failed`.
16
+ * - cache-hit não re-extrai: `getOrCompute` serve do store sem baixar nem rodar o
17
+ * extrator caro de novo.
18
+ *
19
+ * Só anexos cujo MIME provável (extensão/`content_type`) tem extrator registrado
20
+ * são processados — os demais nem entram no mapa (não há trabalho a fazer). A
21
+ * ROTEAMENTO real dentro do `compute` ainda é decidido por magic bytes.
22
+ */
23
+ import { buildAttachmentKey, defaultCacheDir, getOrCompute, } from './cache/index.js';
24
+ import { RedmineAuthError } from './client/index.js';
25
+ import { dispatchExtraction, downloadAttachment, isSkipped, mimeForExtension, } from './extract/index.js';
26
+ /**
27
+ * MIME provável de um anexo, para PRÉ-FILTRO e seleção do extrator da chave de
28
+ * cache: a extensão declarada e, em falta, o `content_type`. NÃO é a fonte de
29
+ * verdade do roteamento (isso é magic byte, dentro do `compute`).
30
+ *
31
+ * @param attachment - Anexo do contrato.
32
+ * @returns MIME provável, ou `undefined` se indeterminável.
33
+ */
34
+ export function probableMime(attachment) {
35
+ return mimeForExtension(attachment.filename) ?? attachment.content_type;
36
+ }
37
+ /**
38
+ * Deriva a {@link ExtractorConfig} da chave de cache a partir de um extrator,
39
+ * usando defaults estáveis quando `model`/`params` não são declarados.
40
+ *
41
+ * @param extractor - Extrator resolvido pelo registry.
42
+ * @returns Config `(version, model, params)` para {@link buildAttachmentKey}.
43
+ */
44
+ export function toExtractorConfig(extractor) {
45
+ return {
46
+ version: extractor.version,
47
+ model: extractor.model ?? extractor.id,
48
+ params: extractor.params ?? {},
49
+ };
50
+ }
51
+ /** Monta um `ExtractionResult` `failed` a partir de um erro do pipeline. */
52
+ function failedFromError(error) {
53
+ return {
54
+ status: 'failed',
55
+ metadata: {
56
+ reason: 'erro-pipeline',
57
+ error: error instanceof Error ? error.message : String(error),
58
+ },
59
+ };
60
+ }
61
+ /**
62
+ * Extrai UM anexo sob cache: `getOrCompute(store, key, () => baixa + roteia)`.
63
+ * A chave attachment-level é imutável por conteúdo+extrator, então um cache-hit
64
+ * dispensa baixar e re-extrair.
65
+ *
66
+ * @param ctx - Contexto resolvido — ver {@link ExtractOneContext}.
67
+ * @returns O {@link ExtractionResult} (cacheado ou recém-computado).
68
+ */
69
+ function extractOne(ctx) {
70
+ const { http, attachment, extractor, instanceUrl, cacheDir, store, registry, maxBytes, signal, logger } = ctx;
71
+ const key = buildAttachmentKey({ instanceUrl, attachment, extractor: toExtractorConfig(extractor) });
72
+ return getOrCompute(store, key, async () => {
73
+ const download = await downloadAttachment(http, attachment, {
74
+ cacheDir,
75
+ instanceUrl,
76
+ ...(maxBytes !== undefined ? { maxBytes } : {}),
77
+ });
78
+ if (isSkipped(download)) {
79
+ return { status: 'skipped', metadata: { reason: download.reason } };
80
+ }
81
+ return dispatchExtraction(download, {
82
+ registry,
83
+ filename: attachment.filename,
84
+ ...(logger !== undefined ? { logger } : {}),
85
+ ...(signal !== undefined ? { signal } : {}),
86
+ });
87
+ });
88
+ }
89
+ /**
90
+ * Extrai o texto de TODOS os anexos de imagem de uma issue, em paralelo e com
91
+ * degradação graciosa por anexo (ADR-002).
92
+ *
93
+ * @param http - Client HTTP autenticado da instância — ver {@link HttpClient}.
94
+ * @param issue - Issue normalizada cujos anexos serão processados.
95
+ * @param options - Ver {@link ExtractIssueAttachmentsOptions}.
96
+ * @returns `Map<attachmentId, ExtractionResult>` — só com anexos de imagem
97
+ * (extrator registrado). Um anexo que falhe entra como `failed`, nunca ausente.
98
+ * @example
99
+ * const registry = await createDefaultRegistry();
100
+ * const store = new DiskCacheStore<ExtractionResult>({ cacheDir });
101
+ * const map = await extractIssueAttachments(http, issue, {
102
+ * instanceUrl: 'https://redmine.example', cacheDir, store, registry,
103
+ * });
104
+ */
105
+ export async function extractIssueAttachments(http, issue, options) {
106
+ const { instanceUrl, registry, store, maxBytes, signal, logger } = options;
107
+ const cacheDir = options.cacheDir ?? defaultCacheDir();
108
+ // Pré-filtro: só anexos cujo MIME provável tem extrator registrado. Os demais
109
+ // (PDFs, textos, tipos sem extrator) não geram trabalho nem entram no mapa.
110
+ //
111
+ // INVARIANTE DE CACHE (NIT-1 do BLOCKER-1, ADR-004): a chave attachment-level é
112
+ // consistente entre o caminho síncrono (aqui) e o de background porque áudio e
113
+ // vídeo DELEGAM `version`/`model`/`params` ao MESMO {@link WhisperExtractor}
114
+ // compartilhado (ver `createDefaultRegistry`). Se um dia áudio/vídeo passarem a
115
+ // ter model/params PRÓPRIOS (divergindo do transcritor), a chave deixaria de
116
+ // bater e o cache-first re-dispararia em loop — manter a delegação é o que evita
117
+ // essa regressão.
118
+ const targets = [];
119
+ for (const attachment of issue.attachments) {
120
+ const mime = probableMime(attachment);
121
+ const extractor = mime !== undefined ? registry.find(mime) : undefined;
122
+ if (extractor !== undefined) {
123
+ targets.push({ attachment, extractor });
124
+ }
125
+ }
126
+ const entries = await Promise.all(targets.map(async ({ attachment, extractor }) => {
127
+ try {
128
+ const result = await extractOne({
129
+ http,
130
+ attachment,
131
+ extractor,
132
+ instanceUrl,
133
+ cacheDir,
134
+ store,
135
+ registry,
136
+ maxBytes,
137
+ signal,
138
+ logger,
139
+ });
140
+ return [attachment.id, result];
141
+ }
142
+ catch (error) {
143
+ // Erro de AUTENTICAÇÃO não é falha de UM anexo: a credencial do
144
+ // processo expirou e nenhum download vai funcionar — propaga para o
145
+ // chamador acionar o re-login (review #141). O resto (rede/IO/
146
+ // subprocesso) degrada por anexo, como manda o ADR-002.
147
+ if (error instanceof RedmineAuthError) {
148
+ throw error;
149
+ }
150
+ logger?.warn(`extract: falha ao processar o anexo #${attachment.id} (${attachment.filename}); ` +
151
+ `seguindo sem ele: ${error instanceof Error ? error.message : String(error)}`);
152
+ return [attachment.id, failedFromError(error)];
153
+ }
154
+ }));
155
+ return new Map(entries);
156
+ }