@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.
@@ -0,0 +1,68 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { pdfTextItemViewportBox } from './quoteLocate.js';
5
+ export class PdfDocumentCache {
6
+ documents = new Map();
7
+ pages = new Map();
8
+ async pageCount(filePath) {
9
+ return (await this.load(filePath)).doc.numPages;
10
+ }
11
+ async pageItems(filePath, pageNumber) {
12
+ const key = `${resolve(filePath)}:${pageNumber}`;
13
+ let pending = this.pages.get(key);
14
+ if (!pending) {
15
+ pending = this.readPage(filePath, pageNumber);
16
+ this.pages.set(key, pending);
17
+ }
18
+ return pending;
19
+ }
20
+ async close() {
21
+ const loaded = await Promise.allSettled([...this.documents.values()]);
22
+ for (const result of loaded) {
23
+ if (result.status === 'fulfilled') {
24
+ await result.value.doc.destroy();
25
+ }
26
+ }
27
+ }
28
+ load(filePath) {
29
+ const key = resolve(filePath);
30
+ let pending = this.documents.get(key);
31
+ if (!pending) {
32
+ pending = this.loadDocument(key);
33
+ this.documents.set(key, pending);
34
+ }
35
+ return pending;
36
+ }
37
+ async loadDocument(filePath) {
38
+ const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
39
+ const require = createRequire(import.meta.url);
40
+ const packageDir = dirname(require.resolve('pdfjs-dist/package.json'));
41
+ const loadingTask = pdfjs.getDocument({
42
+ data: new Uint8Array(readFileSync(filePath)),
43
+ standardFontDataUrl: `${packageDir}/standard_fonts/`,
44
+ cMapUrl: `${packageDir}/cmaps/`,
45
+ cMapPacked: true,
46
+ });
47
+ return { doc: await loadingTask.promise, pdfjs };
48
+ }
49
+ async readPage(filePath, pageNumber) {
50
+ const { doc, pdfjs } = await this.load(filePath);
51
+ const page = await doc.getPage(pageNumber);
52
+ const viewport = page.getViewport({ scale: 1 });
53
+ const content = await page.getTextContent();
54
+ const items = [];
55
+ for (const raw of content.items) {
56
+ if (!('str' in raw) || typeof raw.str !== 'string' || raw.str.length === 0)
57
+ continue;
58
+ if (!Array.isArray(raw.transform) || raw.transform.length !== 6)
59
+ continue;
60
+ const tx = pdfjs.Util.transform(viewport.transform, raw.transform);
61
+ items.push({
62
+ str: raw.str,
63
+ ...pdfTextItemViewportBox(tx, Number(raw.width ?? 0), Number(raw.height ?? 0)),
64
+ });
65
+ }
66
+ return items;
67
+ }
68
+ }
@@ -0,0 +1,181 @@
1
+ export function normalizeSearchText(value) {
2
+ return value
3
+ .toLocaleLowerCase('pt-BR')
4
+ .normalize('NFD')
5
+ .replace(/[\u0300-\u036f]/g, '');
6
+ }
7
+ export function pdfTextItemViewportBox(tx, itemWidth, itemHeight) {
8
+ const em = Math.hypot(tx[2] ?? 0, tx[3] ?? 0);
9
+ const w = Math.max(0, itemWidth);
10
+ const h = itemHeight > 0 ? itemHeight : em;
11
+ return { x: tx[4] ?? 0, y: (tx[5] ?? 0) - h * 0.8, w, h };
12
+ }
13
+ const NO_ITEM = -1;
14
+ const SAME_LINE_TOL_PTS = 2;
15
+ function collapseSpaces(value) {
16
+ return value.replace(/\s+/g, ' ').trim();
17
+ }
18
+ function itemCenterY(item) {
19
+ return item.y + item.h / 2;
20
+ }
21
+ function sameLine(a, b) {
22
+ return Math.abs(itemCenterY(a) - itemCenterY(b)) <= SAME_LINE_TOL_PTS;
23
+ }
24
+ export function normalizePageItems(items) {
25
+ let norm = '';
26
+ const map = [];
27
+ const starts = [];
28
+ const lens = [];
29
+ let lastWasSpace = true;
30
+ for (let i = 0; i < items.length; i += 1) {
31
+ const normalized = normalizeSearchText(items[i].str);
32
+ starts.push(norm.length);
33
+ let emitted = 0;
34
+ for (const ch of normalized) {
35
+ if (/\s/.test(ch)) {
36
+ if (!lastWasSpace && norm.length > 0) {
37
+ norm += ' ';
38
+ map.push(i);
39
+ emitted += 1;
40
+ }
41
+ lastWasSpace = true;
42
+ continue;
43
+ }
44
+ norm += ch;
45
+ map.push(i);
46
+ emitted += 1;
47
+ lastWasSpace = false;
48
+ }
49
+ lens.push(emitted);
50
+ const next = items[i + 1];
51
+ if (next && !lastWasSpace && !/\s$/.test(normalizeSearchText(items[i].str)) && !sameLine(items[i], next)) {
52
+ norm += ' ';
53
+ map.push(NO_ITEM);
54
+ lastWasSpace = true;
55
+ }
56
+ }
57
+ return { norm, map, starts, lens };
58
+ }
59
+ function matchesBefore(norm, matchStart, context) {
60
+ if (context.length === 0)
61
+ return false;
62
+ if (matchStart >= context.length && norm.slice(matchStart - context.length, matchStart) === context) {
63
+ return true;
64
+ }
65
+ return (matchStart >= context.length + 1 &&
66
+ norm[matchStart - 1] === ' ' &&
67
+ norm.slice(matchStart - 1 - context.length, matchStart - 1) === context);
68
+ }
69
+ function matchesAfter(norm, matchEnd, context) {
70
+ if (context.length === 0)
71
+ return false;
72
+ if (norm.slice(matchEnd, matchEnd + context.length) === context)
73
+ return true;
74
+ return (norm[matchEnd] === ' ' &&
75
+ norm.slice(matchEnd + 1, matchEnd + 1 + context.length) === context);
76
+ }
77
+ function findOccurrences(haystack, needle) {
78
+ const out = [];
79
+ let from = 0;
80
+ for (;;) {
81
+ const at = haystack.indexOf(needle, from);
82
+ if (at < 0)
83
+ return out;
84
+ out.push(at);
85
+ from = at + 1;
86
+ }
87
+ }
88
+ function rectsForRange(items, page, start, end) {
89
+ const rects = [];
90
+ let i = start;
91
+ while (i < end) {
92
+ const itemIndex = page.map[i];
93
+ if (itemIndex === NO_ITEM) {
94
+ i += 1;
95
+ continue;
96
+ }
97
+ let j = i;
98
+ while (j < end && page.map[j] === itemIndex)
99
+ j += 1;
100
+ const len = Math.max(1, page.lens[itemIndex]);
101
+ const fracStart = Math.min(1, Math.max(0, (i - page.starts[itemIndex]) / len));
102
+ const fracEnd = Math.min(1, Math.max(fracStart, (j - page.starts[itemIndex]) / len));
103
+ const item = items[itemIndex];
104
+ const w = (fracEnd - fracStart) * item.w;
105
+ if (w > 0.01 && item.h > 0) {
106
+ rects.push({ x: item.x + fracStart * item.w, y: item.y, w, h: item.h });
107
+ }
108
+ i = j;
109
+ }
110
+ return rects;
111
+ }
112
+ export function mergeRectsByLine(rects) {
113
+ const sorted = [...rects].sort((a, b) => a.y - b.y || a.x - b.x);
114
+ const lines = [];
115
+ for (const rect of sorted) {
116
+ const line = lines.find((candidate) => Math.abs(candidate[0].y - rect.y) <= SAME_LINE_TOL_PTS);
117
+ if (line)
118
+ line.push(rect);
119
+ else
120
+ lines.push([rect]);
121
+ }
122
+ const out = [];
123
+ for (const line of lines) {
124
+ line.sort((a, b) => a.x - b.x);
125
+ let current = null;
126
+ for (const rect of line) {
127
+ if (!current) {
128
+ current = { ...rect };
129
+ continue;
130
+ }
131
+ const gap = rect.x - (current.x + current.w);
132
+ if (gap <= 1) {
133
+ const right = Math.max(current.x + current.w, rect.x + rect.w);
134
+ current.w = right - current.x;
135
+ current.h = Math.max(current.h, rect.h);
136
+ }
137
+ else {
138
+ out.push(current);
139
+ current = { ...rect };
140
+ }
141
+ }
142
+ if (current)
143
+ out.push(current);
144
+ }
145
+ return out;
146
+ }
147
+ export function locateQuoteInPageItems(items, query) {
148
+ const quote = collapseSpaces(normalizeSearchText(query.quote));
149
+ if (quote.length === 0 || items.length === 0)
150
+ return null;
151
+ const contextBefore = collapseSpaces(normalizeSearchText(query.contextBefore ?? ''));
152
+ const contextAfter = collapseSpaces(normalizeSearchText(query.contextAfter ?? ''));
153
+ const page = normalizePageItems(items);
154
+ const occurrences = findOccurrences(page.norm, quote);
155
+ if (occurrences.length === 0)
156
+ return null;
157
+ let chosen;
158
+ if (occurrences.length === 1) {
159
+ chosen = occurrences[0];
160
+ }
161
+ else {
162
+ let bestScore = -1;
163
+ let best = [];
164
+ for (const at of occurrences) {
165
+ const score = (matchesBefore(page.norm, at, contextBefore) ? 1 : 0) +
166
+ (matchesAfter(page.norm, at + quote.length, contextAfter) ? 1 : 0);
167
+ if (score > bestScore) {
168
+ bestScore = score;
169
+ best = [at];
170
+ }
171
+ else if (score === bestScore) {
172
+ best.push(at);
173
+ }
174
+ }
175
+ if (bestScore <= 0 || best.length !== 1)
176
+ return null;
177
+ chosen = best[0];
178
+ }
179
+ const rects = mergeRectsByLine(rectsForRange(items, page, chosen, chosen + quote.length));
180
+ return rects.length > 0 ? rects : null;
181
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memoring/cli",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "CLI do Memoring — envie flashcards/notas gerados por IA para a sua caixa de revisão, direto do terminal.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,11 +22,17 @@
22
22
  "scripts": {
23
23
  "build": "tsc -p tsconfig.json",
24
24
  "start": "node dist/main.js",
25
+ "test": "vitest run",
25
26
  "prepack": "npm run build"
26
27
  },
28
+ "dependencies": {
29
+ "pdf-lib": "^1.17.1",
30
+ "pdfjs-dist": "^5.7.284"
31
+ },
27
32
  "devDependencies": {
28
33
  "@types/node": "^24.0.0",
29
- "typescript": "^5.8.0"
34
+ "typescript": "^5.8.0",
35
+ "vitest": "^3.2.4"
30
36
  },
31
37
  "license": "UNLICENSED"
32
38
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: memoring-cli
3
- description: Gere flashcards e notas a partir de materiais de estudo locais (PDFs, textos, anotações) e envie para a caixa de revisão do Memoring usando a CLI `memoring`. Use quando o usuário pedir para "usar a cli do memoring", criar flashcards/notas de uma pasta ou arquivo, ou enviar material de estudo para o Memoring.
3
+ description: Gere flashcards e notas a partir de materiais locais, envie para revisão e opere planejamentos de estudo no Memoring usando a CLI `memoring`. Use quando o usuário pedir para "usar a cli do memoring", criar flashcards/notas, analisar PDFs para planejar estudos, gerar Metas, registrar atividades/resultados, diagnósticos ou provas.
4
4
  ---
5
5
 
6
6
  # Memoring CLI — guia completo para agentes
@@ -20,14 +20,24 @@ O Memoring é um app de estudos com revisão espaçada (FSRS). A CLI `memoring`
20
20
  | `memoring auth login\|logout\|status` | sessão (device flow — sem senha no terminal) |
21
21
  | `memoring stage schema [--json]` | O CONTRATO dos itens + limites por tipo + regras de qualidade — leia SEMPRE, logado, antes de gerar |
22
22
  | `memoring stage caps [--max-* N\|livre]` | limites por tipo do usuário (preferência DELE — nunca escreva sem ele pedir) |
23
- | `memoring stage create --file f [--replace] [--max-* N] [--deck nome]` | envia o lote (1 a 50 itens) para a caixa de revisão — **baralho OBRIGATÓRIO** (deckName por item ou `--deck` no lote; nomes novos viram baralhos); `--json` devolve `created` + `ids` |
23
+ | `memoring stage validate --file f --pdf-map map.json` | valida estrutura, páginas e quotes no PDF local, sem qualquer POST; usa o mesmo localizador do destaque do app |
24
+ | `memoring stage create --file f [--replace] [--max-* N] [--deck nome] [--generation-file f]` | envia o lote (1 a 50) num único POST transacional, com baralho/referências; `--json` devolve `created` + `ids` + `importRunId`; generation-file registra provider/modelo sem inferi-los |
25
+ | `memoring batch check\|run\|status\|verify plan.json [--resume]` | preflight, envio retomável e auditoria por ID de várias atividades; nunca aceita itens |
24
26
  | `memoring deck list` | seus baralhos (id, nome, status, pai, a revisar, relevância, meta, recorde) — consulte ANTES de gerar |
25
27
  | `memoring deck create <nome> [--parent id] [--pending]` | cria baralho (idempotente); `--pending` deixa oculto até o primeiro item aceite |
26
28
  | `memoring deck update <id\|nome> [--relevance <0-5>] [--goal "<txt>"] [--tone <gray\|orange\|green\|blue\|purple>] [--clear-*]` | define metadados do baralho (relevância, meta, tom de capa; `--clear-*` limpa o campo) |
29
+ | `memoring plan schema\|analyze\|review\|create\|list\|show` | contrato e ciclo de criação: PDFs → proposta → revisão no JSON ou web (`analyze --web`) → plano; `review --wait` lê o handoff e `create --draft` exige REVIEWED |
30
+ | `memoring plan current\|next\|start\|complete\|postpone\|dismiss\|cancel\|result\|tec-result` | executa Metas/atividades; `next` direto exige `--activities N`; `next --web`/`--draft` faz handoff web; resultados manuais ou cache-only do TEC; `cancel` é redução definitiva de escopo (nunca recompõe o tipo) |
31
+ | `memoring plan performance\|error-patterns\|comparison\|journey\|coordinates\|exams` | diagnósticos, guia e provas (`exams list/create/delete`) |
27
32
  | `memoring inbox list` | itens pendentes de revisão |
28
33
  | `memoring inbox accept <id>\|accept-all` | aceita itens (entram nas listagens e na fila FSRS) — **decisão do usuário: só rode se ELE pedir explicitamente** |
29
34
  | `memoring inbox discard <id>\|discard-all` | descarta item(ns) pendente(s) |
30
35
  | `memoring inbox update <id> [--file parcial.json] [--title ...] [--front ...] [--back ...] [--tags a,b] [--concept trib-art-4] ...` | edita um item AINDA PENDENTE (merge com o estado atual — `conceptKey` é preservado se não for informado) |
36
+ | `memoring reference list <noteId> [--json]` | referências de material de um card (fontes de estudo) — página exibida 1-based, com o tipo de destaque (quote ou região) |
37
+ | `memoring reference update <noteId> <referenceId> --file parcial.json [--json]` | corrige `excerpt`/`quote`/`contextBefore`/`contextAfter`/`region` de uma referência (merge: campo ausente mantém o atual, `null`/string vazia limpa; `region` valida `x,y,w,h` em 0..1, `w/h` > 0; não altera nota, arquivo nem página) |
38
+ | `memoring scratch list [--all]` | rascunhos do caderno no Google Drive (via proxy do backend — default só cadernos FREE_NOTEBOOK; `--all` inclui CARD_SCRATCH), listados pelo **CAMINHO da biblioteca** (pasta/título — cada documento é pasta-folha; cards vivem em `Rascunhos de cards/<noteId\|título>`); `--json` inclui o `path` de cada um |
39
+ | `memoring scratch download <caminho\|driveFileId> [--out <dir>] [--metadata-only]` | baixa o `.scratch.json` + PDF, ou só metadados com `--metadata-only`; aceita caminho completo ou driveFileId, nunca escolhe em ambiguidade |
40
+ | `memoring scratch extract <arquivo.pdf> [--pages A-B] [--all] [--out <alvo>]` | recorta a faixa num sub-PDF (pdf-lib); `--text` extrai o TEXTO da faixa com pdfjs-dist (stdout, `--- Página N ---` por página; `--out` grava em arquivo); > ~30 páginas sem `--pages` exige `--all` explícito |
31
41
  | `memoring skill` | imprime este guia |
32
42
  | `memoring update` | atualiza a CLI (decisão do usuário — nunca rode por conta própria) |
33
43
 
@@ -41,18 +51,58 @@ Flags globais: `--json` (saída legível por máquina), `--no-color`, `--api-bas
41
51
  4. **Defina o baralho** — cartão NÃO fica sem baralho. Rode `memoring deck list`, escolha o baralho adequado ao material e reutilize o nome; se não existir e a estrutura for destinada à importação, crie com `memoring deck create "<nome>" --pending` (ou deixe o próprio `stage create` criar pelo nome — nesse caso o baralho também nasce **PENDING**). **Espelhe a hierarquia do material:** a matéria vira baralho raiz; o tema da aula/capítulo vira sub-baralho criado ANTES com `memoring deck create "<tema>" --parent "<matéria>" --pending` — o create-por-nome do `stage create` só cria baralho raiz. Use `--pending` na estrutura criada para importação: baralhos PENDING ficam ocultos no app (árvore, filtros, fila, estatísticas) até o **primeiro item aceite** — o aceite ativa o baralho E os ancestrais pendentes de uma vez; descartar não ativa. Todo item do lote deve levar `deckName` — ou use `--deck "<nome>"` no create como padrão do lote. Lote sem baralho é REJEITADO pela validação local.
42
52
  5. **Leia os materiais** e gere os itens no contrato, num arquivo JSON (array ou `{ "items": [...] }`).
43
53
  6. **Envie**: `memoring stage create --file <arquivo> --json`. Se o lote for rejeitado por limite de tipo, ajuste o LOTE — não relaxe o limite do usuário.
44
- 7. **Guarde os `ids` retornados** pelo create: enquanto os itens estiverem PENDING_REVIEW você pode corrigi-los com `memoring inbox update <id>` — sem precisar descartar e reenviar.
54
+ 7. **Guarde os `ids` retornados** pelo create: enquanto os itens estiverem PENDING_REVIEW você pode corrigi-los com `memoring inbox update <id>` — sem precisar descartar e reenviar. Errou o quote/contexto/região de uma referência? `memoring reference list <noteId>` + `memoring reference update <noteId> <referenceId> --file parcial.json` corrigem os metadados sem tocar no item.
45
55
  8. **Avise o usuário e PARE**: informe quantos itens foram para a caixa de revisão, os `ids` e onde ele revisa — no app em `/cards/inbox` (ou `memoring inbox list` para ver o estado). O fluxo termina AQUI: aceitar (`memoring inbox accept <id>` / `memoring inbox accept-all`) é decisão do usuário e só acontece quando ELE pedir explicitamente — aponte o caminho, não execute.
46
56
 
57
+ ## Planejamento de estudos (`memoring plan`)
58
+
59
+ Use este fluxo quando o pedido for organizar PDFs num plano, gerar uma Meta ou acompanhar atividades. A análise por IA acontece **somente no backend**: a CLI envia os PDFs, aguarda o job e adapta a resposta num rascunho editável. Não tente reproduzir essa análise com uma LLM local nem chame a API diretamente.
60
+
61
+ 1. Confirme `memoring auth status --json` e leia `memoring plan schema --json`. O schema contém o contrato do plano e da prova, as três maturidades e os motivos de erro.
62
+ 2. **Ofereça a revisão web como opção recomendada**, deixando também o fluxo de JSON no terminal. Na web, o usuário revisa numa interface amigável e o agente continua sendo o único que cria plano/Meta. Os PDFs aceitos são 1 a 10 arquivos de até 50 MB cada, com basenames únicos mesmo entre pastas; upload e polling da análise têm timeouts separados de 10 minutos (`MEMORING_MULTIPART_TIMEOUT_MS` configura o upload, em ms).
63
+ 3. Para a **revisão web**, rode `memoring plan analyze <pdfs...> --web --json`. Isso cria apenas um rascunho `PLAN` e abre `/planning/new?draft=<uuid>` (`--no-open` suprime a abertura; `MEMORING_WEB_BASE` muda a origem, default `https://memoring.com.br`). Leia a resposta estruturada `{draft, reviewUrl, nextCommand}`: guarde `draft.id`, mostre `reviewUrl` ao usuário e aguarde ele concluir a revisão. `memoring plan review <uuid> --wait --json` consulta até `draft.status === "REVIEWED"` (timeout default 10 minutos; `--timeout <segundos>` ajusta). Sem `--wait`, `review <uuid> --json` apenas consulta; `draft` é alias de `review`. Para salvar somente o payload já revisado, acrescente `--out proposta-revisada.json`; `--out` recusa `OPEN`. Timeout não apaga rascunho — consulte novamente ou informe o usuário, sem criar.
64
+ 4. Crie somente dentro do pedido do usuário e **somente após REVIEWED**: `memoring plan create --draft <uuid> --json`. A CLI valida tipo/status/payload, envia a criação e apaga o rascunho apenas depois do sucesso. Nunca tente criar a partir de `OPEN`, nunca trate o clique web como criação e não repita o POST cegamente após erro de rede. Guarde `plan.id`; consulte com `plan list` e `plan show <planId>`.
65
+ 5. Se o usuário preferir o **fluxo antigo**, rode `memoring plan analyze <pdfs...> --out proposta.json` e revise nome, objetivo, `weeklyMinutes`, disciplinas/assuntos, páginas, `deckId`/`tecNotebookId` e, obrigatoriamente, `maturity` por disciplina. O rascunho usa `BEGINNER` como default conservador, não diagnóstico; também são aceitos `INTERMEDIATE`/`ADVANCED` e rótulos PT-BR. Preserve índices 0-based de `materialIndex`/`prerequisiteTopicIndex`. Depois rode `memoring plan create --file proposta.json --json`. Sem `--web`, `analyze --json` continua imprimindo apenas o rascunho e permite `> proposta.json`.
66
+ 6. Para abrir um ciclo no fluxo direto, `memoring plan next <planId> --activities N --json`. **Nesse fluxo `--activities` continua obrigatório (1..200)** e vira `activityTarget`; não omita nem presuma um alvo se o usuário não o informou. Para escolha web, ofereça `memoring plan next <planId> --web --json`: nasce só um handoff `GOAL` com `payload: {}` e URL `/planning/<planId>?goalDraft=<uuid>`. Aguarde `plan review <uuid> --wait --json` chegar a REVIEWED e rode `memoring plan next <planId> --draft <uuid> --json`; a CLI confere plano, tipo, status e `activityTarget` antes de criar, apagando o rascunho só após sucesso. `current <planId>` lê a Meta ativa e seus IDs de atividade.
67
+ 7. Execute com `start|complete|postpone|dismiss|cancel <activityId>` (`cancel` = cancelar definitivamente: encerra a atividade na Meta e o tipo não volta nas próximas — só use quando o usuário pedir redução de escopo). Resultado manual: `result <activityId> --total N --correct N [--seconds N] [--errors FORGETTING:2,INATTENTION:1]`; total/correct são obrigatórios, correct ≤ total e a soma classificada ≤ total-correct. Ao regravar sem `--seconds`/`--errors`, a CLI omite esses campos e preserva os valores atuais; no contrato do backend, `errors` omitido preserva a classificação e `errors: []` limpa. Motivos: `UNKNOWN`, `FORGETTING`, `CONCEPTUAL_CONFUSION`, `INATTENTION`, `INTERPRETATION`, `TIME_SHORTAGE`, com aliases PT-BR. `tec-result <activityId>` apenas importa o resultado já disponível no caderno TEC (addon necessário) — não faz sync nem login.
68
+ 8. Acompanhe com `performance|error-patterns|comparison|journey <planId>` e `coordinates`. Provas: `plan exams list`, `plan exams create --file prova.json` (contrato no schema) e `plan exams delete <examId>` só quando o usuário pedir a exclusão. Create de prova também aceita `--name --date YYYY-MM-DD --total --correct [--plan-id N] [--subjects '<array JSON>']`.
69
+
70
+ Todos os comandos aceitam `--json`, sem texto humano no stdout. Leia os campos estruturados em vez de parsear texto: handoffs web usam `{draft, reviewUrl, nextCommand}`; `review` devolve o rascunho completo com `status` e `payload`; o `analyze` sem web e os recibos de delete são exceções de formato já descritas. As operações usam somente endpoints autenticados e user-scoped; tokens nunca entram nas URLs de revisão.
71
+
72
+ ## Vários lotes e proveniência
73
+
74
+ - Antes de enviar referências, rode `stage validate --file lote.json --pdf-map map.json`: ele usa o mesmo localizador do app e rejeita quotes inexistentes/ambíguos, sem escrever nada.
75
+ - Para várias atividades, use `batch check` antes e `batch run --resume` depois. O manifesto tem `version`, `pdfMap` e `activities` com `id`, `batchFile`, `deckName`, `expectedReferences` e `generationFile` opcional. O batch exige baralho existente e único; crie a hierarquia antes com `deck create`.
76
+ - O state fica em `<plan>.state.json`. O resume usa IDs do state; se ele for perdido após um POST, o replay recupera a resposta original. O dedupe dura 7 dias — não trate reenvio antigo como idempotente.
77
+ - `generationFile` registra provider/modelo solicitado e resolvido, versões e métricas. Só preencha o que a chamada à LLM realmente informou; nunca presuma o modelo a partir do agente. O aceite continua sendo decisão do usuário.
78
+ - O piloto `scripts/notes/gemini-pdf-to-batch.mjs` apenas gera JSON e o valida localmente. Depois siga `batch check`/`run`; ele não cria nem aceita itens.
79
+
80
+ ## Rascunhos do Drive → flashcards (`memoring scratch`)
81
+
82
+ Quando o usuário pedir flashcards/notas de um **rascunho do caderno manuscrito** (que vive no Google Drive do app), use o pacote `scratch` — 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, o comando falha com 409 e você deve orientar o usuário a conectar pelo app (Caderno → vincular Drive).
83
+
84
+ 1. **`memoring scratch list`** — encontre o rascunho pelo **caminho** da biblioteca (ex.: `Direito Administrativo/Aula 02`); use o caminho (ou o `driveFileId`) no download. Default lista só cadernos; `--all` inclui rascunhos de card.
85
+ 2. **`memoring scratch download "<caminho>" [--out <dir>]`** — aceita o caminho completo mostrado no list (acentos, maiúsculas e espaços livres — `"direito administrativo/ AULA 02"` funciona) ou o `driveFileId`. Baixa o `.scratch.json` e, se o rascunho tem PDF de fundo, o PDF junto (nome legível do arquivo; asset `<hash>.pdf` como fallback). A saída diz quantas páginas o PDF tem (≈ **2.700 tokens/página densa**) e, acima de ~30 páginas, já sugere o recorte. Caminho não encontrado ou ambíguo → o erro lista os candidatos: refine o caminho ou use o `driveFileId` — nunca chute.
86
+ 3. **Interprete a faixa pedida em linguagem natural** — "páginas 20 a 35", "só o capítulo 2", "da metade em diante". O recorte é OBRIGATÓRIO quando o material é grosso: **acima de ~60 páginas, NUNCA gere do PDF inteiro**. Quando a faixa for vaga ("o capítulo 2"), estime as páginas (índice/sumário do material) e **peça confirmação do usuário antes de gerar**.
87
+ 4. **`memoring scratch extract <pdf> --pages A-B`** — gera o sub-PDF `<nome>-pA-B.pdf` com a estimativa de tokens da faixa. O comando **força a decisão**: PDF com mais de ~30 páginas sem `--pages` só sai com `--all` explícito — um erro aí NÃO é falha, é a trava de contexto funcionando; recorte e siga. **PDF digitalizado em texto?** Acrescente `--text`: o comando extrai o texto puro da faixa (páginas separadas por `--- Página N ---`) no stdout — muito mais barato que "ler" o PDF. PDF 100% escaneado (só imagem) sai vazio no `--text`: nesse caso gere do sub-PDF recortado.
88
+ 5. **Gere os cartões APENAS do recorte** — leia o PDF recortado (não o original), aplique o `stage schema` logado (limites, regras de qualidade, instruções custom) e a taxonomia: `NORMAL` (flashcard frente/verso), `ONE_SHOT` (resposta de um termo — leis, datas, prazos, siglas), `QUESTION` (questões prontas do material — aproveite-as), `NOTE` (síntese em markdown). `conceptKey` no slug do conceito para o whiteboard agrupar.
89
+ **Vincule cada item à página do material:** inclua `materialReferences: [{ "driveFileId": "<id do `scratch list --json`>", "page": <página visível 1-based>, "quote": "<trecho LITERAL do material>", "excerpt": "<dica curta>" }]` — `page` é a posição 1-based no documento (a mesma numeração do `--pages` do recorte, não um índice 0-based). Ao aceitar o item, o link abre o PDF **na página** e destaca o quote; para PDF escaneado/sem texto use `"region": { "x", "y", "w", "h" }` (0..1) em vez de quote; `source` continua sendo o texto livre da origem.
90
+ 6. **`memoring stage create --file itens.json`** e reporte: o que foi estagiado, quantos itens, os `ids` e que o usuário revisa em `/cards/inbox` — o aceite continua sendo decisão DELE.
91
+
47
92
  ## O contrato (a fonte da verdade é `stage schema`)
48
93
 
49
- - `type`: `NOTE` (nota, exige `content` markdown; `NORMAL` legado é aceito e mapeado para `NOTE`), `FLASHCARD` ou `ONE_SHOT` (exigem `front` + `back`) ou `QUESTION` (exige `question` — ver abaixo).
94
+ - `type`: `NOTE` (nota, exige `content` markdown), `NORMAL` (flashcard frente/verso) ou `ONE_SHOT` (exigem `front` + `back`) ou `QUESTION` (exige `question` — ver abaixo). `FLASHCARD` legado é aceito e mapeado para `NORMAL` no backend.
50
95
  - `title` sempre obrigatório; lote de 1 a 50 itens, all-or-nothing — qualquer item inválido rejeita o lote inteiro.
51
96
  - `deckName` **OBRIGATÓRIO** (ou `--deck` no create como padrão do lote): cartão não fica sem baralho. Use o nome de um baralho de `memoring deck list`; nome inexistente é criado no envio como **PENDING** (oculto no app até o 1º item aceite). Espelhe a hierarquia do material: matéria = baralho raiz; tema da aula/capítulo = sub-baralho criado ANTES com `deck create "<tema>" --parent "<matéria>" --pending` (o `stage create` só cria raiz pelo nome). O primeiro item aceito ativa o baralho e os ancestrais pendentes.
52
- - `ONE_SHOT` = pergunta de resposta única (UMA palavra ou termo curto, texto puro). Ative o tipo sempre que o cobrável for recall de termo puro — nome de princípio/instituto, termo em latim, sigla, número de lei/artigo, data, prazo. Resposta cabe num termo → `ONE_SHOT`; exige frase ou explicação → `FLASHCARD`.
97
+ - `ONE_SHOT` = pergunta de resposta única (UMA palavra ou termo curto, texto puro). Ative o tipo sempre que o cobrável for recall de termo puro — nome de princípio/instituto, termo em latim, sigla, número de lei/artigo, data, prazo. Resposta cabe num termo → `ONE_SHOT`; exige frase ou explicação → `NORMAL`.
53
98
  - `QUESTION` = questão objetiva para verificação: `question.kind` (`MULTIPLA_ESCOLHA` | `CERTO_ERRADO`), `question.statementHtml` (enunciado), `question.correctAlternative` (gabarito, int 1-based; em CERTO_ERRADO, 1 = Certo, 2 = Errado), `question.alternatives` (2 a 5 textos — só em MULTIPLA_ESCOLHA), `question.commentHtml` (explicação do gabarito). **Se o material contiver questões prontas (provas, simulados, listas C/E), APROVEITE-AS como QUESTION** preservando enunciado e gabarito — não as achate em flashcards.
54
- - `conceptKey` = slug minúsculo (letras, números e hífens, até 80 chars) que identifica o CONCEITO do item. Opcional por compatibilidade, mas **fortemente recomendado em FLASHCARD/ONE_SHOT/QUESTION**. A regra é simples: cartões sobre o MESMO conceito compartilham a MESMA chave — ex.: um flashcard e uma questão sobre o art. 4º do CTN usam ambos `"trib-art-4"` — e conceitos DIFERENTES usam chaves diferentes, nunca reuse uma chave para outro conceito. No app, o whiteboard do Rascunho usa a chave para AGRUPAR por conceito: ângulos do mesmo conceito (flashcard, questão, 1-shot) contam como uma frente só nas rodadas guiadas — sem chave, cada cartão vira uma frente individual. Chaves curtas e estáveis, com contexto da matéria (ex.: `trib-ctn-art-3`, `const-cf88-art-3`), evitando chaves genéricas que fundam conceitos distintos.
99
+ - `conceptKey` = slug minúsculo (letras, números e hífens, até 80 chars) que identifica o CONCEITO do item. Opcional por compatibilidade, mas **fortemente recomendado em NORMAL/ONE_SHOT/QUESTION**. A regra é simples: cartões sobre o MESMO conceito compartilham a MESMA chave — ex.: um flashcard e uma questão sobre o art. 4º do CTN usam ambos `"trib-art-4"` — e conceitos DIFERENTES usam chaves diferentes, nunca reuse uma chave para outro conceito. No app, o whiteboard do Rascunho usa a chave para AGRUPAR por conceito: ângulos do mesmo conceito (flashcard, questão, 1-shot) contam como uma frente só nas rodadas guiadas — sem chave, cada cartão vira uma frente individual. Chaves curtas e estáveis, com contexto da matéria (ex.: `trib-ctn-art-3`, `const-cf88-art-3`), evitando chaves genéricas que fundam conceitos distintos.
55
100
  - Preencha `source` com a origem real do material (nome do arquivo/pasta) — não use o default quando souber a origem.
101
+ - `materialReferences` (opcional): referências ao material de origem que vinculam o item à **página do PDF** e destacam o trecho exato usado no card — ao aceitar, o link do item abre o material **na página** com o destaque. Cada entrada é `{ "driveFileId", "page", "excerpt?", "quote?", "contextBefore?", "contextAfter?", "region?" }`: `driveFileId` vem do `memoring scratch list --json` (id do arquivo no Drive), `page` é a posição **1-based** no documento (a CLI converte para 0-based no envio; a validação local rejeita page < 1 ou não inteira antes de qualquer POST). A regra do destaque:
102
+ - **PDF com texto → `quote` literal** (até 1000 chars): é o que o app localiza e destaca. `contextBefore`/`contextAfter` (até 500 cada) desempatam ocorrências repetidas.
103
+ - **PDF escaneado/sem texto → `region`** (`{ x, y, w, h }` normalizada 0..1, origem topo-esquerdo; números finitos, `w/h` > 0, `x+w <= 1`, `y+h <= 1`): selecione a região do recorte em vez de quote.
104
+ - **`excerpt` é só uma dica curta** (até 500) — sozinho NÃO gera destaque.
105
+ Referência já criada com quote/contextos errados? Não reenvie o lote: `memoring reference update <noteId> <referenceId> --file parcial.json` faz merge (campo ausente mantém; `region: null` limpa). Não substitui `source` (texto livre) — use os dois juntos.
56
106
  - `subjectName`/`subtopicName` quando o material tiver matéria/subtópico claros; `tags` curtas e em minúsculas.
57
107
  - NUNCA invente campos fora do contrato — o servidor rejeita o lote inteiro.
58
108
 
@@ -67,7 +117,7 @@ Flags globais: `--json` (saída legível por máquina), `--no-color`, `--api-bas
67
117
 
68
118
  ## Qualidade dos flashcards (o padrão que o app espera)
69
119
 
70
- Cartões atômicos: UMA ideia por cartão — se um conceito tem 3 partes cobráveis, crie 3 cartões, nunca um cartão com lista como resposta. A frente é uma pergunta direta que força LEMBRAR (não reconhecer). A resposta é auto-contida (faz sentido sem o material) e curta. Pares confundíveis (prazos, siglas, exceções) levam na frente o traço que os diferencia. Não invente conteúdo que não esteja no material. Distribua os tipos: um bom lote usa NOTE, FLASHCARD, ONE_SHOT e QUESTION conforme o material pedir — não zere um tipo por timidez (termos, leis, datas e prazos cobráveis viram ONE_SHOT).
120
+ Cartões atômicos: UMA ideia por cartão — se um conceito tem 3 partes cobráveis, crie 3 cartões, nunca um cartão com lista como resposta. A frente é uma pergunta direta que força LEMBRAR (não reconhecer). A resposta é auto-contida (faz sentido sem o material) e curta. Pares confundíveis (prazos, siglas, exceções) levam na frente o traço que os diferencia. Não invente conteúdo que não esteja no material. Distribua os tipos: um bom lote usa NOTE, NORMAL, ONE_SHOT e QUESTION conforme o material pedir — não zere um tipo por timidez (termos, leis, datas e prazos cobráveis viram ONE_SHOT).
71
121
 
72
122
  ## Armadilhas
73
123