@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 +4 -2
- package/dist/api.js +9 -0
- package/dist/auth.js +98 -18
- package/dist/commands/inbox.js +5 -2
- package/dist/commands/skill.js +20 -0
- package/dist/commands/stage.js +163 -30
- package/dist/main.js +7 -1
- package/package.json +1 -1
- package/skills/memoring-cli/SKILL.md +37 -12
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
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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 {
|
package/dist/commands/inbox.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/commands/stage.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
84
|
+
else if (storedByType[type] !== undefined) {
|
|
85
|
+
max = storedByType[type];
|
|
86
|
+
source = 'sua preferência (`memoring stage caps`)';
|
|
30
87
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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', {
|
|
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
|
-
'
|
|
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.
|
|
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
|
@@ -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 —
|
|
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
|
-
##
|
|
15
|
+
## Mapa dos comandos
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
##
|
|
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
|
-
-
|
|
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.
|