@memoring/cli 0.13.0 → 0.15.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.
package/README.md CHANGED
@@ -47,27 +47,165 @@ Os itens enviados nascem **PENDING_REVIEW** — nada entra nas suas listagens ne
47
47
 
48
48
  1. **Você** roda `memoring auth login` uma vez (device flow do Keycloak — o token fica em `~/.config/memoring/config.json`, ou `%APPDATA%\memoring` no Windows).
49
49
  2. **O agente** lê seus PDFs/textos/anotações, gera os itens no contrato (`memoring stage schema`) e envia com `memoring stage create`.
50
- 3. **Você** revisa e aceita no app (ou pelo terminal). FLASHCARDs e ONE_SHOTs aceitos entram na fila de revisão espaçada FSRS.
50
+ 3. **Você** revisa e aceita no app (ou pelo terminal). NORMALs e ONE_SHOTs aceitos entram na fila de revisão espaçada FSRS.
51
51
 
52
52
  ## Commands
53
53
 
54
54
  | Command | Purpose |
55
55
  | --- | --- |
56
56
  | `memoring auth` | login / logout / status |
57
- | `memoring stage` | schema (o contrato + seus limites por tipo + as regras de qualidade e suas instruções custom — tudo que o agente deve seguir) / caps (mostra/grava os limites — valem para o app e para os agentes) / create (envia o lote, `--file` ou stdin, `--replace` esvazia a caixa antes; `--max-*` vence a preferência por lote) |
57
+ | `memoring stage` | schema (o contrato + limites + regras de qualidade e instruções custom) / caps (limites por tipo) / validate (`--file` + `--pdf-map`: valida lote e quotes no PDF, sem enviar) / create (envia o lote num único POST transacional, com baralho e referências; `--generation-file` registra provider/modelo; `--replace` esvazia a caixa antes) |
58
+ | `memoring batch` | `check plan.json` (preflight sem mutações) / `run plan.json [--resume]` (envia e audita por atividade) / `status` (state local) / `verify` (confere IDs, baralhos, referências e import runs) |
58
59
  | `memoring inbox` | list / accept `<id>` / accept-all / discard `<id>` / discard-all / update `<id>` (edita item PENDENTE — merge do `--file` parcial e/ou flags `--title --front --back --tags a,b --deck nome ...`) |
60
+ | `memoring reference` | list `<noteId>` (referências de material de um card — página exibida 1-based, com o tipo de destaque) / update `<noteId> <referenceId>` `--file parcial.json` (corrige `excerpt`/`quote`/`contextBefore`/`contextAfter`/`region` — merge: campo ausente mantém o atual, `null` ou string vazia limpa; geometria de `region` validada localmente; não altera nota, arquivo nem página) |
59
61
  | `memoring deck` | list (seus baralhos, com status ACTIVE/PENDING) / create `<nome>` (idempotente; `--parent` p/ sub-baralho; `--pending` p/ estrutura de importação — o baralho só aparece no app quando o primeiro item é aceito) |
62
+ | `memoring plan` | planejamento completo: schema / analyze (PDFs, IA no backend; `--web` para revisão no app) / review (`--wait`, `--out`) / create (`--file` ou `--draft`) / list / show / current / next (`--activities`, `--web` ou `--draft`) / atividades / resultados / diagnósticos / exams |
63
+ | `memoring scratch` | list `[--all]` (rascunhos do caderno no Google Drive via proxy do backend, pelo **caminho da biblioteca** — default só cadernos FREE_NOTEBOOK; `--all` inclui CARD_SCRATCH em `Rascunhos de cards/…`) / download `<caminho\|driveFileId>` `[--out dir]` (`.scratch.json` + PDF de fundo) / extract `<arquivo.pdf> --pages A-B` (recorte de PDF com pdf-lib; `--text` extrai o texto da faixa com pdfjs-dist) |
60
64
  | `memoring update` | atualiza a CLI para a última versão (via npm) |
61
65
  | `memoring skill` | imprime o guia completo da CLI para agentes de IA (o SKILL.md empacotado) |
