wendkeep 0.38.0 → 0.38.2

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,206 +1,206 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
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
-
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
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
-
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
204
  const registry = readSessionRegistry(vaultBase);
205
205
  const active = Object.entries(registry.sessions || {})
206
206
  .filter(([, item]) => item?.status === 'active' && item.session_file)
@@ -211,22 +211,22 @@ export function writeControl(vaultBase, data) {
211
211
 
212
212
  const content = `---
213
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
-
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
222
  # CURRENT_SESSION
223
223
 
224
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'}
225
+
226
+ - **Status:** ${status}
227
+ - **Sessão ativa:** ${sessionFile || 'nenhuma'}
228
+ - **Última sessão encerrada:** ${lastSessionFile || 'nenhuma'}
229
+ - **Início:** ${startedAt || 'n/a'}
230
230
  - **Fim:** ${endedAt || 'n/a'}
231
231
 
232
232
  ## Sessões ativas (${active.length})
@@ -234,36 +234,36 @@ last_logged_turn_id: "${lastLoggedTurnId}"
234
234
  | Conversa | Provider | Sessão | Change vinculada | Último sinal |
235
235
  |---|---|---|---|---|
236
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
-
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
244
  export function readSessionRegistry(vaultBase) {
245
- const path = registryPath(vaultBase);
245
+ const path = registryPath(vaultBase);
246
246
  if (!existsSync(path)) return { version: 2, sessions: {} };
247
-
248
- try {
249
- const parsed = JSON.parse(readFileSync(path, 'utf-8'));
250
- return {
247
+
248
+ try {
249
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
250
+ return {
251
251
  version: Math.max(2, parsed.version || 1),
252
- sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
253
- };
254
- } catch {
252
+ sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
253
+ };
254
+ } catch {
255
255
  return { version: 2, sessions: {} };
256
- }
257
- }
258
-
256
+ }
257
+ }
258
+
259
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);
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
267
  }
268
268
 
269
269
  function registryLockPath(vaultBase) {
@@ -318,7 +318,7 @@ function meaningfulPatch(patch = {}) {
318
318
  return true;
319
319
  }));
320
320
  }
321
-
321
+
322
322
  export function upsertSessionRegistry(vaultBase, sessionId, patch) {
323
323
  if (!sessionId) return null;
324
324
  const clean = meaningfulPatch(patch);
@@ -344,358 +344,358 @@ export function upsertSessionRegistry(vaultBase, sessionId, patch) {
344
344
  writeControl(vaultBase, focus);
345
345
  return next;
346
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.
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
399
  export function sweepStaleSessionsFile(vaultBase, now = new Date(), maxIdleMs = SESSION_IDLE_CLOSE_MS, excludeTranscriptPath = '') {
400
400
  const closed = mutateSessionRegistry(vaultBase, (registry) => {
401
401
  const result = sweepStaleSessions(registry, now.getTime(), maxIdleMs, excludeTranscriptPath);
402
402
  pruneRegistry(registry, now.getTime());
403
403
  return result;
404
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`.
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
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;
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
602
  const paths = [...(Array.isArray(item.transcript_paths) ? item.transcript_paths : []), item.transcript_path].filter(Boolean);
603
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
- }
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
+ }