@memoring/cli 0.3.0 → 0.5.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,9 +54,10 @@ 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
+ | `memoring skill` | imprime o guia completo da CLI para agentes de IA (o SKILL.md empacotado) |
60
61
  | `memoring version` | versão da CLI |
61
62
 
62
63
  ## Flags
@@ -92,10 +93,11 @@ Cada item do lote (1 a 50, all-or-nothing):
92
93
 
93
94
  - `type`: `NOTE` (nota, exige `content` em markdown; `NORMAL` legado é aceito e mapeado para `NOTE`), `FLASHCARD` ou `ONE_SHOT` (exigem `front` + `back`).
94
95
  - `source`: origem do material — default `"Importação assistida (CLI)"`.
96
+ - Limites por tipo: `memoring stage caps` mostra/grava sua preferência (`livre`/`-1` = à vontade — o padrão; `0` = não gerar o tipo) — o `stage schema` a expõe ao agente (`userCaps`) e o `create` a impõe; flags `--max-*` no `create` vencem por lote.
95
97
 
96
98
  ## Para agentes de IA
97
99
 
98
- Este pacote inclui uma skill em `skills/memoring-cli/SKILL.md` com o fluxo completo (auth → schema → gerar → stage → avisar o usuário). Aponte o agente para ela, ou simplesmente peça "use a cli do memoring" — os comandos são auto-descritivos (`--help` em todos).
100
+ Este pacote inclui uma skill em `skills/memoring-cli/SKILL.md` com o fluxo completo (auth → schema → gerar → stage → avisar o usuário). O jeito mais fácil de um agente aprender a CLI de cabo a rabo é rodar **`memoring skill`** — o comando imprime o guia completo empacotado. Ou simplesmente peça "use a cli do memoring" — os comandos são auto-descritivos (`--help` em todos).
99
101
 
100
102
  ## Dev (backend local)
101
103
 
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
  }
