wendkeep 0.37.0 → 0.38.1

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.
@@ -1,619 +1,701 @@
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
- import { getLocale } from './locale.mjs';
5
-
6
- // Neutral fallback only. The vault is normally resolved from the
7
- // OBSIDIAN_VAULT_PATH env var (set by `wendkeep init`) via getVaultBase() below.
8
- export const DEFAULT_VAULT_BASE = join(
9
- process.env.USERPROFILE || process.env.HOME || process.cwd(),
10
- 'wendkeep-vault',
11
- );
12
- export const MONTH_FOLDERS = [
13
- '01-JAN', '02-FEV', '03-MAR', '04-ABR', '05-MAI', '06-JUN',
14
- '07-JUL', '08-AGO', '09-SET', '10-OUT', '11-NOV', '12-DEZ',
15
- ];
16
-
17
- export const VAULT_COMPLEMENT_RULES = [
18
- '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.',
19
- '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.',
20
- '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.',
21
- 'Atualize `SHARED_MEMORY.md` somente quando a síntese mudar estado ativo que outro agente precise saber.',
22
- ];
23
-
24
- export function readHookInput() {
25
- const raw = readFileSync(0, 'utf-8').trim();
26
- if (!raw) return {};
27
- return JSON.parse(raw);
28
- }
29
-
30
- export function writeHookOutput(payload = {}) {
31
- process.stdout.write(JSON.stringify(payload));
32
- }
33
-
34
- // Resolve the vault and report WHERE the path came from, so callers can react to
35
- // an unconfigured install instead of silently writing to the home fallback.
36
- // env -> OBSIDIAN_VAULT_PATH (set by `wendkeep init`)
37
- // payload -> obsidian_vault_path from the hook's JSON input
38
- // default -> DEFAULT_VAULT_BASE (~/wendkeep-vault) — a phantom vault nobody opened
39
- export function resolveVault(input = {}) {
40
- if (process.env.OBSIDIAN_VAULT_PATH) {
41
- return { base: process.env.OBSIDIAN_VAULT_PATH, source: 'env' };
42
- }
43
- if (input && input.obsidian_vault_path) {
44
- return { base: input.obsidian_vault_path, source: 'payload' };
45
- }
46
- return { base: DEFAULT_VAULT_BASE, source: 'default' };
47
- }
48
-
49
- export function getVaultBase(input = {}) {
50
- return resolveVault(input).base;
51
- }
52
-
53
- // Diagnostic logger. No-op unless WENDKEEP_DEBUG is set, so it never pollutes the
54
- // stdout hook contract during normal runs but makes fail-open paths debuggable.
55
- export function debugLog(...args) {
56
- if (!process.env.WENDKEEP_DEBUG) return;
57
- const text = args
58
- .map((a) => (a && a.stack ? a.stack : String(a)))
59
- .join(' ');
60
- process.stderr.write(`[wendkeep] ${text}\n`);
61
- }
62
-
63
- // Warn loudly (stderr) when the vault resolved to the home fallback — i.e. neither
64
- // OBSIDIAN_VAULT_PATH nor a payload path was provided. Without this the hooks write
65
- // notes into ~/wendkeep-vault with zero signal that the install is misconfigured.
66
- // Returns the resolution source so callers can branch if they want.
67
- export function warnIfDefaultVault(input = {}) {
68
- const { base, source } = resolveVault(input);
69
- if (source === 'default') {
70
- process.stderr.write(
71
- `[wendkeep] WARNING: OBSIDIAN_VAULT_PATH não definido — gravando no fallback "${base}". ` +
72
- 'Rode `wendkeep init` ou defina OBSIDIAN_VAULT_PATH apontando ao seu vault Obsidian.\n',
73
- );
74
- }
75
- return source;
76
- }
77
-
78
- // Detecta o agente real que está executando o hook. Claude Code expõe
79
- // CLAUDECODE / CLAUDE_CODE_SESSION_ID / CLAUDE_PROJECT_DIR; Codex não.
80
- export function detectProvider() {
81
- if (process.env.CLAUDECODE === '1' || process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_PROJECT_DIR) {
82
- return 'claude';
83
- }
84
- return 'codex';
85
- }
86
-
87
- export function providerMeta(provider = detectProvider()) {
88
- if (provider === 'claude') {
89
- return { id: 'claude', label: 'Claude Code', tag: 'claude', source: 'claude-hook' };
90
- }
91
- return { id: 'codex', label: 'Codex', tag: 'codex', source: 'codex-hook' };
92
- }
93
-
94
- export function ensureDir(path) {
95
- if (!existsSync(path)) mkdirSync(path, { recursive: true });
96
- }
97
-
98
- export function pad2(value) {
99
- return String(value).padStart(2, '0');
100
- }
101
-
102
- export function localDateParts(date = new Date()) {
103
- return {
104
- year: date.getFullYear(),
105
- month: date.getMonth() + 1,
106
- day: date.getDate(),
107
- hour: date.getHours(),
108
- minute: date.getMinutes(),
109
- second: date.getSeconds(),
110
- };
111
- }
112
-
113
- export function formatDate(date = new Date()) {
114
- const p = localDateParts(date);
115
- return `${p.year}-${pad2(p.month)}-${pad2(p.day)}`;
116
- }
117
-
118
- export function formatTime(date = new Date()) {
119
- const p = localDateParts(date);
120
- return `${pad2(p.hour)}:${pad2(p.minute)}:${pad2(p.second)}`;
121
- }
122
-
123
- export function formatHourMinute(date = new Date()) {
124
- const p = localDateParts(date);
125
- return `${pad2(p.hour)}-${pad2(p.minute)}`;
126
- }
127
-
128
- export function formatLocalIso(date = new Date()) {
129
- return `${formatDate(date)}T${formatTime(date)}`;
130
- }
131
-
132
- // Locale (0.8.0): month labels + folder names come from the vault locale when a
133
- // vaultBase is given; without it, pt-BR (backward compat — every legacy caller).
134
- export function datedFolderRel(rootFolder, date = new Date(), vaultBase) {
135
- const p = localDateParts(date);
136
- return join(rootFolder, String(p.year), getLocale(vaultBase).months[p.month - 1], `DIA ${pad2(p.day)}`);
137
- }
138
-
139
- // Mesma estrutura datada a partir de uma string 'YYYY-MM-DD' (sessões: até o DIA).
140
- export function datedFolderRelFromDateStr(rootFolder, dateStr, vaultBase) {
141
- const [year, month, day] = String(dateStr).split('-');
142
- return join(rootFolder, year, getLocale(vaultBase).months[Number(month) - 1], `DIA ${pad2(day)}`);
143
- }
144
-
145
- // Estrutura até o MÊS (sem DIA) — usada pelas notas derivadas (decisões/bugs/
146
- // aprendizados): tudo do mês fica junto em <pasta>/<ano>/<MM-MMM>/.
147
- export function monthFolderRelFromDateStr(rootFolder, dateStr, vaultBase) {
148
- const [year, month] = String(dateStr).split('-');
149
- return join(rootFolder, year, getLocale(vaultBase).months[Number(month) - 1]);
150
- }
151
-
152
- export function sessionFolderRel(date = new Date(), vaultBase) {
153
- return datedFolderRel(getLocale(vaultBase).folders.sessions, date, vaultBase);
154
- }
155
-
156
- export function controlPath(vaultBase) {
157
- return join(vaultBase, '.brain', 'CURRENT_SESSION.md');
158
- }
159
-
160
- export function registryPath(vaultBase) {
161
- return join(vaultBase, '.brain', 'SESSION_REGISTRY.json');
162
- }
163
-
164
- export function toVaultRelative(vaultBase, path) {
165
- return relative(vaultBase, path).replaceAll('\\', '/');
166
- }
167
-
168
- export function stripYamlQuotes(value = '') {
169
- return value.trim().replace(/^["']|["']$/g, '');
170
- }
171
-
172
- export function yamlQuote(value = '') {
173
- return JSON.stringify(String(value || ''));
174
- }
175
-
176
- export function readControl(vaultBase) {
177
- const path = controlPath(vaultBase);
178
- if (!existsSync(path)) return {};
179
-
180
- const content = readFileSync(path, 'utf-8');
181
- const match = content.match(/^---\n([\s\S]*?)\n---/);
182
- if (!match) return {};
183
-
184
- const data = {};
185
- for (const line of match[1].split('\n')) {
186
- const item = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
187
- if (item) data[item[1]] = stripYamlQuotes(item[2]);
188
- }
189
- return data;
190
- }
191
-
192
- export function writeControl(vaultBase, data) {
193
- const path = controlPath(vaultBase);
194
- ensureDir(dirname(path));
195
-
196
- const status = data.status || 'inactive';
197
- const sessionFile = data.session_file || '';
198
- const lastSessionFile = data.last_session_file || sessionFile || '';
199
- const startedAt = data.started_at || '';
200
- const endedAt = data.ended_at || '';
201
- const sessionId = data.session_id || '';
202
- const lastLoggedTurnId = data.last_logged_turn_id || '';
203
-
204
- const content = `---
205
- status: "${status}"
206
- session_file: "${sessionFile}"
207
- last_session_file: "${lastSessionFile}"
208
- started_at: "${startedAt}"
209
- ended_at: "${endedAt}"
210
- session_id: "${sessionId}"
211
- last_logged_turn_id: "${lastLoggedTurnId}"
212
- ---
213
-
214
- # CURRENT_SESSION
215
-
216
- - **Status:** ${status}
217
- - **Sessão ativa:** ${sessionFile || 'nenhuma'}
218
- - **Última sessão encerrada:** ${lastSessionFile || 'nenhuma'}
219
- - **Início:** ${startedAt || 'n/a'}
220
- - **Fim:** ${endedAt || 'n/a'}
221
-
222
- Regra crítica: sempre anexar conteúdo à sessão ativa. Nunca sobrescrever o histórico de iterações.
223
- `;
224
-
225
- writeFileSync(path, content, 'utf-8');
226
- }
227
-
228
- export function readSessionRegistry(vaultBase) {
229
- const path = registryPath(vaultBase);
230
- if (!existsSync(path)) return { version: 1, sessions: {} };
231
-
232
- try {
233
- const parsed = JSON.parse(readFileSync(path, 'utf-8'));
234
- return {
235
- version: parsed.version || 1,
236
- sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
237
- };
238
- } catch {
239
- return { version: 1, sessions: {} };
240
- }
241
- }
242
-
243
- export function writeSessionRegistry(vaultBase, registry) {
244
- const path = registryPath(vaultBase);
245
- ensureDir(dirname(path));
246
- // Escrita atômica: grava em tmp e renomeia (rename é atômico no mesmo volume),
247
- // evitando registry truncado/corrompido quando dois hooks gravam ao mesmo tempo.
248
- const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
249
- writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf-8');
250
- renameSync(tmp, path);
251
- }
252
-
253
- export function upsertSessionRegistry(vaultBase, sessionId, patch) {
254
- if (!sessionId) return null;
255
-
256
- const registry = readSessionRegistry(vaultBase);
257
- const current = registry.sessions[sessionId] || {};
258
- const next = {
259
- ...current,
260
- ...patch,
261
- updated_at: patch.updated_at || formatLocalIso(new Date()),
262
- };
263
- registry.sessions[sessionId] = next;
264
- writeSessionRegistry(vaultBase, registry);
265
- return next;
266
- }
267
-
268
- // Sessões sem evento de fim (janela fechada, crash, agente sem SessionEnd) ficam
269
- // `active` para sempre. Após este limite ocioso, considera-se a sessão encerrada.
270
- export const SESSION_IDLE_CLOSE_MS = 12 * 60 * 60 * 1000;
271
-
272
- // Pura: marca como `done` toda sessão `active` cujo último sinal de vida
273
- // (`updated_at`, senão `started_at`) é mais antigo que `maxIdleMs`. `ended_at`
274
- // recebe esse último sinal (melhor estimativa de quando parou). Não toca na
275
- // sessão de `excludeTranscriptPath` — ela pode estar sendo reaproveitada agora.
276
- // Muta o registry recebido e devolve quantas fechou.
277
- export function sweepStaleSessions(registry, nowMs, maxIdleMs, excludeTranscriptPath = '') {
278
- const closed = [];
279
- for (const item of Object.values(registry?.sessions || {})) {
280
- if (!item || item.status !== 'active') continue;
281
- if (excludeTranscriptPath && transcriptsMatch(item.transcript_path, excludeTranscriptPath)) continue;
282
- const lastSeen = item.updated_at || item.started_at || '';
283
- const lastMs = Date.parse(lastSeen);
284
- if (!Number.isFinite(lastMs) || nowMs - lastMs <= maxIdleMs) continue;
285
- item.status = 'done';
286
- item.ended_at = lastSeen;
287
- closed.push({ session_file: item.session_file, ended_at: lastSeen });
288
- }
289
- return closed;
290
- }
291
-
292
- // Registry retention. The registry is read/serialized in full on every hook and scanned O(N)
293
- // for routing it only needs active + recent sessions (historical audit lives in the notes).
294
- // Left unbounded it grew to 330 entries / ~170 KB in production.
295
- export const REGISTRY_KEEP_DONE = 200;
296
- export const REGISTRY_DONE_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
297
-
298
- // Pure: drop 'done' entries older than maxAgeMs, then cap the remaining 'done' at keepDone
299
- // (newest by ended_at/updated_at/started_at kept). Never touches active entries. Mutates the
300
- // registry and returns how many were pruned.
301
- export function pruneRegistry(registry, nowMs, { keepDone = REGISTRY_KEEP_DONE, maxAgeMs = REGISTRY_DONE_MAX_AGE_MS } = {}) {
302
- const sessions = registry?.sessions || {};
303
- const stamp = (v) => Date.parse((v && (v.ended_at || v.updated_at || v.started_at)) || '') || 0;
304
- let pruned = 0;
305
- for (const [id, v] of Object.entries(sessions)) {
306
- if (!v || v.status !== 'done') continue;
307
- const t = stamp(v);
308
- if (t && nowMs - t > maxAgeMs) { delete sessions[id]; pruned += 1; }
309
- }
310
- const done = Object.entries(sessions)
311
- .filter(([, v]) => v && v.status === 'done')
312
- .sort((a, b) => stamp(b[1]) - stamp(a[1]));
313
- for (const [id] of done.slice(keepDone)) { delete sessions[id]; pruned += 1; }
314
- return pruned;
315
- }
316
-
317
- // Wrapper de IO: varre as ociosas, poda o registry, grava e fecha a NOTA `.md` de cada
318
- // sessão encerrada (mantém vault e registry alinhados). Devolve quantas fechou.
319
- export function sweepStaleSessionsFile(vaultBase, now = new Date(), maxIdleMs = SESSION_IDLE_CLOSE_MS, excludeTranscriptPath = '') {
320
- const registry = readSessionRegistry(vaultBase);
321
- const closed = sweepStaleSessions(registry, now.getTime(), maxIdleMs, excludeTranscriptPath);
322
- const pruned = pruneRegistry(registry, now.getTime());
323
- if (closed.length || pruned) writeSessionRegistry(vaultBase, registry);
324
- for (const { session_file, ended_at } of closed) {
325
- try { closeSessionNoteFile(vaultBase, session_file, ended_at); } catch { /* nunca derruba o sweep */ }
326
- }
327
- return closed.length;
328
- }
329
-
330
- // IO: alinha a nota `.md` da sessão ao `done` (idempotente; no-op se ausente ou
331
- // já fechada com o mesmo `endedAt`). Devolve true se gravou.
332
- export function closeSessionNoteFile(vaultBase, sessionFileRel, endedAt) {
333
- if (!sessionFileRel) return false;
334
- const path = join(vaultBase, sessionFileRel);
335
- if (!existsSync(path)) return false;
336
- const content = readFileSync(path, 'utf-8');
337
- const next = closeSessionNote(content, endedAt);
338
- if (next === content) return false;
339
- writeFileSync(path, next, 'utf-8');
340
- return true;
341
- }
342
-
343
- // Marca de sessão ainda aberta no corpo da nota (template do hook de início).
344
- export const SESSION_OPEN_PLACEHOLDER = 'Sessão ainda em andamento.';
345
-
346
- // Pura e NÃO-DESTRUTIVA: alinha a NOTA `.md` ao `done` do registry mexendo só no
347
- // frontmatter (`status`/`ended_at`) e trocando o placeholder de sessão aberta
348
- // pelos campos de fechamento. Preserva todo o resto inclusive seções anexadas
349
- // depois de `## Encerramento`. No-op idempotente em nota fechada.
350
- export function closeSessionNote(content, endedAt) {
351
- const src = String(content);
352
- const isOpen = /^status:\s*"?active/m.test(src) || src.includes(SESSION_OPEN_PLACEHOLDER);
353
- if (!isOpen) return src;
354
- let next = src.replace(/^ended_at:.*$/m, `ended_at: ${endedAt}`);
355
- next = next.replace(/^status:.*$/m, 'status: done');
356
- next = next.replace(SESSION_OPEN_PLACEHOLDER, [
357
- `- **Fim:** ${endedAt}`,
358
- '- **Status:** done',
359
- '- **Resumo final:** Sessão encerrada na reconciliação de histórico (status alinhado ao SESSION_REGISTRY).',
360
- ].join('\n'));
361
- return next;
362
- }
363
-
364
- export function slugify(text, fallback = 'nota', maxLen = 60) {
365
- let slug = String(text || '')
366
- .normalize('NFD')
367
- .replace(/[\u0300-\u036f]/g, '')
368
- .toLowerCase()
369
- .replace(/[^a-z0-9]+/g, '-')
370
- .replace(/^-+|-+$/g, '');
371
- if (slug.length > maxLen) {
372
- // Truncate on a word boundary (last '-' before maxLen) when a reasonable one exists,
373
- // instead of cutting mid-word \u2014 keeps generated note names readable.
374
- const cut = slug.slice(0, maxLen);
375
- const lastDash = cut.lastIndexOf('-');
376
- slug = (lastDash > maxLen * 0.5 ? cut.slice(0, lastDash) : cut).replace(/-+$/g, '');
377
- }
378
- return slug || fallback;
379
- }
380
-
381
- // Chave de conteúdo p/ dedup de notas derivadas: normaliza e corta em 60 chars.
382
- // Mesma normalização do slugify, mas preserva espaços (legível) e sem hífens.
383
- export function derivedContentKey(text = '') {
384
- return String(text)
385
- .normalize('NFD')
386
- .replace(/[̀-ͯ]/g, '')
387
- .toLowerCase()
388
- .replace(/[^a-z0-9]+/g, ' ')
389
- .trim()
390
- .slice(0, 60)
391
- .trim();
392
- }
393
-
394
- // "Bate" = chaves iguais OU uma é prefixo da outra (cobre reformulação que
395
- // estende o texto). Chave vazia nunca bate (evita falso-positivo).
396
- export function keysBate(a = '', b = '') {
397
- if (!a || !b) return false;
398
- return a === b || a.startsWith(b) || b.startsWith(a);
399
- }
400
-
401
- export function extractHookPrompt(input = {}) {
402
- const candidates = [
403
- input.prompt,
404
- input.user_prompt,
405
- input.userPrompt,
406
- input.message,
407
- input.input,
408
- ];
409
-
410
- for (const candidate of candidates) {
411
- if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
412
- }
413
-
414
- if (Array.isArray(input.messages)) {
415
- const text = input.messages
416
- .map((message) => message?.content || message?.text || '')
417
- .filter((item) => typeof item === 'string' && item.trim())
418
- .join('\n')
419
- .trim();
420
- if (text) return text;
421
- }
422
-
423
- return '';
424
- }
425
-
426
- export function isBootstrapPrompt(text = '') {
427
- const clean = String(text || '').trim();
428
- return clean.startsWith('# AGENTS.md instructions')
429
- || clean.startsWith('<environment_context>')
430
- || clean.startsWith('<permissions instructions>')
431
- || clean.includes('You are Codex, a coding agent')
432
- || clean.startsWith('## Memory');
433
- }
434
-
435
- export function summarizePromptForTitle(text = '', fallback = 'session') {
436
- const cleaned = redactSecrets(String(text || ''))
437
- .replace(/\[@[^\]]+\]\([^)]+\)/g, ' ')
438
- .replace(/<image>[\s\S]*?<\/image>/gi, ' ')
439
- .replace(/<[^>\n]+>/g, ' ')
440
- .replace(/\r/g, '\n');
441
-
442
- const source = cleaned
443
- .split('\n')
444
- .map((line) => line.trim())
445
- .filter((line) => line && !isBootstrapPrompt(line))
446
- .find((line) => !/^[-*_`#\s]+$/.test(line));
447
-
448
- if (!source) return fallback;
449
-
450
- const withoutCommitPrefix = source.replace(/^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\([^)]+\))?:\s*/i, '');
451
- const words = withoutCommitPrefix
452
- .replace(/[`*_>#()[\]{}]/g, ' ')
453
- .replace(/[^\p{L}\p{N}@+./:-]+/gu, ' ')
454
- .split(/\s+/)
455
- .map((word) => word.replace(/^[.:;,-]+|[.:;,-]+$/g, ''))
456
- .filter(Boolean)
457
- .slice(0, 10);
458
-
459
- const summary = words.join(' ');
460
- if (!summary) return fallback;
461
- return `${summary.charAt(0).toLocaleUpperCase('pt-BR')}${summary.slice(1)}`;
462
- }
463
-
464
- export function sessionSummaryFromInput(input = {}, fallback = 'session') {
465
- return summarizePromptForTitle(extractHookPrompt(input), fallback);
466
- }
467
-
468
- // Evita retitular a sessão com resumo fraco (fallback ou palavra única),
469
- // para que o título reflita o primeiro prompt real da conversa.
470
- export function isUsableSummary(summary = '', fallback = 'session') {
471
- if (!summary || summary === fallback) return false;
472
- return String(summary).trim().split(/\s+/).filter(Boolean).length >= 2;
473
- }
474
-
475
- export function sessionFileName(date = new Date(), summary = 'session') {
476
- return `${formatHourMinute(date)}-${slugify(summary, 'session')}.md`;
477
- }
478
-
479
- export function isPlaceholderSessionFile(relPath = '') {
480
- return /^\d{2}-\d{2}-(?:codex|session)(?:-\d+)?\.md$/i.test(basename(relPath));
481
- }
482
-
483
- export function shouldReuseActiveSession(control = {}, now = new Date()) {
484
- if (control.status !== 'active' || !control.session_file || control.ended_at) return false;
485
- const startedMs = Date.parse(control.started_at || '');
486
- if (!Number.isFinite(startedMs)) return true;
487
- const windowMinutes = Number(process.env.OBSIDIAN_REUSE_ACTIVE_WINDOW_MINUTES || process.env.CODEX_OBSIDIAN_REUSE_ACTIVE_WINDOW_MINUTES || 10);
488
- return now.getTime() - startedMs <= windowMinutes * 60 * 1000;
489
- }
490
-
491
- function normalizeTranscript(p) {
492
- return String(p || '').replace(/\\/g, '/').toLowerCase();
493
- }
494
-
495
- function transcriptBasename(p) {
496
- const n = normalizeTranscript(p);
497
- const i = n.lastIndexOf('/');
498
- return i === -1 ? n : n.slice(i + 1);
499
- }
500
-
501
- // Mesmo transcript apesar de caixa/separador diferentes (o Claude Code emite o
502
- // slug do projeto ora `c--`, ora `C--`) ou prefixo de path diferente (WSL vs
503
- // Windows). Compara normalizado e, em último caso, pelo basename
504
- // (`<session_id>.jsonl`, globalmente único). Evita rupturas de sessão no restart.
505
- export function transcriptsMatch(a, b) {
506
- if (!a || !b) return false;
507
- if (normalizeTranscript(a) === normalizeTranscript(b)) return true;
508
- const ba = transcriptBasename(a);
509
- return !!ba && ba === transcriptBasename(b);
510
- }
511
-
512
- // O `transcript_path` é estável dentro de uma conversa mesmo quando o
513
- // SessionStart re-dispara (compactação/resume) com `session_id` novo. Achar a
514
- // sessão ativa do mesmo transcript evita criar placeholders `HH-MM-codex`.
515
- export function findActiveSessionByTranscript(vaultBase, transcriptPath) {
516
- if (!transcriptPath) return null;
517
- const registry = readSessionRegistry(vaultBase);
518
- let best = null;
519
- for (const [sessionId, item] of Object.entries(registry.sessions || {})) {
520
- if (!item || item.status !== 'active' || !item.session_file) continue;
521
- if (!transcriptsMatch(item.transcript_path, transcriptPath)) continue;
522
- if (!best || String(item.started_at || '') > String(best.started_at || '')) {
523
- best = { sessionId, session_file: item.session_file, started_at: item.started_at || '' };
524
- }
525
- }
526
- return best;
527
- }
528
-
529
- export function redactSecrets(text) {
530
- if (!text) return '';
531
- return String(text)
532
- .replace(/\b(sk-[A-Za-z0-9_-]{12,})\b/g, '[REDACTED_SECRET]')
533
- .replace(/\b(whsec_[A-Za-z0-9_/-]{8,})\b/g, '[REDACTED_SECRET]')
534
- .replace(/\b(gh[pousr]_[A-Za-z0-9_]{12,})\b/g, '[REDACTED_SECRET]')
535
- .replace(/\b(xox[baprs]-[A-Za-z0-9-]{12,})\b/g, '[REDACTED_SECRET]')
536
- .replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY)[A-Z0-9_]*)\s*[:=]\s*["']?[^"'\s]+/gi, '$1=[REDACTED_SECRET]')
537
- .replace(/:\/\/([^:\s/@]+):([^@\s/]+)@/g, '://[REDACTED_SECRET]@');
538
- }
539
-
540
- export function truncate(text, max = 240) {
541
- const clean = redactSecrets(String(text || '').replace(/\s+/g, ' ').trim());
542
- if (clean.length <= max) return clean;
543
- return `${clean.slice(0, Math.max(0, max - 3)).trim()}...`;
544
- }
545
-
546
- export function uniquePath(basePath) {
547
- if (!existsSync(basePath)) return basePath;
548
- const extMatch = basePath.match(/(\.[^.\/]+)$/);
549
- const ext = extMatch ? extMatch[1] : '';
550
- const stem = ext ? basePath.slice(0, -ext.length) : basePath;
551
- let index = 2;
552
- while (existsSync(`${stem}-${index}${ext}`)) index += 1;
553
- return `${stem}-${index}${ext}`;
554
- }
555
-
556
- export function wikilinkFromRel(relPath) {
557
- return `[[${relPath.replace(/\.md$/i, '').replaceAll('\\', '/')}]]`;
558
- }
559
-
560
- // Per-iteration dedup marker (an invisible HTML comment). Provider-neutral name `wk-turn`; the
561
- // old `codex-turn` (legacy, from when this was a Codex-only tool) is still RECOGNIZED so notes
562
- // written by older versions keep deduping, and normalizeTurnMarkers migrates them on the next write.
563
- export const TURN_MARKER = 'wk-turn';
564
- export const LEGACY_TURN_MARKERS = ['codex-turn'];
565
-
566
- export function turnMarker(id) {
567
- return `<!-- ${TURN_MARKER}: ${id} -->`;
568
- }
569
-
570
- export function hasTurnMarker(content, id) {
571
- return [TURN_MARKER, ...LEGACY_TURN_MARKERS].some((m) => String(content || '').includes(`<!-- ${m}: ${id} -->`));
572
- }
573
-
574
- // Rewrite any legacy turn markers in a note to the current name (self-healing migration).
575
- export function normalizeTurnMarkers(content) {
576
- let c = String(content || '');
577
- for (const m of LEGACY_TURN_MARKERS) c = c.replaceAll(`<!-- ${m}: `, `<!-- ${TURN_MARKER}: `);
578
- return c;
579
- }
580
-
581
- export function listMarkdownFiles(dir) {
582
- try {
583
- return readdirSync(dir).filter((f) => f.endsWith('.md'));
584
- } catch {
585
- return [];
586
- }
587
- }
588
-
589
- export function getNextAdrNumber(vaultBase) {
590
- const decisionsDir = join(vaultBase, getLocale(vaultBase).folders.decisions);
591
- let max = 0;
592
- // Varre recursivamente: os ADRs agora vivem em subpastas datadas (AAAA/MM-MMM/DIA DD).
593
- const walk = (dir) => {
594
- let entries;
595
- try {
596
- entries = readdirSync(dir, { withFileTypes: true });
597
- } catch {
598
- return;
599
- }
600
- for (const entry of entries) {
601
- if (entry.isDirectory()) {
602
- walk(join(dir, entry.name));
603
- } else {
604
- const match = entry.name.match(/^ADR-(\d+)/i);
605
- if (match) max = Math.max(max, Number(match[1]));
606
- }
607
- }
608
- };
609
- walk(decisionsDir);
610
- return max + 1;
611
- }
612
-
613
- export function statExists(path) {
614
- try {
615
- return statSync(path);
616
- } catch {
617
- return null;
618
- }
619
- }
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
3
+ import { basename, dirname, join, relative } from 'path';
4
+ import { getLocale } from './locale.mjs';
5
+
6
+ // Neutral fallback only. The vault is normally resolved from the
7
+ // OBSIDIAN_VAULT_PATH env var (set by `wendkeep init`) via getVaultBase() below.
8
+ export const DEFAULT_VAULT_BASE = join(
9
+ process.env.USERPROFILE || process.env.HOME || process.cwd(),
10
+ 'wendkeep-vault',
11
+ );
12
+ export const MONTH_FOLDERS = [
13
+ '01-JAN', '02-FEV', '03-MAR', '04-ABR', '05-MAI', '06-JUN',
14
+ '07-JUL', '08-AGO', '09-SET', '10-OUT', '11-NOV', '12-DEZ',
15
+ ];
16
+
17
+ export const VAULT_COMPLEMENT_RULES = [
18
+ '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.',
19
+ '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.',
20
+ '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.',
21
+ 'Atualize `SHARED_MEMORY.md` somente quando a síntese mudar estado ativo que outro agente precise saber.',
22
+ ];
23
+
24
+ export function readHookInput() {
25
+ const raw = readFileSync(0, 'utf-8').trim();
26
+ if (!raw) return {};
27
+ return JSON.parse(raw);
28
+ }
29
+
30
+ export function writeHookOutput(payload = {}) {
31
+ process.stdout.write(JSON.stringify(payload));
32
+ }
33
+
34
+ // Resolve the vault and report WHERE the path came from, so callers can react to
35
+ // an unconfigured install instead of silently writing to the home fallback.
36
+ // env -> OBSIDIAN_VAULT_PATH (set by `wendkeep init`)
37
+ // payload -> obsidian_vault_path from the hook's JSON input
38
+ // default -> DEFAULT_VAULT_BASE (~/wendkeep-vault) — a phantom vault nobody opened
39
+ export function resolveVault(input = {}) {
40
+ if (process.env.OBSIDIAN_VAULT_PATH) {
41
+ return { base: process.env.OBSIDIAN_VAULT_PATH, source: 'env' };
42
+ }
43
+ if (input && input.obsidian_vault_path) {
44
+ return { base: input.obsidian_vault_path, source: 'payload' };
45
+ }
46
+ return { base: DEFAULT_VAULT_BASE, source: 'default' };
47
+ }
48
+
49
+ export function getVaultBase(input = {}) {
50
+ return resolveVault(input).base;
51
+ }
52
+
53
+ // Diagnostic logger. No-op unless WENDKEEP_DEBUG is set, so it never pollutes the
54
+ // stdout hook contract during normal runs but makes fail-open paths debuggable.
55
+ export function debugLog(...args) {
56
+ if (!process.env.WENDKEEP_DEBUG) return;
57
+ const text = args
58
+ .map((a) => (a && a.stack ? a.stack : String(a)))
59
+ .join(' ');
60
+ process.stderr.write(`[wendkeep] ${text}\n`);
61
+ }
62
+
63
+ // Warn loudly (stderr) when the vault resolved to the home fallback — i.e. neither
64
+ // OBSIDIAN_VAULT_PATH nor a payload path was provided. Without this the hooks write
65
+ // notes into ~/wendkeep-vault with zero signal that the install is misconfigured.
66
+ // Returns the resolution source so callers can branch if they want.
67
+ export function warnIfDefaultVault(input = {}) {
68
+ const { base, source } = resolveVault(input);
69
+ if (source === 'default') {
70
+ process.stderr.write(
71
+ `[wendkeep] WARNING: OBSIDIAN_VAULT_PATH não definido — gravando no fallback "${base}". ` +
72
+ 'Rode `wendkeep init` ou defina OBSIDIAN_VAULT_PATH apontando ao seu vault Obsidian.\n',
73
+ );
74
+ }
75
+ return source;
76
+ }
77
+
78
+ // Detecta o agente real que está executando o hook. Claude Code expõe
79
+ // CLAUDECODE / CLAUDE_CODE_SESSION_ID / CLAUDE_PROJECT_DIR; Codex não.
80
+ export function detectProvider() {
81
+ if (process.env.CLAUDECODE === '1' || process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_PROJECT_DIR) {
82
+ return 'claude';
83
+ }
84
+ return 'codex';
85
+ }
86
+
87
+ export function providerMeta(provider = detectProvider()) {
88
+ if (provider === 'claude') {
89
+ return { id: 'claude', label: 'Claude Code', tag: 'claude', source: 'claude-hook' };
90
+ }
91
+ return { id: 'codex', label: 'Codex', tag: 'codex', source: 'codex-hook' };
92
+ }
93
+
94
+ export function ensureDir(path) {
95
+ if (!existsSync(path)) mkdirSync(path, { recursive: true });
96
+ }
97
+
98
+ export function pad2(value) {
99
+ return String(value).padStart(2, '0');
100
+ }
101
+
102
+ export function localDateParts(date = new Date()) {
103
+ return {
104
+ year: date.getFullYear(),
105
+ month: date.getMonth() + 1,
106
+ day: date.getDate(),
107
+ hour: date.getHours(),
108
+ minute: date.getMinutes(),
109
+ second: date.getSeconds(),
110
+ };
111
+ }
112
+
113
+ export function formatDate(date = new Date()) {
114
+ const p = localDateParts(date);
115
+ return `${p.year}-${pad2(p.month)}-${pad2(p.day)}`;
116
+ }
117
+
118
+ export function formatTime(date = new Date()) {
119
+ const p = localDateParts(date);
120
+ return `${pad2(p.hour)}:${pad2(p.minute)}:${pad2(p.second)}`;
121
+ }
122
+
123
+ export function formatHourMinute(date = new Date()) {
124
+ const p = localDateParts(date);
125
+ return `${pad2(p.hour)}-${pad2(p.minute)}`;
126
+ }
127
+
128
+ export function formatLocalIso(date = new Date()) {
129
+ return `${formatDate(date)}T${formatTime(date)}`;
130
+ }
131
+
132
+ // Locale (0.8.0): month labels + folder names come from the vault locale when a
133
+ // vaultBase is given; without it, pt-BR (backward compat — every legacy caller).
134
+ export function datedFolderRel(rootFolder, date = new Date(), vaultBase) {
135
+ const p = localDateParts(date);
136
+ return join(rootFolder, String(p.year), getLocale(vaultBase).months[p.month - 1], `DIA ${pad2(p.day)}`);
137
+ }
138
+
139
+ // Mesma estrutura datada a partir de uma string 'YYYY-MM-DD' (sessões: até o DIA).
140
+ export function datedFolderRelFromDateStr(rootFolder, dateStr, vaultBase) {
141
+ const [year, month, day] = String(dateStr).split('-');
142
+ return join(rootFolder, year, getLocale(vaultBase).months[Number(month) - 1], `DIA ${pad2(day)}`);
143
+ }
144
+
145
+ // Estrutura até o MÊS (sem DIA) — usada pelas notas derivadas (decisões/bugs/
146
+ // aprendizados): tudo do mês fica junto em <pasta>/<ano>/<MM-MMM>/.
147
+ export function monthFolderRelFromDateStr(rootFolder, dateStr, vaultBase) {
148
+ const [year, month] = String(dateStr).split('-');
149
+ return join(rootFolder, year, getLocale(vaultBase).months[Number(month) - 1]);
150
+ }
151
+
152
+ export function sessionFolderRel(date = new Date(), vaultBase) {
153
+ return datedFolderRel(getLocale(vaultBase).folders.sessions, date, vaultBase);
154
+ }
155
+
156
+ export function controlPath(vaultBase) {
157
+ return join(vaultBase, '.brain', 'CURRENT_SESSION.md');
158
+ }
159
+
160
+ export function registryPath(vaultBase) {
161
+ return join(vaultBase, '.brain', 'SESSION_REGISTRY.json');
162
+ }
163
+
164
+ export function toVaultRelative(vaultBase, path) {
165
+ return relative(vaultBase, path).replaceAll('\\', '/');
166
+ }
167
+
168
+ export function stripYamlQuotes(value = '') {
169
+ return value.trim().replace(/^["']|["']$/g, '');
170
+ }
171
+
172
+ export function yamlQuote(value = '') {
173
+ return JSON.stringify(String(value || ''));
174
+ }
175
+
176
+ export function readControl(vaultBase) {
177
+ const path = controlPath(vaultBase);
178
+ if (!existsSync(path)) return {};
179
+
180
+ const content = readFileSync(path, 'utf-8');
181
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
182
+ if (!match) return {};
183
+
184
+ const data = {};
185
+ for (const line of match[1].split('\n')) {
186
+ const item = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
187
+ if (item) data[item[1]] = stripYamlQuotes(item[2]);
188
+ }
189
+ return data;
190
+ }
191
+
192
+ export function writeControl(vaultBase, data) {
193
+ const path = controlPath(vaultBase);
194
+ ensureDir(dirname(path));
195
+
196
+ const status = data.status || 'inactive';
197
+ const sessionFile = data.session_file || '';
198
+ const lastSessionFile = data.last_session_file || sessionFile || '';
199
+ const startedAt = data.started_at || '';
200
+ const endedAt = data.ended_at || '';
201
+ const sessionId = data.session_id || '';
202
+ const lastLoggedTurnId = data.last_logged_turn_id || '';
203
+
204
+ const registry = readSessionRegistry(vaultBase);
205
+ const active = Object.entries(registry.sessions || {})
206
+ .filter(([, item]) => item?.status === 'active' && item.session_file)
207
+ .sort((a, b) => String(b[1].last_seen || b[1].updated_at || '').localeCompare(String(a[1].last_seen || a[1].updated_at || '')));
208
+ const activeRows = active.length
209
+ ? active.map(([id, item]) => `| ${id} | ${item.provider || 'unknown'} | ${item.session_file} | ${item.change_slug || '-'} | ${item.last_seen || item.updated_at || '-'} |`).join('\n')
210
+ : '| - | - | nenhuma | - | - |';
211
+
212
+ const content = `---
213
+ status: "${status}"
214
+ session_file: "${sessionFile}"
215
+ last_session_file: "${lastSessionFile}"
216
+ started_at: "${startedAt}"
217
+ ended_at: "${endedAt}"
218
+ session_id: "${sessionId}"
219
+ last_logged_turn_id: "${lastLoggedTurnId}"
220
+ ---
221
+
222
+ # CURRENT_SESSION
223
+
224
+ > Visão gerada pelo WendKeep. A autoridade de roteamento é .brain/SESSION_REGISTRY.json; hooks não usam este foco como fallback de escrita.
225
+
226
+ - **Status:** ${status}
227
+ - **Sessão ativa:** ${sessionFile || 'nenhuma'}
228
+ - **Última sessão encerrada:** ${lastSessionFile || 'nenhuma'}
229
+ - **Início:** ${startedAt || 'n/a'}
230
+ - **Fim:** ${endedAt || 'n/a'}
231
+
232
+ ## Sessões ativas (${active.length})
233
+
234
+ | Conversa | Provider | Sessão | Change vinculada | Último sinal |
235
+ |---|---|---|---|---|
236
+ ${activeRows}
237
+
238
+ Regra crítica: sempre anexar conteúdo à sessão ativa. Nunca sobrescrever o histórico de iterações.
239
+ `;
240
+
241
+ writeFileSync(path, content, 'utf-8');
242
+ }
243
+
244
+ export function readSessionRegistry(vaultBase) {
245
+ const path = registryPath(vaultBase);
246
+ if (!existsSync(path)) return { version: 2, sessions: {} };
247
+
248
+ try {
249
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
250
+ return {
251
+ version: Math.max(2, parsed.version || 1),
252
+ sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
253
+ };
254
+ } catch {
255
+ return { version: 2, sessions: {} };
256
+ }
257
+ }
258
+
259
+ export function writeSessionRegistry(vaultBase, registry) {
260
+ const path = registryPath(vaultBase);
261
+ ensureDir(dirname(path));
262
+ // Escrita atômica: grava em tmp e renomeia (rename é atômico no mesmo volume),
263
+ // evitando registry truncado/corrompido quando dois hooks gravam ao mesmo tempo.
264
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
265
+ writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf-8');
266
+ renameSync(tmp, path);
267
+ }
268
+
269
+ function registryLockPath(vaultBase) {
270
+ return `${registryPath(vaultBase)}.lock`;
271
+ }
272
+
273
+ function waitBriefly(ms) {
274
+ const signal = new Int32Array(new SharedArrayBuffer(4));
275
+ Atomics.wait(signal, 0, 0, ms);
276
+ }
277
+
278
+ export function mutateSessionRegistry(vaultBase, mutator, { timeoutMs = 2000 } = {}) {
279
+ const lock = registryLockPath(vaultBase);
280
+ ensureDir(dirname(lock));
281
+ const deadline = Date.now() + timeoutMs;
282
+ while (true) {
283
+ try {
284
+ mkdirSync(lock);
285
+ break;
286
+ } catch (error) {
287
+ if (error?.code === 'EEXIST') {
288
+ try {
289
+ if (Date.now() - statSync(lock).mtimeMs > 10_000) {
290
+ rmSync(lock, { recursive: true, force: true });
291
+ continue;
292
+ }
293
+ } catch { /* outro processo pode ter liberado o lock */ }
294
+ }
295
+ if (error?.code !== 'EEXIST' || Date.now() >= deadline) {
296
+ throw new Error(`SESSION_REGISTRY lock indisponível: ${error.message}`);
297
+ }
298
+ waitBriefly(10);
299
+ }
300
+ }
301
+
302
+ try {
303
+ const registry = readSessionRegistry(vaultBase);
304
+ registry.version = 2;
305
+ const result = mutator(registry);
306
+ writeSessionRegistry(vaultBase, registry);
307
+ return result;
308
+ } finally {
309
+ rmSync(lock, { recursive: true, force: true });
310
+ }
311
+ }
312
+
313
+ function meaningfulPatch(patch = {}) {
314
+ const protectedNonEmpty = new Set(['session_file', 'transcript_path', 'transcript_id', 'provider', 'started_at', 'change_slug']);
315
+ return Object.fromEntries(Object.entries(patch).filter(([key, value]) => {
316
+ if (value === undefined || value === null) return false;
317
+ if (value === '' && protectedNonEmpty.has(key)) return false;
318
+ return true;
319
+ }));
320
+ }
321
+
322
+ export function upsertSessionRegistry(vaultBase, sessionId, patch) {
323
+ if (!sessionId) return null;
324
+ const clean = meaningfulPatch(patch);
325
+ const next = mutateSessionRegistry(vaultBase, (registry) => {
326
+ const current = registry.sessions[sessionId] || {};
327
+ const transcriptPaths = [...new Set([
328
+ ...(Array.isArray(current.transcript_paths) ? current.transcript_paths : []),
329
+ current.transcript_path,
330
+ ...(Array.isArray(clean.transcript_paths) ? clean.transcript_paths : []),
331
+ clean.transcript_path,
332
+ ].filter(Boolean))];
333
+ const value = {
334
+ ...current,
335
+ ...clean,
336
+ ...(transcriptPaths.length ? { transcript_paths: transcriptPaths, transcript_path: clean.transcript_path || current.transcript_path || transcriptPaths.at(-1) } : {}),
337
+ last_seen: clean.last_seen || clean.updated_at || formatLocalIso(new Date()),
338
+ updated_at: clean.updated_at || formatLocalIso(new Date()),
339
+ };
340
+ registry.sessions[sessionId] = value;
341
+ return value;
342
+ });
343
+ const focus = readControl(vaultBase);
344
+ writeControl(vaultBase, focus);
345
+ return next;
346
+ }
347
+
348
+ // Sessões sem evento de fim (janela fechada, crash, agente sem SessionEnd) ficam
349
+ // `active` para sempre. Após este limite ocioso, considera-se a sessão encerrada.
350
+ export const SESSION_IDLE_CLOSE_MS = 12 * 60 * 60 * 1000;
351
+
352
+ // Pura: marca como `done` toda sessão `active` cujo último sinal de vida
353
+ // (`updated_at`, senão `started_at`) é mais antigo que `maxIdleMs`. `ended_at`
354
+ // recebe esse último sinal (melhor estimativa de quando parou). Não toca na
355
+ // sessão de `excludeTranscriptPath` — ela pode estar sendo reaproveitada agora.
356
+ // Muta o registry recebido e devolve quantas fechou.
357
+ export function sweepStaleSessions(registry, nowMs, maxIdleMs, excludeTranscriptPath = '') {
358
+ const closed = [];
359
+ for (const item of Object.values(registry?.sessions || {})) {
360
+ if (!item || item.status !== 'active') continue;
361
+ if (excludeTranscriptPath && transcriptsMatch(item.transcript_path, excludeTranscriptPath)) continue;
362
+ const lastSeen = item.updated_at || item.started_at || '';
363
+ const lastMs = Date.parse(lastSeen);
364
+ if (!Number.isFinite(lastMs) || nowMs - lastMs <= maxIdleMs) continue;
365
+ item.status = 'done';
366
+ item.ended_at = lastSeen;
367
+ closed.push({ session_file: item.session_file, ended_at: lastSeen });
368
+ }
369
+ return closed;
370
+ }
371
+
372
+ // Registry retention. The registry is read/serialized in full on every hook and scanned O(N)
373
+ // for routing it only needs active + recent sessions (historical audit lives in the notes).
374
+ // Left unbounded it grew to 330 entries / ~170 KB in production.
375
+ export const REGISTRY_KEEP_DONE = 200;
376
+ export const REGISTRY_DONE_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
377
+
378
+ // Pure: drop 'done' entries older than maxAgeMs, then cap the remaining 'done' at keepDone
379
+ // (newest by ended_at/updated_at/started_at kept). Never touches active entries. Mutates the
380
+ // registry and returns how many were pruned.
381
+ export function pruneRegistry(registry, nowMs, { keepDone = REGISTRY_KEEP_DONE, maxAgeMs = REGISTRY_DONE_MAX_AGE_MS } = {}) {
382
+ const sessions = registry?.sessions || {};
383
+ const stamp = (v) => Date.parse((v && (v.ended_at || v.updated_at || v.started_at)) || '') || 0;
384
+ let pruned = 0;
385
+ for (const [id, v] of Object.entries(sessions)) {
386
+ if (!v || v.status !== 'done') continue;
387
+ const t = stamp(v);
388
+ if (t && nowMs - t > maxAgeMs) { delete sessions[id]; pruned += 1; }
389
+ }
390
+ const done = Object.entries(sessions)
391
+ .filter(([, v]) => v && v.status === 'done')
392
+ .sort((a, b) => stamp(b[1]) - stamp(a[1]));
393
+ for (const [id] of done.slice(keepDone)) { delete sessions[id]; pruned += 1; }
394
+ return pruned;
395
+ }
396
+
397
+ // Wrapper de IO: varre as ociosas, poda o registry, grava e fecha a NOTA `.md` de cada
398
+ // sessão encerrada (mantém vault e registry alinhados). Devolve quantas fechou.
399
+ export function sweepStaleSessionsFile(vaultBase, now = new Date(), maxIdleMs = SESSION_IDLE_CLOSE_MS, excludeTranscriptPath = '') {
400
+ const closed = mutateSessionRegistry(vaultBase, (registry) => {
401
+ const result = sweepStaleSessions(registry, now.getTime(), maxIdleMs, excludeTranscriptPath);
402
+ pruneRegistry(registry, now.getTime());
403
+ return result;
404
+ });
405
+ for (const { session_file, ended_at } of closed) {
406
+ try { closeSessionNoteFile(vaultBase, session_file, ended_at); } catch { /* nunca derruba o sweep */ }
407
+ }
408
+ return closed.length;
409
+ }
410
+
411
+ // IO: alinha a nota `.md` da sessão ao `done` (idempotente; no-op se ausente ou
412
+ // já fechada com o mesmo `endedAt`). Devolve true se gravou.
413
+ export function closeSessionNoteFile(vaultBase, sessionFileRel, endedAt) {
414
+ if (!sessionFileRel) return false;
415
+ const path = join(vaultBase, sessionFileRel);
416
+ if (!existsSync(path)) return false;
417
+ const content = readFileSync(path, 'utf-8');
418
+ const next = closeSessionNote(content, endedAt);
419
+ if (next === content) return false;
420
+ writeFileSync(path, next, 'utf-8');
421
+ return true;
422
+ }
423
+
424
+ // Marca de sessão ainda aberta no corpo da nota (template do hook de início).
425
+ export const SESSION_OPEN_PLACEHOLDER = 'Sessão ainda em andamento.';
426
+
427
+ // Pura e NÃO-DESTRUTIVA: alinha a NOTA `.md` ao `done` do registry mexendo só no
428
+ // frontmatter (`status`/`ended_at`) e trocando o placeholder de sessão aberta
429
+ // pelos campos de fechamento. Preserva todo o resto — inclusive seções anexadas
430
+ // depois de `## Encerramento`. No-op idempotente em nota já fechada.
431
+ export function closeSessionNote(content, endedAt) {
432
+ const src = String(content);
433
+ const isOpen = /^status:\s*"?active/m.test(src) || src.includes(SESSION_OPEN_PLACEHOLDER);
434
+ if (!isOpen) return src;
435
+ let next = src.replace(/^ended_at:.*$/m, `ended_at: ${endedAt}`);
436
+ next = next.replace(/^status:.*$/m, 'status: done');
437
+ next = next.replace(SESSION_OPEN_PLACEHOLDER, [
438
+ `- **Fim:** ${endedAt}`,
439
+ '- **Status:** done',
440
+ '- **Resumo final:** Sessão encerrada na reconciliação de histórico (status alinhado ao SESSION_REGISTRY).',
441
+ ].join('\n'));
442
+ return next;
443
+ }
444
+
445
+ export function slugify(text, fallback = 'nota', maxLen = 60) {
446
+ let slug = String(text || '')
447
+ .normalize('NFD')
448
+ .replace(/[\u0300-\u036f]/g, '')
449
+ .toLowerCase()
450
+ .replace(/[^a-z0-9]+/g, '-')
451
+ .replace(/^-+|-+$/g, '');
452
+ if (slug.length > maxLen) {
453
+ // Truncate on a word boundary (last '-' before maxLen) when a reasonable one exists,
454
+ // instead of cutting mid-word \u2014 keeps generated note names readable.
455
+ const cut = slug.slice(0, maxLen);
456
+ const lastDash = cut.lastIndexOf('-');
457
+ slug = (lastDash > maxLen * 0.5 ? cut.slice(0, lastDash) : cut).replace(/-+$/g, '');
458
+ }
459
+ return slug || fallback;
460
+ }
461
+
462
+ // Chave de conteúdo p/ dedup de notas derivadas: normaliza e corta em 60 chars.
463
+ // Mesma normalização do slugify, mas preserva espaços (legível) e sem hífens.
464
+ export function derivedContentKey(text = '') {
465
+ return String(text)
466
+ .normalize('NFD')
467
+ .replace(/[̀-ͯ]/g, '')
468
+ .toLowerCase()
469
+ .replace(/[^a-z0-9]+/g, ' ')
470
+ .trim()
471
+ .slice(0, 60)
472
+ .trim();
473
+ }
474
+
475
+ // "Bate" = chaves iguais OU uma é prefixo da outra (cobre reformulação que
476
+ // estende o texto). Chave vazia nunca bate (evita falso-positivo).
477
+ export function keysBate(a = '', b = '') {
478
+ if (!a || !b) return false;
479
+ return a === b || a.startsWith(b) || b.startsWith(a);
480
+ }
481
+
482
+ export function extractHookPrompt(input = {}) {
483
+ const candidates = [
484
+ input.prompt,
485
+ input.user_prompt,
486
+ input.userPrompt,
487
+ input.message,
488
+ input.input,
489
+ ];
490
+
491
+ for (const candidate of candidates) {
492
+ if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
493
+ }
494
+
495
+ if (Array.isArray(input.messages)) {
496
+ const text = input.messages
497
+ .map((message) => message?.content || message?.text || '')
498
+ .filter((item) => typeof item === 'string' && item.trim())
499
+ .join('\n')
500
+ .trim();
501
+ if (text) return text;
502
+ }
503
+
504
+ return '';
505
+ }
506
+
507
+ export function isBootstrapPrompt(text = '') {
508
+ const clean = String(text || '').trim();
509
+ return clean.startsWith('# AGENTS.md instructions')
510
+ || clean.startsWith('<environment_context>')
511
+ || clean.startsWith('<permissions instructions>')
512
+ || clean.includes('You are Codex, a coding agent')
513
+ || clean.startsWith('## Memory');
514
+ }
515
+
516
+ export function summarizePromptForTitle(text = '', fallback = 'session') {
517
+ const cleaned = redactSecrets(String(text || ''))
518
+ .replace(/\[@[^\]]+\]\([^)]+\)/g, ' ')
519
+ .replace(/<image>[\s\S]*?<\/image>/gi, ' ')
520
+ .replace(/<[^>\n]+>/g, ' ')
521
+ .replace(/\r/g, '\n');
522
+
523
+ const source = cleaned
524
+ .split('\n')
525
+ .map((line) => line.trim())
526
+ .filter((line) => line && !isBootstrapPrompt(line))
527
+ .find((line) => !/^[-*_`#\s]+$/.test(line));
528
+
529
+ if (!source) return fallback;
530
+
531
+ const withoutCommitPrefix = source.replace(/^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\([^)]+\))?:\s*/i, '');
532
+ const words = withoutCommitPrefix
533
+ .replace(/[`*_>#()[\]{}]/g, ' ')
534
+ .replace(/[^\p{L}\p{N}@+./:-]+/gu, ' ')
535
+ .split(/\s+/)
536
+ .map((word) => word.replace(/^[.:;,-]+|[.:;,-]+$/g, ''))
537
+ .filter(Boolean)
538
+ .slice(0, 10);
539
+
540
+ const summary = words.join(' ');
541
+ if (!summary) return fallback;
542
+ return `${summary.charAt(0).toLocaleUpperCase('pt-BR')}${summary.slice(1)}`;
543
+ }
544
+
545
+ export function sessionSummaryFromInput(input = {}, fallback = 'session') {
546
+ return summarizePromptForTitle(extractHookPrompt(input), fallback);
547
+ }
548
+
549
+ // Evita retitular a sessão com resumo fraco (fallback ou palavra única),
550
+ // para que o título reflita o primeiro prompt real da conversa.
551
+ export function isUsableSummary(summary = '', fallback = 'session') {
552
+ if (!summary || summary === fallback) return false;
553
+ return String(summary).trim().split(/\s+/).filter(Boolean).length >= 2;
554
+ }
555
+
556
+ export function sessionFileName(date = new Date(), summary = 'session') {
557
+ return `${formatHourMinute(date)}-${slugify(summary, 'session')}.md`;
558
+ }
559
+
560
+ export function isPlaceholderSessionFile(relPath = '') {
561
+ return /^\d{2}-\d{2}-(?:codex|session)(?:-\d+)?\.md$/i.test(basename(relPath));
562
+ }
563
+
564
+ export function shouldReuseActiveSession(control = {}, now = new Date()) {
565
+ if (control.status !== 'active' || !control.session_file || control.ended_at) return false;
566
+ const startedMs = Date.parse(control.started_at || '');
567
+ if (!Number.isFinite(startedMs)) return true;
568
+ const windowMinutes = Number(process.env.OBSIDIAN_REUSE_ACTIVE_WINDOW_MINUTES || process.env.CODEX_OBSIDIAN_REUSE_ACTIVE_WINDOW_MINUTES || 10);
569
+ return now.getTime() - startedMs <= windowMinutes * 60 * 1000;
570
+ }
571
+
572
+ function normalizeTranscript(p) {
573
+ return String(p || '').replace(/\\/g, '/').toLowerCase();
574
+ }
575
+
576
+ function transcriptBasename(p) {
577
+ const n = normalizeTranscript(p);
578
+ const i = n.lastIndexOf('/');
579
+ return i === -1 ? n : n.slice(i + 1);
580
+ }
581
+
582
+ // Mesmo transcript apesar de caixa/separador diferentes (o Claude Code emite o
583
+ // slug do projeto ora `c--`, ora `C--`) ou prefixo de path diferente (WSL vs
584
+ // Windows). Compara normalizado e, em último caso, pelo basename
585
+ // (`<session_id>.jsonl`, globalmente único). Evita rupturas de sessão no restart.
586
+ export function transcriptsMatch(a, b) {
587
+ if (!a || !b) return false;
588
+ if (normalizeTranscript(a) === normalizeTranscript(b)) return true;
589
+ const ba = transcriptBasename(a);
590
+ return !!ba && ba === transcriptBasename(b);
591
+ }
592
+
593
+ // O `transcript_path` é estável dentro de uma conversa mesmo quando o
594
+ // SessionStart re-dispara (compactação/resume) com `session_id` novo. Achar a
595
+ // sessão ativa do mesmo transcript evita criar placeholders `HH-MM-codex`.
596
+ export function findActiveSessionByTranscript(vaultBase, transcriptPath) {
597
+ if (!transcriptPath) return null;
598
+ const registry = readSessionRegistry(vaultBase);
599
+ let best = null;
600
+ for (const [sessionId, item] of Object.entries(registry.sessions || {})) {
601
+ if (!item || item.status !== 'active' || !item.session_file) continue;
602
+ const paths = [...(Array.isArray(item.transcript_paths) ? item.transcript_paths : []), item.transcript_path].filter(Boolean);
603
+ if (!paths.some((path) => transcriptsMatch(path, transcriptPath))) continue;
604
+ if (!best || String(item.started_at || '') > String(best.started_at || '')) {
605
+ best = { sessionId, session_file: item.session_file, started_at: item.started_at || '' };
606
+ }
607
+ }
608
+ return best;
609
+ }
610
+
611
+ export function redactSecrets(text) {
612
+ if (!text) return '';
613
+ return String(text)
614
+ .replace(/\b(sk-[A-Za-z0-9_-]{12,})\b/g, '[REDACTED_SECRET]')
615
+ .replace(/\b(whsec_[A-Za-z0-9_/-]{8,})\b/g, '[REDACTED_SECRET]')
616
+ .replace(/\b(gh[pousr]_[A-Za-z0-9_]{12,})\b/g, '[REDACTED_SECRET]')
617
+ .replace(/\b(xox[baprs]-[A-Za-z0-9-]{12,})\b/g, '[REDACTED_SECRET]')
618
+ .replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY)[A-Z0-9_]*)\s*[:=]\s*["']?[^"'\s]+/gi, '$1=[REDACTED_SECRET]')
619
+ .replace(/:\/\/([^:\s/@]+):([^@\s/]+)@/g, '://[REDACTED_SECRET]@');
620
+ }
621
+
622
+ export function truncate(text, max = 240) {
623
+ const clean = redactSecrets(String(text || '').replace(/\s+/g, ' ').trim());
624
+ if (clean.length <= max) return clean;
625
+ return `${clean.slice(0, Math.max(0, max - 3)).trim()}...`;
626
+ }
627
+
628
+ export function uniquePath(basePath) {
629
+ if (!existsSync(basePath)) return basePath;
630
+ const extMatch = basePath.match(/(\.[^.\/]+)$/);
631
+ const ext = extMatch ? extMatch[1] : '';
632
+ const stem = ext ? basePath.slice(0, -ext.length) : basePath;
633
+ let index = 2;
634
+ while (existsSync(`${stem}-${index}${ext}`)) index += 1;
635
+ return `${stem}-${index}${ext}`;
636
+ }
637
+
638
+ export function wikilinkFromRel(relPath) {
639
+ return `[[${relPath.replace(/\.md$/i, '').replaceAll('\\', '/')}]]`;
640
+ }
641
+
642
+ // Per-iteration dedup marker (an invisible HTML comment). Provider-neutral name `wk-turn`; the
643
+ // old `codex-turn` (legacy, from when this was a Codex-only tool) is still RECOGNIZED so notes
644
+ // written by older versions keep deduping, and normalizeTurnMarkers migrates them on the next write.
645
+ export const TURN_MARKER = 'wk-turn';
646
+ export const LEGACY_TURN_MARKERS = ['codex-turn'];
647
+
648
+ export function turnMarker(id) {
649
+ return `<!-- ${TURN_MARKER}: ${id} -->`;
650
+ }
651
+
652
+ export function hasTurnMarker(content, id) {
653
+ return [TURN_MARKER, ...LEGACY_TURN_MARKERS].some((m) => String(content || '').includes(`<!-- ${m}: ${id} -->`));
654
+ }
655
+
656
+ // Rewrite any legacy turn markers in a note to the current name (self-healing migration).
657
+ export function normalizeTurnMarkers(content) {
658
+ let c = String(content || '');
659
+ for (const m of LEGACY_TURN_MARKERS) c = c.replaceAll(`<!-- ${m}: `, `<!-- ${TURN_MARKER}: `);
660
+ return c;
661
+ }
662
+
663
+ export function listMarkdownFiles(dir) {
664
+ try {
665
+ return readdirSync(dir).filter((f) => f.endsWith('.md'));
666
+ } catch {
667
+ return [];
668
+ }
669
+ }
670
+
671
+ export function getNextAdrNumber(vaultBase) {
672
+ const decisionsDir = join(vaultBase, getLocale(vaultBase).folders.decisions);
673
+ let max = 0;
674
+ // Varre recursivamente: os ADRs agora vivem em subpastas datadas (AAAA/MM-MMM/DIA DD).
675
+ const walk = (dir) => {
676
+ let entries;
677
+ try {
678
+ entries = readdirSync(dir, { withFileTypes: true });
679
+ } catch {
680
+ return;
681
+ }
682
+ for (const entry of entries) {
683
+ if (entry.isDirectory()) {
684
+ walk(join(dir, entry.name));
685
+ } else {
686
+ const match = entry.name.match(/^ADR-(\d+)/i);
687
+ if (match) max = Math.max(max, Number(match[1]));
688
+ }
689
+ }
690
+ };
691
+ walk(decisionsDir);
692
+ return max + 1;
693
+ }
694
+
695
+ export function statExists(path) {
696
+ try {
697
+ return statSync(path);
698
+ } catch {
699
+ return null;
700
+ }
701
+ }