@memoring/cli 0.12.1 → 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,27 @@ 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`). */
19
+ /**
20
+ * Slug minúsculo (letras, números, hífens, até 80 chars) que identifica o conceito
21
+ * do item. Segmentos alfanuméricos separados por hífen único — sem hífen no
22
+ * início/fim nem duplo, espelhando a validação do backend (`assignConceptKey`).
23
+ */
24
+ const CONCEPT_KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
25
+ const CONCEPT_KEY_MAX = 80;
13
26
  const UNLIMITED = -1;
14
27
  /**
15
28
  * GET /notes/import/agent-guidance, fail-open como o fetchStoredCaps: sem sessão/rede
@@ -66,13 +79,13 @@ async function fetchStoredCaps() {
66
79
  /** Flags --max-<tipo>: no `stage create` limitam o lote; no `stage caps` gravam a preferência. */
67
80
  const MAX_FLAG_BY_TYPE = {
68
81
  NOTE: 'max-notes',
69
- FLASHCARD: 'max-flashcards',
82
+ NORMAL: 'max-flashcards',
70
83
  ONE_SHOT: 'max-one-shots',
71
84
  QUESTION: 'max-questions',
72
85
  };
73
86
  const TYPE_LABEL = {
74
87
  NOTE: 'notas',
75
- FLASHCARD: 'flashcards',
88
+ NORMAL: 'flashcards',
76
89
  ONE_SHOT: '1-shots',
77
90
  QUESTION: 'questões',
78
91
  };
@@ -92,7 +105,7 @@ function enforceTypeCaps(items, flags, stored) {
92
105
  const storedByType = stored
93
106
  ? {
94
107
  NOTE: stored.maxNotes,
95
- FLASHCARD: stored.maxFlashcards,
108
+ NORMAL: stored.maxFlashcards,
96
109
  ONE_SHOT: stored.maxOneShots,
97
110
  QUESTION: stored.maxQuestions,
98
111
  }
@@ -139,29 +152,42 @@ const QUESTION_CONTRACT = {
139
152
  const SCHEMA_JSON = {
140
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.',
141
154
  item: {
142
- 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)',
143
156
  title: 'string (obrigatório)',
144
157
  content: 'string — corpo da nota em markdown (obrigatório quando type=NOTE)',
145
- front: 'string — pergunta (obrigatório quando type=FLASHCARD|ONE_SHOT)',
146
- 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)',
147
160
  subjectName: 'string? — matéria (ex.: "Direito Constitucional")',
148
161
  subtopicName: 'string? — subtópico',
149
162
  tags: 'string[]?',
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`,
150
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)',
151
165
  question: 'objeto? — snapshot da questão (obrigatório quando type=QUESTION; contrato em questionContract)',
152
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',
153
168
  },
154
169
  questionContract: QUESTION_CONTRACT,
170
+ materialReferenceContract: MATERIAL_REFERENCE_CONTRACT,
155
171
  example: [
156
172
  {
157
- type: 'FLASHCARD',
173
+ type: 'NORMAL',
158
174
  title: 'CF/88 — objetivos fundamentais',
159
175
  front: 'Quais são os quatro objetivos fundamentais da República (art. 3º da CF/88)?',
160
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.',
161
177
  subjectName: 'Direito Constitucional',
162
178
  deckName: 'Direito Constitucional',
163
179
  tags: ['cf88', 'art-3'],
180
+ conceptKey: 'const-cf88-art-3',
164
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
+ ],
165
191
  },