62
66
  | `memoring version` | versão da CLI |
63
67
 
68
+ ## Planejamento de estudos (`memoring plan`)
69
+
70
+ O backend analisa os PDFs com IA e devolve uma proposta; a CLI não tem LLM local e **não cria o planejamento automaticamente**. O fluxo tradicional mantém uma revisão explícita do JSON entre análise e criação:
71
+
72
+ ```bash
73
+ memoring plan schema --json # contrato, maturidades e motivos de erro
74
+ memoring plan analyze aula1.pdf aula2.pdf --out proposta.json
75
+ # revise proposta.json: nome, objetivo, tempo semanal, disciplinas, assuntos e maturidade
76
+ memoring plan create --file proposta.json --json
77
+ memoring plan list
78
+ memoring plan show 123
79
+ ```
80
+
81
+ `analyze` aceita de 1 a 10 PDFs de até 50 MB cada, envia os arquivos por streaming no multipart ao backend e faz polling até a proposta ficar pronta. Os basenames precisam ser únicos, mesmo quando os arquivos vêm de pastas diferentes. O upload tem timeout separado de 10 minutos, configurável em milissegundos por `MEMORING_MULTIPART_TIMEOUT_MS`; o polling também aguarda no máximo 10 minutos. O rascunho começa com `BEGINNER` em todas as disciplinas apenas como valor conservador de revisão. Ajuste cada `maturity` para `BEGINNER` (Iniciante), `INTERMEDIATE` (Intermediário) ou `ADVANCED` (Avançado); os rótulos em PT-BR também são aceitos no arquivo. `deckId`, `weeklyMinutes`, `objective` e `tecNotebookId` são opcionais. Sem `--web`, `--json` imprime somente o rascunho e pode ser redirecionado: `memoring plan analyze aula.pdf --json > proposta.json`.
82
+
83
+ ### Revisão web pelo agente
84
+
85
+ Para revisar no app sem tirar do agente o controle da criação:
86
+
87
+ ```bash
88
+ memoring plan analyze aula1.pdf aula2.pdf --web --json
89
+ # a CLI cria o rascunho PLAN e abre /planning/new?draft=<uuid>; nenhum plano é criado
90
+ memoring plan review <uuid> --wait --out proposta-revisada.json --json
91
+ memoring plan create --draft <uuid> --json
92
+ ```
93
+
94
+ `--no-open` evita abrir o browser; a URL sempre aparece na resposta humana ou no campo `reviewUrl` do JSON. `MEMORING_WEB_BASE` define a origem do app (default `https://memoring.com.br`). A URL contém somente o UUID do rascunho, nunca tokens.
95
+
96
+ Com `--web`, o JSON de `analyze` é um handoff estruturado `{ "draft", "reviewUrl", "nextCommand" }`; `draft` contém `id`, `kind`, `planId`, `payload`, `status`, `createdAt`, `updatedAt` e `expiresAt`. `plan review <uuid> --json` (ou o alias `plan draft`) devolve esse rascunho completo. `--wait` faz polling até `REVIEWED`, com timeout default de 10 minutos e ajuste por `--timeout <segundos>`; timeout não apaga o rascunho. `--out` exporta apenas o `payload` e exige `REVIEWED`.
97
+
98
+ `create --draft` recusa rascunho `OPEN`, de outro tipo ou inválido. Ele cria o plano com o payload revisado e apaga o rascunho **somente depois da criação bem-sucedida**. O botão na página web apenas conclui a revisão; a criação continua sendo feita pelo agente via CLI.
99
+
100
+ Cada ciclo de execução é uma **Meta**. O fluxo direto continua exigindo alvo de atividades:
101
+
102
+ ```bash
103
+ memoring plan next 123 --activities 20 --json # activityTarget = 20
104
+ memoring plan current 123
105
+ memoring plan start 456
106
+ memoring plan result 456 --total 20 --correct 16 --seconds 900 \
107
+ --errors FORGETTING:2,INATTENTION:1
108
+ memoring plan complete 456
109
+ memoring plan postpone 457 # ou dismiss, quando aplicável
110
+ memoring plan tec-result 458 # importa só o resultado TEC já disponível
111
+ ```
112
+
113
+ Para escolher o alvo da próxima Meta na web:
114
+
115
+ ```bash
116
+ memoring plan next 123 --web --json
117
+ # abre /planning/123?goalDraft=<uuid>, sem criar Meta
118
+ memoring plan review <uuid> --wait --json
119
+ memoring plan next 123 --draft <uuid> --json
120
+ ```
121
+
122
+ O handoff de Meta nasce `GOAL` com `payload: {}`. A revisão define `activityTarget` inteiro entre 1 e 200; `next --draft` só cria depois de `REVIEWED`, confere se o rascunho pertence ao plano informado e apaga o rascunho após o sucesso. `--activities` permanece obrigatório somente no fluxo antigo, sem `--web`/`--draft`.
123
+
124
+ Em `result`, `--total` e `--correct` são obrigatórios; `--seconds` e `--errors` são opcionais. Ao regravar um resultado sem essas flags, a CLI omite `actualSeconds` e `errors`, preservando os valores já registrados; no contrato do backend, `errors` omitido preserva a classificação e `errors: []` limpa. Separe motivos por vírgula e some no máximo o número de erros (`total - correct`). Enums aceitos: `UNKNOWN`, `FORGETTING`, `CONCEPTUAL_CONFUSION`, `INATTENTION`, `INTERPRETATION`, `TIME_SHORTAGE`; aliases PT-BR como `esquecimento`, `confusão conceitual`, `desatenção`, `interpretação` e `falta de tempo` também funcionam. `tec-result` exige o addon TEC e não inicia sincronização nem login.
125
+
126
+ Acompanhamento e provas:
127
+
128
+ ```bash
129
+ memoring plan performance 123
130
+ memoring plan error-patterns 123
131
+ memoring plan comparison 123
132
+ memoring plan journey 123
133
+ memoring plan coordinates
134
+ memoring plan exams list
135
+ memoring plan exams create --file prova.json
136
+ memoring plan exams delete 789
137
+ ```
138
+
139
+ A prova também pode ser criada com `--name "Simulado 1" --date 2026-11-15 --total 40 --correct 28 [--plan-id 123] [--subjects '<array JSON>']`. O contrato de `prova.json` está em `memoring plan schema`. Todas as leituras e escritas usam a sessão autenticada e os endpoints user-scoped do backend. `--json` funciona em todos os subcomandos; em `exams delete`, devolve `{ "deleted": true, "examId": N }`.
140
+
141
+ ## Validação e lotes em sequência
142
+
143
+ Antes de enviar, confira estrutura e referências sem criar nada:
144
+
145
+ ```bash
146
+ memoring stage validate --file itens.json --pdf-map pdf-map.json --json
147
+ ```
148
+
149
+ O mapa é um objeto `{ "driveFileId": "/caminho/material.pdf" }`. A CLI abre as páginas com pdfjs e aplica o mesmo localizador do destaque do app: quote inexistente ou ambíguo falha; referência só com página gera aviso; `region` dispensa quote.
150
+
151
+ Para várias atividades, use um manifesto:
152
+
153
+ ```json
154
+ {
155
+ "version": 1,
156
+ "pdfMap": { "drive-id": "material.pdf" },
157
+ "activities": [
158
+ {
159
+ "id": "meta-4-atividade-19",
160
+ "batchFile": "atividade-19.json",
161
+ "deckName": "Imunidade Recíproca",
162
+ "generationFile": "atividade-19-generation.json",
163
+ "expectedReferences": [{ "driveFileId": "drive-id", "pages": "13-23" }]
164
+ }
165
+ ]
166
+ }
167
+ ```
168
+
169
+ `generationFile` é opcional e contém `provider`, `requestedModel`, `resolvedModel`, versões de prompt/schema/CLI, `materialHash` e métricas. A CLI não infere esses dados: quem chamou a LLM os informa. No backend, cada stage com `generation` cria um `note_import_run` e liga as notas a ele na mesma transação; aceite/edição preserva a proveniência.
170
+
171
+ ```bash
172
+ memoring batch check plan.json
173
+ memoring batch run plan.json --resume
174
+ memoring batch status plan.json
175
+ memoring batch verify plan.json
176
+ ```
177
+
178
+ O `run` salva `plan.json.state.json` após cada atividade e confere os IDs individualmente. O replay do stage recupera a resposta original com IDs e `importRunId`, sem duplicar notas nem runs. O dedupe tem retenção de 7 dias: depois disso, reenviar o mesmo arquivo pode criar outro lote; o state do batch é a fonte primária para não reprocessar.
179
+
180
+ ## Piloto Gemini (somente geração local)
181
+
182
+ `scripts/notes/gemini-pdf-to-batch.mjs` envia o PDF ao Gemini pela Files API, produz o lote e o `generation.json`, valida com pdfjs e faz no máximo uma correção. Não chama o Memoring para criar nem aceitar itens. Requer `GEMINI_API_KEY` e `GEMINI_MODEL` no ambiente; o envio continua sendo `stage validate` → `batch check` → `batch run`.
183
+
184
+ ## Rascunhos do Drive (`memoring scratch`)
185
+
186
+ Rascunhos do caderno manuscrito vivem no Google Drive do usuário; a CLI lista e baixa via proxy do backend. **A CLI não inicia o OAuth do Google** — se o Drive não estiver conectado, os comandos falham com 409 e orientam conectar pelo app (Caderno → vincular Drive).
187
+
188
+ ```
189
+ memoring scratch list # cadernos pelo caminho da biblioteca (pasta/título), mais novos primeiro
190
+ memoring scratch list --all # inclui rascunhos de card (Rascunhos de cards/<noteId|título>)
191
+ memoring scratch download "Direito Administrativo/Aula 02" # caminho completo (ou driveFileId) → .scratch.json + PDF
192
+ memoring scratch extract apostila.pdf --pages 20-35 # recorta a faixa num sub-PDF
193
+ ```
194
+
195
+ - O `list` monta o caminho client-side (`GET /handwriting/documents` + `GET /handwriting/folders`): cada documento é pasta-folha, então o caminho é `pasta/pasta/título`. O `--json` inclui o `path` de cada rascunho sem remover os campos de antes.
196
+ - O `download` aceita o **caminho completo** mostrado no `list` (acentos, maiúsculas e espaços livres — `direito administrativo/ AULA 02` funciona) ou o `driveFileId` direto. Caminho não encontrado ou ambíguo = erro listando os candidatos — a CLI nunca escolhe por você. Baixa `<título-slug>.scratch.json` + o PDF de fundo. Com `--metadata-only`, baixa só o JSON e informa `pdfMetadata` (id, nome e páginas), sem o binário.
197
+ - O PDF de fundo baixa **direto pelo `driveFileId` do próprio arquivo** quando o `.scratch.json` o traz (nome local = `pdf.name` legível, sanitizado); rascunhos antigos continuam resolvidos pelo asset `<hash>.pdf` na pasta assets — que também é o fallback se o arquivo direto der 404.
198
+ - O `extract` usa pdf-lib (pure JS, sem deps nativas): `--pages A-B` (ou `A` sozinho) recorta num sub-PDF `<nome>-pA-B.pdf` com a estimativa de tokens da faixa; `to` além do fim é limitado ao total. PDF com mais de ~30 páginas sem `--pages` **exige `--all` explícito** — é a trava de contexto: acima de ~60 páginas, gere sempre de um recorte.
199
+ - `--text` extrai o **texto** da faixa com pdfjs-dist (build legacy, roda em Node sem worker): imprime no stdout com as páginas separadas por `--- Página N ---` (resumo vai pro stderr; com `--out alvo.txt` grava em arquivo). Ideal para PDFs digitalizados em texto — o agente lê o texto em vez de "ver" o PDF. PDFs 100% escaneados (só imagem) saem vazios: nesse caso recorte e gere do sub-PDF.
200
+ - Fluxo para agentes: `scratch list` → `download` → interpretar a faixa ("capítulo 2" ≈ páginas X-Y, confirme se vago) → `extract --pages A-B` (adicione `--text` quando quiser o texto puro) → gerar cartões **só do recorte** → `stage create`. Detalhes no `memoring skill`.
201
+
64
202
  ## Flags
