@memoring/cli 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +162 -9
- package/dist/api.js +90 -4
- package/dist/args.js +14 -1
- package/dist/auth.js +1 -1
- package/dist/commands/batch.js +327 -0
- package/dist/commands/materialReferences.js +359 -0
- package/dist/commands/plan-analyze.js +256 -0
- package/dist/commands/plan-diagnostics.js +65 -0
- package/dist/commands/plan-exams.js +122 -0
- package/dist/commands/plan-goal.js +131 -0
- package/dist/commands/plan-shared.js +133 -0
- package/dist/commands/plan.js +219 -0
- package/dist/commands/planDrafts.js +162 -0
- package/dist/commands/scratch.js +632 -0
- package/dist/commands/stage.js +215 -77
- package/dist/config.js +2 -0
- package/dist/main.js +43 -2
- package/dist/pdf/batchValidation.js +72 -0
- package/dist/pdf/documentCache.js +68 -0
- package/dist/pdf/quoteLocate.js +181 -0
- package/package.json +8 -2
- package/skills/memoring-cli/SKILL.md +57 -7
package/dist/commands/stage.js
CHANGED
|
@@ -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', '
|
|
8
|
-
// Retrocompat: itens.json antigos com "type": "
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
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=
|
|
153
|
-
back: 'string — resposta (obrigatório quando type=
|
|
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
|
|
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: '
|
|
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: '
|
|
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 |
|
|
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=
|
|
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
|
|
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:
|
|
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 === '
|
|
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
|
-
|
|
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
|
|
485
|
-
const
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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.
|
|
626
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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 -
|
|
651
|
-
failed:
|
|
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 |
|
|
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.
|
|
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
|
+
}
|