@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.
@@ -2,14 +2,20 @@ import { createHash } from 'node:crypto';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { apiFetch, ApiDedupedError, ApiError } from '../api.js';
4
4
  import { hasSession } from '../config.js';
5
+ import { validateBatchReferences } from '../pdf/batchValidation.js';
6
+ import { PdfDocumentCache } from '../pdf/documentCache.js';
5
7
  import { createDeck, listDecks, resolveDeck } from './deck.js';
8
+ import { MATERIAL_REFERENCE_CONTRACT, toWireMaterialReference, validateMaterialReferences, } from './materialReferences.js';
6
9
  import { dim, fail, green, printJson, stringFlag, yellow } from './shared.js';
7
- const TYPES = ['NOTE', 'FLASHCARD', 'ONE_SHOT', 'QUESTION'];
8
- // Retrocompat: itens.json antigos com "type": "NORMAL" são normalizados para "NOTE".
9
- const LEGACY_TYPE_ALIASES = { NORMAL: 'NOTE' };
10
+ const TYPES = ['NOTE', 'NORMAL', 'ONE_SHOT', 'QUESTION'];
11
+ // Retrocompat: itens.json antigos com "type": "FLASHCARD" são normalizados para "NORMAL"
12
+ // (rename da espécie V238 — o backend também aceita o token legado na entrada).
13
+ const LEGACY_TYPE_ALIASES = { FLASHCARD: 'NORMAL' };
10
14
  const MAX_ITEMS = 50;
11
15
  const DEFAULT_SOURCE = 'Importação assistida (CLI)';
12
16
  const QUESTION_KINDS = ['MULTIPLA_ESCOLHA', 'CERTO_ERRADO'];
17
+ /** MaterialReferenceInput e validação de referências vivem em materialReferences.ts
18
+ * (fronteira única usada pelo stage e pelo `memoring reference`). */
13
19
  /**
14
20
  * Slug minúsculo (letras, números, hífens, até 80 chars) que identifica o conceito
15
21
  * do item. Segmentos alfanuméricos separados por hífen único — sem hífen no
@@ -73,13 +79,13 @@ async function fetchStoredCaps() {
73
79
  /** Flags --max-<tipo>: no `stage create` limitam o lote; no `stage caps` gravam a preferência. */
74
80
  const MAX_FLAG_BY_TYPE = {
75
81
  NOTE: 'max-notes',
76
- FLASHCARD: 'max-flashcards',
82
+ NORMAL: 'max-flashcards',
77
83
  ONE_SHOT: 'max-one-shots',
78
84
  QUESTION: 'max-questions',
79
85
  };
80
86
  const TYPE_LABEL = {
81
87
  NOTE: 'notas',
82
- FLASHCARD: 'flashcards',
88
+ NORMAL: 'flashcards',
83
89
  ONE_SHOT: '1-shots',
84
90
  QUESTION: 'questões',
85
91
  };