65
203
 
66
204
  | Flag | Purpose |
67
205
  | --- | --- |
68
206
  | `--json` | saída legível por máquina (para agentes e pipes) |
69
207
  | `--no-color` | desativa cores |
70
- | `--no-open` | (auth login) não abre o browser automaticamente |
208
+ | `--no-open` | (auth login, plan analyze/next `--web`) não abre o browser automaticamente |
71
209
  | `--api-base` | sobrescreve a URL da API |
72
210
 
73
211
  Exemplo de pipeline:
@@ -82,21 +220,36 @@ Cada item do lote (1 a 50, all-or-nothing):
82
220
 
83
221
  ```json
84
222
  {
85
- "type": "FLASHCARD",
223
+ "type": "NORMAL",
86
224
  "title": "CF/88 — objetivos fundamentais",
87
225
  "front": "Quais são os quatro objetivos fundamentais da República (art. 3º da CF/88)?",
88
226
  "back": "I — construir uma sociedade livre, justa e solidária; II — garantir o desenvolvimento nacional; III — erradicar a pobreza e reduzir as desigualdades; IV — promover o bem de todos.",
89
227
  "subjectName": "Direito Constitucional",
90
228
  "conceptKey": "const-cf88-art-3",
91
229
  "tags": ["cf88", "art-3"],
92
- "source": "Apostila constitucional.pdf"
230
+ "source": "Apostila constitucional.pdf",
231
+ "materialReferences": [
232
+ {
233
+ "driveFileId": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
234
+ "page": 35,
235
+ "excerpt": "Objetivos fundamentais (art. 3º, CF/88).",
236
+ "quote": "Art. 3º — Constituem objetivos fundamentais da República Federativa do Brasil construir uma sociedade livre, justa e solidária [...].",
237
+ "contextBefore": "CAPÍTULO I — DOS PRINCÍPIOS FUNDAMENTAIS"
238
+ }
239
+ ]
93
240
  }
94
241
  ```
