@memoring/cli 0.12.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +163 -8
- package/dist/api.js +90 -4
- package/dist/args.js +14 -1
- package/dist/auth.js +1 -1
- package/dist/commands/batch.js +327 -0
- package/dist/commands/inbox.js +3 -1
- package/dist/commands/materialReferences.js +359 -0
- package/dist/commands/plan-analyze.js +256 -0
- package/dist/commands/plan-diagnostics.js +65 -0
- package/dist/commands/plan-exams.js +122 -0
- package/dist/commands/plan-goal.js +131 -0
- package/dist/commands/plan-shared.js +133 -0
- package/dist/commands/plan.js +219 -0
- package/dist/commands/planDrafts.js +162 -0
- package/dist/commands/scratch.js +632 -0
- package/dist/commands/stage.js +274 -73
- package/dist/config.js +2 -0
- package/dist/main.js +43 -2
- package/dist/pdf/batchValidation.js +72 -0
- package/dist/pdf/documentCache.js +68 -0
- package/dist/pdf/quoteLocate.js +181 -0
- package/package.json +8 -2
- package/skills/memoring-cli/SKILL.md +58 -7
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { apiFetch } from '../api.js';
|
|
7
|
+
import { listDecks } from './deck.js';
|
|
8
|
+
import { fail, green, printJson, yellow } from './shared.js';
|
|
9
|
+
const MAIN_PATH = fileURLToPath(new URL('../main.js', import.meta.url));
|
|
10
|
+
function parseJsonFile(filePath) {
|
|
11
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
12
|
+
}
|
|
13
|
+
function batchItems(filePath) {
|
|
14
|
+
const payload = parseJsonFile(filePath);
|
|
15
|
+
return Array.isArray(payload) ? payload : payload.items;
|
|
16
|
+
}
|
|
17
|
+
function sha256(value) {
|
|
18
|
+
return createHash('sha256').update(value).digest('hex');
|
|
19
|
+
}
|
|
20
|
+
function loadPlan(planPath) {
|
|
21
|
+
const raw = readFileSync(planPath, 'utf8');
|
|
22
|
+
const plan = JSON.parse(raw);
|
|
23
|
+
if (plan.version !== 1 || !Array.isArray(plan.activities) || plan.activities.length === 0) {
|
|
24
|
+
fail('manifesto inválido: esperado version=1 e activities não vazio.');
|
|
25
|
+
}
|
|
26
|
+
if (typeof plan.pdfMap !== 'object' || plan.pdfMap === null || Array.isArray(plan.pdfMap)) {
|
|
27
|
+
fail('manifesto inválido: pdfMap deve ser um objeto driveFileId → PDF local.');
|
|
28
|
+
}
|
|
29
|
+
const base = dirname(resolve(planPath));
|
|
30
|
+
const ids = new Set();
|
|
31
|
+
for (const [index, activity] of plan.activities.entries()) {
|
|
32
|
+
const where = `atividade #${index + 1}`;
|
|
33
|
+
if (!activity.id || ids.has(activity.id))
|
|
34
|
+
fail(`${where}: id ausente ou duplicado (${activity.id}).`);
|
|
35
|
+
ids.add(activity.id);
|
|
36
|
+
if (!activity.batchFile || !activity.deckName)
|
|
37
|
+
fail(`${where} (${activity.id}): batchFile/deckName obrigatórios.`);
|
|
38
|
+
activity.batchFile = resolve(base, activity.batchFile);
|
|
39
|
+
if (activity.generationFile)
|
|
40
|
+
activity.generationFile = resolve(base, activity.generationFile);
|
|
41
|
+
if (!Array.isArray(activity.expectedReferences))
|
|
42
|
+
activity.expectedReferences = [];
|
|
43
|
+
}
|
|
44
|
+
for (const [id, file] of Object.entries(plan.pdfMap)) {
|
|
45
|
+
if (typeof file !== 'string' || file.trim() === '')
|
|
46
|
+
fail(`pdfMap.${id}: caminho inválido.`);
|
|
47
|
+
plan.pdfMap[id] = resolve(base, file);
|
|
48
|
+
}
|
|
49
|
+
return { plan, raw };
|
|
50
|
+
}
|
|
51
|
+
function strictDeck(decks, ref, activityId) {
|
|
52
|
+
const numeric = Number(ref);
|
|
53
|
+
const matches = Number.isInteger(numeric) && numeric > 0
|
|
54
|
+
? decks.filter((deck) => deck.id === numeric)
|
|
55
|
+
: decks.filter((deck) => deck.name.trim().toLocaleLowerCase('pt-BR') === ref.trim().toLocaleLowerCase('pt-BR'));
|
|
56
|
+
if (matches.length === 0) {
|
|
57
|
+
throw new Error(`${activityId}: baralho ${ref} não existe; crie-o explicitamente antes do batch.`);
|
|
58
|
+
}
|
|
59
|
+
if (matches.length > 1) {
|
|
60
|
+
throw new Error(`${activityId}: baralho ${ref} é ambíguo (${matches.map((deck) => `#${deck.id}`).join(', ')}); use o ID.`);
|
|
61
|
+
}
|
|
62
|
+
return matches[0];
|
|
63
|
+
}
|
|
64
|
+
async function runCliJson(args) {
|
|
65
|
+
return new Promise((resolvePromise, reject) => {
|
|
66
|
+
execFile(process.execPath, [MAIN_PATH, ...args, '--json', '--no-color'], {
|
|
67
|
+
env: { ...process.env, MEMORING_NO_UPDATE_CHECK: '1' },
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
70
|
+
}, (error, stdout, stderr) => {
|
|
71
|
+
const code = error && typeof error.code === 'number'
|
|
72
|
+
? Number(error.code)
|
|
73
|
+
: error ? 1 : 0;
|
|
74
|
+
try {
|
|
75
|
+
resolvePromise({ code, data: JSON.parse(stdout), stdout, stderr });
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
reject(new Error(`CLI retornou saída inválida (${args.join(' ')}): ${stderr || stdout || error?.message}`));
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function pageRange(raw) {
|
|
84
|
+
const match = /^(\d+)(?:-(\d+))?$/.exec(raw);
|
|
85
|
+
if (!match)
|
|
86
|
+
throw new Error(`faixa de páginas inválida: ${raw}`);
|
|
87
|
+
const from = Number(match[1]);
|
|
88
|
+
const to = Number(match[2] ?? match[1]);
|
|
89
|
+
if (from < 1 || to < from)
|
|
90
|
+
throw new Error(`faixa de páginas inválida: ${raw}`);
|
|
91
|
+
return { from, to };
|
|
92
|
+
}
|
|
93
|
+
function writeJsonAtomic(filePath, value) {
|
|
94
|
+
const temp = `${filePath}.${process.pid}.tmp`;
|
|
95
|
+
writeFileSync(temp, JSON.stringify(value, null, 2));
|
|
96
|
+
renameSync(temp, filePath);
|
|
97
|
+
}
|
|
98
|
+
async function checkPlan(planPath, plan) {
|
|
99
|
+
const errors = [];
|
|
100
|
+
const warnings = [];
|
|
101
|
+
const checked = [];
|
|
102
|
+
const pdfMapFile = `${planPath}.pdf-map.json`;
|
|
103
|
+
writeJsonAtomic(pdfMapFile, plan.pdfMap);
|
|
104
|
+
let decks;
|
|
105
|
+
try {
|
|
106
|
+
decks = await listDecks();
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return { valid: false, errors: [`não consegui listar os baralhos: ${error.message}`], warnings, activities: [], pdfMapFile };
|
|
110
|
+
}
|
|
111
|
+
for (const activity of plan.activities) {
|
|
112
|
+
let deck;
|
|
113
|
+
try {
|
|
114
|
+
deck = strictDeck(decks, activity.deckName, activity.id);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
errors.push(error.message);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!existsSync(activity.batchFile)) {
|
|
121
|
+
errors.push(`${activity.id}: batchFile não encontrado (${activity.batchFile}).`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (activity.generationFile && !existsSync(activity.generationFile)) {
|
|
125
|
+
errors.push(`${activity.id}: generationFile não encontrado (${activity.generationFile}).`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const items = batchItems(activity.batchFile);
|
|
129
|
+
for (const [index, item] of items.entries()) {
|
|
130
|
+
if (item.deckName !== activity.deckName) {
|
|
131
|
+
errors.push(`${activity.id}, item #${index + 1} (${item.title}): deckName ${item.deckName} difere de ${activity.deckName}.`);
|
|
132
|
+
}
|
|
133
|
+
for (const reference of item.materialReferences ?? []) {
|
|
134
|
+
if (activity.expectedReferences.length === 0)
|
|
135
|
+
continue;
|
|
136
|
+
const matches = activity.expectedReferences.some((expected) => {
|
|
137
|
+
if (expected.driveFileId !== reference.driveFileId)
|
|
138
|
+
return false;
|
|
139
|
+
const range = pageRange(expected.pages);
|
|
140
|
+
return reference.page >= range.from && reference.page <= range.to;
|
|
141
|
+
});
|
|
142
|
+
if (!matches) {
|
|
143
|
+
errors.push(`${activity.id}, item #${index + 1} (${item.title}): referência ${reference.driveFileId} página ${reference.page} fora do esperado.`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const validation = await runCliJson(['stage', 'validate', '--file', activity.batchFile, '--pdf-map', pdfMapFile]);
|
|
148
|
+
if (!validation.data.valid) {
|
|
149
|
+
errors.push(...(validation.data.errors ?? []).map((error) => `${activity.id}: ${error}`));
|
|
150
|
+
}
|
|
151
|
+
warnings.push(...(validation.data.warnings ?? []).map((warning) => `${activity.id}: ${warning}`));
|
|
152
|
+
checked.push({
|
|
153
|
+
...activity,
|
|
154
|
+
deckId: deck.id,
|
|
155
|
+
items,
|
|
156
|
+
batchHash: sha256(readFileSync(activity.batchFile, 'utf8')),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return { valid: errors.length === 0, errors, warnings, activities: checked, pdfMapFile };
|
|
160
|
+
}
|
|
161
|
+
function statePath(planPath) {
|
|
162
|
+
return `${planPath}.state.json`;
|
|
163
|
+
}
|
|
164
|
+
function loadState(planPath, planHash) {
|
|
165
|
+
const path = statePath(planPath);
|
|
166
|
+
if (!existsSync(path))
|
|
167
|
+
return { version: 1, planHash, activities: {} };
|
|
168
|
+
let state;
|
|
169
|
+
try {
|
|
170
|
+
state = parseJsonFile(path);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
fail(`state corrompido (${path}): ${error.message}`);
|
|
174
|
+
}
|
|
175
|
+
if (state.version !== 1 || state.planHash !== planHash || typeof state.activities !== 'object') {
|
|
176
|
+
fail(`state incompatível com o manifesto (${path}); revise-o ou use outro manifesto.`);
|
|
177
|
+
}
|
|
178
|
+
return state;
|
|
179
|
+
}
|
|
180
|
+
async function verifyActivities(activities, state) {
|
|
181
|
+
const errors = [];
|
|
182
|
+
const inbox = (await apiFetch('/notes/review-inbox'));
|
|
183
|
+
const byId = new Map(inbox.map((note) => [note.id, note]));
|
|
184
|
+
for (const activity of activities) {
|
|
185
|
+
const current = state.activities[activity.id];
|
|
186
|
+
if (!current) {
|
|
187
|
+
errors.push(`${activity.id}: ainda não foi estagiada.`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (current.ids.length !== activity.items.length) {
|
|
191
|
+
errors.push(`${activity.id}: ${current.ids.length} IDs para ${activity.items.length} itens.`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
for (const [index, noteId] of current.ids.entries()) {
|
|
195
|
+
const note = byId.get(noteId);
|
|
196
|
+
if (!note) {
|
|
197
|
+
errors.push(`${activity.id}: nota #${noteId} ausente da caixa de revisão.`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (note.deckId !== activity.deckId) {
|
|
201
|
+
errors.push(`${activity.id}/${noteId}: deckId ${note.deckId}, esperado ${activity.deckId}.`);
|
|
202
|
+
}
|
|
203
|
+
const body = (await apiFetch(`/notes/${noteId}/material-references`));
|
|
204
|
+
const expected = activity.items[index].materialReferences ?? [];
|
|
205
|
+
const actual = body.references;
|
|
206
|
+
if (actual.length !== expected.length) {
|
|
207
|
+
errors.push(`${activity.id}/${noteId}: ${actual.length} referências, esperado ${expected.length}.`);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
for (const [refIndex, reference] of actual.entries()) {
|
|
211
|
+
const source = expected[refIndex];
|
|
212
|
+
if (reference.driveFileId !== source.driveFileId ||
|
|
213
|
+
reference.pageIndex + 1 !== source.page ||
|
|
214
|
+
(reference.quote ?? undefined) !== source.quote ||
|
|
215
|
+
(reference.contextBefore ?? undefined) !== source.contextBefore ||
|
|
216
|
+
(reference.contextAfter ?? undefined) !== source.contextAfter ||
|
|
217
|
+
JSON.stringify(reference.region ?? null) !== JSON.stringify(source.region ?? null)) {
|
|
218
|
+
errors.push(`${activity.id}/${noteId}: referência #${refIndex + 1} divergente.`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (current.importRunId) {
|
|
223
|
+
const run = (await apiFetch(`/notes/import-runs/${current.importRunId}`));
|
|
224
|
+
const linked = new Set(run.noteIds);
|
|
225
|
+
for (const noteId of current.ids) {
|
|
226
|
+
if (!linked.has(noteId))
|
|
227
|
+
errors.push(`${activity.id}: nota #${noteId} ausente no import run ${current.importRunId}.`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return errors;
|
|
232
|
+
}
|
|
233
|
+
function emit(parsed, result, valid) {
|
|
234
|
+
if (parsed.flags.json) {
|
|
235
|
+
printJson(result);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
239
|
+
}
|
|
240
|
+
if (!valid)
|
|
241
|
+
process.exitCode = 1;
|
|
242
|
+
}
|
|
243
|
+
export async function batchCommand(parsed) {
|
|
244
|
+
const [sub, planArg] = parsed._;
|
|
245
|
+
if (!sub || !planArg) {
|
|
246
|
+
fail('uso: memoring batch <check|run|status|verify> plan.json [--resume] [--json]');
|
|
247
|
+
}
|
|
248
|
+
const planPath = resolve(planArg);
|
|
249
|
+
if (sub === 'status') {
|
|
250
|
+
const { raw } = loadPlan(planPath);
|
|
251
|
+
const state = loadState(planPath, sha256(raw));
|
|
252
|
+
emit(parsed, state, true);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const { plan, raw } = loadPlan(planPath);
|
|
256
|
+
if (sub === 'check') {
|
|
257
|
+
const result = await checkPlan(planPath, plan);
|
|
258
|
+
emit(parsed, result, result.valid);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (sub === 'verify') {
|
|
262
|
+
const checked = await checkPlan(planPath, plan);
|
|
263
|
+
if (!checked.valid) {
|
|
264
|
+
emit(parsed, checked, false);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const state = loadState(planPath, sha256(raw));
|
|
268
|
+
const errors = await verifyActivities(checked.activities, state);
|
|
269
|
+
emit(parsed, { valid: errors.length === 0, errors }, errors.length === 0);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (sub !== 'run') {
|
|
273
|
+
fail(`subcomando inválido: ${sub}; use check, run, status ou verify.`);
|
|
274
|
+
}
|
|
275
|
+
const checked = await checkPlan(planPath, plan);
|
|
276
|
+
if (!checked.valid) {
|
|
277
|
+
emit(parsed, checked, false);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const state = loadState(planPath, sha256(raw));
|
|
281
|
+
for (const activity of checked.activities) {
|
|
282
|
+
let current = state.activities[activity.id];
|
|
283
|
+
if (current && current.batchHash !== activity.batchHash) {
|
|
284
|
+
fail(`${activity.id}: o lote mudou desde o último envio; revise o state antes de continuar.`);
|
|
285
|
+
}
|
|
286
|
+
if (current?.status === 'verified' && parsed.flags.resume) {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (!current || current.ids.length !== activity.items.length) {
|
|
290
|
+
const args = ['stage', 'create', '--file', activity.batchFile];
|
|
291
|
+
if (activity.generationFile)
|
|
292
|
+
args.push('--generation-file', activity.generationFile);
|
|
293
|
+
const created = await runCliJson(args);
|
|
294
|
+
if (created.code !== 0) {
|
|
295
|
+
fail(`${activity.id}: stage create falhou: ${created.stderr || created.stdout}`);
|
|
296
|
+
}
|
|
297
|
+
if (!Array.isArray(created.data.ids)) {
|
|
298
|
+
fail(`${activity.id}: stage create não devolveu os IDs esperados.`);
|
|
299
|
+
}
|
|
300
|
+
current = {
|
|
301
|
+
batchHash: activity.batchHash,
|
|
302
|
+
deckId: activity.deckId,
|
|
303
|
+
ids: created.data.ids,
|
|
304
|
+
importRunId: created.data.importRunId ?? null,
|
|
305
|
+
status: 'staged',
|
|
306
|
+
updatedAt: new Date().toISOString(),
|
|
307
|
+
};
|
|
308
|
+
state.activities[activity.id] = current;
|
|
309
|
+
writeJsonAtomic(statePath(planPath), state);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const errors = await verifyActivities(checked.activities, state);
|
|
313
|
+
if (errors.length === 0) {
|
|
314
|
+
for (const activity of checked.activities) {
|
|
315
|
+
state.activities[activity.id].status = 'verified';
|
|
316
|
+
state.activities[activity.id].updatedAt = new Date().toISOString();
|
|
317
|
+
}
|
|
318
|
+
writeJsonAtomic(statePath(planPath), state);
|
|
319
|
+
}
|
|
320
|
+
emit(parsed, { valid: errors.length === 0, errors, state }, errors.length === 0);
|
|
321
|
+
if (errors.length === 0 && !parsed.flags.json) {
|
|
322
|
+
process.stdout.write(green('Batch enviado e conferido.\n'));
|
|
323
|
+
}
|
|
324
|
+
else if (errors.length > 0) {
|
|
325
|
+
process.stderr.write(yellow(`${errors.length} divergência(s) na auditoria.\n`));
|
|
326
|
+
}
|
|
327
|
+
}
|
package/dist/commands/inbox.js
CHANGED
|
@@ -60,6 +60,7 @@ async function updateInboxItem(parsed, id) {
|
|
|
60
60
|
subtopicName: overrides.subtopicName ?? stringFlag(flags, 'subtopic') ?? current.subtopicName ?? null,
|
|
61
61
|
tags: overrides.tags ?? (tagsFlag !== undefined ? parseTagsFlag(tagsFlag) : current.tags ?? []),
|
|
62
62
|
source: overrides.source ?? stringFlag(flags, 'source') ?? current.source ?? null,
|
|
63
|
+
conceptKey: overrides.conceptKey ?? stringFlag(flags, 'concept') ?? current.conceptKey ?? null,
|
|
63
64
|
deckId: deckId ?? overrides.deckId ?? current.deckId ?? null,
|
|
64
65
|
question: overrides.question ?? current.question ?? null,
|
|
65
66
|
};
|
|
@@ -166,7 +167,8 @@ export async function inboxCommand(parsed) {
|
|
|
166
167
|
' discard-all descarta todos os itens pendentes\n' +
|
|
167
168
|
' update <id> edita um item PENDENTE (merge: --file parcial.json e/ou flags\n' +
|
|
168
169
|
' --title --content --front --back --subject --subtopic\n' +
|
|
169
|
-
' --tags a,b,c --source --type)\n'
|
|
170
|
+
' --tags a,b,c --source --concept trib-art-4 --type)\n' +
|
|
171
|
+
' (--concept preserva/define o conceptKey; sem ele, o valor atual é mantido)\n');
|
|
170
172
|
if (sub) {
|
|
171
173
|
process.exitCode = 1;
|
|
172
174
|
}
|