166
192
  {
167
193
  type: 'QUESTION',
@@ -169,6 +195,7 @@ const SCHEMA_JSON = {
169
195
  subjectName: 'Direito Tributário',
170
196
  deckName: 'Direito Tributário',
171
197
  tags: ['ctn', 'art-3'],
198
+ conceptKey: 'trib-ctn-art-3',
172
199
  source: 'aula-00.pdf',
173
200
  question: {
174
201
  kind: 'CERTO_ERRADO',
@@ -177,15 +204,41 @@ const SCHEMA_JSON = {
177
204
  commentHtml: 'É a definição do art. 3º do CTN (Lei 5.172/1966).',
178
205
  },
179
206
  },
207
+ {
208
+ type: 'NORMAL',
209
+ title: 'CTN art. 4º — territorialidade',
210
+ front: 'Qual a regra de incidência dos tributos quanto à aplicação da lei (art. 4º, caput, do CTN)?',
211
+ back: 'Regra geral: a lei que institui o tributo aplica-se a todos os fatos geradores ocorridos no território nacional.',
212
+ subjectName: 'Direito Tributário',
213
+ deckName: 'Direito Tributário',
214
+ tags: ['ctn', 'art-4'],
215
+ conceptKey: 'trib-art-4',
216
+ source: 'aula-00.pdf',
217
+ },
218
+ {
219
+ type: 'QUESTION',
220
+ title: 'CTN art. 4º — exceção à territorialidade',
221
+ subjectName: 'Direito Tributário',
222
+ deckName: 'Direito Tributário',
223
+ tags: ['ctn', 'art-4'],
224
+ conceptKey: 'trib-art-4',
225
+ source: 'aula-00.pdf',
226
+ question: {
227
+ kind: 'CERTO_ERRADO',
228
+ statementHtml: 'Nos termos do art. 4º do CTN, a lei tributária brasileira NUNCA se aplica a fatos geradores ocorridos no exterior, ainda que o fato tenha produzido efeitos no Brasil.',
229
+ correctAlternative: 2,
230
+ commentHtml: 'Parágrafo único do art. 4º: a lei brasileira pode reger fatos ocorridos no exterior quando a eles se refiram dispositivos expressos da Constituição.',
231
+ },
232
+ },
180
233
  ],
181
234
  typeCaps: 'preferência por usuário (`memoring stage caps`, vale para app e CLI) — limites são TETOS, nunca cotas: 50 é só o limite técnico por requisição, não a meta do lote; userCaps -1 = sem teto preferencial (não é incentivo a inflar), 0 = não gerar o tipo. Sem quantidade explícita no pedido, selecione só pontos realmente cobráveis, com cobertura seletiva e carga de revisão sustentável. Flags do `stage create` (--max-notes N | --max-flashcards N | --max-one-shots N | --max-questions N, ou "livre") vencem a preferência por lote.',
182
235
  };