95
242
 
96
- - `type`: `NOTE` (nota, exige `content` em markdown; `NORMAL` legado é aceito e mapeado para `NOTE`), `FLASHCARD` ou `ONE_SHOT` (exigem `front` + `back`), `QUESTION` (exige `question`: `kind` `MULTIPLA_ESCOLHA`/`CERTO_ERRADO`, `statementHtml`, `correctAlternative` 1-based — 1=Certo/2=Errado —, `alternatives` 2–5 só em múltipla escolha, `commentHtml`).
97
- - `conceptKey`: conceito do item em slug minúsculo (letras, números e hífens, até 80 chars). Opcional por compatibilidade, mas **fortemente recomendado em FLASHCARD/ONE_SHOT/QUESTION** — cartões sobre o MESMO conceito compartilham a MESMA chave e conceitos DIFERENTES usam chaves diferentes (nunca reuse a chave). Ex.: um flashcard e uma questão sobre o art. 4º do CTN usam ambos `"trib-art-4"`. No app, o whiteboard do Rascunho agrupa por conceito: ângulos do mesmo conceito contam como uma frente só nas rodadas guiadas; sem chave, cada cartão é uma frente individual. No `inbox update <id>`, o `conceptKey` atual é preservado quando o campo não é informado.
243
+ - `type`: `NOTE` (nota, exige `content` em markdown), `NORMAL` (flashcard frente/verso) ou `ONE_SHOT` (exigem `front` + `back`), `QUESTION` (exige `question`: `kind` `MULTIPLA_ESCOLHA`/`CERTO_ERRADO`, `statementHtml`, `correctAlternative` 1-based — 1=Certo/2=Errado —, `alternatives` 2–5 só em múltipla escolha, `commentHtml`). `FLASHCARD` legado é aceito e mapeado para `NORMAL` no backend.
244
+ - `conceptKey`: conceito do item em slug minúsculo (letras, números e hífens, até 80 chars). Opcional por compatibilidade, mas **fortemente recomendado em NORMAL/ONE_SHOT/QUESTION** — cartões sobre o MESMO conceito compartilham a MESMA chave e conceitos DIFERENTES usam chaves diferentes (nunca reuse a chave). Ex.: um flashcard e uma questão sobre o art. 4º do CTN usam ambos `"trib-art-4"`. No app, o whiteboard do Rascunho agrupa por conceito: ângulos do mesmo conceito contam como uma frente só nas rodadas guiadas; sem chave, cada cartão é uma frente individual. No `inbox update <id>`, o `conceptKey` atual é preservado quando o campo não é informado.
98
245
  - `deckName`: **obrigatório** (ou `--deck` no create) — cartão não fica sem baralho; o nome de um baralho existente (`memoring deck list`) é reutilizado, nome novo vira baralho criado no envio.
