wendkeep 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,386 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
3
+ import { basename, dirname, join } from 'path';
4
+ import {
5
+ controlPath,
6
+ ensureDir,
7
+ formatDate,
8
+ formatHourMinute,
9
+ formatLocalIso,
10
+ formatTime,
11
+ getVaultBase,
12
+ readControl,
13
+ readHookInput,
14
+ readSessionRegistry,
15
+ sessionFileName,
16
+ sessionFolderRel,
17
+ sessionSummaryFromInput,
18
+ isUsableSummary,
19
+ providerMeta,
20
+ shouldReuseActiveSession,
21
+ isPlaceholderSessionFile,
22
+ toVaultRelative,
23
+ uniquePath,
24
+ upsertSessionRegistry,
25
+ VAULT_COMPLEMENT_RULES,
26
+ wikilinkFromRel,
27
+ writeControl,
28
+ writeHookOutput,
29
+ yamlQuote,
30
+ } from './obsidian-common.mjs';
31
+
32
+ function sessionIdFromInput(input) {
33
+ return input.session_id || input.sessionId || input.codex_session_id || '';
34
+ }
35
+
36
+ function buildSessionContent({ relPath, now, summary = 'session', reason = 'Sessão criada automaticamente pelo hook UserPromptSubmit.' }) {
37
+ const date = formatDate(now);
38
+ const startedAt = formatLocalIso(now);
39
+ const titleTime = formatTime(now).slice(0, 5);
40
+ const objective = summary === 'session' ? 'Preencher durante a sessão.' : summary;
41
+ const provider = providerMeta();
42
+
43
+ return `---
44
+ type: session
45
+ date: ${date}
46
+ started_at: ${startedAt}
47
+ ended_at:
48
+ provider: ${provider.id}
49
+ status: active
50
+ summary: ${yamlQuote(summary)}
51
+ cssclasses:
52
+ - topic-session
53
+ tags:
54
+ - sessao
55
+ - ${provider.tag}
56
+ - llm
57
+ source: ${provider.source}
58
+ related:
59
+ ---
60
+
61
+ # ${titleTime} - ${summary}
62
+
63
+ ## Metadados
64
+
65
+ - **Provider:** ${provider.label}
66
+ - **Início:** ${startedAt}
67
+ - **Fim:**
68
+ - **Status:** active
69
+ - **Arquivo:** \`${relPath}\`
70
+
71
+ ## Objetivo da sessão
72
+
73
+ > ${objective}
74
+
75
+ ## Resumo vivo
76
+
77
+ > Esta seção pode ser atualizada ao longo da sessão, mas o histórico de iterações deve ser preservado.
78
+
79
+ ## Iterações
80
+
81
+ ### ${titleTime} - Início da sessão
82
+
83
+ ${reason}
84
+
85
+ ## Decisões geradas nesta sessão
86
+
87
+ Nenhuma decisão registrada ainda.
88
+
89
+ ## Bugs gerados nesta sessão
90
+
91
+ Nenhum bug registrado ainda.
92
+
93
+ ## Aprendizados gerados nesta sessão
94
+
95
+ Nenhum aprendizado registrado ainda.
96
+
97
+ ## Arquivos consultados
98
+
99
+ Nenhum arquivo registrado ainda.
100
+
101
+ ## Arquivos criados ou alterados
102
+
103
+ Nenhum arquivo registrado ainda.
104
+
105
+ ## Pendências
106
+
107
+ Nenhuma pendência identificada automaticamente.
108
+
109
+ ## Encerramento
110
+
111
+ Sessão ainda em andamento.
112
+ `;
113
+ }
114
+
115
+ function allocateSessionPath(vaultBase, now, summary = 'session') {
116
+ const folderRel = sessionFolderRel(now);
117
+ const folderAbs = join(vaultBase, folderRel);
118
+ ensureDir(folderAbs);
119
+
120
+ const baseName = sessionFileName(now, summary);
121
+ const filePath = uniquePath(join(folderAbs, baseName));
122
+ return {
123
+ absPath: filePath,
124
+ relPath: toVaultRelative(vaultBase, filePath),
125
+ };
126
+ }
127
+
128
+ function buildAdditionalContext({ relPath, startedAt, vaultBase }) {
129
+ const controlRel = toVaultRelative(vaultBase, controlPath(vaultBase));
130
+ return [
131
+ '<obsidian_session>',
132
+ `Sessão Obsidian ativa: ${relPath}`,
133
+ `Controle atualizado: ${controlRel}`,
134
+ `Início: ${startedAt}`,
135
+ '',
136
+ 'Use a sessão ativa como log desta conversa.',
137
+ 'Antes de registrar informações, leia `.brain/CURRENT_SESSION.md` no vault.',
138
+ 'Nunca sobrescreva o histórico anterior. Registre cada iteração como `### HH:MM - Título` DENTRO da seção `## Iterações` (logo antes de `## Decisões geradas nesta sessão`). Cada iteração deve trazer contexto conversado suficiente: pedido do usuário, investigação/ações, evidências relevantes e estado final. NUNCA escreva iterações após `## Encerramento`.',
139
+ '',
140
+ ...VAULT_COMPLEMENT_RULES,
141
+ 'Não registre chaves, tokens, senhas ou segredos; substitua por `[REDACTED_SECRET]`.',
142
+ `Wikilink da sessão: ${wikilinkFromRel(relPath)}`,
143
+ '</obsidian_session>',
144
+ ].join('\n');
145
+ }
146
+
147
+ function updateSessionFrontmatter(content) {
148
+ let next = content;
149
+ next = next.replace(/^status:.*$/m, 'status: active');
150
+ next = next.replace(/^ended_at:.*$/m, 'ended_at:');
151
+ return next;
152
+ }
153
+
154
+ function upsertSummaryFrontmatter(content, summary) {
155
+ if (/^summary:/m.test(content)) return content.replace(/^summary:.*$/m, `summary: ${yamlQuote(summary)}`);
156
+ return content.replace(/^status:.*$/m, (line) => `${line}\nsummary: ${yamlQuote(summary)}`);
157
+ }
158
+
159
+ function updateSessionDescription(content, { relPath, summary, startedAt }) {
160
+ const startedDate = startedAt ? new Date(startedAt) : new Date();
161
+ const titleTime = Number.isFinite(startedDate.getTime()) ? formatTime(startedDate).slice(0, 5) : '';
162
+ let next = upsertSummaryFrontmatter(content, summary);
163
+ if (titleTime) {
164
+ next = next.replace(/^# .+$/m, `# ${titleTime} - ${summary}`);
165
+ }
166
+ next = next.replace(/- \*\*Arquivo:\*\* `[^`]+`/m, `- **Arquivo:** \`${relPath}\``);
167
+ next = next.replace(
168
+ /(## Objetivo da sessão\n\n)>[^\n]*/m,
169
+ `$1> ${summary === 'session' ? 'Preencher durante a sessão.' : summary}`,
170
+ );
171
+ return next;
172
+ }
173
+
174
+ function maybeRetitleSession({ vaultBase, relPath, startedAt, input }) {
175
+ const summary = sessionSummaryFromInput(input);
176
+ if (!isUsableSummary(summary)) return { relPath, summary, changed: false };
177
+
178
+ const currentPath = join(vaultBase, relPath);
179
+ if (!existsSync(currentPath)) return { relPath, summary, changed: false };
180
+
181
+ let nextRelPath = relPath;
182
+ if (isPlaceholderSessionFile(relPath)) {
183
+ const startedDate = startedAt ? new Date(startedAt) : new Date();
184
+ const baseDate = Number.isFinite(startedDate.getTime()) ? startedDate : new Date();
185
+ const nextPath = uniquePath(join(dirname(currentPath), sessionFileName(baseDate, summary)));
186
+ if (nextPath !== currentPath) {
187
+ renameSync(currentPath, nextPath);
188
+ nextRelPath = toVaultRelative(vaultBase, nextPath);
189
+ }
190
+ }
191
+
192
+ const sessionPath = join(vaultBase, nextRelPath);
193
+ const content = readFileSync(sessionPath, 'utf-8');
194
+ const updated = updateSessionDescription(content, { relPath: nextRelPath, summary, startedAt });
195
+ if (updated !== content) writeFileSync(sessionPath, updated, 'utf-8');
196
+
197
+ return { relPath: nextRelPath, summary, changed: nextRelPath !== relPath || updated !== content };
198
+ }
199
+
200
+ function stripClosingSection(content) {
201
+ const marker = '\n## Encerramento';
202
+ const index = content.indexOf(marker);
203
+ if (index === -1) return content;
204
+ return `${content.slice(0, index).trimEnd()}\n`;
205
+ }
206
+
207
+ function reopenSessionFile(sessionPath) {
208
+ const content = readFileSync(sessionPath, 'utf-8');
209
+ const reopened = stripClosingSection(updateSessionFrontmatter(content));
210
+ writeFileSync(sessionPath, reopened, 'utf-8');
211
+ }
212
+
213
+ function findSessionForInput(vaultBase, input, control) {
214
+ const sessionId = sessionIdFromInput(input);
215
+ const registry = readSessionRegistry(vaultBase);
216
+ const registered = sessionId ? registry.sessions[sessionId] : null;
217
+
218
+ if (registered?.session_file) {
219
+ return {
220
+ sessionId,
221
+ relPath: registered.session_file,
222
+ startedAt: registered.started_at || control.started_at || '',
223
+ fromRegistry: true,
224
+ };
225
+ }
226
+
227
+ if (sessionId && control.session_id === sessionId && control.last_session_file) {
228
+ return {
229
+ sessionId,
230
+ relPath: control.last_session_file,
231
+ startedAt: control.started_at || '',
232
+ fromRegistry: false,
233
+ };
234
+ }
235
+
236
+ return { sessionId, relPath: '', startedAt: '', fromRegistry: false };
237
+ }
238
+
239
+ function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, input, now }) {
240
+ const sessionPath = join(vaultBase, relPath);
241
+ if (!existsSync(sessionPath)) return false;
242
+
243
+ reopenSessionFile(sessionPath);
244
+ const nextStartedAt = startedAt || formatLocalIso(now);
245
+ writeControl(vaultBase, {
246
+ status: 'active',
247
+ session_file: relPath,
248
+ last_session_file: relPath,
249
+ started_at: nextStartedAt,
250
+ ended_at: '',
251
+ session_id: sessionId,
252
+ last_logged_turn_id: '',
253
+ });
254
+ upsertSessionRegistry(vaultBase, sessionId, {
255
+ session_file: relPath,
256
+ status: 'active',
257
+ started_at: nextStartedAt,
258
+ ended_at: '',
259
+ transcript_path: input.transcript_path || input.transcriptPath || '',
260
+ });
261
+ return true;
262
+ }
263
+
264
+ function createSession({ vaultBase, sessionId, input, now }) {
265
+ const summary = sessionSummaryFromInput(input);
266
+ const { absPath, relPath } = allocateSessionPath(vaultBase, now, summary);
267
+ const startedAt = formatLocalIso(now);
268
+ writeFileSync(absPath, buildSessionContent({ relPath, now, summary }), 'utf-8');
269
+ writeControl(vaultBase, {
270
+ status: 'active',
271
+ session_file: relPath,
272
+ last_session_file: relPath,
273
+ started_at: startedAt,
274
+ ended_at: '',
275
+ session_id: sessionId,
276
+ last_logged_turn_id: '',
277
+ });
278
+ upsertSessionRegistry(vaultBase, sessionId, {
279
+ session_file: relPath,
280
+ status: 'active',
281
+ started_at: startedAt,
282
+ ended_at: '',
283
+ transcript_path: input.transcript_path || input.transcriptPath || '',
284
+ });
285
+ return { relPath, startedAt };
286
+ }
287
+
288
+ function outputActiveContext({ relPath, startedAt, vaultBase, message }) {
289
+ writeHookOutput({
290
+ hookSpecificOutput: {
291
+ hookEventName: 'UserPromptSubmit',
292
+ additionalContext: buildAdditionalContext({ relPath, startedAt, vaultBase }),
293
+ },
294
+ systemMessage: message,
295
+ });
296
+ }
297
+
298
+ function main() {
299
+ const input = readHookInput();
300
+ const vaultBase = getVaultBase(input);
301
+ const now = new Date();
302
+ const sessionId = sessionIdFromInput(input);
303
+
304
+ // Fast path: skip all writes if control file touched < 5 min ago and session matches
305
+ try {
306
+ const ctrlPath = controlPath(vaultBase);
307
+ const { mtimeMs } = statSync(ctrlPath);
308
+ if ((now.getTime() - mtimeMs) / 1000 < 300) {
309
+ const ctrl = readControl(vaultBase);
310
+ if (
311
+ ctrl.status === 'active' &&
312
+ ctrl.session_file &&
313
+ !isPlaceholderSessionFile(ctrl.session_file) &&
314
+ existsSync(join(vaultBase, ctrl.session_file)) &&
315
+ (!sessionId || ctrl.session_id === sessionId)
316
+ ) {
317
+ writeHookOutput({});
318
+ return;
319
+ }
320
+ }
321
+ } catch {}
322
+
323
+ const control = readControl(vaultBase);
324
+
325
+ if (control.status === 'active' && control.session_file) {
326
+ const activePath = join(vaultBase, control.session_file);
327
+ // Sem reuso-por-janela: só reaproveita a nota do control quando não há
328
+ // identidade pra checar (!sessionId) ou quando é a MESMA conversa. Conversa
329
+ // concorrente recente NÃO pode herdar a nota ativa do ponteiro global.
330
+ if (existsSync(activePath) && (!sessionId || control.session_id === sessionId)) {
331
+ const titled = maybeRetitleSession({
332
+ vaultBase,
333
+ relPath: control.session_file,
334
+ startedAt: control.started_at,
335
+ input,
336
+ });
337
+ const activeRelPath = titled.relPath;
338
+ if (titled.changed || control.session_file !== activeRelPath || control.session_id !== sessionId) {
339
+ writeControl(vaultBase, {
340
+ status: 'active',
341
+ session_file: activeRelPath,
342
+ last_session_file: activeRelPath,
343
+ started_at: control.started_at,
344
+ ended_at: '',
345
+ session_id: sessionId || control.session_id,
346
+ last_logged_turn_id: control.last_logged_turn_id || '',
347
+ });
348
+ }
349
+ upsertSessionRegistry(vaultBase, sessionId || control.session_id, {
350
+ session_file: activeRelPath,
351
+ status: 'active',
352
+ started_at: control.started_at,
353
+ ended_at: '',
354
+ transcript_path: input.transcript_path || input.transcriptPath || '',
355
+ });
356
+ writeHookOutput({});
357
+ return;
358
+ }
359
+ }
360
+
361
+ const target = findSessionForInput(vaultBase, input, control);
362
+ if (target.relPath && activateExistingSession({ vaultBase, relPath: target.relPath, startedAt: target.startedAt, sessionId: target.sessionId, input, now })) {
363
+ outputActiveContext({
364
+ relPath: target.relPath,
365
+ startedAt: target.startedAt || formatLocalIso(now),
366
+ vaultBase,
367
+ message: `Sessão Obsidian reaberta em ${target.relPath}. ${basename(controlPath(vaultBase))} atualizado.`,
368
+ });
369
+ return;
370
+ }
371
+
372
+ const created = createSession({ vaultBase, sessionId, input, now });
373
+ outputActiveContext({
374
+ relPath: created.relPath,
375
+ startedAt: created.startedAt,
376
+ vaultBase,
377
+ message: `Sessão Obsidian criada em ${created.relPath}. ${basename(controlPath(vaultBase))} atualizado.`,
378
+ });
379
+ }
380
+
381
+ try {
382
+ main();
383
+ } catch (error) {
384
+ process.stderr.write(`[codex-obsidian] UserPromptSubmit falhou: ${error.message}\n`);
385
+ writeHookOutput({});
386
+ }
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, writeFileSync } from 'fs';
3
+ import { basename, join } from 'path';
4
+ import {
5
+ controlPath,
6
+ ensureDir,
7
+ findActiveSessionByTranscript,
8
+ formatDate,
9
+ formatHourMinute,
10
+ formatLocalIso,
11
+ formatTime,
12
+ getVaultBase,
13
+ providerMeta,
14
+ readControl,
15
+ readHookInput,
16
+ readSessionRegistry,
17
+ sessionFileName,
18
+ sessionFolderRel,
19
+ sessionSummaryFromInput,
20
+ shouldReuseActiveSession,
21
+ sweepStaleSessionsFile,
22
+ toVaultRelative,
23
+ uniquePath,
24
+ upsertSessionRegistry,
25
+ VAULT_COMPLEMENT_RULES,
26
+ wikilinkFromRel,
27
+ writeControl,
28
+ writeHookOutput,
29
+ yamlQuote,
30
+ } from './obsidian-common.mjs';
31
+
32
+ function buildSessionContent({ relPath, now, summary = 'session' }) {
33
+ const date = formatDate(now);
34
+ const startedAt = formatLocalIso(now);
35
+ const titleTime = formatTime(now).slice(0, 5);
36
+ const objective = summary === 'session' ? 'Preencher durante a sessão.' : summary;
37
+ const provider = providerMeta();
38
+
39
+ return `---
40
+ type: session
41
+ date: ${date}
42
+ started_at: ${startedAt}
43
+ ended_at:
44
+ provider: ${provider.id}
45
+ status: active
46
+ summary: ${yamlQuote(summary)}
47
+ cssclasses:
48
+ - topic-session
49
+ tags:
50
+ - sessao
51
+ - ${provider.tag}
52
+ - llm
53
+ source: ${provider.source}
54
+ related:
55
+ ---
56
+
57
+ # ${titleTime} - ${summary}
58
+
59
+ ## Metadados
60
+
61
+ - **Provider:** ${provider.label}
62
+ - **Início:** ${startedAt}
63
+ - **Fim:**
64
+ - **Status:** active
65
+ - **Arquivo:** \`${relPath}\`
66
+
67
+ ## Objetivo da sessão
68
+
69
+ > ${objective}
70
+
71
+ ## Resumo vivo
72
+
73
+ > Esta seção pode ser atualizada ao longo da sessão, mas o histórico de iterações deve ser preservado.
74
+
75
+ ## Iterações
76
+
77
+ ### ${titleTime} - Início da sessão
78
+
79
+ Sessão iniciada automaticamente pelo hook de início (${provider.label}).
80
+
81
+ ## Decisões geradas nesta sessão
82
+
83
+ Nenhuma decisão registrada ainda.
84
+
85
+ ## Bugs gerados nesta sessão
86
+
87
+ Nenhum bug registrado ainda.
88
+
89
+ ## Aprendizados gerados nesta sessão
90
+
91
+ Nenhum aprendizado registrado ainda.
92
+
93
+ ## Arquivos consultados
94
+
95
+ Nenhum arquivo registrado ainda.
96
+
97
+ ## Arquivos criados ou alterados
98
+
99
+ Nenhum arquivo registrado ainda.
100
+
101
+ ## Pendências
102
+
103
+ - [ ] Revisar resumo da sessão
104
+ - [ ] Verificar se houve decisões a registrar
105
+ - [ ] Verificar se houve bugs a registrar
106
+ - [ ] Verificar se houve aprendizados a registrar
107
+
108
+ ## Encerramento
109
+
110
+ Sessão ainda em andamento.
111
+ `;
112
+ }
113
+
114
+ function allocateSessionPath(vaultBase, now, summary = 'session') {
115
+ const folderRel = sessionFolderRel(now);
116
+ const folderAbs = join(vaultBase, folderRel);
117
+ ensureDir(folderAbs);
118
+
119
+ const baseName = sessionFileName(now, summary);
120
+ const filePath = uniquePath(join(folderAbs, baseName));
121
+ return {
122
+ absPath: filePath,
123
+ relPath: toVaultRelative(vaultBase, filePath),
124
+ };
125
+ }
126
+
127
+ function buildAdditionalContext({ relPath, startedAt, vaultBase }) {
128
+ const controlRel = toVaultRelative(vaultBase, controlPath(vaultBase));
129
+ return [
130
+ '<obsidian_session>',
131
+ `Sessão Obsidian ativa: ${relPath}`,
132
+ `Controle atualizado: ${controlRel}`,
133
+ `Início: ${startedAt}`,
134
+ '',
135
+ 'Use a sessão ativa como log desta conversa.',
136
+ 'Antes de registrar informações, leia `.brain/CURRENT_SESSION.md` no vault.',
137
+ 'Nunca sobrescreva o histórico anterior. Registre cada iteração como `### HH:MM - Título` DENTRO da seção `## Iterações` (logo antes de `## Decisões geradas nesta sessão`). Cada iteração deve trazer contexto conversado suficiente: pedido do usuário, investigação/ações, evidências relevantes e estado final. NUNCA escreva iterações após `## Encerramento`.',
138
+ '',
139
+ ...VAULT_COMPLEMENT_RULES,
140
+ 'Não registre chaves, tokens, senhas ou segredos; substitua por `[REDACTED_SECRET]`.',
141
+ `Wikilink da sessão: ${wikilinkFromRel(relPath)}`,
142
+ '</obsidian_session>',
143
+ ].join('\n');
144
+ }
145
+
146
+ function main() {
147
+ const input = readHookInput();
148
+ const vaultBase = getVaultBase(input);
149
+ const now = new Date();
150
+ const sessionId = input.session_id || input.sessionId || '';
151
+ const control = readControl(vaultBase);
152
+
153
+ // Fecha sessões `active` órfãs (sem evento de fim — janela fechada/crash) antes
154
+ // de seguir. Preserva a deste transcript: pode ser reaproveitada logo abaixo.
155
+ try {
156
+ sweepStaleSessionsFile(vaultBase, now, undefined, input.transcript_path || input.transcriptPath || '');
157
+ } catch (error) {
158
+ process.stderr.write(`[codex-obsidian] sweep de sessões falhou: ${error.message}\n`);
159
+ }
160
+
161
+ // Reuso da nota apontada pelo CURRENT_SESSION só quando é a MESMA conversa
162
+ // (session_id idêntico). NÃO reusar por janela de tempo: o ponteiro global é
163
+ // racy e uma conversa concorrente recente faria esta adotar a nota da outra.
164
+ // Resume/compactação (session_id novo, mesmo transcript) é tratado abaixo por
165
+ // identidade de transcript (findActiveSessionByTranscript).
166
+ if (control.status === 'active' && control.session_file && control.session_id === sessionId) {
167
+ const activePath = join(vaultBase, control.session_file);
168
+ if (existsSync(activePath)) {
169
+ upsertSessionRegistry(vaultBase, sessionId, {
170
+ session_file: control.session_file,
171
+ status: 'active',
172
+ started_at: control.started_at,
173
+ ended_at: '',
174
+ });
175
+ writeHookOutput({
176
+ hookSpecificOutput: {
177
+ hookEventName: 'SessionStart',
178
+ additionalContext: buildAdditionalContext({
179
+ relPath: control.session_file,
180
+ startedAt: control.started_at,
181
+ vaultBase,
182
+ }),
183
+ },
184
+ });
185
+ return;
186
+ }
187
+ }
188
+
189
+ // Reuso DETERMINISTICO por session_id: o id e estavel em todo o ciclo da conversa
190
+ // (inclusive resume/compactacao apos a janela de 10min e virada de dia). O ponteiro
191
+ // CURRENT_SESSION e racy — sessoes Codex intercaladas o clobberam — entao olhamos o
192
+ // registry direto pelo session_id antes de criar nota nova. Previne o split (mesma
193
+ // sessao em duas notas). Recria o esqueleto no MESMO caminho se a nota sumiu.
194
+ if (sessionId) {
195
+ const known = readSessionRegistry(vaultBase).sessions?.[sessionId];
196
+ if (known && known.status === 'active' && known.session_file) {
197
+ const knownAbs = join(vaultBase, known.session_file);
198
+ if (!existsSync(knownAbs)) {
199
+ ensureDir(join(vaultBase, known.session_file.split('/').slice(0, -1).join('/')));
200
+ writeFileSync(knownAbs, buildSessionContent({ relPath: known.session_file, now, summary: sessionSummaryFromInput(input) }), 'utf-8');
201
+ }
202
+ const startedAt = known.started_at || control.started_at || formatLocalIso(now);
203
+ writeControl(vaultBase, {
204
+ status: 'active',
205
+ session_file: known.session_file,
206
+ last_session_file: known.session_file,
207
+ started_at: startedAt,
208
+ ended_at: '',
209
+ session_id: sessionId,
210
+ last_logged_turn_id: control.last_logged_turn_id || '',
211
+ });
212
+ upsertSessionRegistry(vaultBase, sessionId, {
213
+ session_file: known.session_file,
214
+ status: 'active',
215
+ started_at: startedAt,
216
+ ended_at: '',
217
+ transcript_path: input.transcript_path || input.transcriptPath || known.transcript_path || '',
218
+ });
219
+ writeHookOutput({
220
+ hookSpecificOutput: {
221
+ hookEventName: 'SessionStart',
222
+ additionalContext: buildAdditionalContext({ relPath: known.session_file, startedAt, vaultBase }),
223
+ },
224
+ });
225
+ return;
226
+ }
227
+ }
228
+
229
+ // Re-init da conversa (compactação/resume) traz um session_id novo e cai fora
230
+ // da janela de reuso; o transcript continua o mesmo. Reaproveita a sessão ativa
231
+ // desse transcript em vez de criar um placeholder `HH-MM-codex`.
232
+ const transcriptPath = input.transcript_path || input.transcriptPath || '';
233
+ if (transcriptPath) {
234
+ const match = findActiveSessionByTranscript(vaultBase, transcriptPath);
235
+ if (match) {
236
+ // fail-safe: a nota do registro pode ter sumido do disco (git stash/checkout/
237
+ // sync removeram o arquivo). Em vez de mintar uma nota nova (split de sessão),
238
+ // recria o esqueleto no MESMO caminho e segue reaproveitando.
239
+ const matchAbs = join(vaultBase, match.session_file);
240
+ if (!existsSync(matchAbs)) {
241
+ ensureDir(join(vaultBase, match.session_file.split('/').slice(0, -1).join('/')));
242
+ writeFileSync(matchAbs, buildSessionContent({ relPath: match.session_file, now, summary: sessionSummaryFromInput(input) }), 'utf-8');
243
+ }
244
+ const startedAt = match.started_at || control.started_at || formatLocalIso(now);
245
+ writeControl(vaultBase, {
246
+ status: 'active',
247
+ session_file: match.session_file,
248
+ last_session_file: match.session_file,
249
+ started_at: startedAt,
250
+ ended_at: '',
251
+ session_id: sessionId || match.sessionId,
252
+ last_logged_turn_id: control.last_logged_turn_id || '',
253
+ });
254
+ upsertSessionRegistry(vaultBase, sessionId || match.sessionId, {
255
+ session_file: match.session_file,
256
+ status: 'active',
257
+ started_at: startedAt,
258
+ ended_at: '',
259
+ transcript_path: transcriptPath,
260
+ });
261
+ writeHookOutput({
262
+ hookSpecificOutput: {
263
+ hookEventName: 'SessionStart',
264
+ additionalContext: buildAdditionalContext({ relPath: match.session_file, startedAt, vaultBase }),
265
+ },
266
+ });
267
+ return;
268
+ }
269
+ }
270
+
271
+ const summary = sessionSummaryFromInput(input);
272
+ const { absPath, relPath } = allocateSessionPath(vaultBase, now, summary);
273
+ const startedAt = formatLocalIso(now);
274
+ writeFileSync(absPath, buildSessionContent({ relPath, now, summary }), 'utf-8');
275
+ writeControl(vaultBase, {
276
+ status: 'active',
277
+ session_file: relPath,
278
+ last_session_file: relPath,
279
+ started_at: startedAt,
280
+ session_id: sessionId,
281
+ });
282
+ upsertSessionRegistry(vaultBase, sessionId, {
283
+ session_file: relPath,
284
+ status: 'active',
285
+ started_at: startedAt,
286
+ ended_at: '',
287
+ });
288
+
289
+ writeHookOutput({
290
+ hookSpecificOutput: {
291
+ hookEventName: 'SessionStart',
292
+ additionalContext: buildAdditionalContext({ relPath, startedAt, vaultBase }),
293
+ },
294
+ systemMessage: [
295
+ `Sessão ${providerMeta().label} criada em ${relPath}.`,
296
+ `${basename(controlPath(vaultBase))} atualizado.`,
297
+ 'Iterações devem ser anexadas, nunca sobrescritas.',
298
+ ].join(' '),
299
+ });
300
+ }
301
+
302
+ try {
303
+ main();
304
+ } catch (error) {
305
+ process.stderr.write(`[codex-obsidian] SessionStart falhou: ${error.message}\n`);
306
+ writeHookOutput({
307
+ systemMessage: `[codex-obsidian] Não foi possível criar a sessão Obsidian: ${error.message}`,
308
+ });
309
+ }