@@ -99,7 +105,7 @@ function enforceTypeCaps(items, flags, stored) {
99
105
  const storedByType = stored
100
106
  ? {
101
107
  NOTE: stored.maxNotes,
102
- FLASHCARD: stored.maxFlashcards,
108
+ NORMAL: stored.maxFlashcards,
103
109
  ONE_SHOT: stored.maxOneShots,
104
110
  QUESTION: stored.maxQuestions,
105
111
  }
@@ -146,23 +152,25 @@ const QUESTION_CONTRACT = {
146
152
  const SCHEMA_JSON = {
147
153
  description: 'Lote de 1..50 itens para POST /api/notes/review-inbox. All-or-nothing: qualquer item inválido rejeita o lote. Os itens nascem PENDING_REVIEW — o usuário aceita/edita/recusa em /cards/inbox.',
148
154
  item: {
149
- type: 'NOTE | FLASHCARD | ONE_SHOT | QUESTION (obrigatório; NORMAL legado é aceito e mapeado para NOTE)',
155
+ type: 'NOTE | NORMAL | ONE_SHOT | QUESTION (obrigatório; FLASHCARD legado é aceito e mapeado para NORMAL)',
150
156
  title: 'string (obrigatório)',
151
157
  content: 'string — corpo da nota em markdown (obrigatório quando type=NOTE)',
152
- front: 'string — pergunta (obrigatório quando type=FLASHCARD|ONE_SHOT)',
153
- back: 'string — resposta (obrigatório quando type=FLASHCARD|ONE_SHOT)',
158
+ front: 'string — pergunta (obrigatório quando type=NORMAL|ONE_SHOT)',
159
+ back: 'string — resposta (obrigatório quando type=NORMAL|ONE_SHOT)',
154
160
  subjectName: 'string? — matéria (ex.: "Direito Constitucional")',
155
161
  subtopicName: 'string? — subtópico',
156
162
  tags: 'string[]?',
157
- conceptKey: `string? — conceito do item em slug minúsculo (letras/números/hífens, até ${CONCEPT_KEY_MAX} chars). Opcional por compatibilidade, mas FORTEMENTE RECOMENDADO em FLASHCARD/ONE_SHOT/QUESTION: cartões sobre o MESMO conceito compartilham a chave (ex.: "trib-art-4" num flashcard E numa questão do art. 4º do CTN — o whiteboard do app agrupa por conceito) e conceitos DIFERENTES usam chaves diferentes — nunca reuse a chave para outro conceito`,
163
+ conceptKey: `string? — conceito do item em slug minúsculo (letras/números/hífens, até ${CONCEPT_KEY_MAX} chars). Opcional por compatibilidade, mas FORTEMENTE RECOMENDADO em NORMAL/ONE_SHOT/QUESTION: cartões sobre o MESMO conceito compartilham a chave (ex.: "trib-art-4" num flashcard E numa questão do art. 4º do CTN — o whiteboard do app agrupa por conceito) e conceitos DIFERENTES usam chaves diferentes — nunca reuse a chave para outro conceito`,
158
164
  deckName: 'string (OBRIGATÓRIO — cartão não fica sem baralho: consulte `memoring deck list`, reutilize um existente ou crie com `memoring deck create`; nome inexistente é criado no envio como PENDING — oculto no app até o 1º item aceite, que ativa o baralho e os ancestrais pendentes. A flag --deck do create vale como padrão do lote)',
159
165
  question: 'objeto? — snapshot da questão (obrigatório quando type=QUESTION; contrato em questionContract)',
160
166
  source: `string? — origem do material (default: "${DEFAULT_SOURCE}")`,
167
+ materialReferences: 'array? — referências ao material de origem (contrato em materialReferenceContract): vinculam o item à página do PDF no app — ao aceitar, o link abre o material NA PÁGINA e destaca o trecho exato (quote para PDF com texto, region para escaneado). Não confunda com source (texto livre) — use os dois juntos',
161
168
  },
162
169
  questionContract: QUESTION_CONTRACT,
170
+ materialReferenceContract: MATERIAL_REFERENCE_CONTRACT,
163
171
  example: [
164
172
  {
165
- type: 'FLASHCARD',
173
+ type: 'NORMAL',
166
174
  title: 'CF/88 — objetivos fundamentais',
167
175
  front: 'Quais são os quatro objetivos fundamentais da República (art. 3º da CF/88)?',
168
176
  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.',
@@ -171,6 +179,15 @@ const SCHEMA_JSON = {
171
179
  tags: ['cf88', 'art-3'],
172
180
  conceptKey: 'const-cf88-art-3',
173
181
  source: 'Apostila constitucional.pdf',
182
+ materialReferences: [
183
+ {
184
+ driveFileId: '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms',
185
+ page: 35,
186
+ excerpt: 'Objetivos fundamentais (art. 3º, CF/88).',
187
+ quote: 'Art. 3º — Constituem objetivos fundamentais da República Federativa do Brasil construir uma sociedade livre, justa e solidária [...].',
188
+ contextBefore: 'CAPÍTULO I — DOS PRINCÍPIOS FUNDAMENTAIS',
189
+ },
190
+ ],
174
191
  },
175
192
  {
176
193
  type: 'QUESTION',
@@ -188,7 +205,7 @@ const SCHEMA_JSON = {
188
205
  },
189
206
  },
190
207
  {
191
- type: 'FLASHCARD',
208
+ type: 'NORMAL',
192
209
  title: 'CTN art. 4º — territorialidade',
193
210
  front: 'Qual a regra de incidência dos tributos quanto à aplicação da lei (art. 4º, caput, do CTN)?',
194
211
  back: 'Regra geral: a lei que institui o tributo aplica-se a todos os fatos geradores ocorridos no território nacional.',
@@ -218,10 +235,10 @@ const SCHEMA_JSON = {
218
235
  };
219
236
  const SCHEMA_TEXT = `Contrato dos itens da caixa de revisão (POST /api/notes/review-inbox):
220
237
 
221
- type NOTE | FLASHCARD | ONE_SHOT | QUESTION (obrigatório; NORMAL legado é mapeado para NOTE)
238
+ type NOTE | NORMAL | ONE_SHOT | QUESTION (obrigatório; FLASHCARD legado é mapeado para NORMAL)
222
239
  title string (obrigatório)
223
240
  content string — corpo da nota (markdown) (obrigatório quando type=NOTE)
224
- front, back string — pergunta e resposta (obrigatório quando type=FLASHCARD|ONE_SHOT)
241
+ front, back string — pergunta e resposta (obrigatório quando type=NORMAL|ONE_SHOT)
225
242
  question objeto — snapshot da questão (obrigatório quando type=QUESTION)
226
243
  deckName string — baralho de destino (OBRIGATÓRIO — cartão não fica sem baralho:
227
244
  veja \`memoring deck list\`, reutilize ou crie
@@ -237,13 +254,37 @@ const SCHEMA_TEXT = `Contrato dos itens da caixa de revisão (POST /api/notes/re
237
254
  tags? string[]
238
255
  conceptKey? string — conceito do item em slug minúsculo (letras, números
239
256
  e hífens, até 80 chars). Opcional por compatibilidade, mas
240
- FORTEMENTE RECOMENDADO em FLASHCARD/ONE_SHOT/QUESTION: cartões
257
+ FORTEMENTE RECOMENDADO em NORMAL/ONE_SHOT/QUESTION: cartões
241
258
  sobre o MESMO conceito compartilham a MESMA chave — ex.:
242
259
  "trib-art-4" num flashcard E numa questão do art. 4º do CTN
243
260
  (o whiteboard do app agrupa por conceito, não por cartão) —
244
261
  e conceitos DIFERENTES usam chaves diferentes: nunca reuse
245
262
  uma chave para outro conceito
246
263
  source? string — origem do material (default: "${DEFAULT_SOURCE}")
264
+ materialReferences? array — referências ao material de origem (opcional):
265
+ vinculam o item à página do PDF no app; ao aceitar, o link abre o material
266
+ NA PÁGINA e destaca o trecho exato. Não confunda com source (texto livre) —
267
+ use os dois juntos.
268
+ [ { driveFileId, page, excerpt?, quote?, contextBefore?, contextAfter?, region? } ]
269
+ driveFileId string (obrigatório) — id do arquivo no Drive
270
+ (\`memoring scratch list --json\`); trim, não vazio, <= 255
271
+ page int >= 1 (obrigatório) — posição 1-based no documento
272
+ (mesma numeração de \`scratch extract --pages\`); o POST
273
+ converte para pageIndex 0-based — o campo page nunca vai no fio
274
+ excerpt? string — dica CURTA do trecho que originou o item (trim,
275
+ <= 500; vazio = omitido). É SÓ um rótulo humano: NÃO gera
276
+ destaque no PDF
277
+ quote? string — trecho LITERAL do material (trim, <= 1000; vazio =
278
+ omitido). RECOMENDADO para PDF com texto: o app localiza e
279
+ destaca esse trecho na página
280
+ contextBefore?/contextAfter? string — texto colado antes/depois do quote no
281
+ material (trim, <= 500 cada) — desempata ocorrências repetidas
282
+ region? objeto { x, y, w, h } — região visual da página, normalizada
283
+ 0..1 com origem no topo-esquerdo (x,y) e tamanho (w,h); números
284
+ FINITOS, w/h > 0, contida no quadrado 0..1 (x+w <= 1, y+h <= 1).
285
+ Para PDF ESCANEADO/sem texto, use a região em vez de quote.
286
+ PDF com texto → quote literal; escaneado → região; excerpt é
287
+ só a dica curta — sozinho ele NÃO destaca nada
247
288
 
248
289
  Snapshot de QUESTION (questões manuais — sem identidade TEC):
249
290
  kind MULTIPLA_ESCOLHA | CERTO_ERRADO (obrigatório)
@@ -257,7 +298,7 @@ Snapshot de QUESTION (questões manuais — sem identidade TEC):
257
298
  Lote: 1 a ${MAX_ITEMS} itens, all-or-nothing — qualquer item inválido rejeita o lote inteiro.
258
299
  Os itens nascem PENDING_REVIEW: o usuário aceita, edita ou recusa em /cards/inbox
259
300
  (agentes podem editar pendentes com \`memoring inbox update <id>\`).
260
- Dica: FLASHCARD entra na fila de revisão espaçada (FSRS); ONE_SHOT é pergunta de resposta única
301
+ Dica: NORMAL entra na fila de revisão espaçada (FSRS); ONE_SHOT é pergunta de resposta única
261
302
  (termo curto — ideal para princípios, leis, datas, prazos, siglas, termos em latim);
262
303
  QUESTION treina verificação objetiva (aproveite questões prontas do material).
263
304
  Limites por tipo: a preferência do usuário (abaixo, quando logado) vale por padrão;
@@ -312,44 +353,6 @@ async function resolveBatchDecks(items, flags) {
312
353
  }
313
354
  return { deckIds: items.map((it) => idByRef.get(refOf(it) ?? '') ?? null), createdNames };
314
355
  }
315
- /**
316
- * Atribui o baralho aos itens recém-estagiados (o POST da caixa de revisão não tem
317
- * deckId — a atribuição é um PUT /notes/{id} por item, com o payload completo do
318
- * item + deckId). Devolve os rótulos dos itens cuja atribuição falhou.
319
- */
320
- async function assignDecks(items, created, deckIds) {
321
- const failures = [];
322
- for (let i = 0; i < created.length; i++) {
323
- const deckId = deckIds[i];
324
- if (deckId === null || deckId === undefined) {
325
- continue;
326
- }
327
- const item = items[i];
328
- try {
329
- await apiFetch(`/notes/${created[i].id}`, {
330
- method: 'PUT',
331
- body: {
332
- type: item.type,
333
- title: item.title,
334
- content: item.content ?? null,
335
- front: item.front ?? null,
336
- back: item.back ?? null,
337
- subjectName: item.subjectName ?? null,
338
- subtopicName: item.subtopicName ?? null,
339
- tags: item.tags ?? [],
340
- source: item.source ?? null,
341
- conceptKey: item.conceptKey ?? null,
342
- deckId,
343
- question: item.question ?? null,
344
- },
345
- });
346
- }
347
- catch {
348
- failures.push(`#${created[i].id} (${item.title})`);
349
- }
350
- }
351
- return failures;
352
- }
353
356
  async function readStdin() {
354
357
  const chunks = [];
355
358
  for await (const chunk of process.stdin) {
@@ -419,6 +422,10 @@ function validateQuestion(raw, where) {
419
422
  ...subjects,
420
423
  };
421
424
  }
425
+ /**
426
+ * Validação das referências de material vive em materialReferences.ts
427
+ * (validateMaterialReferences — fronteira única com o `memoring reference`).
428
+ */
422
429
  function validateItems(raw) {
423
430
  const items = Array.isArray(raw) ? raw : raw?.items;
424
431
  if (!Array.isArray(items) || items.length === 0) {
@@ -445,7 +452,7 @@ function validateItems(raw) {
445
452
  if (it.type === 'NOTE' && (typeof it.content !== 'string' || it.content.trim() === '')) {
446
453
  fail(`${where} (${it.title}): type=NOTE exige content.`);
447
454
  }
448
- if ((it.type === 'FLASHCARD' || it.type === 'ONE_SHOT') &&
455
+ if ((it.type === 'NORMAL' || it.type === 'ONE_SHOT') &&
449
456
  (typeof it.front !== 'string' || it.front.trim() === '' || typeof it.back !== 'string' || it.back.trim() === '')) {
450
457
  fail(`${where} (${it.title}): type=${it.type} exige front e back.`);
451
458
  }
@@ -469,20 +476,67 @@ function validateItems(raw) {
469
476
  conceptKey = key;
470
477
  }
471
478
  }
472
- return {
479
+ const materialReferences = validateMaterialReferences(it.materialReferences, `${where} (${it.title})`);
480
+ const stageItem = {
473
481
  ...it,
474
482
  conceptKey,
475
483
  source: typeof it.source === 'string' && it.source.trim() !== '' ? it.source : DEFAULT_SOURCE,
476
484
  };
485
+ if (materialReferences) {
486
+ stageItem.materialReferences = materialReferences;
487
+ }
488
+ else {
489
+ delete stageItem.materialReferences;
490
+ }
491
+ return stageItem;
477
492
  });
478
493
  }
494
+ /** Item no formato do POST /notes/review-inbox, com baralho e referências na mesma transação. */
495
+ function toReviewInboxItem({ deckName, materialReferences, ...rest }, deckId) {
496
+ void deckName;
497
+ return {
498
+ ...rest,
499
+ deckId,
500
+ ...(materialReferences && materialReferences.length > 0
501
+ ? { materialReferences: materialReferences.map(toWireMaterialReference) }
502
+ : {}),
503
+ };
504
+ }
479
505
  /**
480
506
  * Idempotência do lote: UUID derivado do conteúdo bruto. Re-rodar o mesmo arquivo
481
507
  * após uma falha ambígua (timeout pós-commit) reenvia o mesmo X-Client-Request-Id —
482
508
  * o servidor devolve o replay deduplicado em vez de duplicar os itens.
483
509
  */
484
- function batchRequestId(raw) {
485
- const digest = createHash('sha256').update(raw).digest();
510
+ function readGeneration(flags) {
511
+ const file = stringFlag(flags, 'generation-file');
512
+ if (!file) {
513
+ return null;
514
+ }
515
+ const raw = readFileSync(file, 'utf8');
516
+ let value;
517
+ try {
518
+ value = JSON.parse(raw);
519
+ }
520
+ catch (error) {
521
+ fail(`generation-file inválido: ${error.message}`);
522
+ }
523
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
524
+ fail('generation-file deve conter um objeto JSON com provider e requestedModel.');
525
+ }
526
+ const generation = value;
527
+ for (const field of ['provider', 'requestedModel']) {
528
+ if (typeof generation[field] !== 'string' || generation[field].trim() === '') {
529
+ fail(`generation-file: ${field} é obrigatório e não pode ser vazio.`);
530
+ }
531
+ }
532
+ return { value: generation, raw };
533
+ }
534
+ function batchRequestId(raw, generationRaw = '') {
535
+ const hash = createHash('sha256').update(raw);
536
+ if (generationRaw) {
537
+ hash.update('\0').update(generationRaw);
538
+ }
539
+ const digest = hash.digest();
486
540
  digest[6] = (digest[6] & 0x0f) | 0x80;
487
541
  digest[8] = (digest[8] & 0x3f) | 0x80;
488
542
  const hex = digest.subarray(0, 16).toString('hex');
@@ -500,6 +554,61 @@ const NO_SESSION_WARNING = 'SEM SESSÃO: userCaps, qualityRules e customInstruct
500
554
  export async function stageCommand(parsed) {
501
555
  const [sub] = parsed._;
502
556
  switch (sub) {
557
+ case 'validate': {
558
+ const file = stringFlag(parsed.flags, 'file');
559
+ const pdfMapFile = stringFlag(parsed.flags, 'pdf-map');
560
+ if (!file || file === '-' || !pdfMapFile) {
561
+ fail('informe --file lote.json e --pdf-map pdf-map.json (a validação não envia nada ao servidor).');
562
+ }
563
+ let items;
564
+ let pdfMap;
565
+ try {
566
+ items = validateItems(JSON.parse(readFileSync(file, 'utf8')));
567
+ const rawMap = JSON.parse(readFileSync(pdfMapFile, 'utf8'));
568
+ if (typeof rawMap !== 'object' || rawMap === null || Array.isArray(rawMap)) {
569
+ throw new Error('esperado objeto driveFileId → caminho');
570
+ }
571
+ pdfMap = Object.fromEntries(Object.entries(rawMap).map(([key, value]) => {
572
+ if (typeof value !== 'string' || value.trim() === '') {
573
+ throw new Error(`caminho inválido para ${key}`);
574
+ }
575
+ return [key, value];
576
+ }));
577
+ }
578
+ catch (error) {
579
+ if (error instanceof Error && error.name === 'CliError') {
580
+ throw error;
581
+ }
582
+ fail(`não consegui ler o lote/pdf-map: ${error.message}`);
583
+ }
584
+ enforceDeck(items, parsed.flags);
585
+ enforceTypeCaps(items, parsed.flags, await fetchStoredCaps());
586
+ const cache = new PdfDocumentCache();
587
+ let result;
588
+ try {
589
+ result = await validateBatchReferences(items, pdfMap, cache);
590
+ }
591
+ finally {
592
+ await cache.close();
593
+ }
594
+ if (parsed.flags.json) {
595
+ printJson(result);
596
+ }
597
+ else {
598
+ for (const warning of result.warnings) {
599
+ process.stdout.write(yellow(`aviso: ${warning}\n`));
600
+ }
601
+ for (const error of result.errors) {
602
+ process.stdout.write(`erro: ${error}\n`);
603
+ }
604
+ process.stdout.write(`${result.valid ? green('Lote válido') : 'Lote inválido'} — ${result.stats.items} item(ns), ` +
605
+ `${result.stats.references} referência(s), ${result.stats.validatedHighlights} destaque(s) conferido(s).\n`);
606
+ }
607
+ if (!result.valid) {
608
+ process.exitCode = 1;
609
+ }
610
+ return;
611
+ }
503
612
  case 'schema': {
504
613
  const authenticated = hasSession();
505
614
  const [caps, guidance] = await Promise.all([fetchStoredCaps(), fetchAgentGuidance()]);
@@ -610,45 +719,73 @@ export async function stageCommand(parsed) {
610
719
  if (batchDecks.createdNames.length > 0 && !parsed.flags.json) {
611
720
  process.stdout.write(dim(`Baralho(s) criado(s) como PENDING (ocultos até o 1º aceite): ${batchDecks.createdNames.join(', ')}.\n`));
612
721
  }
722
+ const generation = readGeneration(parsed.flags);
613
723
  let created;
724
+ let importRunId = null;
725
+ let deduplicated = false;
614
726
  try {
615
727
  const res = (await apiFetch('/notes/review-inbox', {
616
728
  method: 'POST',
617
- // deckName não faz parte do contrato do POST — o baralho é atribuído via PUT abaixo
618
- body: { items: items.map(({ deckName, ...rest }) => (void deckName, rest)) },
619
- requestId: batchRequestId(raw),
729
+ body: {
730
+ items: items.map((item, index) => toReviewInboxItem(item, batchDecks.deckIds[index] ?? null)),
731
+ ...(generation ? { generation: generation.value } : {}),
732
+ },
733
+ requestId: batchRequestId(raw, generation?.raw),
620
734
  }));
621
735
  created = res.created;
736
+ importRunId = res.importRunId ?? null;
622
737
  }
623
738
  catch (e) {
624
739
  if (e instanceof ApiDedupedError) {
625
- if (parsed.flags.json) {
626
- printJson({ created: [], ids: [], deduplicated: true });
740
+ if (parsed.flags.replace) {
741
+ const warning = '--replace removeu os itens antes do replay; os IDs anteriores não são mais válidos. Altere o lote e reenvie.';
742
+ if (parsed.flags.json) {
743
+ printJson({ created: [], ids: [], deduplicated: true, warning });
744
+ }
745
+ else {
746
+ process.stdout.write(yellow(`Atenção: ${warning}\n`));
747
+ }
627
748
  return;
628
749
  }
629
- process.stdout.write(green('Este lote já foi enviado antes — nada foi duplicado.\n'));
630
- if (parsed.flags.replace) {
631
- process.stdout.write(yellow('Atenção: --replace removeu os itens pendentes antes do envio — a caixa ficou vazia. Altere o lote e reenvie.\n'));
750
+ if (!e.body) {
751
+ if (parsed.flags.json) {
752
+ printJson({ created: [], ids: [], deduplicated: true });
753
+ }
754
+ else {
755
+ process.stdout.write(green('Este lote já foi enviado antes — nada foi duplicado.\n'));
756
+ }
757
+ return;
758
+ }
759
+ try {
760
+ const replay = JSON.parse(e.body);
761
+ if (!Array.isArray(replay.created)) {
762
+ throw new Error('created ausente');
763
+ }
764
+ created = replay.created;
765
+ importRunId = replay.importRunId ?? null;
766
+ deduplicated = true;
767
+ }
768
+ catch {
769
+ fail('o replay deduplicado não trouxe uma resposta JSON válida; nenhum ID foi presumido.');
632
770
  }
633
- return;
634
771
  }
635
- if (e instanceof ApiError) {
772
+ else if (e instanceof ApiError) {
636
773
  fail(`o servidor rejeitou o lote (${e.status}): ${e.body}`);
637
774
  }
638
- throw e;
639
- }
640
- const deckFailures = await assignDecks(items, created, batchDecks.deckIds);
641
- if (deckFailures.length > 0) {
642
- process.stderr.write(yellow(`aviso: ${deckFailures.length} item(ns) ficaram sem baralho por erro no PUT — corrija com \`memoring inbox update <id> --deck\`: ${deckFailures.join(', ')}\n`));
775
+ else {
776
+ throw e;
777
+ }
643
778
  }
644
779
  if (parsed.flags.json) {
645
780
  printJson({
646
781
  created,
647
782
  ids: created.map((note) => note.id),
783
+ importRunId,
784
+ ...(deduplicated ? { deduplicated: true } : {}),
648
785
  decks: {
649
786
  created: batchDecks.createdNames,
650
- assigned: created.length - deckFailures.length - batchDecks.deckIds.filter((id) => id === null).length,
651
- failed: deckFailures,
787
+ assigned: created.length - batchDecks.deckIds.filter((id) => id === null).length,
788
+ failed: [],
652
789
  },
653
790
  });
654
791
  return;
@@ -660,14 +797,15 @@ export async function stageCommand(parsed) {
660
797
  return;
661
798
  }
662
799
  default:
663
- process.stdout.write('Uso: memoring stage <schema|caps|create> [--file itens.json] [--replace] [--json]\n' +
800
+ process.stdout.write('Uso: memoring stage <schema|validate|caps|create> [--file itens.json] [--replace] [--json]\n' +
664
801
  ' schema imprime o contrato JSON dos itens (para humanos e agentes)\n' +
802
+ ' validate valida lote e referências sem enviar (--file + --pdf-map)\n' +
665
803
  ' logado, inclui seus limites por tipo e as regras de qualidade —\n' +
666
804
  ' o agente deve ler o schema LOGADO, antes de gerar\n' +
667
805
  ' caps mostra/grava seus limites por tipo (--max-notes N | --max-flashcards N |\n' +
668
806
  ' --max-one-shots N | --max-questions N; 0 = não gerar o tipo)\n' +
669
807
  ' create envia o lote para a caixa de revisão (arquivo ou stdin)\n' +
670
- ' tipos: NOTE | FLASHCARD | ONE_SHOT | QUESTION\n' +
808
+ ' tipos: NOTE | NORMAL | ONE_SHOT | QUESTION\n' +
671
809
  ' baralho OBRIGATÓRIO: deckName por item ou --deck "<nome|id>" no lote\n' +
672
810
  ' (nomes inexistentes são criados como PENDING — ocultos no app até o\n' +
673
811
  ' primeiro aceite; veja `memoring deck list`)\n' +
package/dist/config.js CHANGED
@@ -6,6 +6,7 @@ const DEFAULTS = {
6
6
  realm: 'memoring',
7
7
  clientId: 'memoring-cli',
8
8
  apiBase: 'https://memoring.com.br/api',
9
+ webBase: 'https://memoring.com.br',
9
10
  };
10
11
  export function configDir() {
11
12
  if (process.env.MEMORING_CONFIG_HOME) {
@@ -35,6 +36,7 @@ export function loadConfig() {
35
36
  realm: process.env.MEMORING_KC_REALM ?? stored.realm ?? DEFAULTS.realm,
36
37
  clientId: process.env.MEMORING_KC_CLIENT ?? stored.clientId ?? DEFAULTS.clientId,
37
38
  apiBase: process.env.MEMORING_API_BASE ?? stored.apiBase ?? DEFAULTS.apiBase,
39
+ webBase: process.env.MEMORING_WEB_BASE ?? stored.webBase ?? DEFAULTS.webBase,
38
40
  accessToken: stored.accessToken,
39
41
  refreshToken: stored.refreshToken,
40
42
  accessTokenExpiresAt: stored.accessTokenExpiresAt,
package/dist/main.js CHANGED
@@ -2,14 +2,18 @@
2
2
  import { ApiError } from './api.js';
3
3
  import { CliError, setColorEnabled, yellow } from './output.js';
4
4
  import { authCommand } from './commands/auth.js';
5
+ import { batchCommand } from './commands/batch.js';
5
6
  import { deckCommand } from './commands/deck.js';
6
7
  import { inboxCommand } from './commands/inbox.js';
8
+ import { referenceCommand } from './commands/materialReferences.js';
9
+ import { planCommand } from './commands/plan.js';
10
+ import { scratchCommand } from './commands/scratch.js';
7
11
  import { stageCommand } from './commands/stage.js';
8
12
  import { updateCommand } from './commands/update.js';
9
13
  import { skillCommand } from './commands/skill.js';
10
14
  import { parseArgs as parse } from './commands/shared.js';
11
15
  import { newerVersionAvailable, scheduleUpdateCheck } from './update.js';
12
- const VERSION = '0.13.0';
16
+ const VERSION = '0.15.0';
13
17
  const HELP = `Memoring CLI — flashcards e notas por IA, direto do terminal.
14
18
 
15
19
  Uso: memoring <comando> [subcomando] [flags]
@@ -19,19 +23,44 @@ Comandos:
19
23
  stage schema contrato JSON dos itens da caixa de revisão
20
24
  (logado: inclui limites por tipo + regras de qualidade —
21
25
  agentes devem ler o schema LOGADO, antes de gerar)
26
+ stage validate --file f valida lote e referências localmente, sem enviar
27
+ --pdf-map map.json mapa driveFileId → PDF local
22
28
  stage create [--file f] envia itens (arquivo ou stdin) para a caixa de revisão
23
29
  [--replace] esvazia a caixa antes de enviar
30
+ [--generation-file f] proveniência provider/modelo do lote
24
31
  stage caps [--max-* N] seus limites por tipo (valem pro app e pros agentes)
32
+ batch check|run|status|verify plan.json
33
+ preflight, envio retomável e auditoria de atividades
25
34
  inbox list itens pendentes de revisão
26
35
  inbox accept <id>|accept-all aceita itens (entram nas listagens e na fila FSRS)
27
36
  inbox discard <id> descarta um item pendente
28
37
  inbox discard-all descarta todos os itens pendentes
29
38
  inbox update <id> edita um item pendente (--file parcial e/ou flags,
30
39
  inclui --deck <id|nome>)
40
+ reference list <noteId> referências de material de um card (fontes de estudo)
41
+ reference update <noteId> <refId> --file parcial.json
42
+ corrige excerpt/quote/contextos/região de uma referência
43
+ (merge: campo ausente mantém; null limpa; não troca
44
+ nota, arquivo nem página)
45
+ scratch list [--all] rascunhos do caderno no Google Drive (via proxy do
46
+ backend — conecte o Drive pelo app; a CLI não faz OAuth)
47
+ listados pelo CAMINHO da biblioteca (pasta/título)
48
+ scratch download <caminho|driveFileId> baixa o .scratch.json + o PDF de fundo
49
+ [--out <dir>] (se houver) — aceita o caminho do list ou o driveFileId
50
+ [--metadata-only] só o JSON + metadados do PDF, sem baixar o binário
51
+ scratch extract <arquivo.pdf> recorta páginas A-B num sub-PDF (pdf-lib)
52
+ [--pages A-B] [--all] PDF > ~30 páginas sem --pages exige --all explícito
31
53
  deck list seus baralhos (id, nome, status, pai, a revisar, relevância, meta, recorde)
32
54
  deck create <nome> cria um baralho (--parent <id|nome> p/ sub-baralho; --pending p/ importação)
33
55
  deck update <id|nome> metadados do baralho (--relevance/--goal/--tone;
34
56
  --clear-* limpa um campo)
57
+ plan analyze <pdfs...> analisa materiais; --web abre revisão sem criar plano
58
+ plan review <draftId> lê/aguarda o rascunho (--wait) e exporta payload (--out f)
59
+ plan create --file f cria o planejamento revisado; --draft uuid usa revisão web
60
+ plan current|next <planId> Meta ativa / próxima Meta (--activities N no fluxo direto;
61
+ --web revisa no app; --draft uuid conclui após REVIEWED)
62
+ plan start|complete|result executa atividades e registra desempenho
63
+ plan exams list|create|delete gerencia provas; diagnostics e guia no subcomando plan
35
64
  update atualiza a CLI para a versão mais recente (via npm)
36
65
  skill guia completo da CLI para agentes de IA (imprime o SKILL.md)
37
66
  version versão da CLI
@@ -43,7 +72,7 @@ Flags globais:
43
72
  --version imprime a versão da CLI
44
73
  --help, -h ajuda
45
74
 
46
- Ambiente (dev): MEMORING_API_BASE, MEMORING_KEYCLOAK_URL, MEMORING_KC_REALM, MEMORING_KC_CLIENT
75
+ Ambiente (dev): MEMORING_API_BASE, MEMORING_WEB_BASE, MEMORING_KEYCLOAK_URL, MEMORING_KC_REALM, MEMORING_KC_CLIENT
47
76
 
48
77
  Atualização: a CLI verifica em background (1x/24h) se há versão nova e avisa no stderr.
49
78
  MEMORING_NO_UPDATE_CHECK=1 desativa a verificação.
@@ -91,12 +120,24 @@ async function main() {
91
120
  case 'stage':
92
121
  await stageCommand(sub);
93
122
  return;
123
+ case 'batch':
124
+ await batchCommand(sub);
125
+ return;
94
126
  case 'inbox':
95
127
  await inboxCommand(sub);
96
128
  return;
129
+ case 'reference':
130
+ await referenceCommand(sub);
131
+ return;
132
+ case 'scratch':
133
+ await scratchCommand(sub);
134
+ return;
97
135
  case 'deck':
98
136
  await deckCommand(sub);
99
137
  return;
138
+ case 'plan':
139
+ await planCommand(sub);
140
+ return;
100
141
  case 'update':
101
142
  await updateCommand(VERSION);
102
143
  return;
@@ -0,0 +1,72 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { PdfDocumentCache } from './documentCache.js';
3
+ import { locateQuoteInPageItems } from './quoteLocate.js';
4
+ export async function validateBatchReferences(items, pdfMap, cache = new PdfDocumentCache()) {
5
+ const errors = [];
6
+ const warnings = [];
7
+ const fronts = new Map();
8
+ let references = 0;
9
+ let validatedHighlights = 0;
10
+ for (const [itemIndex, item] of items.entries()) {
11
+ const front = item.front?.trim().toLocaleLowerCase('pt-BR');
12
+ if (front) {
13
+ const previous = fronts.get(front);
14
+ if (previous !== undefined) {
15
+ warnings.push(`itens #${previous + 1} e #${itemIndex + 1} (${item.title}): frente exatamente duplicada.`);
16
+ }
17
+ else {
18
+ fronts.set(front, itemIndex);
19
+ }
20
+ }
21
+ for (const [refIndex, reference] of (item.materialReferences ?? []).entries()) {
22
+ references += 1;
23
+ const where = `item #${itemIndex + 1} (${item.title}), referência #${refIndex + 1}`;
24
+ const filePath = pdfMap[reference.driveFileId];
25
+ if (!filePath) {
26
+ errors.push(`${where}: driveFileId ${reference.driveFileId} não existe no pdf-map.`);
27
+ continue;
28
+ }
29
+ if (!existsSync(filePath)) {
30
+ errors.push(`${where}: PDF não encontrado em ${filePath}.`);
31
+ continue;
32
+ }
33
+ try {
34
+ const pageCount = await cache.pageCount(filePath);
35
+ if (reference.page > pageCount) {
36
+ errors.push(`${where}: página ${reference.page} além do fim do PDF (${pageCount} páginas).`);
37
+ continue;
38
+ }
39
+ if (reference.quote) {
40
+ const pageItems = await cache.pageItems(filePath, reference.page);
41
+ const located = locateQuoteInPageItems(pageItems, {
42
+ quote: reference.quote,
43
+ contextBefore: reference.contextBefore,
44
+ contextAfter: reference.contextAfter,
45
+ });
46
+ if (!located) {
47
+ errors.push(`${where}: quote não encontrado de forma única na página ${reference.page}; ` +
48
+ 'revise o texto literal ou os contextos.');
49
+ }
50
+ else {
51
+ validatedHighlights += 1;
52
+ }
53
+ }
54
+ else if (reference.region) {
55
+ validatedHighlights += 1;
56
+ }
57
+ else {
58
+ warnings.push(`${where}: referência somente com página abre sem destaque.`);
59
+ }
60
+ }
61
+ catch (error) {
62
+ errors.push(`${where}: falha ao ler ${filePath}: ${error.message}`);
63
+ }
64
+ }
65
+ }
66
+ return {
67
+ valid: errors.length === 0,
68
+ errors,
69
+ warnings,
70
+ stats: { items: items.length, references, validatedHighlights },
71
+ };
72
+ }