99
246
  - `source`: origem do material — default `"Importação assistida (CLI)"`.
247
+ - `materialReferences` (opcional): referências ao material de origem que vinculam o item à **página do PDF** e destacam o trecho exato usado no card. Cada entrada é `{ "driveFileId", "page", "excerpt?", "quote?", "contextBefore?", "contextAfter?", "region?" }`: `driveFileId` é o id do arquivo no Drive (descubra com `memoring scratch list --json`), `page` é a posição **1-based** no documento (a mesma numeração de `scratch extract --pages`; a CLI converte para 0-based no envio). O destaque vem de:
248
+ - `quote` (até 1000 chars): trecho **literal** do material — **recomendado para PDF com texto**; o app localiza e destaca o trecho na página. `contextBefore`/`contextAfter` (até 500 chars cada) desempatam ocorrências repetidas do quote.
249
+ - `region` (`{ "x", "y", "w", "h" }`): região visual da página normalizada de 0 a 1, origem no topo-esquerdo — **para PDF escaneado/sem texto**. Exige números finitos, `w`/`h` > 0 e retângulo contido no quadrado 0..1 (`x+w <= 1`, `y+h <= 1`).
250
+ - `excerpt` (até 500 chars): **só uma dica curta** para humanos — sozinho não gera destaque.
251
+
252
+ Referências já criadas podem ser corrigidas sem reenviar o lote: `memoring reference list <noteId>` mostra as referências de um card (página 1-based) e `memoring reference update <noteId> <referenceId> --file parcial.json` faz merge dos metadados (campo ausente mantém o atual; `region: null` limpa a região; nota, arquivo e página não são alterados). Não substitui `source` (texto livre) — use os dois juntos.
100
253
  - Limites por tipo são **tetos, nunca cotas**: `memoring stage caps` mostra/grava sua preferência (`livre`/`-1` = sem teto preferencial — o padrão, não um incentivo a inflar o lote; `0` = não gerar o tipo) — o `stage schema` a expõe ao agente (`userCaps`) e o `create` a impõe; flags `--max-*` no `create` vencem por lote. O 50 é apenas o limite técnico por requisição, não a meta: sem quantidade explícita, o agente deve selecionar apenas os pontos realmente cobráveis e manter uma carga de revisão sustentável.
