@memoring/cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,7 +54,7 @@ Os itens enviados nascem **PENDING_REVIEW** — nada entra nas suas listagens ne
54
54
  | Command | Purpose |
55
55
  | --- | --- |
56
56
  | `memoring auth` | login / logout / status |
57
- | `memoring stage` | schema (o contrato) / create (envia o lote, `--file` ou stdin, `--replace` esvazia a caixa antes, `--max-notes\|--max-flashcards\|--max-one-shots` limitam cada tipo) |
57
+ | `memoring stage` | schema (o contrato + seus limites por tipo) / caps (mostra/grava os limites — valem para o app e para os agentes) / create (envia o lote, `--file` ou stdin, `--replace` esvazia a caixa antes; `--max-*` vence a preferência por lote) |
58
58
  | `memoring inbox` | list / accept `<id>` / accept-all / discard `<id>` |
59
59
  | `memoring update` | atualiza a CLI para a última versão (via npm) |
60
60
  | `memoring version` | versão da CLI |
@@ -92,6 +92,7 @@ Cada item do lote (1 a 50, all-or-nothing):
92
92
 
93
93
  - `type`: `NOTE` (nota, exige `content` em markdown; `NORMAL` legado é aceito e mapeado para `NOTE`), `FLASHCARD` ou `ONE_SHOT` (exigem `front` + `back`).
94
94
  - `source`: origem do material — default `"Importação assistida (CLI)"`.
95
+ - Limites por tipo: `memoring stage caps` mostra/grava sua preferência (0 = não gerar o tipo) — o `stage schema` a expõe ao agente e o `create` a impõe; flags `--max-*` no `create` vencem por lote.
95
96
 
96
97
  ## Para agentes de IA
97
98
 
package/dist/api.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { getAccessToken } from './auth.js';
2
2
  import { loadConfig } from './config.js';
3
+ const API_TIMEOUT_MS = 30_000;
3
4
  export class ApiError extends Error {
4
5
  status;
5
6
  body;
@@ -9,6 +10,9 @@ export class ApiError extends Error {
9
10
  this.body = body;
10
11
  }
11
12
  }
13
+ /** Resposta reutilizada do dedupe server-side (X-Client-Request-Deduped) — a escrita já foi processada antes. */
14
+ export class ApiDedupedError extends Error {
15
+ }
12
16
  /** fetch autenticado contra a API do Memoring; em 401, força refresh e tenta uma vez mais. */
13
17
  export async function apiFetch(path, options = {}) {
14
18
  const cfg = loadConfig();
@@ -18,13 +22,18 @@ export async function apiFetch(path, options = {}) {
18
22
  headers: {
19
23
  Authorization: `Bearer ${token}`,
20
24
  ...(options.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
25
+ ...(options.requestId ? { 'X-Client-Request-Id': options.requestId } : {}),
21
26
  },
22
27
  body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
28
+ signal: AbortSignal.timeout(API_TIMEOUT_MS),
23
29
  });
24
30
  let res = await doFetch(await getAccessToken());
25
31
  if (res.status === 401) {
26
32
  res = await doFetch(await getAccessToken({ forceRefresh: true }));
27
33
  }
34
+ if (res.headers.get('X-Client-Request-Deduped') === 'true') {
35
+ throw new ApiDedupedError();
36
+ }
28
37
  if (res.status === 204) {
29
38
  return null;
30
39
  }
package/dist/auth.js CHANGED
@@ -1,8 +1,12 @@
1
+ import { closeSync, openSync, statSync } from 'node:fs';
1
2
  import { exec } from 'node:child_process';
2
- import { loadConfig, saveSession, saveTokens } from './config.js';
3
+ import { loadConfig, ensureConfigDir, refreshLockPath, releaseRefreshLock, saveSession, saveTokens, storedEndpoints } from './config.js';
3
4
  import { bold, cyan, dim, fail } from './output.js';
4
5
  const EXPIRY_MARGIN_MS = 30_000;
5
6
  const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
7
+ const FETCH_TIMEOUT_MS = 15_000;
8
+ const REFRESH_LOCK_WAIT_MS = 20_000;
9
+ const REFRESH_LOCK_STALE_MS = 60_000;
6
10
  function realmBase(cfg) {
7
11
  return `${cfg.keycloakUrl}/realms/${cfg.realm}`;
8
12
  }
@@ -43,6 +47,7 @@ async function requestToken(cfg, params) {
43
47
  method: 'POST',
44
48
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
45
49
  body: new URLSearchParams({ client_id: cfg.clientId, ...params }),
50
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
46
51
  });
