wendkeep 0.1.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.
@@ -0,0 +1,517 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'fs';
3
+ import { basename, dirname, join, relative } from 'path';
4
+
5
+ // Neutral fallback only. The vault is normally resolved from the
6
+ // OBSIDIAN_VAULT_PATH env var (set by `wendkeep init`) via getVaultBase() below.
7
+ export const DEFAULT_VAULT_BASE = join(
8
+ process.env.USERPROFILE || process.env.HOME || process.cwd(),
9
+ 'wendkeep-vault',
10
+ );
11
+ export const MONTH_FOLDERS = [
12
+ '01-JAN', '02-FEV', '03-MAR', '04-ABR', '05-MAI', '06-JUN',
13
+ '07-JUL', '08-AGO', '09-SET', '10-OUT', '11-NOV', '12-DEZ',
14
+ ];
15
+
16
+ export const VAULT_COMPLEMENT_RULES = [
17
+ 'Regra prática do Vault: os hooks garantem o histórico automático por turno; o agente só complementa manualmente quando houver valor durável de memória, decisão, bug, aprendizado ou auditoria/validação.',
18
+ 'Evite duplicar o que o hook já registra. Use escrita manual para síntese curada baseada em evidências, não para histórico bruto nem raciocínio interno.',
19
+ 'Quando complementar, registre a síntese na sessão ativa dentro de `## Iterações` antes de `## Decisões geradas nesta sessão`, ou crie nota derivada em `04-Decisões/`, `05-Bugs/` ou `06-Aprendizados/` com backlink para a sessão.',
20
+ 'Atualize `SHARED_MEMORY.md` somente quando a síntese mudar estado ativo que outro agente precise saber.',
21
+ ];
22
+
23
+ export function readHookInput() {
24
+ const raw = readFileSync(0, 'utf-8').trim();
25
+ if (!raw) return {};
26
+ return JSON.parse(raw);
27
+ }
28
+
29
+ export function writeHookOutput(payload = {}) {
30
+ process.stdout.write(JSON.stringify(payload));
31
+ }
32
+
33
+ export function getVaultBase(input = {}) {
34
+ return process.env.OBSIDIAN_VAULT_PATH || input.obsidian_vault_path || DEFAULT_VAULT_BASE;
35
+ }
36
+
37
+ // Detecta o agente real que está executando o hook. Claude Code expõe
38
+ // CLAUDECODE / CLAUDE_CODE_SESSION_ID / CLAUDE_PROJECT_DIR; Codex não.
39
+ export function detectProvider() {
40
+ if (process.env.CLAUDECODE === '1' || process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_PROJECT_DIR) {
41
+ return 'claude';
42
+ }
43
+ return 'codex';
44
+ }
45
+
46
+ export function providerMeta(provider = detectProvider()) {
47
+ if (provider === 'claude') {
48
+ return { id: 'claude', label: 'Claude Code', tag: 'claude', source: 'claude-hook' };
49
+ }
50
+ return { id: 'codex', label: 'Codex', tag: 'codex', source: 'codex-hook' };
51
+ }
52
+
53
+ export function ensureDir(path) {
54
+ if (!existsSync(path)) mkdirSync(path, { recursive: true });
55
+ }
56
+
57
+ export function pad2(value) {
58
+ return String(value).padStart(2, '0');
59
+ }
60
+
61
+ export function localDateParts(date = new Date()) {
62
+ return {
63
+ year: date.getFullYear(),
64
+ month: date.getMonth() + 1,
65
+ day: date.getDate(),
66
+ hour: date.getHours(),
67
+ minute: date.getMinutes(),
68
+ second: date.getSeconds(),
69
+ };
70
+ }
71
+
72
+ export function formatDate(date = new Date()) {
73
+ const p = localDateParts(date);
74
+ return `${p.year}-${pad2(p.month)}-${pad2(p.day)}`;
75
+ }
76
+
77
+ export function formatTime(date = new Date()) {
78
+ const p = localDateParts(date);
79
+ return `${pad2(p.hour)}:${pad2(p.minute)}:${pad2(p.second)}`;
80
+ }
81
+
82
+ export function formatHourMinute(date = new Date()) {
83
+ const p = localDateParts(date);
84
+ return `${pad2(p.hour)}-${pad2(p.minute)}`;
85
+ }
86
+
87
+ export function formatLocalIso(date = new Date()) {
88
+ return `${formatDate(date)}T${formatTime(date)}`;
89
+ }
90
+
91
+ export function datedFolderRel(rootFolder, date = new Date()) {
92
+ const p = localDateParts(date);
93
+ return join(rootFolder, String(p.year), MONTH_FOLDERS[p.month - 1], `DIA ${pad2(p.day)}`);
94
+ }
95
+
96
+ // Mesma estrutura datada a partir de uma string 'YYYY-MM-DD' (usada pelas notas derivadas).
97
+ export function datedFolderRelFromDateStr(rootFolder, dateStr) {
98
+ const [year, month, day] = String(dateStr).split('-');
99
+ return join(rootFolder, year, MONTH_FOLDERS[Number(month) - 1], `DIA ${pad2(day)}`);
100
+ }
101
+
102
+ export function sessionFolderRel(date = new Date()) {
103
+ return datedFolderRel('02-Sessões', date);
104
+ }
105
+
106
+ export function controlPath(vaultBase) {
107
+ return join(vaultBase, '.brain', 'CURRENT_SESSION.md');
108
+ }
109
+
110
+ export function registryPath(vaultBase) {
111
+ return join(vaultBase, '.brain', 'SESSION_REGISTRY.json');
112
+ }
113
+
114
+ export function toVaultRelative(vaultBase, path) {
115
+ return relative(vaultBase, path).replaceAll('\\', '/');
116
+ }
117
+
118
+ export function stripYamlQuotes(value = '') {
119
+ return value.trim().replace(/^["']|["']$/g, '');
120
+ }
121
+
122
+ export function yamlQuote(value = '') {
123
+ return JSON.stringify(String(value || ''));
124
+ }
125
+
126
+ export function readControl(vaultBase) {
127
+ const path = controlPath(vaultBase);
128
+ if (!existsSync(path)) return {};
129
+
130
+ const content = readFileSync(path, 'utf-8');
131
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
132
+ if (!match) return {};
133
+
134
+ const data = {};
135
+ for (const line of match[1].split('\n')) {
136
+ const item = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
137
+ if (item) data[item[1]] = stripYamlQuotes(item[2]);
138
+ }
139
+ return data;
140
+ }
141
+
142
+ export function writeControl(vaultBase, data) {
143
+ const path = controlPath(vaultBase);
144
+ ensureDir(dirname(path));
145
+
146
+ const status = data.status || 'inactive';
147
+ const sessionFile = data.session_file || '';
148
+ const lastSessionFile = data.last_session_file || sessionFile || '';
149
+ const startedAt = data.started_at || '';
150
+ const endedAt = data.ended_at || '';
151
+ const sessionId = data.session_id || '';
152
+ const lastLoggedTurnId = data.last_logged_turn_id || '';
153
+
154
+ const content = `---
155
+ status: "${status}"
156
+ session_file: "${sessionFile}"
157
+ last_session_file: "${lastSessionFile}"
158
+ started_at: "${startedAt}"
159
+ ended_at: "${endedAt}"
160
+ session_id: "${sessionId}"
161
+ last_logged_turn_id: "${lastLoggedTurnId}"
162
+ ---
163
+
164
+ # CURRENT_SESSION
165
+
166
+ - **Status:** ${status}
167
+ - **Sessão ativa:** ${sessionFile || 'nenhuma'}
168
+ - **Última sessão encerrada:** ${lastSessionFile || 'nenhuma'}
169
+ - **Início:** ${startedAt || 'n/a'}
170
+ - **Fim:** ${endedAt || 'n/a'}
171
+
172
+ Regra crítica: sempre anexar conteúdo à sessão ativa. Nunca sobrescrever o histórico de iterações.
173
+ `;
174
+
175
+ writeFileSync(path, content, 'utf-8');
176
+ }
177
+
178
+ export function readSessionRegistry(vaultBase) {
179
+ const path = registryPath(vaultBase);
180
+ if (!existsSync(path)) return { version: 1, sessions: {} };
181
+
182
+ try {
183
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
184
+ return {
185
+ version: parsed.version || 1,
186
+ sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
187
+ };
188
+ } catch {
189
+ return { version: 1, sessions: {} };
190
+ }
191
+ }
192
+
193
+ export function writeSessionRegistry(vaultBase, registry) {
194
+ const path = registryPath(vaultBase);
195
+ ensureDir(dirname(path));
196
+ // Escrita atômica: grava em tmp e renomeia (rename é atômico no mesmo volume),
197
+ // evitando registry truncado/corrompido quando dois hooks gravam ao mesmo tempo.
198
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
199
+ writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf-8');
200
+ renameSync(tmp, path);
201
+ }
202
+
203
+ export function upsertSessionRegistry(vaultBase, sessionId, patch) {
204
+ if (!sessionId) return null;
205
+
206
+ const registry = readSessionRegistry(vaultBase);
207
+ const current = registry.sessions[sessionId] || {};
208
+ const next = {
209
+ ...current,
210
+ ...patch,
211
+ updated_at: patch.updated_at || formatLocalIso(new Date()),
212
+ };
213
+ registry.sessions[sessionId] = next;
214
+ writeSessionRegistry(vaultBase, registry);
215
+ return next;
216
+ }
217
+
218
+ // Sessões sem evento de fim (janela fechada, crash, agente sem SessionEnd) ficam
219
+ // `active` para sempre. Após este limite ocioso, considera-se a sessão encerrada.
220
+ export const SESSION_IDLE_CLOSE_MS = 12 * 60 * 60 * 1000;
221
+
222
+ // Pura: marca como `done` toda sessão `active` cujo último sinal de vida
223
+ // (`updated_at`, senão `started_at`) é mais antigo que `maxIdleMs`. `ended_at`
224
+ // recebe esse último sinal (melhor estimativa de quando parou). Não toca na
225
+ // sessão de `excludeTranscriptPath` — ela pode estar sendo reaproveitada agora.
226
+ // Muta o registry recebido e devolve quantas fechou.
227
+ export function sweepStaleSessions(registry, nowMs, maxIdleMs, excludeTranscriptPath = '') {
228
+ const closed = [];
229
+ for (const item of Object.values(registry?.sessions || {})) {
230
+ if (!item || item.status !== 'active') continue;
231
+ if (excludeTranscriptPath && transcriptsMatch(item.transcript_path, excludeTranscriptPath)) continue;
232
+ const lastSeen = item.updated_at || item.started_at || '';
233
+ const lastMs = Date.parse(lastSeen);
234
+ if (!Number.isFinite(lastMs) || nowMs - lastMs <= maxIdleMs) continue;
235
+ item.status = 'done';
236
+ item.ended_at = lastSeen;
237
+ closed.push({ session_file: item.session_file, ended_at: lastSeen });
238
+ }
239
+ return closed;
240
+ }
241
+
242
+ // Wrapper de IO: varre as ociosas, grava o registry e fecha a NOTA `.md` de cada
243
+ // sessão encerrada (mantém vault e registry alinhados). Devolve quantas fechou.
244
+ export function sweepStaleSessionsFile(vaultBase, now = new Date(), maxIdleMs = SESSION_IDLE_CLOSE_MS, excludeTranscriptPath = '') {
245
+ const registry = readSessionRegistry(vaultBase);
246
+ const closed = sweepStaleSessions(registry, now.getTime(), maxIdleMs, excludeTranscriptPath);
247
+ if (closed.length) writeSessionRegistry(vaultBase, registry);
248
+ for (const { session_file, ended_at } of closed) {
249
+ try { closeSessionNoteFile(vaultBase, session_file, ended_at); } catch { /* nunca derruba o sweep */ }
250
+ }
251
+ return closed.length;
252
+ }
253
+
254
+ // IO: alinha a nota `.md` da sessão ao `done` (idempotente; no-op se ausente ou
255
+ // já fechada com o mesmo `endedAt`). Devolve true se gravou.
256
+ export function closeSessionNoteFile(vaultBase, sessionFileRel, endedAt) {
257
+ if (!sessionFileRel) return false;
258
+ const path = join(vaultBase, sessionFileRel);
259
+ if (!existsSync(path)) return false;
260
+ const content = readFileSync(path, 'utf-8');
261
+ const next = closeSessionNote(content, endedAt);
262
+ if (next === content) return false;
263
+ writeFileSync(path, next, 'utf-8');
264
+ return true;
265
+ }
266
+
267
+ // Marca de sessão ainda aberta no corpo da nota (template do hook de início).
268
+ export const SESSION_OPEN_PLACEHOLDER = 'Sessão ainda em andamento.';
269
+
270
+ // Pura e NÃO-DESTRUTIVA: alinha a NOTA `.md` ao `done` do registry mexendo só no
271
+ // frontmatter (`status`/`ended_at`) e trocando o placeholder de sessão aberta
272
+ // pelos campos de fechamento. Preserva todo o resto — inclusive seções anexadas
273
+ // depois de `## Encerramento`. No-op idempotente em nota já fechada.
274
+ export function closeSessionNote(content, endedAt) {
275
+ const src = String(content);
276
+ const isOpen = /^status:\s*"?active/m.test(src) || src.includes(SESSION_OPEN_PLACEHOLDER);
277
+ if (!isOpen) return src;
278
+ let next = src.replace(/^ended_at:.*$/m, `ended_at: ${endedAt}`);
279
+ next = next.replace(/^status:.*$/m, 'status: done');
280
+ next = next.replace(SESSION_OPEN_PLACEHOLDER, [
281
+ `- **Fim:** ${endedAt}`,
282
+ '- **Status:** done',
283
+ '- **Resumo final:** Sessão encerrada na reconciliação de histórico (status alinhado ao SESSION_REGISTRY).',
284
+ ].join('\n'));
285
+ return next;
286
+ }
287
+
288
+ export function slugify(text, fallback = 'nota') {
289
+ const slug = String(text || '')
290
+ .normalize('NFD')
291
+ .replace(/[\u0300-\u036f]/g, '')
292
+ .toLowerCase()
293
+ .replace(/[^a-z0-9]+/g, '-')
294
+ .replace(/^-+|-+$/g, '')
295
+ .slice(0, 70)
296
+ .replace(/-+$/g, '');
297
+ return slug || fallback;
298
+ }
299
+
300
+ // Chave de conteúdo p/ dedup de notas derivadas: normaliza e corta em 60 chars.
301
+ // Mesma normalização do slugify, mas preserva espaços (legível) e sem hífens.
302
+ export function derivedContentKey(text = '') {
303
+ return String(text)
304
+ .normalize('NFD')
305
+ .replace(/[̀-ͯ]/g, '')
306
+ .toLowerCase()
307
+ .replace(/[^a-z0-9]+/g, ' ')
308
+ .trim()
309
+ .slice(0, 60)
310
+ .trim();
311
+ }
312
+
313
+ // "Bate" = chaves iguais OU uma é prefixo da outra (cobre reformulação que
314
+ // estende o texto). Chave vazia nunca bate (evita falso-positivo).
315
+ export function keysBate(a = '', b = '') {
316
+ if (!a || !b) return false;
317
+ return a === b || a.startsWith(b) || b.startsWith(a);
318
+ }
319
+
320
+ export function extractHookPrompt(input = {}) {
321
+ const candidates = [
322
+ input.prompt,
323
+ input.user_prompt,
324
+ input.userPrompt,
325
+ input.message,
326
+ input.input,
327
+ ];
328
+
329
+ for (const candidate of candidates) {
330
+ if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
331
+ }
332
+
333
+ if (Array.isArray(input.messages)) {
334
+ const text = input.messages
335
+ .map((message) => message?.content || message?.text || '')
336
+ .filter((item) => typeof item === 'string' && item.trim())
337
+ .join('\n')
338
+ .trim();
339
+ if (text) return text;
340
+ }
341
+
342
+ return '';
343
+ }
344
+
345
+ export function isBootstrapPrompt(text = '') {
346
+ const clean = String(text || '').trim();
347
+ return clean.startsWith('# AGENTS.md instructions')
348
+ || clean.startsWith('<environment_context>')
349
+ || clean.startsWith('<permissions instructions>')
350
+ || clean.includes('You are Codex, a coding agent')
351
+ || clean.startsWith('## Memory');
352
+ }
353
+
354
+ export function summarizePromptForTitle(text = '', fallback = 'session') {
355
+ const cleaned = redactSecrets(String(text || ''))
356
+ .replace(/\[@[^\]]+\]\([^)]+\)/g, ' ')
357
+ .replace(/<image>[\s\S]*?<\/image>/gi, ' ')
358
+ .replace(/<[^>\n]+>/g, ' ')
359
+ .replace(/\r/g, '\n');
360
+
361
+ const source = cleaned
362
+ .split('\n')
363
+ .map((line) => line.trim())
364
+ .filter((line) => line && !isBootstrapPrompt(line))
365
+ .find((line) => !/^[-*_`#\s]+$/.test(line));
366
+
367
+ if (!source) return fallback;
368
+
369
+ const withoutCommitPrefix = source.replace(/^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\([^)]+\))?:\s*/i, '');
370
+ const words = withoutCommitPrefix
371
+ .replace(/[`*_>#()[\]{}]/g, ' ')
372
+ .replace(/[^\p{L}\p{N}@+./:-]+/gu, ' ')
373
+ .split(/\s+/)
374
+ .map((word) => word.replace(/^[.:;,-]+|[.:;,-]+$/g, ''))
375
+ .filter(Boolean)
376
+ .slice(0, 10);
377
+
378
+ const summary = words.join(' ');
379
+ if (!summary) return fallback;
380
+ return `${summary.charAt(0).toLocaleUpperCase('pt-BR')}${summary.slice(1)}`;
381
+ }
382
+
383
+ export function sessionSummaryFromInput(input = {}, fallback = 'session') {
384
+ return summarizePromptForTitle(extractHookPrompt(input), fallback);
385
+ }
386
+
387
+ // Evita retitular a sessão com resumo fraco (fallback ou palavra única),
388
+ // para que o título reflita o primeiro prompt real da conversa.
389
+ export function isUsableSummary(summary = '', fallback = 'session') {
390
+ if (!summary || summary === fallback) return false;
391
+ return String(summary).trim().split(/\s+/).filter(Boolean).length >= 2;
392
+ }
393
+
394
+ export function sessionFileName(date = new Date(), summary = 'session') {
395
+ return `${formatHourMinute(date)}-${slugify(summary, 'session')}.md`;
396
+ }
397
+
398
+ export function isPlaceholderSessionFile(relPath = '') {
399
+ return /^\d{2}-\d{2}-(?:codex|session)(?:-\d+)?\.md$/i.test(basename(relPath));
400
+ }
401
+
402
+ export function shouldReuseActiveSession(control = {}, now = new Date()) {
403
+ if (control.status !== 'active' || !control.session_file || control.ended_at) return false;
404
+ const startedMs = Date.parse(control.started_at || '');
405
+ if (!Number.isFinite(startedMs)) return true;
406
+ const windowMinutes = Number(process.env.OBSIDIAN_REUSE_ACTIVE_WINDOW_MINUTES || process.env.CODEX_OBSIDIAN_REUSE_ACTIVE_WINDOW_MINUTES || 10);
407
+ return now.getTime() - startedMs <= windowMinutes * 60 * 1000;
408
+ }
409
+
410
+ function normalizeTranscript(p) {
411
+ return String(p || '').replace(/\\/g, '/').toLowerCase();
412
+ }
413
+
414
+ function transcriptBasename(p) {
415
+ const n = normalizeTranscript(p);
416
+ const i = n.lastIndexOf('/');
417
+ return i === -1 ? n : n.slice(i + 1);
418
+ }
419
+
420
+ // Mesmo transcript apesar de caixa/separador diferentes (o Claude Code emite o
421
+ // slug do projeto ora `c--`, ora `C--`) ou prefixo de path diferente (WSL vs
422
+ // Windows). Compara normalizado e, em último caso, pelo basename
423
+ // (`<session_id>.jsonl`, globalmente único). Evita rupturas de sessão no restart.
424
+ export function transcriptsMatch(a, b) {
425
+ if (!a || !b) return false;
426
+ if (normalizeTranscript(a) === normalizeTranscript(b)) return true;
427
+ const ba = transcriptBasename(a);
428
+ return !!ba && ba === transcriptBasename(b);
429
+ }
430
+
431
+ // O `transcript_path` é estável dentro de uma conversa mesmo quando o
432
+ // SessionStart re-dispara (compactação/resume) com `session_id` novo. Achar a
433
+ // sessão ativa do mesmo transcript evita criar placeholders `HH-MM-codex`.
434
+ export function findActiveSessionByTranscript(vaultBase, transcriptPath) {
435
+ if (!transcriptPath) return null;
436
+ const registry = readSessionRegistry(vaultBase);
437
+ let best = null;
438
+ for (const [sessionId, item] of Object.entries(registry.sessions || {})) {
439
+ if (!item || item.status !== 'active' || !item.session_file) continue;
440
+ if (!transcriptsMatch(item.transcript_path, transcriptPath)) continue;
441
+ if (!best || String(item.started_at || '') > String(best.started_at || '')) {
442
+ best = { sessionId, session_file: item.session_file, started_at: item.started_at || '' };
443
+ }
444
+ }
445
+ return best;
446
+ }
447
+
448
+ export function redactSecrets(text) {
449
+ if (!text) return '';
450
+ return String(text)
451
+ .replace(/\b(sk-[A-Za-z0-9_-]{12,})\b/g, '[REDACTED_SECRET]')
452
+ .replace(/\b(whsec_[A-Za-z0-9_/-]{8,})\b/g, '[REDACTED_SECRET]')
453
+ .replace(/\b(gh[pousr]_[A-Za-z0-9_]{12,})\b/g, '[REDACTED_SECRET]')
454
+ .replace(/\b(xox[baprs]-[A-Za-z0-9-]{12,})\b/g, '[REDACTED_SECRET]')
455
+ .replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY)[A-Z0-9_]*)\s*[:=]\s*["']?[^"'\s]+/gi, '$1=[REDACTED_SECRET]')
456
+ .replace(/:\/\/([^:\s/@]+):([^@\s/]+)@/g, '://[REDACTED_SECRET]@');
457
+ }
458
+
459
+ export function truncate(text, max = 240) {
460
+ const clean = redactSecrets(String(text || '').replace(/\s+/g, ' ').trim());
461
+ if (clean.length <= max) return clean;
462
+ return `${clean.slice(0, Math.max(0, max - 3)).trim()}...`;
463
+ }
464
+
465
+ export function uniquePath(basePath) {
466
+ if (!existsSync(basePath)) return basePath;
467
+ const extMatch = basePath.match(/(\.[^.\/]+)$/);
468
+ const ext = extMatch ? extMatch[1] : '';
469
+ const stem = ext ? basePath.slice(0, -ext.length) : basePath;
470
+ let index = 2;
471
+ while (existsSync(`${stem}-${index}${ext}`)) index += 1;
472
+ return `${stem}-${index}${ext}`;
473
+ }
474
+
475
+ export function wikilinkFromRel(relPath) {
476
+ return `[[${relPath.replace(/\.md$/i, '').replaceAll('\\', '/')}]]`;
477
+ }
478
+
479
+ export function listMarkdownFiles(dir) {
480
+ try {
481
+ return readdirSync(dir).filter((f) => f.endsWith('.md'));
482
+ } catch {
483
+ return [];
484
+ }
485
+ }
486
+
487
+ export function getNextAdrNumber(vaultBase) {
488
+ const decisionsDir = join(vaultBase, '04-Decisões');
489
+ let max = 0;
490
+ // Varre recursivamente: os ADRs agora vivem em subpastas datadas (AAAA/MM-MMM/DIA DD).
491
+ const walk = (dir) => {
492
+ let entries;
493
+ try {
494
+ entries = readdirSync(dir, { withFileTypes: true });
495
+ } catch {
496
+ return;
497
+ }
498
+ for (const entry of entries) {
499
+ if (entry.isDirectory()) {
500
+ walk(join(dir, entry.name));
501
+ } else {
502
+ const match = entry.name.match(/^ADR-(\d+)/i);
503
+ if (match) max = Math.max(max, Number(match[1]));
504
+ }
505
+ }
506
+ };
507
+ walk(decisionsDir);
508
+ return max + 1;
509
+ }
510
+
511
+ export function statExists(path) {
512
+ try {
513
+ return statSync(path);
514
+ } catch {
515
+ return null;
516
+ }
517
+ }
@@ -0,0 +1,48 @@
1
+ {
2
+ "_nota": "Preços API por milhão de tokens. cachedInput = cache read. Cache write aplica multiplicador no código: 5m = 1.25x input, 1h = 2x input. Editar aqui quando o provedor mudar preços (sem mexer no .mjs). Se o arquivo sumir ou ficar inválido, o hook usa a tabela embutida em token-usage.mjs.",
3
+ "_fonte": "Anthropic https://www.anthropic.com/pricing — conferido 2026-06-13 (Opus 4.8: $5 input / $25 output; cache read 0.1x; cache write 1.25x 5m / 2x 1h)",
4
+ "models": {
5
+ "gpt-5.5": {
6
+ "label": "GPT-5.5 API",
7
+ "provider": "openai",
8
+ "input": 5,
9
+ "cachedInput": 0.5,
10
+ "output": 30
11
+ },
12
+ "claude-opus-4.7": {
13
+ "label": "Claude Opus 4.7 API",
14
+ "provider": "anthropic",
15
+ "input": 5,
16
+ "cachedInput": 0.5,
17
+ "output": 25
18
+ },
19
+ "claude-opus-4.8": {
20
+ "label": "Claude Opus 4.8 API",
21
+ "provider": "anthropic",
22
+ "input": 5,
23
+ "cachedInput": 0.5,
24
+ "output": 25
25
+ },
26
+ "claude-sonnet-4.6": {
27
+ "label": "Claude Sonnet 4.6 API",
28
+ "provider": "anthropic",
29
+ "input": 3,
30
+ "cachedInput": 0.3,
31
+ "output": 15
32
+ },
33
+ "claude-haiku-4.5": {
34
+ "label": "Claude Haiku 4.5 API",
35
+ "provider": "anthropic",
36
+ "input": 1,
37
+ "cachedInput": 0.1,
38
+ "output": 5
39
+ },
40
+ "claude-fable-5": {
41
+ "label": "Claude Fable 5 API",
42
+ "provider": "anthropic",
43
+ "input": 10,
44
+ "cachedInput": 1,
45
+ "output": 50
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { pathToFileURL } from 'url';
5
+ import {
6
+ buildIterationBlock,
7
+ insertIteration,
8
+ parseTranscript,
9
+ } from './session-stop.mjs';
10
+ import {
11
+ getVaultBase,
12
+ readControl,
13
+ readSessionRegistry,
14
+ } from './obsidian-common.mjs';
15
+
16
+ function parseArgs(argv) {
17
+ const args = { write: false, limit: 0, session: '' };
18
+ for (let i = 0; i < argv.length; i += 1) {
19
+ const item = argv[i];
20
+ if (item === '--write') {
21
+ args.write = true;
22
+ continue;
23
+ }
24
+ if (!item.startsWith('--')) continue;
25
+ const key = item.slice(2);
26
+ const next = argv[i + 1];
27
+ if (!next || next.startsWith('--')) {
28
+ args[key] = true;
29
+ } else {
30
+ args[key] = next;
31
+ i += 1;
32
+ }
33
+ }
34
+ args.limit = Number(args.limit || 0);
35
+ return args;
36
+ }
37
+
38
+ function hasTurnMarker(content, turnId) {
39
+ return content.includes(`<!-- codex-turn: ${turnId} -->`);
40
+ }
41
+
42
+ function sessionEntries(vaultBase, args) {
43
+ const registry = readSessionRegistry(vaultBase);
44
+ const entries = Object.entries(registry.sessions || {})
45
+ .map(([sessionId, item]) => ({ sessionId, ...item }))
46
+ .filter((item) => item.session_file && item.transcript_path);
47
+
48
+ if (args.session) {
49
+ return entries.filter((item) => item.session_file === args.session || item.sessionId === args.session);
50
+ }
51
+
52
+ return entries;
53
+ }
54
+
55
+ export function backfillSessions({ vaultBase, write = false, limit = 0, session = '' }) {
56
+ const args = { write, limit: Number(limit || 0), session };
57
+ const report = {
58
+ ok: true,
59
+ mode: write ? 'write' : 'dry-run',
60
+ scanned: 0,
61
+ candidates: 0,
62
+ inserted: 0,
63
+ skipped: 0,
64
+ missing: [],
65
+ sessions: [],
66
+ };
67
+
68
+ for (const entry of sessionEntries(vaultBase, args)) {
69
+ if (args.limit && report.scanned >= args.limit) break;
70
+ report.scanned += 1;
71
+
72
+ const sessionPath = join(vaultBase, entry.session_file);
73
+ if (!existsSync(sessionPath) || !existsSync(entry.transcript_path)) {
74
+ report.missing.push({
75
+ session: entry.session_file,
76
+ sessionExists: existsSync(sessionPath),
77
+ transcriptExists: existsSync(entry.transcript_path),
78
+ });
79
+ continue;
80
+ }
81
+
82
+ const tx = parseTranscript(entry.transcript_path);
83
+ const turns = tx.turns.filter((turn) => turn.turnId && turn.userPrompts.length);
84
+ const content = readFileSync(sessionPath, 'utf-8');
85
+ const missingTurns = turns.filter((turn) => !hasTurnMarker(content, turn.turnId));
86
+
87
+ if (!missingTurns.length) {
88
+ report.skipped += 1;
89
+ continue;
90
+ }
91
+
92
+ report.candidates += 1;
93
+ const sessionReport = {
94
+ session: entry.session_file,
95
+ transcript: entry.transcript_path,
96
+ missingTurns: missingTurns.map((turn) => turn.turnId),
97
+ inserted: 0,
98
+ };
99
+
100
+ if (write) {
101
+ for (const turn of missingTurns) {
102
+ const inserted = insertIteration(
103
+ sessionPath,
104
+ buildIterationBlock(tx, { turn_id: turn.turnId, now: turn.timestamp }),
105
+ turn.turnId,
106
+ tx,
107
+ );
108
+ if (inserted) {
109
+ report.inserted += 1;
110
+ sessionReport.inserted += 1;
111
+ }
112
+ }
113
+ }
114
+
115
+ report.sessions.push(sessionReport);
116
+ }
117
+
118
+ return report;
119
+ }
120
+
121
+ function main() {
122
+ const args = parseArgs(process.argv.slice(2));
123
+ const vaultBase = getVaultBase({ obsidian_vault_path: args.vault });
124
+ const control = readControl(vaultBase);
125
+ const result = backfillSessions({
126
+ vaultBase,
127
+ write: args.write,
128
+ limit: args.limit,
129
+ session: args.all ? '' : (args.session || control.session_file || ''),
130
+ });
131
+ console.log(JSON.stringify(result, null, 2));
132
+ }
133
+
134
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
135
+ try {
136
+ main();
137
+ } catch (error) {
138
+ process.stderr.write(`[codex-obsidian] Backfill falhou: ${error.message}\n`);
139
+ process.exitCode = 1;
140
+ }
141
+ }