101
254
  - Sem sessão, o `stage schema --json` sai com `authenticated: false`, `userCaps`/`qualityRules`/`customInstructions` nulos e um `warning` — agentes devem logar (`auth login`) e reler o schema ANTES de gerar.
102
255
 
@@ -114,7 +267,7 @@ MEMORING_KEYCLOAK_URL=http://localhost:8080 \
114
267
  memoring auth login
115
268
  ```
116
269
 
117
- Variáveis: `MEMORING_API_BASE`, `MEMORING_KEYCLOAK_URL`, `MEMORING_KC_REALM`, `MEMORING_KC_CLIENT`, `MEMORING_CONFIG_HOME` (isola o config.json).
270
+ Variáveis: `MEMORING_API_BASE`, `MEMORING_WEB_BASE` (origem do app para handoffs; default `https://memoring.com.br`), `MEMORING_KEYCLOAK_URL`, `MEMORING_KC_REALM`, `MEMORING_KC_CLIENT`, `MEMORING_CONFIG_HOME` (isola o config.json), `MEMORING_MULTIPART_TIMEOUT_MS` (timeout do upload multipart; default 600000).
118
271
  Override de ambiente vale por sessão: o `auth login` não o grava no config.json — exporte as variáveis em cada shell (ou use `memoring --api-base ...` por comando).