@@ -0,0 +1,20 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fail } from './shared.js';
3
+ /**
4
+ * `memoring skill` — imprime o SKILL.md empacotado no npm (guia completo da CLI para
5
+ * agentes de IA: fluxo, contrato, limites por tipo, armadilhas). É como um agente
6
+ * aprende a CLI de cabo a rabo sem precisar caçar o arquivo no node_modules.
7
+ */
8
+ export function skillCommand() {
9
+ let content;
10
+ try {
11
+ content = readFileSync(new URL('../../skills/memoring-cli/SKILL.md', import.meta.url), 'utf8');
12
+ }
13
+ catch {
14
+ fail('não encontrei o SKILL.md empacotado — reinstale com `npm i -g @memoring/cli@latest`.');
15
+ }
16
+ process.stdout.write(content);
17
+ if (!content.endsWith('\n')) {
18
+ process.stdout.write('\n');
19
+ }
20
+ }
@@ -1,12 +1,52 @@
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
+ const UNLIMITED = -1;
12
+ /** Rótulo de exibição de um teto: -1 → "à vontade". */
13
+ function capLabel(max) {
14
+ return max === UNLIMITED ? 'à vontade' : `até ${max}`;
15
+ }
16
+ /**
17
+ * Teto vindo de flag ou preferência: inteiro -1..50, ou a palavra "livre" (= -1, à vontade).
18
+ * `null` quando a flag está ausente.
19
+ */
20
+ function parseCapFlag(raw, flagName) {
21
+ if (raw === undefined) {
22
+ return null;
23
+ }
24
+ if (raw.trim().toLowerCase() === 'livre') {
25
+ return UNLIMITED;
26
+ }
27
+ const value = Number(raw);
28
+ if (!Number.isInteger(value) || value < UNLIMITED || value > 50) {
29
+ fail(`--${flagName} inválido: use "livre" ou um inteiro entre -1 (à vontade) e 50 (veio ${JSON.stringify(raw)}).`);
30
+ }
31
+ return value;
32
+ }
33
+ /**
34
+ * GET /notes/import/caps, fail-open: sem sessão ou sem rede devolve null (o schema
35
+ * continua útil deslogado e o create só perde a validação extra). Só chama a API
36
+ * quando HÁ sessão — o getAccessToken encerra o processo se não houver refresh token.
37
+ */
38
+ async function fetchStoredCaps() {
39
+ if (!hasSession()) {
40
+ return null;
41
+ }
42
+ try {
43
+ return (await apiFetch('/notes/import/caps'));
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ /** Flags --max-<tipo>: no `stage create` limitam o lote; no `stage caps` gravam a preferência. */
10
50
  const MAX_FLAG_BY_TYPE = {
11
51
  NOTE: 'max-notes',
12
52
  FLASHCARD: 'max-flashcards',
@@ -17,27 +57,43 @@ const TYPE_LABEL = {
17
57
  FLASHCARD: 'flashcards',
18
58
  ONE_SHOT: '1-shots',
19
59
  };
20
- function enforceTypeCaps(items, flags) {
21
- const caps = Object.entries(MAX_FLAG_BY_TYPE)
22
- .map(([type, flag]) => {
23
- const raw = stringFlag(flags, flag);
24
- if (raw === undefined) {
25
- return null;
60
+ /** Campo do ImportCaps ↔ flag (o `stage caps` também aceita --max-questions, usado pelo app). */
61
+ const CAPS_FLAG_BY_FIELD = {
62
+ maxNotes: 'max-notes',
63
+ maxFlashcards: 'max-flashcards',
64
+ maxOneShots: 'max-one-shots',
65
+ maxQuestions: 'max-questions',
66
+ };
67
+ /**
68
+ * Teto por tipo no lote (validação local): a flag --max-<tipo> vence; sem flag, vale a
69
+ * preferência gravada do usuário (`memoring stage caps` / app). "livre"/-1 (à vontade)
70
+ * não limita; tipo sem flag nem preferência fica livre.
71
+ */
72
+ function enforceTypeCaps(items, flags, stored) {
73
+ const storedByType = stored
74
+ ? { NOTE: stored.maxNotes, FLASHCARD: stored.maxFlashcards, ONE_SHOT: stored.maxOneShots }
75
+ : {};
76
+ for (const [type, flag] of Object.entries(MAX_FLAG_BY_TYPE)) {
77
+ const fromFlag = parseCapFlag(stringFlag(flags, flag), flag);
78
+ let max;
79
+ let source;
80
+ if (fromFlag !== null) {
81
+ max = fromFlag;
82
+ source = `--${flag}`;
26
83
  }
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)}).`);
84
+ else if (storedByType[type] !== undefined) {
85
+ max = storedByType[type];
86
+ source = 'sua preferência (`memoring stage caps`)';
30
87
  }
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. ` +
88
+ else {
89
+ continue;
90
+ }
91
+ if (max === UNLIMITED) {
92
+ continue;
93
+ }
94
+ const count = items.filter((item) => item.type === type).length;
95
+ if (count > max) {
96
+ fail(`lote tem ${count} ${TYPE_LABEL[type]} e o limite é ${max} (${source}). Ajuste o lote ou o limite ("livre" = à vontade). ` +
41
97
  'Se um agente gerou este lote, peça a ele para respeitar os limites por tipo.');
42
98
  }
43
99
  }
@@ -66,7 +122,7 @@ const SCHEMA_JSON = {
66
122
  source: 'Apostila constitucional.pdf',
67
123
  },
68
124
  ],
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.',
125
+ typeCaps: 'preferência por usuário (`memoring stage caps`, vale para app e CLI) — o `stage schema` mostra os limites ao agente (userCaps: -1 = à vontade, 0 = não gerar o tipo); flags do `stage create` (--max-notes N | --max-flashcards N | --max-one-shots N, ou "livre") vencem a preferência por lote.',
70
126
  };
71
127
  const SCHEMA_TEXT = `Contrato dos itens da caixa de revisão (POST /api/notes/review-inbox):
72
128
 
@@ -82,7 +138,8 @@ const SCHEMA_TEXT = `Contrato dos itens da caixa de revisão (POST /api/notes/re
82
138
  Lote: 1 a ${MAX_ITEMS} itens, all-or-nothing — qualquer item inválido rejeita o lote inteiro.
83
139
  Os itens nascem PENDING_REVIEW: o usuário aceita, edita ou recusa em /cards/inbox.
84
140
  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).`;
141
+ Limites por tipo: a preferência do usuário (abaixo, quando logado) vale por padrão;
142
+ --max-notes N | --max-flashcards N | --max-one-shots N no create vencem por lote ("livre" = à vontade).`;
86
143
  async function readStdin() {
87
144
  const chunks = [];
88
145
  for await (const chunk of process.stdin) {
@@ -126,10 +183,22 @@ function validateItems(raw) {
126
183
  return { ...it, source: typeof it.source === 'string' && it.source.trim() !== '' ? it.source : DEFAULT_SOURCE };
127
184
  });
128
185
  }
186
+ /**
187
+ * Idempotência do lote: UUID derivado do conteúdo bruto. Re-rodar o mesmo arquivo
188
+ * após uma falha ambígua (timeout pós-commit) reenvia o mesmo X-Client-Request-Id —
189
+ * o servidor devolve o replay deduplicado em vez de duplicar os itens.
190
+ */
191
+ function batchRequestId(raw) {
192
+ const digest = createHash('sha256').update(raw).digest();
193
+ digest[6] = (digest[6] & 0x0f) | 0x80;
194
+ digest[8] = (digest[8] & 0x3f) | 0x80;
195
+ const hex = digest.subarray(0, 16).toString('hex');
196
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
197
+ }
129
198
  async function emptyInbox() {
130
199
  const pending = (await apiFetch('/notes/review-inbox'));
131
200
  for (const note of pending) {
132
- await apiFetch(`/notes/${note.id}`, { method: 'DELETE' });
201
+ await apiFetch(`/notes/review-inbox/${note.id}`, { method: 'DELETE' });
133
202
  }
134
203
  return pending.length;
135
204
  }
@@ -137,11 +206,57 @@ export async function stageCommand(parsed) {
137
206
  const [sub] = parsed._;
138
207
  switch (sub) {
139
208
  case 'schema': {
209
+ const caps = await fetchStoredCaps();
140
210
  if (parsed.flags.json) {
141
- printJson(SCHEMA_JSON);
211
+ printJson(caps
212
+ ? {
213
+ ...SCHEMA_JSON,
214
+ userCaps: {
215
+ maxNotes: caps.maxNotes,
216
+ maxFlashcards: caps.maxFlashcards,
217
+ maxOneShots: caps.maxOneShots,
218
+ },
219
+ }
220
+ : SCHEMA_JSON);
142
221
  }
143
222
  else {
144
223
  process.stdout.write(SCHEMA_TEXT + '\n');
224
+ if (caps) {
225
+ process.stdout.write(`\nLimites por tipo configurados por você — o agente DEVE respeitá-los ao gerar o lote: ` +
226
+ `notas: ${capLabel(caps.maxNotes)}, flashcards: ${capLabel(caps.maxFlashcards)}, 1-shots: ${capLabel(caps.maxOneShots)}.\n` +
227
+ dim('("à vontade" = crie quantos o material justificar; altere com `memoring stage caps`; QUESTION não é estagiável pela CLI)\n'));
228
+ }
229
+ else {
230
+ process.stdout.write(dim('\n(limites por tipo indisponíveis — faça `memoring auth login` ou verifique a conexão)\n'));
231
+ }
232
+ }
233
+ return;
234
+ }
235
+ case 'caps': {
236
+ // Sem flags mostra; com --max-* grava (merge com o atual). Sessão obrigatória.
237
+ const current = (await apiFetch('/notes/import/caps'));
238
+ const updates = {};
239
+ for (const [field, flag] of Object.entries(CAPS_FLAG_BY_FIELD)) {
240
+ const value = parseCapFlag(stringFlag(parsed.flags, flag), flag);
241
+ if (value === null) {
242
+ continue;
243
+ }
244
+ updates[field] = value;
245
+ }
246
+ const hasUpdates = Object.keys(updates).length > 0;
247
+ const effective = hasUpdates
248
+ ? (await apiFetch('/notes/import/caps', { method: 'PUT', body: { ...current, ...updates } }))
249
+ : current;
250
+ if (parsed.flags.json) {
251
+ printJson(effective);
252
+ return;
253
+ }
254
+ process.stdout.write((hasUpdates ? green('Limites gravados') : 'Limites por tipo') +
255
+ ' — valem para a importação do app e para os agentes via CLI:\n');
256
+ process.stdout.write(` notas: ${capLabel(effective.maxNotes)} flashcards: ${capLabel(effective.maxFlashcards)} ` +
257
+ `1-shots: ${capLabel(effective.maxOneShots)} questões: ${capLabel(effective.maxQuestions)}\n`);
258
+ if (!hasUpdates) {
259
+ process.stdout.write(dim('Altere com: memoring stage caps --max-flashcards 30 (0 = não gerar o tipo; "livre" = à vontade)\n'));
145
260
  }
146
261
  return;
147
262
  }
@@ -165,7 +280,7 @@ export async function stageCommand(parsed) {
165
280
  fail(`JSON inválido: ${e.message}`);
166
281
  }
167
282
  const items = validateItems(parsedJson);
168
- enforceTypeCaps(items, parsed.flags);
283
+ enforceTypeCaps(items, parsed.flags, await fetchStoredCaps());
169
284
  if (parsed.flags.replace) {
170
285
  const removed = await emptyInbox();
171
286
  if (!parsed.flags.json) {
@@ -174,10 +289,25 @@ export async function stageCommand(parsed) {
174
289
  }
175
290
  let created;
176
291
  try {
177
- const res = (await apiFetch('/notes/review-inbox', { method: 'POST', body: { items } }));
292
+ const res = (await apiFetch('/notes/review-inbox', {
293
+ method: 'POST',
294
+ body: { items },
295
+ requestId: batchRequestId(raw),
296
+ }));
178
297
  created = res.created;
179
298
  }
180
299
  catch (e) {
300
+ if (e instanceof ApiDedupedError) {
301
+ if (parsed.flags.json) {
302
+ printJson({ created: [], deduplicated: true });
303
+ return;
304
+ }
305
+ process.stdout.write(green('Este lote já foi enviado antes — nada foi duplicado.\n'));
306
+ if (parsed.flags.replace) {
307
+ process.stdout.write(yellow('Atenção: --replace removeu os itens pendentes antes do envio — a caixa ficou vazia. Altere o lote e reenvie.\n'));
308
+ }
309
+ return;
310
+ }
181
311
  if (e instanceof ApiError) {
182
312
  fail(`o servidor rejeitou o lote (${e.status}): ${e.body}`);
183
313
  }
@@ -194,12 +324,15 @@ export async function stageCommand(parsed) {
194
324
  return;
195
325
  }
196
326
  default:
197
- process.stdout.write('Uso: memoring stage <schema|create> [--file itens.json] [--replace] [--json]\n' +
327
+ process.stdout.write('Uso: memoring stage <schema|caps|create> [--file itens.json] [--replace] [--json]\n' +
198
328
  ' schema imprime o contrato JSON dos itens (para humanos e agentes)\n' +
329
+ ' logado, inclui seus limites por tipo — o agente deve respeitá-los\n' +
330
+ ' caps mostra/grava seus limites por tipo (--max-notes N | --max-flashcards N |\n' +
331
+ ' --max-one-shots N | --max-questions N; 0 = não gerar o tipo)\n' +
199
332
  ' create envia o lote para a caixa de revisão (arquivo ou stdin)\n' +
200
333
  ' --replace esvazia a caixa antes de enviar\n' +
201
334
  ' --max-notes N | --max-flashcards N | --max-one-shots N\n' +
202
- ' limitam a quantidade de cada tipo no lote (validação local)\n');
335
+ ' vencem a preferência gravada por lote (validação local)\n');
203
336
  if (sub) {
204
337
  process.exitCode = 1;
205
338
  }
package/dist/main.js CHANGED
@@ -5,9 +5,10 @@ import { authCommand } from './commands/auth.js';
5
5
  import { inboxCommand } from './commands/inbox.js';
6
6
  import { stageCommand } from './commands/stage.js';
7
7
  import { updateCommand } from './commands/update.js';
8
+ import { skillCommand } from './commands/skill.js';
8
9
  import { fail, parseArgs as parse } from './commands/shared.js';
9
10
  import { newerVersionAvailable, scheduleUpdateCheck } from './update.js';
10
- const VERSION = '0.3.0';
11
+ const VERSION = '0.5.0';
11
12
  const HELP = `Memoring CLI — flashcards e notas por IA, direto do terminal.
12
13
 
13
14
  Uso: memoring <comando> [subcomando] [flags]
@@ -17,10 +18,12 @@ Comandos:
17
18
  stage schema contrato JSON dos itens da caixa de revisão
18
19
  stage create [--file f] envia itens (arquivo ou stdin) para a caixa de revisão
19
20
  [--replace] esvazia a caixa antes de enviar
21
+ stage caps [--max-* N] seus limites por tipo (valem pro app e pros agentes)
20
22
  inbox list itens pendentes de revisão
21
23
  inbox accept <id>|accept-all aceita itens (entram nas listagens e na fila FSRS)
22
24
  inbox discard <id> descarta um item pendente
23
25
  update atualiza a CLI para a versão mais recente (via npm)
26
+ skill guia completo da CLI para agentes de IA (imprime o SKILL.md)
24
27
  version versão da CLI
25
28
 
26
29
  Flags globais:
@@ -84,6 +87,9 @@ async function main() {
84
87
  case 'update':
85
88
  await updateCommand(VERSION);
86
89
  return;
90
+ case 'skill':
91
+ skillCommand();
92
+ return;
87
93
  case 'version':
88
94
  process.stdout.write(`memoring-cli ${VERSION}\n`);
89
95
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memoring/cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",
@@ -3,37 +3,62 @@ name: memoring-cli
3
3
  description: Gere flashcards e notas a partir de materiais de estudo locais (PDFs, textos, anotações) e envie para a caixa de revisão do Memoring usando a CLI `memoring`. Use quando o usuário pedir para "usar a cli do memoring", criar flashcards/notas de uma pasta ou arquivo, ou enviar material de estudo para o Memoring.
4
4
  ---
5
5
 
6
- # Memoring CLI — geração de flashcards/notas por agente
6
+ # Memoring CLI — guia completo para agentes
7
7
 
8
- O Memoring é um app de estudos com revisão espaçada (FSRS). A CLI `memoring` é a ponte entre você (o agente, que lê o material e gera o conteúdo) e a conta do usuário no app. Você NÃO chama a API HTTP diretamente — use sempre a CLI.
8
+ O Memoring é um app de estudos com revisão espaçada (FSRS). A CLI `memoring` é a ponte entre você (o agente, que lê o material e gera o conteúdo) e a conta do usuário no app. Você NÃO chama a API HTTP diretamente — use sempre a CLI. Este guia está sempre disponível via `memoring skill`.
9
9
 
10
10
  ## Pré-requisitos
11
11
 
12
12
  1. CLI instalada: `memoring version` deve responder. Se não, peça ao usuário: `npm install -g @memoring/cli`.
13
13
  2. Sessão ativa: `memoring auth status --json` → `authenticated: true`. Se não, peça ao usuário para rodar `memoring auth login` (device flow — ele aprova no browser; você não consegue fazer isso por ele).
14
14
 
15
- ## Fluxo
15
+ ## Mapa dos comandos
16
16
 
17
- 1. **Entenda o pedido** — qual pasta/arquivo/material virar flashcards/notas.
18
- 2. **Leia o contrato**: `memoring stage schema --json`.
17
+ | Comando | O que faz |
18
+ | --- | --- |
19
+ | `memoring auth login\|logout\|status` | sessão (device flow — sem senha no terminal) |
20
+ | `memoring stage schema [--json]` | O CONTRATO dos itens + os limites por tipo do usuário — leia SEMPRE antes de gerar |
21
+ | `memoring stage caps [--max-* N\|livre]` | limites por tipo do usuário (preferência DELE — nunca escreva sem ele pedir) |
22
+ | `memoring stage create --file f [--replace] [--max-* N]` | envia o lote (1 a 50 itens) para a caixa de revisão |
23
+ | `memoring inbox list` | itens pendentes de revisão |
24
+ | `memoring inbox accept <id>\|accept-all` | aceita itens (entram nas listagens e na fila FSRS) |
25
+ | `memoring inbox discard <id>` | descarta um item pendente |
26
+ | `memoring skill` | imprime este guia |
27
+ | `memoring update` | atualiza a CLI (decisão do usuário — nunca rode por conta própria) |
28
+
29
+ Flags globais: `--json` (saída legível por máquina), `--no-color`, `--api-base`.
30
+
31
+ ## Fluxo padrão
32
+
33
+ 1. **Entenda o pedido** — qual pasta/arquivo/material vira flashcards/notas e se o usuário pediu quantidades.
34
+ 2. **Leia o contrato e os limites**: `memoring stage schema --json`. Além do contrato, a saída traz `userCaps` — os limites por tipo DO USUÁRIO (`-1` = à vontade, `0` = não gerar o tipo, `N` = teto). Respeite-os SEMPRE, mesmo sem pedido explícito no chat.
19
35
  3. **Leia os materiais** e gere os itens no contrato, num arquivo JSON (array ou `{ "items": [...] }`).
20
- 4. **Envie**: `memoring stage create --file <arquivo> --json`.
36
+ 4. **Envie**: `memoring stage create --file <arquivo> --json`. Se o lote for rejeitado por limite de tipo, ajuste o LOTE — não relaxe o limite do usuário.
21
37
  5. **Avise o usuário**: quantos itens foram para a caixa de revisão e onde aceitar — no app em `/cards/inbox` ou `memoring inbox list` / `memoring inbox accept-all`.
22
38
 
23
- ## Regras do contrato (resumo — a fonte da verdade é `stage schema`)
39
+ ## O contrato (a fonte da verdade é `stage schema`)
24
40
 
25
41
  - `type`: `NOTE` (nota, exige `content` markdown; `NORMAL` legado é aceito e mapeado para `NOTE`), `FLASHCARD` ou `ONE_SHOT` (exigem `front` + `back`).
26
- - `title` sempre obrigatório; lote de 1 a 50 itens, all-or-nothing.
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
- - `ONE_SHOT` = pergunta de resposta única; prefira `FLASHCARD` para memorização.
42
+ - `title` sempre obrigatório; lote de 1 a 50 itens, all-or-nothing — qualquer item inválido rejeita o lote inteiro.
43
+ - `ONE_SHOT` = pergunta de resposta única (uma palavra ou termo curto); prefira `FLASHCARD` para memorização.
29
44
  - Preencha `source` com a origem real do material (nome do arquivo/pasta) — não use o default quando souber a origem.
30
45
  - `subjectName`/`subtopicName` quando o material tiver matéria/subtópico claros; `tags` curtas e em minúsculas.
31
46
  - NUNCA invente campos fora do contrato — o servidor rejeita o lote inteiro.
32
47
 
48
+ ## Quantidades (limites por tipo)
49
+
50
+ - O default do usuário costuma ser **à vontade** (`userCaps` com `-1`): crie QUANTOS itens o material justificar — qualidade acima de quantidade, sem encher cota e sem repetir o mesmo fato em variações triviais.
51
+ - Se `userCaps` trouxer tetos numéricos, distribua a geração para caber neles; `0` proíbe o tipo.
52
+ - Se o usuário pedir quantidades no chat (ex.: "quero 30 flashcards e 5 notas"), o pedido vence o `userCaps`: gere e valide com `--max-flashcards 30 --max-notes 5` no `stage create`.
53
+ - Lotes grandes: divida em vários arquivos de até 50 itens e envie sequencialmente.
54
+
55
+ ## Qualidade dos flashcards (o padrão que o app espera)
56
+
57
+ Cartões atômicos: UMA ideia por cartão — se um conceito tem 3 partes cobráveis, crie 3 cartões, nunca um cartão com lista como resposta. A frente é uma pergunta direta que força LEMBRAR (não reconhecer). A resposta é auto-contida (faz sentido sem o material) e curta. Pares confundíveis (prazos, siglas, exceções) levam na frente o traço que os diferencia. Não invente conteúdo que não esteja no material.
58
+
33
59
  ## Armadilhas
34
60
 
35
61
  - **Nunca** use `--replace` sem o usuário pedir explicitamente — ele apaga os itens pendentes existentes da caixa.
36
62
  - Se aparecer um aviso de versão nova no stderr, ignore e siga o fluxo — **não** rode `memoring update` por conta própria; atualizar a CLI é decisão do usuário.
63
+ - **Não** altere os limites do usuário (`stage caps`) por conta própria — é preferência dele, compartilhada com o app.
37
64
  - Se `stage create` falhar com erro de validação do servidor, corrija os itens indicados e reenvie — não relaxe o contrato.
38
- - Lotes grandes: divida em vários arquivos de até 50 itens e envie sequencialmente.
39
- - Flashcards bons são atômicos: uma ideia por cartão, pergunta direta, resposta completa mas curta.