47
52
  }
48
53
  /** Device Authorization Grant (RFC 8628): mostra o código, abre o browser e faz poll até aprovar. */
@@ -52,6 +57,7 @@ export async function deviceLogin(options = {}) {
52
57
  method: 'POST',
53
58
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
54
59
  body: new URLSearchParams({ client_id: cfg.clientId, scope: 'openid profile email' }),
60
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
55
61
  });
56
62
  if (!res.ok) {
57
63
  fail(`não foi possível iniciar o login (${res.status}): ${await res.text()}`);
@@ -74,16 +80,18 @@ export async function deviceLogin(options = {}) {
74
80
  fail('o código expirou — rode `memoring auth login` de novo.');
75
81
  }
76
82
  await sleep(intervalMs);
77
- const poll = await requestToken(cfg, { grant_type: DEVICE_GRANT, device_code: device.device_code });
83
+ let poll;
84
+ try {
85
+ poll = await requestToken(cfg, { grant_type: DEVICE_GRANT, device_code: device.device_code });
86
+ }
87
+ catch {
88
+ // erro transitório de rede — o poll continua (o código já pode ter sido aprovado)
89
+ continue;
90
+ }
78
91
  if (poll.ok) {
79
92
  const tokens = (await poll.json());
80
93
  const email = decodeJwtEmail(tokens.access_token);
81
- saveSession({
82
- keycloakUrl: cfg.keycloakUrl,
83
- realm: cfg.realm,
84
- clientId: cfg.clientId,
85
- apiBase: cfg.apiBase,
86
- }, {
94
+ saveSession(storedEndpoints(), {
87
95
  accessToken: tokens.access_token,
88
96
  refreshToken: tokens.refresh_token,
89
97
  accessTokenExpiresAt: Date.now() + tokens.expires_in * 1000,
@@ -98,6 +106,8 @@ export async function deviceLogin(options = {}) {
98
106
  case 'slow_down':
99
107
  intervalMs += 5000;
100
108
  continue;
109
+ case 'temporarily_unavailable':
110
+ continue;
101
111
  case 'access_denied':
102
112
  fail('login negado no navegador.');
103
113
  break;
@@ -105,24 +115,56 @@ export async function deviceLogin(options = {}) {
105
115
  fail('o código expirou — rode `memoring auth login` de novo.');
106
116
  break;
107
117
  default:
118
+ if (poll.status === 429 || poll.status >= 500) {
119
+ // erro transitório do servidor — backoff e segue o poll
120
+ intervalMs = Math.min(intervalMs + 5000, 60_000);
121
+ continue;
122
+ }
108
123
  fail(`falha no login: ${err.error_description ?? err.error ?? `HTTP ${poll.status}`}`);
109
124
  }
110
125
  }
111
126
  }
112
- /** Access token válido — renova via refresh token quando perto de expirar. */
113
- export async function getAccessToken(options = {}) {
114
- const cfg = loadConfig();
115
- if (!options.forceRefresh &&
116
- cfg.accessToken &&
117
- cfg.accessTokenExpiresAt &&
118
- cfg.accessTokenExpiresAt > Date.now() + EXPIRY_MARGIN_MS) {
119
- return cfg.accessToken;
127
+ function hasValidAccessToken(cfg) {
128
+ return Boolean(cfg.accessToken && cfg.accessTokenExpiresAt && cfg.accessTokenExpiresAt > Date.now() + EXPIRY_MARGIN_MS);
129
+ }
130
+ function tryAcquireRefreshLock() {
131
+ ensureConfigDir();
132
+ try {
133
+ const fd = openSync(refreshLockPath(), 'wx');
134
+ closeSync(fd);
135
+ return true;
120
136
  }
121
- if (!cfg.refreshToken) {
122
- fail('você não está autenticado. Rode `memoring auth login`.');
137
+ catch (e) {
138
+ if (e.code !== 'EEXIST') {
139
+ return false;
140
+ }
141
+ }
142
+ try {
143
+ // lock de processo morto — libera e tenta de novo
144
+ if (Date.now() - statSync(refreshLockPath()).mtimeMs > REFRESH_LOCK_STALE_MS) {
145
+ releaseRefreshLock();
146
+ const fd = openSync(refreshLockPath(), 'wx');
147
+ closeSync(fd);
148
+ return true;
149
+ }
150
+ }
151
+ catch {
152
+ // lock removido por outro processo entre o stat e a liberação
123
153
  }
154
+ return false;
155
+ }
156
+ async function doRefresh(cfg) {
124
157
  const res = await requestToken(cfg, { grant_type: 'refresh_token', refresh_token: cfg.refreshToken });
125
158
  if (!res.ok) {
159
+ const err = (await res.json().catch(() => ({})));
160
+ if (err.error === 'invalid_grant') {
161
+ // O realm revoga o refresh token no primeiro uso: se outro processo renovou
162
+ // antes, o config.json já tem os tokens novos — re-leio em vez de deslogar.
163
+ const fresh = loadConfig();
164
+ if (fresh.refreshToken && fresh.refreshToken !== cfg.refreshToken && hasValidAccessToken(fresh)) {
165
+ return fresh.accessToken;
166
+ }
167
+ }
126
168
  fail('sessão expirada. Rode `memoring auth login`.');
127
169
  }
128
170
  const tokens = (await res.json());
@@ -134,6 +176,43 @@ export async function getAccessToken(options = {}) {
134
176
  });
135
177
  return tokens.access_token;
136
178
  }
179
+ /** Access token válido — renova via refresh token quando perto de expirar. */
180
+ export async function getAccessToken(options = {}) {
181
+ const cfg = loadConfig();
182
+ if (!options.forceRefresh && hasValidAccessToken(cfg)) {
183
+ return cfg.accessToken;
184
+ }
185
+ if (!cfg.refreshToken) {
186
+ fail('você não está autenticado. Rode `memoring auth login`.');
187
+ }
188
+ // Single-flight entre processos: o primeiro a renovar revoga o refresh token
189
+ // antigo (revokeRefreshToken do realm), então um segundo refresh paralelo daria
190
+ // invalid_grant espúrio. Quem chega depois espera o lock e re-le o config.json;
191
+ // um lock velho (processo morto) é reapado pelo próprio tryAcquire.
192
+ const deadline = Date.now() + REFRESH_LOCK_WAIT_MS;
193
+ for (;;) {
194
+ if (tryAcquireRefreshLock()) {
195
+ try {
196
+ const fresh = loadConfig();
197
+ if (!options.forceRefresh && hasValidAccessToken(fresh)) {
198
+ return fresh.accessToken;
199
+ }
200
+ return await doRefresh(fresh);
201
+ }
202
+ finally {
203
+ releaseRefreshLock();
204
+ }
205
+ }
206
+ await sleep(500);
207
+ const fresh = loadConfig();
208
+ if (hasValidAccessToken(fresh)) {
209
+ return fresh.accessToken;
210
+ }
211
+ if (Date.now() >= deadline) {
212
+ fail('sessão expirada. Rode `memoring auth login`.');
213
+ }
214
+ }
215
+ }
137
216
  /** Logout no Keycloak (melhor esforço) — revoga o refresh token. */
138
217
  export async function serverLogout() {
139
218
  const cfg = loadConfig();
@@ -145,6 +224,7 @@ export async function serverLogout() {
145
224
  method: 'POST',
146
225
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
147
226
  body: new URLSearchParams({ client_id: cfg.clientId, refresh_token: cfg.refreshToken }),
227
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
148
228
  });
149
229
  }
150
230
  catch {
@@ -40,7 +40,7 @@ export async function inboxCommand(parsed) {
40
40
  process.stdout.write(green(`Item #${note.id} [${note.type}] "${note.title}" aceito — entrou nas listagens e na fila de revisão.\n`));
41
41
  }
42
42
  else {
43
- await apiFetch(`/notes/${id}`, { method: 'DELETE' });
43
+ await apiFetch(`/notes/review-inbox/${id}`, { method: 'DELETE' });
44
44
  if (parsed.flags.json) {
45
45
  printJson({ discarded: id });
46
46
  return;
@@ -50,7 +50,10 @@ export async function inboxCommand(parsed) {
50
50
  }
51
51
  catch (e) {
52
52
  if (e instanceof ApiError && e.status === 404) {
53
- fail(`item #${id} não encontrado — confira \`memoring inbox list\`.`);
53
+ if (sub === 'accept') {
54
+ fail(`item #${id} não encontrado — confira \`memoring inbox list\`.`);
55
+ }
56
+ fail(`item #${id} não encontrado ou já aceito — descarte só vale para itens pendentes (veja \`memoring inbox list\`).`);
54
57
  }
55
58
  throw e;
56
59
  }
@@ -1,12 +1,30 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { readFileSync } from 'node:fs';
2
- import { apiFetch, ApiError } from '../api.js';
3
- import { dim, fail, green, printJson, stringFlag } from './shared.js';
3
+ import { apiFetch, ApiDedupedError, ApiError } from '../api.js';
4
+ import { hasSession } from '../config.js';
5
+ import { dim, fail, green, printJson, stringFlag, yellow } from './shared.js';
4
6
  const TYPES = ['NOTE', 'FLASHCARD', 'ONE_SHOT'];
5
7
  // Retrocompat: itens.json antigos com "type": "NORMAL" são normalizados para "NOTE".
6
8
  const LEGACY_TYPE_ALIASES = { NORMAL: 'NOTE' };
7
9
  const MAX_ITEMS = 50;
8
10
  const DEFAULT_SOURCE = 'Importação assistida (CLI)';
9
- /** Flags --max-<tipo> do `stage create`: teto por tipo no lote (validação local). */
11
+ /**
12
+ * GET /notes/import/caps, fail-open: sem sessão ou sem rede devolve null (o schema
13
+ * continua útil deslogado e o create só perde a validação extra). Só chama a API
14
+ * quando HÁ sessão — o getAccessToken encerra o processo se não houver refresh token.
15
+ */
16
+ async function fetchStoredCaps() {
17
+ if (!hasSession()) {
18
+ return null;
19
+ }
20
+ try {
21
+ return (await apiFetch('/notes/import/caps'));
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ /** Flags --max-<tipo>: no `stage create` limitam o lote; no `stage caps` gravam a preferência. */
10
28
  const MAX_FLAG_BY_TYPE = {
11
29
  NOTE: 'max-notes',
12
30
  FLASHCARD: 'max-flashcards',
@@ -17,27 +35,44 @@ const TYPE_LABEL = {
17
35
  FLASHCARD: 'flashcards',
18
36
  ONE_SHOT: '1-shots',
19
37
  };
20
- function enforceTypeCaps(items, flags) {
21
- const caps = Object.entries(MAX_FLAG_BY_TYPE)
22
- .map(([type, flag]) => {
38
+ /** Campo do ImportCaps ↔ flag (o `stage caps` também aceita --max-questions, usado pelo app). */
39
+ const CAPS_FLAG_BY_FIELD = {
40
+ maxNotes: 'max-notes',
41
+ maxFlashcards: 'max-flashcards',
42
+ maxOneShots: 'max-one-shots',
43
+ maxQuestions: 'max-questions',
44
+ };
45
+ /**
46
+ * Teto por tipo no lote (validação local): a flag --max-<tipo> vence; sem flag, vale a
47
+ * preferência gravada do usuário (`memoring stage caps` / app). Tipo sem flag nem
48
+ * preferência fica livre.
49
+ */
50
+ function enforceTypeCaps(items, flags, stored) {
51
+ const storedByType = stored
52
+ ? { NOTE: stored.maxNotes, FLASHCARD: stored.maxFlashcards, ONE_SHOT: stored.maxOneShots }
53
+ : {};
54
+ for (const [type, flag] of Object.entries(MAX_FLAG_BY_TYPE)) {
23
55
  const raw = stringFlag(flags, flag);
24
- if (raw === undefined) {
25
- return null;
56
+ let max;
57
+ let source;
58
+ if (raw !== undefined) {
59
+ const parsed = Number(raw);
60
+ if (!Number.isInteger(parsed) || parsed < 0) {
61
+ fail(`--${flag} inválido: use um inteiro >= 0 (veio ${JSON.stringify(raw)}).`);
62
+ }
63
+ max = parsed;
64
+ source = `--${flag}`;
26
65
  }
27
- const max = Number(raw);
28
- if (!Number.isInteger(max) || max < 0) {
29
- fail(`--${flag} inválido: use um inteiro >= 0 (veio ${JSON.stringify(raw)}).`);
66
+ else if (storedByType[type] !== undefined) {
67
+ max = storedByType[type];
68
+ source = 'sua preferência (`memoring stage caps`)';
30
69
  }
31
- return { type, flag, max };
32
- })
33
- .filter((cap) => cap !== null);
34
- if (caps.length === 0) {
35
- return;
36
- }
37
- for (const cap of caps) {
38
- const count = items.filter((item) => item.type === cap.type).length;
39
- if (count > cap.max) {
40
- fail(`lote tem ${count} ${TYPE_LABEL[cap.type]} e --${cap.flag}=${cap.max}: ajuste o lote ou o limite. ` +
70
+ else {
71
+ continue;
72
+ }
73
+ const count = items.filter((item) => item.type === type).length;
74
+ if (count > max) {
75
+ fail(`lote tem ${count} ${TYPE_LABEL[type]} e o limite é ${max} (${source}). Ajuste o lote ou o limite. ` +
41
76
  'Se um agente gerou este lote, peça a ele para respeitar os limites por tipo.');
42
77
  }
43
78
  }
@@ -66,7 +101,7 @@ const SCHEMA_JSON = {
66
101
  source: 'Apostila constitucional.pdf',
67
102
  },
68
103
  ],
69
- typeCaps: 'flags opcionais do stage create: --max-notes N | --max-flashcards N | --max-one-shots N — o lote é rejeitado se algum tipo exceder o limite.',
104
+ typeCaps: 'preferência por usuário (`memoring stage caps`, vale para app e CLI) — o `stage schema` mostra os limites ao agente; flags do `stage create` (--max-notes N | --max-flashcards N | --max-one-shots N) vencem a preferência por lote.',
70
105
  };
71
106
  const SCHEMA_TEXT = `Contrato dos itens da caixa de revisão (POST /api/notes/review-inbox):
72
107
 
@@ -82,7 +117,8 @@ const SCHEMA_TEXT = `Contrato dos itens da caixa de revisão (POST /api/notes/re
82
117
  Lote: 1 a ${MAX_ITEMS} itens, all-or-nothing — qualquer item inválido rejeita o lote inteiro.
83
118
  Os itens nascem PENDING_REVIEW: o usuário aceita, edita ou recusa em /cards/inbox.
84
119
  Dica: FLASHCARD entra na fila de revisão espaçada (FSRS); ONE_SHOT é pergunta de resposta única.
85
- Limites por tipo: --max-notes N | --max-flashcards N | --max-one-shots N (o lote é rejeitado se exceder).`;
120
+ Limites por tipo: a preferência do usuário (abaixo, quando logado) vale por padrão;
121
+ --max-notes N | --max-flashcards N | --max-one-shots N no create vencem por lote.`;
86
122
  async function readStdin() {
87
123
  const chunks = [];
88
124
  for await (const chunk of process.stdin) {
@@ -126,10 +162,22 @@ function validateItems(raw) {
126
162
  return { ...it, source: typeof it.source === 'string' && it.source.trim() !== '' ? it.source : DEFAULT_SOURCE };
127
163
  });
128
164
  }
165
+ /**
166
+ * Idempotência do lote: UUID derivado do conteúdo bruto. Re-rodar o mesmo arquivo
167
+ * após uma falha ambígua (timeout pós-commit) reenvia o mesmo X-Client-Request-Id —
168
+ * o servidor devolve o replay deduplicado em vez de duplicar os itens.
169
+ */
170
+ function batchRequestId(raw) {
171
+ const digest = createHash('sha256').update(raw).digest();
172
+ digest[6] = (digest[6] & 0x0f) | 0x80;
173
+ digest[8] = (digest[8] & 0x3f) | 0x80;
174
+ const hex = digest.subarray(0, 16).toString('hex');
175
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
176
+ }
129
177
  async function emptyInbox() {
130
178
  const pending = (await apiFetch('/notes/review-inbox'));
131
179
  for (const note of pending) {
132
- await apiFetch(`/notes/${note.id}`, { method: 'DELETE' });
180
+ await apiFetch(`/notes/review-inbox/${note.id}`, { method: 'DELETE' });
133
181
  }
134
182
  return pending.length;
135
183
  }
@@ -137,11 +185,61 @@ export async function stageCommand(parsed) {
137
185
  const [sub] = parsed._;
138
186
  switch (sub) {
139
187
  case 'schema': {
188
+ const caps = await fetchStoredCaps();
140
189
  if (parsed.flags.json) {
141
- printJson(SCHEMA_JSON);
190
+ printJson(caps
191
+ ? {
192
+ ...SCHEMA_JSON,
193
+ userCaps: {
194
+ maxNotes: caps.maxNotes,
195
+ maxFlashcards: caps.maxFlashcards,
196
+ maxOneShots: caps.maxOneShots,
197
+ },
198
+ }
199
+ : SCHEMA_JSON);
142
200
  }
143
201
  else {
144
202
  process.stdout.write(SCHEMA_TEXT + '\n');
203
+ if (caps) {
204
+ process.stdout.write(`\nLimites por tipo configurados por você — o agente DEVE respeitá-los ao gerar o lote: ` +
205
+ `até ${caps.maxNotes} nota(s), até ${caps.maxFlashcards} flashcard(s), até ${caps.maxOneShots} 1-shot(s).\n` +
206
+ dim('(altere com `memoring stage caps`; QUESTION não é estagiável pela CLI)\n'));
207
+ }
208
+ else {
209
+ process.stdout.write(dim('\n(limites por tipo indisponíveis — faça `memoring auth login` ou verifique a conexão)\n'));
210
+ }
211
+ }
212
+ return;
213
+ }
214
+ case 'caps': {
215
+ // Sem flags mostra; com --max-* grava (merge com o atual). Sessão obrigatória.
216
+ const current = (await apiFetch('/notes/import/caps'));
217
+ const updates = {};
218
+ for (const [field, flag] of Object.entries(CAPS_FLAG_BY_FIELD)) {
219
+ const raw = stringFlag(parsed.flags, flag);
220
+ if (raw === undefined) {
221
+ continue;
222
+ }
223
+ const value = Number(raw);
224
+ if (!Number.isInteger(value) || value < 0 || value > 50) {
225
+ fail(`--${flag} inválido: use um inteiro entre 0 e 50 (veio ${JSON.stringify(raw)}).`);
226
+ }
227
+ updates[field] = value;
228
+ }
229
+ const hasUpdates = Object.keys(updates).length > 0;
230
+ const effective = hasUpdates
231
+ ? (await apiFetch('/notes/import/caps', { method: 'PUT', body: { ...current, ...updates } }))
232
+ : current;
233
+ if (parsed.flags.json) {
234
+ printJson(effective);
235
+ return;
236
+ }
237
+ process.stdout.write((hasUpdates ? green('Limites gravados') : 'Limites por tipo') +
238
+ ' — valem para a importação do app e para os agentes via CLI:\n');
239
+ process.stdout.write(` notas: até ${effective.maxNotes} flashcards: até ${effective.maxFlashcards} ` +
240
+ `1-shots: até ${effective.maxOneShots} questões: até ${effective.maxQuestions}\n`);
241
+ if (!hasUpdates) {
242
+ process.stdout.write(dim('Altere com: memoring stage caps --max-flashcards 30 (0 = não gerar o tipo)\n'));
145
243
  }
146
244
  return;
147
245
  }
@@ -165,7 +263,7 @@ export async function stageCommand(parsed) {
165
263
  fail(`JSON inválido: ${e.message}`);
166
264
  }
167
265
  const items = validateItems(parsedJson);
168
- enforceTypeCaps(items, parsed.flags);
266
+ enforceTypeCaps(items, parsed.flags, await fetchStoredCaps());
169
267
  if (parsed.flags.replace) {
170
268
  const removed = await emptyInbox();
171
269
  if (!parsed.flags.json) {
@@ -174,10 +272,25 @@ export async function stageCommand(parsed) {
174
272
  }
175
273
  let created;
176
274
  try {
177
- const res = (await apiFetch('/notes/review-inbox', { method: 'POST', body: { items } }));
275
+ const res = (await apiFetch('/notes/review-inbox', {
276
+ method: 'POST',
277
+ body: { items },
278
+ requestId: batchRequestId(raw),
279
+ }));
178
280
  created = res.created;
179
281
  }
180
282
  catch (e) {
283
+ if (e instanceof ApiDedupedError) {
284
+ if (parsed.flags.json) {
285
+ printJson({ created: [], deduplicated: true });
286
+ return;
287
+ }
288
+ process.stdout.write(green('Este lote já foi enviado antes — nada foi duplicado.\n'));
289
+ if (parsed.flags.replace) {
290
+ process.stdout.write(yellow('Atenção: --replace removeu os itens pendentes antes do envio — a caixa ficou vazia. Altere o lote e reenvie.\n'));
291
+ }
292
+ return;
293
+ }
181
294
  if (e instanceof ApiError) {
182
295
  fail(`o servidor rejeitou o lote (${e.status}): ${e.body}`);
183
296
  }
@@ -194,12 +307,15 @@ export async function stageCommand(parsed) {
194
307
  return;
195
308
  }
196
309
  default:
197
- process.stdout.write('Uso: memoring stage <schema|create> [--file itens.json] [--replace] [--json]\n' +
310
+ process.stdout.write('Uso: memoring stage <schema|caps|create> [--file itens.json] [--replace] [--json]\n' +
198
311
  ' schema imprime o contrato JSON dos itens (para humanos e agentes)\n' +
312
+ ' logado, inclui seus limites por tipo — o agente deve respeitá-los\n' +
313
+ ' caps mostra/grava seus limites por tipo (--max-notes N | --max-flashcards N |\n' +
314
+ ' --max-one-shots N | --max-questions N; 0 = não gerar o tipo)\n' +
199
315
  ' create envia o lote para a caixa de revisão (arquivo ou stdin)\n' +
200
316
  ' --replace esvazia a caixa antes de enviar\n' +
201
317
  ' --max-notes N | --max-flashcards N | --max-one-shots N\n' +
202
- ' limitam a quantidade de cada tipo no lote (validação local)\n');
318
+ ' vencem a preferência gravada por lote (validação local)\n');
203
319
  if (sub) {
204
320
  process.exitCode = 1;
205
321
  }
package/dist/main.js CHANGED
@@ -7,7 +7,7 @@ import { stageCommand } from './commands/stage.js';
7
7
  import { updateCommand } from './commands/update.js';
8
8
  import { fail, parseArgs as parse } from './commands/shared.js';
9
9
  import { newerVersionAvailable, scheduleUpdateCheck } from './update.js';
10
- const VERSION = '0.3.0';
10
+ const VERSION = '0.4.0';
11
11
  const HELP = `Memoring CLI — flashcards e notas por IA, direto do terminal.
12
12
 
13
13
  Uso: memoring <comando> [subcomando] [flags]
@@ -17,6 +17,7 @@ Comandos:
17
17
  stage schema contrato JSON dos itens da caixa de revisão
18
18
  stage create [--file f] envia itens (arquivo ou stdin) para a caixa de revisão
19
19
  [--replace] esvazia a caixa antes de enviar
20
+ stage caps [--max-* N] seus limites por tipo (valem pro app e pros agentes)
20
21
  inbox list itens pendentes de revisão
21
22
  inbox accept <id>|accept-all aceita itens (entram nas listagens e na fila FSRS)
22
23
  inbox discard <id> descarta um item pendente
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memoring/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "CLI do Memoring — envie flashcards/notas gerados por IA para a sua caixa de revisão, direto do terminal.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,6 +25,7 @@ O Memoring é um app de estudos com revisão espaçada (FSRS). A CLI `memoring`
25
25
  - `type`: `NOTE` (nota, exige `content` markdown; `NORMAL` legado é aceito e mapeado para `NOTE`), `FLASHCARD` ou `ONE_SHOT` (exigem `front` + `back`).
26
26
  - `title` sempre obrigatório; lote de 1 a 50 itens, all-or-nothing.
27
27
  - Se o usuário pedir limites por tipo (ex.: "no máximo 20 flashcards e 5 notas"), respeite-os ao gerar e valide com `--max-notes N --max-flashcards N --max-one-shots N` no `stage create` — o lote é rejeitado se exceder.
28
+ - O `stage schema` mostra os **limites por tipo do usuário** (`userCaps` no JSON; texto no fim da saída em modo humano) — respeite-os ao gerar, mesmo sem pedido explícito no chat. Sem limites visíveis, o create ainda pode rejeitar pela preferência gravada: ajuste o lote e reenvie. Para MUDAR a preferência permanente, o usuário roda `memoring stage caps` — não rode por conta própria.
28
29
  - `ONE_SHOT` = pergunta de resposta única; prefira `FLASHCARD` para memorização.
29
30
  - Preencha `source` com a origem real do material (nome do arquivo/pasta) — não use o default quando souber a origem.
30
31
  - `subjectName`/`subtopicName` quando o material tiver matéria/subtópico claros; `tags` curtas e em minúsculas.