119
272
 
120
- Build a partir do repo: `cd cli && npm install && npm run build && node dist/main.js --help`.
273
+ Build a partir do repo: `cd cli && npm install && npm run build && node dist/main.js --help`. Testes: `cd cli && npm test` (vitest — `tests/`, com a API mockada e fixtures PDF gerados por pdf-lib).
package/dist/api.js CHANGED
@@ -1,6 +1,13 @@
1
1
  import { getAccessToken } from './auth.js';
2
2
  import { loadConfig } from './config.js';
3
3
  const API_TIMEOUT_MS = 30_000;
4
+ const API_MULTIPART_TIMEOUT_MS = 10 * 60 * 1_000;
5
+ function multipartTimeoutMs() {
6
+ const configured = Number(process.env.MEMORING_MULTIPART_TIMEOUT_MS);
7
+ return Number.isSafeInteger(configured) && configured > 0 && configured <= 2_147_483_647
8
+ ? configured
9
+ : API_MULTIPART_TIMEOUT_MS;
10
+ }
4
11
  export class ApiError extends Error {
5
12
  status;
6
13
  body;
@@ -12,6 +19,46 @@ export class ApiError extends Error {
12
19
  }
13
20
  /** Resposta reutilizada do dedupe server-side (X-Client-Request-Deduped) — a escrita já foi processada antes. */
14
21
  export class ApiDedupedError extends Error {
22
+ body;
23
+ constructor(body) {
24
+ super('Resposta deduplicada pelo servidor');
25
+ this.body = body;
26
+ }
27
+ }
28
+ /** GET autenticado com o tratamento de 401 (refresh + 1 retry) compartilhado. */
29
+ async function authorizedGet(path) {
30
+ const cfg = loadConfig();
31
+ const url = `${cfg.apiBase}${path}`;
32
+ const doFetch = async (token) => fetch(url, {
33
+ method: 'GET',
34
+ headers: { Authorization: `Bearer ${token}` },
35
+ signal: AbortSignal.timeout(API_TIMEOUT_MS),
36
+ });
37
+ let res = await doFetch(await getAccessToken());
38
+ if (res.status === 401) {
39
+ res = await doFetch(await getAccessToken({ forceRefresh: true }));
40
+ }
41
+ return res;
42
+ }
43
+ /**
44
+ * Corpo de erro lido como texto: se for um ProblemDetail JSON (padrão do
45
+ * backend), extrai `detail`/`title` para uma mensagem curta; senão devolve o
46
+ * texto cru (truncado — download de mídia pode ter corpo binário parcial).
47
+ */
48
+ function errorMessageFromBody(text) {
49
+ const trimmed = text.trim();
50
+ try {
51
+ const parsed = JSON.parse(trimmed);
52
+ for (const key of ['detail', 'title']) {
53
+ if (typeof parsed[key] === 'string' && parsed[key] !== '') {
54
+ return parsed[key];
55
+ }
56
+ }
57
+ }
58
+ catch {
59
+ // corpo não-JSON — cai no texto cru
60
+ }
61
+ return trimmed.length > 500 ? `${trimmed.slice(0, 500)}…` : trimmed;
15
62
  }
16
63
  /** fetch autenticado contra a API do Memoring; em 401, força refresh e tenta uma vez mais. */
17
64
  export async function apiFetch(path, options = {}) {
@@ -31,15 +78,54 @@ export async function apiFetch(path, options = {}) {
31
78
  if (res.status === 401) {
32
79
  res = await doFetch(await getAccessToken({ forceRefresh: true }));
33
80
  }
81
+ if (res.status === 204 && res.headers.get('X-Client-Request-Deduped') !== 'true') {
82
+ return null;
83
+ }
84
+ const text = await res.text();
34
85
  if (res.headers.get('X-Client-Request-Deduped') === 'true') {
35
- throw new ApiDedupedError();
86
+ throw new ApiDedupedError(text);
36
87
  }
37
- if (res.status === 204) {
38
- return null;
88
+ if (!res.ok) {
89
+ throw new ApiError(res.status, errorMessageFromBody(text));
90
+ }
91
+ return text ? JSON.parse(text) : null;
92
+ }
93
+ export async function apiFetchMultipart(path, files) {
94
+ const cfg = loadConfig();
95
+ const url = `${cfg.apiBase}${path}`;
96
+ const doFetch = async (token) => {
97
+ const form = new FormData();
98
+ for (const file of files) {
99
+ form.append(file.fieldName, file.data, file.fileName);
100
+ }
101
+ return fetch(url, {
102
+ method: 'POST',
103
+ headers: { Authorization: `Bearer ${token}` },
104
+ body: form,
105
+ signal: AbortSignal.timeout(multipartTimeoutMs()),
106
+ });
107
+ };
108
+ let res = await doFetch(await getAccessToken());
109
+ if (res.status === 401) {
110
+ res = await doFetch(await getAccessToken({ forceRefresh: true }));
39
111
  }
40
112
  const text = await res.text();
41
113
  if (!res.ok) {
42
- throw new ApiError(res.status, text);
114
+ throw new ApiError(res.status, errorMessageFromBody(text));
43
115
  }
44
116
  return text ? JSON.parse(text) : null;
45
117
  }
118
+ /**
119
+ * GET binário: devolve os bytes crus + o content-type declarado (download
120
+ * streaming dos arquivos do Drive). Em status != 2xx lê o corpo como texto e
121
+ * tenta extrair o `detail` do ProblemDetail — o proxy devolve o erro do Drive
122
+ * (404/conflito/etc.) ANTES de qualquer byte, então o corpo de erro é JSON.
123
+ */
124
+ export async function apiFetchBytes(path) {
125
+ const res = await authorizedGet(path);
126
+ if (!res.ok) {
127
+ throw new ApiError(res.status, errorMessageFromBody(await res.text()));
128
+ }
129
+ const arrayBuffer = await res.arrayBuffer();
130
+ return { body: Buffer.from(arrayBuffer), contentType: res.headers.get('Content-Type') };
131
+ }
package/dist/args.js CHANGED
@@ -1,4 +1,17 @@
1
- const BOOLEAN_FLAGS = new Set(['json', 'no-color', 'replace', 'help', 'version', 'no-open']);
1
+ const BOOLEAN_FLAGS = new Set([
2
+ 'json',
3
+ 'no-color',
4
+ 'replace',
5
+ 'help',
6
+ 'version',
7
+ 'no-open',
8
+ 'all',
9
+ 'text',
10
+ 'resume',
11
+ 'metadata-only',
12
+ 'web',
13
+ 'wait',
14
+ ]);
2
15
  export function parseArgs(argv) {
3
16
  const positional = [];
4
17
  const flags = {};
package/dist/auth.js CHANGED
@@ -32,7 +32,7 @@ function decodeJwtEmail(token) {
32
32
  return undefined;
33
33
  }
34
34
  }
35
- function openBrowser(url) {
35
+ export function openBrowser(url) {
36
36
  const command = process.platform === 'win32'
37
37
  ? `start "" "${url}"`
38
38
  : process.platform === 'darwin'