183
236
  const SCHEMA_TEXT = `Contrato dos itens da caixa de revisão (POST /api/notes/review-inbox):
184
237
 
185
- 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)
186
239
  title string (obrigatório)
187
240
  content string — corpo da nota (markdown) (obrigatório quando type=NOTE)
188
- 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)
189
242
  question objeto — snapshot da questão (obrigatório quando type=QUESTION)
190
243
  deckName string — baralho de destino (OBRIGATÓRIO — cartão não fica sem baralho:
191
244
  veja \`memoring deck list\`, reutilize ou crie
@@ -199,7 +252,39 @@ const SCHEMA_TEXT = `Contrato dos itens da caixa de revisão (POST /api/notes/re
199
252
  subjectName? string — matéria (ex.: "Direito Constitucional")
200
253
  subtopicName? string — subtópico
201
254
  tags? string[]
255
+ conceptKey? string — conceito do item em slug minúsculo (letras, números
256
+ e hífens, até 80 chars). Opcional por compatibilidade, mas
257
+ FORTEMENTE RECOMENDADO em NORMAL/ONE_SHOT/QUESTION: cartões
258
+ sobre o MESMO conceito compartilham a MESMA chave — ex.:
259
+ "trib-art-4" num flashcard E numa questão do art. 4º do CTN
260
+ (o whiteboard do app agrupa por conceito, não por cartão) —
261
+ e conceitos DIFERENTES usam chaves diferentes: nunca reuse
262
+ uma chave para outro conceito
202
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
203
288
 
204
289
  Snapshot de QUESTION (questões manuais — sem identidade TEC):
205
290
  kind MULTIPLA_ESCOLHA | CERTO_ERRADO (obrigatório)
@@ -213,7 +298,7 @@ Snapshot de QUESTION (questões manuais — sem identidade TEC):
213
298
  Lote: 1 a ${MAX_ITEMS} itens, all-or-nothing — qualquer item inválido rejeita o lote inteiro.
214
299
  Os itens nascem PENDING_REVIEW: o usuário aceita, edita ou recusa em /cards/inbox
215
300
  (agentes podem editar pendentes com \`memoring inbox update <id>\`).
216
- 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
217
302
  (termo curto — ideal para princípios, leis, datas, prazos, siglas, termos em latim);
218
303
  QUESTION treina verificação objetiva (aproveite questões prontas do material).
219
304
  Limites por tipo: a preferência do usuário (abaixo, quando logado) vale por padrão;
@@ -268,43 +353,6 @@ async function resolveBatchDecks(items, flags) {
268
353
  }
269
354
  return { deckIds: items.map((it) => idByRef.get(refOf(it) ?? '') ?? null), createdNames };
270
355
  }
271
- /**
272
- * Atribui o baralho aos itens recém-estagiados (o POST da caixa de revisão não tem
273
- * deckId — a atribuição é um PUT /notes/{id} por item, com o payload completo do
274
- * item + deckId). Devolve os rótulos dos itens cuja atribuição falhou.
275
- */
276
- async function assignDecks(items, created, deckIds) {
277
- const failures = [];
278
- for (let i = 0; i < created.length; i++) {
279
- const deckId = deckIds[i];
280
- if (deckId === null || deckId === undefined) {
281
- continue;
282
- }
283
- const item = items[i];
284
- try {
285
- await apiFetch(`/notes/${created[i].id}`, {
286
- method: 'PUT',
287
- body: {
288
- type: item.type,
289
- title: item.title,
290
- content: item.content ?? null,
291
- front: item.front ?? null,
292
- back: item.back ?? null,
293
- subjectName: item.subjectName ?? null,
294
- subtopicName: item.subtopicName ?? null,
295
- tags: item.tags ?? [],
296
- source: item.source ?? null,
297
- deckId,
298
- question: item.question ?? null,
299
- },
300
- });
301
- }
302
- catch {
303
- failures.push(`#${created[i].id} (${item.title})`);
304
- }
305
- }
306
- return failures;
307
- }
308
356
  async function readStdin() {
309
357
  const chunks = [];
310
358
  for await (const chunk of process.stdin) {
@@ -374,6 +422,10 @@ function validateQuestion(raw, where) {
374
422
  ...subjects,
375
423
  };
376
424
  }
425
+ /**
426
+ * Validação das referências de material vive em materialReferences.ts
427
+ * (validateMaterialReferences — fronteira única com o `memoring reference`).
428
+ */
377
429
  function validateItems(raw) {
378
430
  const items = Array.isArray(raw) ? raw : raw?.items;
379
431
  if (!Array.isArray(items) || items.length === 0) {
@@ -400,7 +452,7 @@ function validateItems(raw) {
400
452
  if (it.type === 'NOTE' && (typeof it.content !== 'string' || it.content.trim() === '')) {
401
453
  fail(`${where} (${it.title}): type=NOTE exige content.`);
402
454
  }
403
- if ((it.type === 'FLASHCARD' || it.type === 'ONE_SHOT') &&
455
+ if ((it.type === 'NORMAL' || it.type === 'ONE_SHOT') &&
404
456
  (typeof it.front !== 'string' || it.front.trim() === '' || typeof it.back !== 'string' || it.back.trim() === '')) {
405
457
  fail(`${where} (${it.title}): type=${it.type} exige front e back.`);
406
458
  }
@@ -410,16 +462,81 @@ function validateItems(raw) {
410
462
  if (it.tags !== undefined && (!Array.isArray(it.tags) || it.tags.some((t) => typeof t !== 'string'))) {
411
463
  fail(`${where} (${it.title}): tags deve ser um array de strings.`);
412
464
  }
413
- return { ...it, source: typeof it.source === 'string' && it.source.trim() !== '' ? it.source : DEFAULT_SOURCE };
465
+ let conceptKey = null;
466
+ if (it.conceptKey !== undefined && it.conceptKey !== null) {
467
+ if (typeof it.conceptKey !== 'string') {
468
+ fail(`${where} (${it.title}): conceptKey deve ser uma string (slug minúsculo) ou null.`);
469
+ }
470
+ const key = it.conceptKey.trim();
471
+ if (key !== '') {
472
+ if (key.length > CONCEPT_KEY_MAX || !CONCEPT_KEY_PATTERN.test(key)) {
473
+ fail(`${where} (${it.title}): conceptKey "${key}" inválido — use slug minúsculo com letras, ` +
474
+ `números e hífens, até ${CONCEPT_KEY_MAX} chars (ex.: "trib-art-4"). Mesmo conceito, mesma chave; conceito diferente, chave diferente.`);
475
+ }
476
+ conceptKey = key;
477
+ }
478
+ }
479
+ const materialReferences = validateMaterialReferences(it.materialReferences, `${where} (${it.title})`);
480
+ const stageItem = {
481
+ ...it,
482
+ conceptKey,
483
+ source: typeof it.source === 'string' && it.source.trim() !== '' ? it.source : DEFAULT_SOURCE,
484
+ };
485
+ if (materialReferences) {
486
+ stageItem.materialReferences = materialReferences;
487
+ }
488
+ else {
489
+ delete stageItem.materialReferences;
490
+ }
491
+ return stageItem;
414
492
  });
415
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
+ }
416
505
  /**
417
506
  * Idempotência do lote: UUID derivado do conteúdo bruto. Re-rodar o mesmo arquivo
418
507
  * após uma falha ambígua (timeout pós-commit) reenvia o mesmo X-Client-Request-Id —
419
508
  * o servidor devolve o replay deduplicado em vez de duplicar os itens.
420
509
  */
421
- function batchRequestId(raw) {
422
- 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();
423
540
  digest[6] = (digest[6] & 0x0f) | 0x80;
424
541
  digest[8] = (digest[8] & 0x3f) | 0x80;
425
542
  const hex = digest.subarray(0, 16).toString('hex');
@@ -437,6 +554,61 @@ const NO_SESSION_WARNING = 'SEM SESSÃO: userCaps, qualityRules e customInstruct
437
554
  export async function stageCommand(parsed) {
438
555
  const [sub] = parsed._;
439
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
+ }
440
612
  case 'schema': {
441
613
  const authenticated = hasSession();
442
614
  const [caps, guidance] = await Promise.all([fetchStoredCaps(), fetchAgentGuidance()]);
@@ -547,45 +719,73 @@ export async function stageCommand(parsed) {
547
719
  if (batchDecks.createdNames.length > 0 && !parsed.flags.json) {
548
720
  process.stdout.write(dim(`Baralho(s) criado(s) como PENDING (ocultos até o 1º aceite): ${batchDecks.createdNames.join(', ')}.\n`));
549
721
  }
722
+ const generation = readGeneration(parsed.flags);
550
723
  let created;
724
+ let importRunId = null;
725
+ let deduplicated = false;
551
726
  try {
552
727
  const res = (await apiFetch('/notes/review-inbox', {
553
728
  method: 'POST',
554
- // deckName não faz parte do contrato do POST — o baralho é atribuído via PUT abaixo
555
- body: { items: items.map(({ deckName, ...rest }) => (void deckName, rest)) },
556
- 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),
557
734
  }));
558
735
  created = res.created;
736
+ importRunId = res.importRunId ?? null;
559
737
  }
560
738
  catch (e) {
561
739
  if (e instanceof ApiDedupedError) {
562
- if (parsed.flags.json) {
563
- 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
+ }
564
748
  return;
565
749
  }
566
- process.stdout.write(green('Este lote já foi enviado antes — nada foi duplicado.\n'));
567
- if (parsed.flags.replace) {
568
- 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.');
569
770
  }
570
- return;
571
771
  }
572
- if (e instanceof ApiError) {
772
+ else if (e instanceof ApiError) {
573
773
  fail(`o servidor rejeitou o lote (${e.status}): ${e.body}`);
574
774
  }
575
- throw e;
576
- }
577
- const deckFailures = await assignDecks(items, created, batchDecks.deckIds);
578
- if (deckFailures.length > 0) {
579
- 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
+ }
580
778
  }
581
779
  if (parsed.flags.json) {
582
780
  printJson({
583
781
  created,
584
782
  ids: created.map((note) => note.id),
783
+ importRunId,
784
+ ...(deduplicated ? { deduplicated: true } : {}),
585
785
  decks: {
586
786
  created: batchDecks.createdNames,
587
- assigned: created.length - deckFailures.length - batchDecks.deckIds.filter((id) => id === null).length,
588
- failed: deckFailures,
787
+ assigned: created.length - batchDecks.deckIds.filter((id) => id === null).length,
788
+ failed: [],
589
789
  },
590
790
  });
591
791
  return;
@@ -597,14 +797,15 @@ export async function stageCommand(parsed) {
597
797
  return;
598
798
  }
599
799
  default:
600
- 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' +
601
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' +
602
803
  ' logado, inclui seus limites por tipo e as regras de qualidade —\n' +
603
804
  ' o agente deve ler o schema LOGADO, antes de gerar\n' +
604
805
  ' caps mostra/grava seus limites por tipo (--max-notes N | --max-flashcards N |\n' +
605
806
  ' --max-one-shots N | --max-questions N; 0 = não gerar o tipo)\n' +
606
807
  ' create envia o lote para a caixa de revisão (arquivo ou stdin)\n' +
607
- ' tipos: NOTE | FLASHCARD | ONE_SHOT | QUESTION\n' +
808
+ ' tipos: NOTE | NORMAL | ONE_SHOT | QUESTION\n' +
608
809
  ' baralho OBRIGATÓRIO: deckName por item ou --deck "<nome|id>" no lote\n' +
609
810
  ' (nomes inexistentes são criados como PENDING — ocultos no app até o\n' +
610
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.12.1';
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;