@memoring/cli 0.2.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 +14 -1
- package/dist/api.js +9 -0
- package/dist/auth.js +98 -18
- package/dist/commands/inbox.js +5 -2
- package/dist/commands/stage.js +145 -29
- package/dist/commands/update.js +35 -0
- package/dist/config.js +41 -4
- package/dist/main.js +26 -2
- package/dist/update.js +126 -0
- package/package.json +1 -1
- package/skills/memoring-cli/SKILL.md +2 -0
package/README.md
CHANGED
|
@@ -10,6 +10,16 @@ A CLI não tem LLM própria: quem lê o material e gera os flashcards é o seu a
|
|
|
10
10
|
npm install -g @memoring/cli
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
## Atualização
|
|
14
|
+
|
|
15
|
+
A CLI verifica em background (no máximo 1x a cada 24h) se há versão nova no npm e avisa no stderr — nunca bloqueia o comando nem polui a saída `--json`. Para atualizar:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
memoring update # equivale a npm i -g @memoring/cli@latest
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`MEMORING_NO_UPDATE_CHECK=1` desativa a verificação.
|
|
22
|
+
|
|
13
23
|
## Quickstart
|
|
14
24
|
|
|
15
25
|
Autentique (abre o browser — sem senha no terminal):
|
|
@@ -44,8 +54,9 @@ Os itens enviados nascem **PENDING_REVIEW** — nada entra nas suas listagens ne
|
|
|
44
54
|
| Command | Purpose |
|
|
45
55
|
| --- | --- |
|
|
46
56
|
| `memoring auth` | login / logout / status |
|
|
47
|
-
| `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) |
|
|
48
58
|
| `memoring inbox` | list / accept `<id>` / accept-all / discard `<id>` |
|
|
59
|
+
| `memoring update` | atualiza a CLI para a última versão (via npm) |
|
|
49
60
|
| `memoring version` | versão da CLI |
|
|
50
61
|
|
|
51
62
|
## Flags
|
|
@@ -81,6 +92,7 @@ Cada item do lote (1 a 50, all-or-nothing):
|
|
|
81
92
|
|
|
82
93
|
- `type`: `NOTE` (nota, exige `content` em markdown; `NORMAL` legado é aceito e mapeado para `NOTE`), `FLASHCARD` ou `ONE_SHOT` (exigem `front` + `back`).
|
|
83
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.
|
|
84
96
|
|
|
85
97
|
## Para agentes de IA
|
|
86
98
|
|
|
@@ -95,5 +107,6 @@ memoring auth login
|
|
|
95
107
|
```
|
|
96
108
|
|
|
97
109
|
Variáveis: `MEMORING_API_BASE`, `MEMORING_KEYCLOAK_URL`, `MEMORING_KC_REALM`, `MEMORING_KC_CLIENT`, `MEMORING_CONFIG_HOME` (isola o config.json).
|
|
110
|
+
Override de ambiente vale por sessão: o `auth login` não o grava no config.json — exporte as variáveis em cada shell (ou use `memoring --api-base ...` por comando).
|
|
98
111
|
|
|
99
112
|
Build a partir do repo: `cd cli && npm install && npm run build && node dist/main.js --help`.
|
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
|
}
|
package/dist/commands/stage.js
CHANGED
|
@@ -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 {
|
|
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
|
+
/**
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
66
|
+
else if (storedByType[type] !== undefined) {
|
|
67
|
+
max = storedByType[type];
|
|
68
|
+
source = 'sua preferência (`memoring stage caps`)';
|
|
30
69
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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', {
|
|
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
|
-
'
|
|
318
|
+
' vencem a preferência gravada por lote (validação local)\n');
|
|
203
319
|
if (sub) {
|
|
204
320
|
process.exitCode = 1;
|
|
205
321
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { bold, dim, fail, green, yellow } from './shared.js';
|
|
3
|
+
import { fetchLatestVersion, isNewerVersion, recordUpdateCheck } from '../update.js';
|
|
4
|
+
const PACKAGE = '@memoring/cli';
|
|
5
|
+
export async function updateCommand(current) {
|
|
6
|
+
process.stdout.write(dim('consultando o registry npm...') + '\n');
|
|
7
|
+
const latest = await fetchLatestVersion();
|
|
8
|
+
if (latest && !isNewerVersion(latest, current)) {
|
|
9
|
+
recordUpdateCheck(latest);
|
|
10
|
+
process.stdout.write(green(`o memoring já está na versão mais recente (${current}).`) + '\n');
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (latest) {
|
|
14
|
+
process.stdout.write(`atualizando ${current} → ${bold(latest)}...\n`);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
process.stdout.write(yellow('registry indisponível — tentando atualizar via npm mesmo assim...') + '\n');
|
|
18
|
+
}
|
|
19
|
+
const code = await runNpmGlobalInstall();
|
|
20
|
+
if (code !== 0) {
|
|
21
|
+
fail(`a atualização via npm falhou (código ${code}). Tente manualmente: npm install -g ${PACKAGE}@latest`);
|
|
22
|
+
}
|
|
23
|
+
recordUpdateCheck(latest ?? undefined);
|
|
24
|
+
process.stdout.write(green('memoring atualizado — rode `memoring version` para conferir.') + '\n');
|
|
25
|
+
}
|
|
26
|
+
function runNpmGlobalInstall() {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
const child = spawn('npm', ['install', '-g', `${PACKAGE}@latest`], {
|
|
29
|
+
stdio: 'inherit',
|
|
30
|
+
shell: process.platform === 'win32',
|
|
31
|
+
});
|
|
32
|
+
child.on('error', () => resolve(-1));
|
|
33
|
+
child.on('close', (code) => resolve(code ?? -1));
|
|
34
|
+
});
|
|
35
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
const DEFAULTS = {
|
|
@@ -7,7 +7,7 @@ const DEFAULTS = {
|
|
|
7
7
|
clientId: 'memoring-cli',
|
|
8
8
|
apiBase: 'https://memoring.com.br/api',
|
|
9
9
|
};
|
|
10
|
-
function configDir() {
|
|
10
|
+
export function configDir() {
|
|
11
11
|
if (process.env.MEMORING_CONFIG_HOME) {
|
|
12
12
|
return process.env.MEMORING_CONFIG_HOME;
|
|
13
13
|
}
|
|
@@ -41,10 +41,47 @@ export function loadConfig() {
|
|
|
41
41
|
email: stored.email,
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Endpoints para PERSISTIR: só o que veio do arquivo ou do default. Override de
|
|
46
|
+
* ambiente (MEMORING_API_BASE etc.) é efêmero por definição — gravá-lo no
|
|
47
|
+
* config.json faria comandos futuros, sem a variável, apontarem para o
|
|
48
|
+
* ambiente errado (ex.: dev).
|
|
49
|
+
*/
|
|
50
|
+
export function storedEndpoints() {
|
|
51
|
+
const stored = readStored();
|
|
52
|
+
return {
|
|
53
|
+
keycloakUrl: stored.keycloakUrl ?? DEFAULTS.keycloakUrl,
|
|
54
|
+
realm: stored.realm ?? DEFAULTS.realm,
|
|
55
|
+
clientId: stored.clientId ?? DEFAULTS.clientId,
|
|
56
|
+
apiBase: stored.apiBase ?? DEFAULTS.apiBase,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function writeAtomic(json) {
|
|
60
|
+
const path = configPath();
|
|
61
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
62
|
+
writeFileSync(tmp, json, { mode: 0o600 });
|
|
63
|
+
renameSync(tmp, path);
|
|
64
|
+
}
|
|
44
65
|
function writeStored(patch) {
|
|
45
66
|
const merged = { ...readStored(), ...patch };
|
|
46
67
|
mkdirSync(configDir(), { recursive: true });
|
|
47
|
-
|
|
68
|
+
writeAtomic(JSON.stringify(merged, null, 2));
|
|
69
|
+
}
|
|
70
|
+
/** Lockfile que serializa o refresh de token entre processos da CLI (ver auth.ts). */
|
|
71
|
+
export function refreshLockPath() {
|
|
72
|
+
return join(configDir(), 'refresh.lock');
|
|
73
|
+
}
|
|
74
|
+
export function ensureConfigDir() {
|
|
75
|
+
mkdirSync(configDir(), { recursive: true });
|
|
76
|
+
}
|
|
77
|
+
/** Remove o lockfile sem erro quando ele já foi liberado por outro processo. */
|
|
78
|
+
export function releaseRefreshLock() {
|
|
79
|
+
try {
|
|
80
|
+
rmSync(refreshLockPath());
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// já removido por outro processo
|
|
84
|
+
}
|
|
48
85
|
}
|
|
49
86
|
export function saveSession(endpoints, tokens) {
|
|
50
87
|
writeStored({ ...endpoints, ...tokens });
|
|
@@ -60,7 +97,7 @@ export function clearSession() {
|
|
|
60
97
|
void accessTokenExpiresAt;
|
|
61
98
|
void email;
|
|
62
99
|
mkdirSync(configDir(), { recursive: true });
|
|
63
|
-
|
|
100
|
+
writeAtomic(JSON.stringify(rest, null, 2));
|
|
64
101
|
return existed;
|
|
65
102
|
}
|
|
66
103
|
export function hasSession() {
|
package/dist/main.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { ApiError } from './api.js';
|
|
3
|
-
import { setColorEnabled } from './output.js';
|
|
3
|
+
import { setColorEnabled, yellow } from './output.js';
|
|
4
4
|
import { authCommand } from './commands/auth.js';
|
|
5
5
|
import { inboxCommand } from './commands/inbox.js';
|
|
6
6
|
import { stageCommand } from './commands/stage.js';
|
|
7
|
+
import { updateCommand } from './commands/update.js';
|
|
7
8
|
import { fail, parseArgs as parse } from './commands/shared.js';
|
|
8
|
-
|
|
9
|
+
import { newerVersionAvailable, scheduleUpdateCheck } from './update.js';
|
|
10
|
+
const VERSION = '0.4.0';
|
|
9
11
|
const HELP = `Memoring CLI — flashcards e notas por IA, direto do terminal.
|
|
10
12
|
|
|
11
13
|
Uso: memoring <comando> [subcomando] [flags]
|
|
@@ -15,19 +17,25 @@ Comandos:
|
|
|
15
17
|
stage schema contrato JSON dos itens da caixa de revisão
|
|
16
18
|
stage create [--file f] envia itens (arquivo ou stdin) para a caixa de revisão
|
|
17
19
|
[--replace] esvazia a caixa antes de enviar
|
|
20
|
+
stage caps [--max-* N] seus limites por tipo (valem pro app e pros agentes)
|
|
18
21
|
inbox list itens pendentes de revisão
|
|
19
22
|
inbox accept <id>|accept-all aceita itens (entram nas listagens e na fila FSRS)
|
|
20
23
|
inbox discard <id> descarta um item pendente
|
|
24
|
+
update atualiza a CLI para a versão mais recente (via npm)
|
|
21
25
|
version versão da CLI
|
|
22
26
|
|
|
23
27
|
Flags globais:
|
|
24
28
|
--json saída legível por máquina
|
|
25
29
|
--no-color desativa cores
|
|
26
30
|
--api-base sobrescreve a URL da API (default: https://memoring.com.br/api)
|
|
31
|
+
--version imprime a versão da CLI
|
|
27
32
|
--help, -h ajuda
|
|
28
33
|
|
|
29
34
|
Ambiente (dev): MEMORING_API_BASE, MEMORING_KEYCLOAK_URL, MEMORING_KC_REALM, MEMORING_KC_CLIENT
|
|
30
35
|
|
|
36
|
+
Atualização: a CLI verifica em background (1x/24h) se há versão nova e avisa no stderr.
|
|
37
|
+
MEMORING_NO_UPDATE_CHECK=1 desativa a verificação.
|
|
38
|
+
|
|
31
39
|
Exemplos:
|
|
32
40
|
memoring auth login
|
|
33
41
|
memoring stage schema
|
|
@@ -45,8 +53,21 @@ async function main() {
|
|
|
45
53
|
if (apiBase) {
|
|
46
54
|
process.env.MEMORING_API_BASE = apiBase;
|
|
47
55
|
}
|
|
56
|
+
if (parsed._[0] !== 'update') {
|
|
57
|
+
scheduleUpdateCheck();
|
|
58
|
+
}
|
|
59
|
+
process.on('exit', () => {
|
|
60
|
+
const latest = newerVersionAvailable(VERSION);
|
|
61
|
+
if (latest) {
|
|
62
|
+
process.stderr.write(yellow(`\nnova versão do memoring disponível: ${VERSION} → ${latest} — rode \`memoring update\` para atualizar.\n`));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
48
65
|
const [command, ...rest] = parsed._;
|
|
49
66
|
const sub = { _: rest, flags: parsed.flags };
|
|
67
|
+
if (parsed.flags.version) {
|
|
68
|
+
process.stdout.write(`memoring-cli ${VERSION}\n`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
50
71
|
if (parsed.flags.help || !command) {
|
|
51
72
|
process.stdout.write(HELP);
|
|
52
73
|
return;
|
|
@@ -61,6 +82,9 @@ async function main() {
|
|
|
61
82
|
case 'inbox':
|
|
62
83
|
await inboxCommand(sub);
|
|
63
84
|
return;
|
|
85
|
+
case 'update':
|
|
86
|
+
await updateCommand(VERSION);
|
|
87
|
+
return;
|
|
64
88
|
case 'version':
|
|
65
89
|
process.stdout.write(`memoring-cli ${VERSION}\n`);
|
|
66
90
|
return;
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { configDir } from './config.js';
|
|
5
|
+
const REGISTRY_LATEST_URL = 'https://registry.npmjs.org/@memoring%2Fcli/latest';
|
|
6
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
const FETCH_TIMEOUT_MS = 8000;
|
|
8
|
+
function cachePath() {
|
|
9
|
+
return join(configDir(), 'update-check.json');
|
|
10
|
+
}
|
|
11
|
+
function readCache() {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(readFileSync(cachePath(), 'utf8'));
|
|
14
|
+
if (typeof parsed.checkedAt !== 'number') {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
checkedAt: parsed.checkedAt,
|
|
19
|
+
latest: typeof parsed.latest === 'string' ? parsed.latest : undefined,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function writeCache(latest) {
|
|
27
|
+
try {
|
|
28
|
+
mkdirSync(dirname(cachePath()), { recursive: true });
|
|
29
|
+
const tmp = `${cachePath()}.${process.pid}.tmp`;
|
|
30
|
+
writeFileSync(tmp, JSON.stringify({ checkedAt: Date.now(), latest }));
|
|
31
|
+
renameSync(tmp, cachePath());
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// cache é best-effort — nunca atrapalhar o comando em execução
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** true quando `latest` é semver maior que `current` (comparação numérica de x.y.z). */
|
|
38
|
+
export function isNewerVersion(latest, current) {
|
|
39
|
+
const parts = (v) => v.replace(/^v/, '').split('.').map((p) => Number.parseInt(p, 10) || 0);
|
|
40
|
+
const a = parts(latest);
|
|
41
|
+
const b = parts(current);
|
|
42
|
+
for (let i = 0; i < 3; i++) {
|
|
43
|
+
const x = a[i] ?? 0;
|
|
44
|
+
const y = b[i] ?? 0;
|
|
45
|
+
if (x !== y) {
|
|
46
|
+
return x > y;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
/** Versão mais nova segundo o cache local, ou null (cache ausente/stale não é erro). */
|
|
52
|
+
export function newerVersionAvailable(current) {
|
|
53
|
+
const cache = readCache();
|
|
54
|
+
return cache?.latest && isNewerVersion(cache.latest, current) ? cache.latest : null;
|
|
55
|
+
}
|
|
56
|
+
/** Consulta o registry na hora (usado pelo `memoring update`). Null quando offline/indisponível. */
|
|
57
|
+
export async function fetchLatestVersion() {
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(REGISTRY_LATEST_URL, {
|
|
62
|
+
signal: controller.signal,
|
|
63
|
+
headers: { accept: 'application/json' },
|
|
64
|
+
});
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const body = (await response.json());
|
|
69
|
+
return typeof body.version === 'string' ? body.version : null;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Grava o cache (após self-update bem-sucedido, suprime o aviso na hora). */
|
|
79
|
+
export function recordUpdateCheck(latest) {
|
|
80
|
+
writeCache(latest);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Agenda a verificação periódica: um processo filho detached faz o fetch e grava
|
|
84
|
+
* o cache — o comando em execução nunca espera a rede e nunca vê erro (offline ok).
|
|
85
|
+
* No máximo uma verificação a cada 24h; MEMORING_NO_UPDATE_CHECK=1 desativa.
|
|
86
|
+
*/
|
|
87
|
+
export function scheduleUpdateCheck() {
|
|
88
|
+
if (process.env.MEMORING_NO_UPDATE_CHECK) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const cache = readCache();
|
|
92
|
+
if (cache && Date.now() - cache.checkedAt < CHECK_INTERVAL_MS) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const script = `
|
|
96
|
+
const [cachePath, previous] = process.argv.slice(1);
|
|
97
|
+
const fs = require('node:fs');
|
|
98
|
+
const path = require('node:path');
|
|
99
|
+
const writeCache = (latest) => {
|
|
100
|
+
try {
|
|
101
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
102
|
+
const tmp = cachePath + '.' + process.pid + '.tmp';
|
|
103
|
+
fs.writeFileSync(tmp, JSON.stringify({ checkedAt: Date.now(), latest: latest || undefined }));
|
|
104
|
+
fs.renameSync(tmp, cachePath);
|
|
105
|
+
} catch {}
|
|
106
|
+
};
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
const timer = setTimeout(() => controller.abort(), ${FETCH_TIMEOUT_MS});
|
|
109
|
+
fetch(${JSON.stringify(REGISTRY_LATEST_URL)}, { signal: controller.signal, headers: { accept: 'application/json' } })
|
|
110
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
111
|
+
.then((b) => writeCache(b && typeof b.version === 'string' ? b.version : previous))
|
|
112
|
+
.catch(() => writeCache(previous))
|
|
113
|
+
.finally(() => clearTimeout(timer));
|
|
114
|
+
`;
|
|
115
|
+
try {
|
|
116
|
+
const child = spawn(process.execPath, ['-e', script, cachePath(), cache?.latest ?? ''], {
|
|
117
|
+
detached: true,
|
|
118
|
+
stdio: 'ignore',
|
|
119
|
+
windowsHide: true,
|
|
120
|
+
});
|
|
121
|
+
child.unref();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// verificação é best-effort
|
|
125
|
+
}
|
|
126
|
+
}
|
package/package.json
CHANGED
|
@@ -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.
|
|
@@ -33,6 +34,7 @@ O Memoring é um app de estudos com revisão espaçada (FSRS). A CLI `memoring`
|
|
|
33
34
|
## Armadilhas
|
|
34
35
|
|
|
35
36
|
- **Nunca** use `--replace` sem o usuário pedir explicitamente — ele apaga os itens pendentes existentes da caixa.
|
|
37
|
+
- 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.
|
|
36
38
|
- Se `stage create` falhar com erro de validação do servidor, corrija os itens indicados e reenvie — não relaxe o contrato.
|
|
37
39
|
- Lotes grandes: divida em vários arquivos de até 50 itens e envie sequencialmente.
|
|
38
40
|
- Flashcards bons são atômicos: uma ideia por cartão, pergunta direta, resposta completa mas curta.
|