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,392 +1,413 @@
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
- warnIfDefaultVault,
13
- debugLog,
14
- readControl,
15
- readHookInput,
16
- readSessionRegistry,
17
- sessionFileName,
18
- sessionFolderRel,
19
- sessionSummaryFromInput,
20
- isUsableSummary,
21
- providerMeta,
22
- shouldReuseActiveSession,
23
- isPlaceholderSessionFile,
24
- toVaultRelative,
25
- uniquePath,
26
- upsertSessionRegistry,
27
- VAULT_COMPLEMENT_RULES,
28
- wikilinkFromRel,
29
- writeControl,
30
- writeHookOutput,
31
- yamlQuote,
32
- } from './obsidian-common.mjs';
33
-
34
- function sessionIdFromInput(input) {
35
- return input.session_id || input.sessionId || input.codex_session_id || '';
36
- }
37
-
38
- function buildSessionContent({ relPath, now, summary = 'session', sessionId = '', reason = 'Sessão criada automaticamente pelo hook UserPromptSubmit.' }) {
39
- const date = formatDate(now);
40
- const startedAt = formatLocalIso(now);
41
- const titleTime = formatTime(now).slice(0, 5);
42
- const objective = summary === 'session' ? 'Preencher durante a sessão.' : summary;
43
- const provider = providerMeta();
44
-
45
- return `---
46
- type: session
47
- date: ${date}
48
- started_at: ${startedAt}
49
- ended_at:
50
- provider: ${provider.id}
51
- session_id: ${sessionId ? yamlQuote(sessionId) : ''}
52
- status: active
53
- summary: ${yamlQuote(summary)}
54
- cssclasses:
55
- - topic-session
56
- tags:
57
- - sessao
58
- - ${provider.tag}
59
- - llm
60
- source: ${provider.source}
61
- related:
62
- ---
63
-
64
- # ${titleTime} - ${summary}
65
-
66
- ## Metadados
67
-
68
- - **Provider:** ${provider.label}
69
- - **Início:** ${startedAt}
70
- - **Fim:**
71
- - **Status:** active
72
- - **Arquivo:** \`${relPath}\`
73
-
74
- ## Objetivo da sessão
75
-
76
- > ${objective}
77
-
78
- ## Resumo vivo
79
-
80
- > Esta seção pode ser atualizada ao longo da sessão, mas o histórico de iterações deve ser preservado.
81
-
82
- ## Iterações
83
-
84
- ### ${titleTime} - Início da sessão
85
-
86
- ${reason}
87
-
88
- ## Decisões geradas nesta sessão
89
-
90
- Nenhuma decisão registrada ainda.
91
-
92
- ## Bugs gerados nesta sessão
93
-
94
- Nenhum bug registrado ainda.
95
-
96
- ## Aprendizados gerados nesta sessão
97
-
98
- Nenhum aprendizado registrado ainda.
99
-
100
- ## Arquivos consultados
101
-
102
- Nenhum arquivo registrado ainda.
103
-
104
- ## Arquivos criados ou alterados
105
-
106
- Nenhum arquivo registrado ainda.
107
-
108
- ## Pendências
109
-
110
- Nenhuma pendência identificada automaticamente.
111
-
112
- ## Encerramento
113
-
114
- Sessão ainda em andamento.
115
- `;
116
- }
117
-
118
- function allocateSessionPath(vaultBase, now, summary = 'session') {
119
- const folderRel = sessionFolderRel(now, vaultBase);
120
- const folderAbs = join(vaultBase, folderRel);
121
- ensureDir(folderAbs);
122
-
123
- const baseName = sessionFileName(now, summary);
124
- const filePath = uniquePath(join(folderAbs, baseName));
125
- return {
126
- absPath: filePath,
127
- relPath: toVaultRelative(vaultBase, filePath),
128
- };
129
- }
130
-
131
- function buildAdditionalContext({ relPath, startedAt, vaultBase }) {
132
- const controlRel = toVaultRelative(vaultBase, controlPath(vaultBase));
133
- return [
134
- '<obsidian_session>',
135
- `Sessão Obsidian ativa: ${relPath}`,
136
- `Controle atualizado: ${controlRel}`,
137
- `Início: ${startedAt}`,
138
- '',
139
- 'Use a sessão ativa como log desta conversa.',
140
- 'Antes de registrar informações, leia `.brain/CURRENT_SESSION.md` no vault.',
141
- '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`.',
142
- '',
143
- ...VAULT_COMPLEMENT_RULES,
144
- 'Não registre chaves, tokens, senhas ou segredos; substitua por `[REDACTED_SECRET]`.',
145
- `Wikilink da sessão: ${wikilinkFromRel(relPath)}`,
146
- '</obsidian_session>',
147
- ].join('\n');
148
- }
149
-
150
- function updateSessionFrontmatter(content) {
151
- let next = content;
152
- next = next.replace(/^status:.*$/m, 'status: active');
153
- next = next.replace(/^ended_at:.*$/m, 'ended_at:');
154
- return next;
155
- }
156
-
157
- function upsertSummaryFrontmatter(content, summary) {
158
- if (/^summary:/m.test(content)) return content.replace(/^summary:.*$/m, `summary: ${yamlQuote(summary)}`);
159
- return content.replace(/^status:.*$/m, (line) => `${line}\nsummary: ${yamlQuote(summary)}`);
160
- }
161
-
162
- function updateSessionDescription(content, { relPath, summary, startedAt }) {
163
- const startedDate = startedAt ? new Date(startedAt) : new Date();
164
- const titleTime = Number.isFinite(startedDate.getTime()) ? formatTime(startedDate).slice(0, 5) : '';
165
- let next = upsertSummaryFrontmatter(content, summary);
166
- if (titleTime) {
167
- next = next.replace(/^# .+$/m, `# ${titleTime} - ${summary}`);
168
- }
169
- next = next.replace(/- \*\*Arquivo:\*\* `[^`]+`/m, `- **Arquivo:** \`${relPath}\``);
170
- next = next.replace(
171
- /(## Objetivo da sessão\n\n)>[^\n]*/m,
172
- `$1> ${summary === 'session' ? 'Preencher durante a sessão.' : summary}`,
173
- );
174
- return next;
175
- }
176
-
177
- function maybeRetitleSession({ vaultBase, relPath, startedAt, input }) {
178
- const summary = sessionSummaryFromInput(input);
179
- if (!isUsableSummary(summary)) return { relPath, summary, changed: false };
180
-
181
- const currentPath = join(vaultBase, relPath);
182
- if (!existsSync(currentPath)) return { relPath, summary, changed: false };
183
-
184
- let nextRelPath = relPath;
185
- if (isPlaceholderSessionFile(relPath)) {
186
- const startedDate = startedAt ? new Date(startedAt) : new Date();
187
- const baseDate = Number.isFinite(startedDate.getTime()) ? startedDate : new Date();
188
- const nextPath = uniquePath(join(dirname(currentPath), sessionFileName(baseDate, summary)));
189
- if (nextPath !== currentPath) {
190
- renameSync(currentPath, nextPath);
191
- nextRelPath = toVaultRelative(vaultBase, nextPath);
192
- }
193
- }
194
-
195
- const sessionPath = join(vaultBase, nextRelPath);
196
- const content = readFileSync(sessionPath, 'utf-8');
197
- const updated = updateSessionDescription(content, { relPath: nextRelPath, summary, startedAt });
198
- if (updated !== content) writeFileSync(sessionPath, updated, 'utf-8');
199
-
200
- return { relPath: nextRelPath, summary, changed: nextRelPath !== relPath || updated !== content };
201
- }
202
-
203
- function stripClosingSection(content) {
204
- const marker = '\n## Encerramento';
205
- const index = content.indexOf(marker);
206
- if (index === -1) return content;
207
- return `${content.slice(0, index).trimEnd()}\n`;
208
- }
209
-
210
- function reopenSessionFile(sessionPath) {
211
- const content = readFileSync(sessionPath, 'utf-8');
212
- const reopened = stripClosingSection(updateSessionFrontmatter(content));
213
- writeFileSync(sessionPath, reopened, 'utf-8');
214
- }
215
-
216
- function findSessionForInput(vaultBase, input, control) {
217
- const sessionId = sessionIdFromInput(input);
218
- const registry = readSessionRegistry(vaultBase);
219
- const registered = sessionId ? registry.sessions[sessionId] : null;
220
-
221
- if (registered?.session_file) {
222
- return {
223
- sessionId,
224
- relPath: registered.session_file,
225
- startedAt: registered.started_at || control.started_at || '',
226
- fromRegistry: true,
227
- };
228
- }
229
-
230
- if (sessionId && control.session_id === sessionId && control.last_session_file) {
231
- return {
232
- sessionId,
233
- relPath: control.last_session_file,
234
- startedAt: control.started_at || '',
235
- fromRegistry: false,
236
- };
237
- }
238
-
239
- return { sessionId, relPath: '', startedAt: '', fromRegistry: false };
240
- }
241
-
242
- function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, input, now }) {
243
- const sessionPath = join(vaultBase, relPath);
244
- if (!existsSync(sessionPath)) return false;
245
-
246
- reopenSessionFile(sessionPath);
247
- const nextStartedAt = startedAt || formatLocalIso(now);
248
- writeControl(vaultBase, {
249
- status: 'active',
250
- session_file: relPath,
251
- last_session_file: relPath,
252
- started_at: nextStartedAt,
253
- ended_at: '',
254
- session_id: sessionId,
255
- last_logged_turn_id: '',
256
- });
257
- upsertSessionRegistry(vaultBase, sessionId, {
258
- session_file: relPath,
259
- status: 'active',
260
- started_at: nextStartedAt,
261
- ended_at: '',
262
- transcript_path: input.transcript_path || input.transcriptPath || '',
263
- });
264
- return true;
265
- }
266
-
267
- function createSession({ vaultBase, sessionId, input, now }) {
268
- const summary = sessionSummaryFromInput(input);
269
- const { absPath, relPath } = allocateSessionPath(vaultBase, now, summary);
270
- const startedAt = formatLocalIso(now);
271
- writeFileSync(absPath, buildSessionContent({ relPath, now, summary, sessionId }), 'utf-8');
272
- writeControl(vaultBase, {
273
- status: 'active',
274
- session_file: relPath,
275
- last_session_file: relPath,
276
- started_at: startedAt,
277
- ended_at: '',
278
- session_id: sessionId,
279
- last_logged_turn_id: '',
280
- });
281
- upsertSessionRegistry(vaultBase, sessionId, {
282
- session_file: relPath,
283
- status: 'active',
284
- started_at: startedAt,
285
- ended_at: '',
286
- transcript_path: input.transcript_path || input.transcriptPath || '',
287
- });
288
- return { relPath, startedAt };
289
- }
290
-
291
- function outputActiveContext({ relPath, startedAt, vaultBase, message }) {
292
- writeHookOutput({
293
- hookSpecificOutput: {
294
- hookEventName: 'UserPromptSubmit',
295
- additionalContext: buildAdditionalContext({ relPath, startedAt, vaultBase }),
296
- },
297
- systemMessage: message,
298
- });
299
- }
300
-
301
- function main() {
302
- const input = readHookInput();
303
- const vaultBase = getVaultBase(input);
304
- warnIfDefaultVault(input);
305
- const now = new Date();
306
- const sessionId = sessionIdFromInput(input);
307
-
308
- // Fast path: skip all writes if control file touched < 5 min ago and session matches
309
- try {
310
- const ctrlPath = controlPath(vaultBase);
311
- const { mtimeMs } = statSync(ctrlPath);
312
- if ((now.getTime() - mtimeMs) / 1000 < 300) {
313
- const ctrl = readControl(vaultBase);
314
- if (
315
- ctrl.status === 'active' &&
316
- ctrl.session_file &&
317
- !isPlaceholderSessionFile(ctrl.session_file) &&
318
- existsSync(join(vaultBase, ctrl.session_file)) &&
319
- (!sessionId || ctrl.session_id === sessionId)
320
- ) {
321
- writeHookOutput({});
322
- return;
323
- }
324
- }
325
- } catch (err) {
326
- debugLog('session-ensure fast-path skipped:', err);
327
- }
328
-
329
- const control = readControl(vaultBase);
330
-
331
- if (control.status === 'active' && control.session_file) {
332
- const activePath = join(vaultBase, control.session_file);
333
- // Sem reuso-por-janela: só reaproveita a nota do control quando não há
334
- // identidade pra checar (!sessionId) ou quando é a MESMA conversa. Conversa
335
- // concorrente recente NÃO pode herdar a nota ativa do ponteiro global.
336
- if (existsSync(activePath) && (!sessionId || control.session_id === sessionId)) {
337
- const titled = maybeRetitleSession({
338
- vaultBase,
339
- relPath: control.session_file,
340
- startedAt: control.started_at,
341
- input,
342
- });
343
- const activeRelPath = titled.relPath;
344
- if (titled.changed || control.session_file !== activeRelPath || control.session_id !== sessionId) {
345
- writeControl(vaultBase, {
346
- status: 'active',
347
- session_file: activeRelPath,
348
- last_session_file: activeRelPath,
349
- started_at: control.started_at,
350
- ended_at: '',
351
- session_id: sessionId || control.session_id,
352
- last_logged_turn_id: control.last_logged_turn_id || '',
353
- });
354
- }
355
- upsertSessionRegistry(vaultBase, sessionId || control.session_id, {
356
- session_file: activeRelPath,
357
- status: 'active',
358
- started_at: control.started_at,
359
- ended_at: '',
360
- transcript_path: input.transcript_path || input.transcriptPath || '',
361
- });
362
- writeHookOutput({});
363
- return;
364
- }
365
- }
366
-
367
- const target = findSessionForInput(vaultBase, input, control);
368
- if (target.relPath && activateExistingSession({ vaultBase, relPath: target.relPath, startedAt: target.startedAt, sessionId: target.sessionId, input, now })) {
369
- outputActiveContext({
370
- relPath: target.relPath,
371
- startedAt: target.startedAt || formatLocalIso(now),
372
- vaultBase,
373
- message: `Sessão Obsidian reaberta em ${target.relPath}. ${basename(controlPath(vaultBase))} atualizado.`,
374
- });
375
- return;
376
- }
377
-
378
- const created = createSession({ vaultBase, sessionId, input, now });
379
- outputActiveContext({
380
- relPath: created.relPath,
381
- startedAt: created.startedAt,
382
- vaultBase,
383
- message: `Sessão Obsidian criada em ${created.relPath}. ${basename(controlPath(vaultBase))} atualizado.`,
384
- });
385
- }
386
-
387
- try {
388
- main();
389
- } catch (error) {
390
- process.stderr.write(`[wendkeep] UserPromptSubmit falhou: ${error.message}\n`);
391
- writeHookOutput({});
392
- }
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
+ warnIfDefaultVault,
13
+ debugLog,
14
+ readControl,
15
+ readHookInput,
16
+ readSessionRegistry,
17
+ sessionFileName,
18
+ sessionFolderRel,
19
+ sessionSummaryFromInput,
20
+ isUsableSummary,
21
+ providerMeta,
22
+ shouldReuseActiveSession,
23
+ isPlaceholderSessionFile,
24
+ toVaultRelative,
25
+ uniquePath,
26
+ upsertSessionRegistry,
27
+ VAULT_COMPLEMENT_RULES,
28
+ wikilinkFromRel,
29
+ writeControl,
30
+ writeHookOutput,
31
+ yamlQuote,
32
+ } from './obsidian-common.mjs';
33
+ import { resolveSessionIdentity } from './session-identity.mjs';
34
+
35
+ function sessionIdFromInput(input) {
36
+ return input.session_id || input.sessionId || input.codex_session_id || '';
37
+ }
38
+
39
+ function buildSessionContent({ relPath, now, summary = 'session', sessionId = '', reason = 'Sessão criada automaticamente pelo hook UserPromptSubmit.' }) {
40
+ const date = formatDate(now);
41
+ const startedAt = formatLocalIso(now);
42
+ const titleTime = formatTime(now).slice(0, 5);
43
+ const objective = summary === 'session' ? 'Preencher durante a sessão.' : summary;
44
+ const provider = providerMeta();
45
+
46
+ return `---
47
+ type: session
48
+ date: ${date}
49
+ started_at: ${startedAt}
50
+ ended_at:
51
+ provider: ${provider.id}
52
+ session_id: ${sessionId ? yamlQuote(sessionId) : ''}
53
+ status: active
54
+ summary: ${yamlQuote(summary)}
55
+ cssclasses:
56
+ - topic-session
57
+ tags:
58
+ - sessao
59
+ - ${provider.tag}
60
+ - llm
61
+ source: ${provider.source}
62
+ related:
63
+ ---
64
+
65
+ # ${titleTime} - ${summary}
66
+
67
+ ## Metadados
68
+
69
+ - **Provider:** ${provider.label}
70
+ - **Início:** ${startedAt}
71
+ - **Fim:**
72
+ - **Status:** active
73
+ - **Arquivo:** \`${relPath}\`
74
+
75
+ ## Objetivo da sessão
76
+
77
+ > ${objective}
78
+
79
+ ## Resumo vivo
80
+
81
+ > Esta seção pode ser atualizada ao longo da sessão, mas o histórico de iterações deve ser preservado.
82
+
83
+ ## Iterações
84
+
85
+ ### ${titleTime} - Início da sessão
86
+
87
+ ${reason}
88
+
89
+ ## Decisões geradas nesta sessão
90
+
91
+ Nenhuma decisão registrada ainda.
92
+
93
+ ## Bugs gerados nesta sessão
94
+
95
+ Nenhum bug registrado ainda.
96
+
97
+ ## Aprendizados gerados nesta sessão
98
+
99
+ Nenhum aprendizado registrado ainda.
100
+
101
+ ## Arquivos consultados
102
+
103
+ Nenhum arquivo registrado ainda.
104
+
105
+ ## Arquivos criados ou alterados
106
+
107
+ Nenhum arquivo registrado ainda.
108
+
109
+ ## Pendências
110
+
111
+ Nenhuma pendência identificada automaticamente.
112
+
113
+ ## Encerramento
114
+
115
+ Sessão ainda em andamento.
116
+ `;
117
+ }
118
+
119
+ function allocateSessionPath(vaultBase, now, summary = 'session') {
120
+ const folderRel = sessionFolderRel(now, vaultBase);
121
+ const folderAbs = join(vaultBase, folderRel);
122
+ ensureDir(folderAbs);
123
+
124
+ const baseName = sessionFileName(now, summary);
125
+ const filePath = uniquePath(join(folderAbs, baseName));
126
+ return {
127
+ absPath: filePath,
128
+ relPath: toVaultRelative(vaultBase, filePath),
129
+ };
130
+ }
131
+
132
+ function buildAdditionalContext({ relPath, startedAt, vaultBase }) {
133
+ const controlRel = toVaultRelative(vaultBase, controlPath(vaultBase));
134
+ return [
135
+ '<obsidian_session>',
136
+ `Sessão Obsidian ativa: ${relPath}`,
137
+ `Controle atualizado: ${controlRel}`,
138
+ `Início: ${startedAt}`,
139
+ '',
140
+ 'Use a sessão ativa como log desta conversa.',
141
+ 'Antes de registrar informações, leia `.brain/CURRENT_SESSION.md` no vault.',
142
+ '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`.',
143
+ '',
144
+ ...VAULT_COMPLEMENT_RULES,
145
+ 'Não registre chaves, tokens, senhas ou segredos; substitua por `[REDACTED_SECRET]`.',
146
+ `Wikilink da sessão: ${wikilinkFromRel(relPath)}`,
147
+ '</obsidian_session>',
148
+ ].join('\n');
149
+ }
150
+
151
+ function updateSessionFrontmatter(content) {
152
+ let next = content;
153
+ next = next.replace(/^status:.*$/m, 'status: active');
154
+ next = next.replace(/^ended_at:.*$/m, 'ended_at:');
155
+ return next;
156
+ }
157
+
158
+ function upsertSummaryFrontmatter(content, summary) {
159
+ if (/^summary:/m.test(content)) return content.replace(/^summary:.*$/m, `summary: ${yamlQuote(summary)}`);
160
+ return content.replace(/^status:.*$/m, (line) => `${line}\nsummary: ${yamlQuote(summary)}`);
161
+ }
162
+
163
+ function updateSessionDescription(content, { relPath, summary, startedAt }) {
164
+ const startedDate = startedAt ? new Date(startedAt) : new Date();
165
+ const titleTime = Number.isFinite(startedDate.getTime()) ? formatTime(startedDate).slice(0, 5) : '';
166
+ let next = upsertSummaryFrontmatter(content, summary);
167
+ if (titleTime) {
168
+ next = next.replace(/^# .+$/m, `# ${titleTime} - ${summary}`);
169
+ }
170
+ next = next.replace(/- \*\*Arquivo:\*\* `[^`]+`/m, `- **Arquivo:** \`${relPath}\``);
171
+ next = next.replace(
172
+ /(## Objetivo da sessão\n\n)>[^\n]*/m,
173
+ `$1> ${summary === 'session' ? 'Preencher durante a sessão.' : summary}`,
174
+ );
175
+ return next;
176
+ }
177
+
178
+ function maybeRetitleSession({ vaultBase, relPath, startedAt, input }) {
179
+ const summary = sessionSummaryFromInput(input);
180
+ if (!isUsableSummary(summary)) return { relPath, summary, changed: false };
181
+
182
+ const currentPath = join(vaultBase, relPath);
183
+ if (!existsSync(currentPath)) return { relPath, summary, changed: false };
184
+
185
+ let nextRelPath = relPath;
186
+ if (isPlaceholderSessionFile(relPath)) {
187
+ const startedDate = startedAt ? new Date(startedAt) : new Date();
188
+ const baseDate = Number.isFinite(startedDate.getTime()) ? startedDate : new Date();
189
+ const nextPath = uniquePath(join(dirname(currentPath), sessionFileName(baseDate, summary)));
190
+ if (nextPath !== currentPath) {
191
+ renameSync(currentPath, nextPath);
192
+ nextRelPath = toVaultRelative(vaultBase, nextPath);
193
+ }
194
+ }
195
+
196
+ const sessionPath = join(vaultBase, nextRelPath);
197
+ const content = readFileSync(sessionPath, 'utf-8');
198
+ const updated = updateSessionDescription(content, { relPath: nextRelPath, summary, startedAt });
199
+ if (updated !== content) writeFileSync(sessionPath, updated, 'utf-8');
200
+
201
+ return { relPath: nextRelPath, summary, changed: nextRelPath !== relPath || updated !== content };
202
+ }
203
+
204
+ function stripClosingSection(content) {
205
+ const marker = '\n## Encerramento';
206
+ const index = content.indexOf(marker);
207
+ if (index === -1) return content;
208
+ return `${content.slice(0, index).trimEnd()}\n`;
209
+ }
210
+
211
+ function reopenSessionFile(sessionPath) {
212
+ const content = readFileSync(sessionPath, 'utf-8');
213
+ const reopened = stripClosingSection(updateSessionFrontmatter(content));
214
+ writeFileSync(sessionPath, reopened, 'utf-8');
215
+ }
216
+
217
+ function findSessionForInput(vaultBase, input, control) {
218
+ const sessionId = sessionIdFromInput(input);
219
+ const registry = readSessionRegistry(vaultBase);
220
+ const registered = sessionId ? registry.sessions[sessionId] : null;
221
+
222
+ if (registered?.session_file) {
223
+ return {
224
+ sessionId,
225
+ relPath: registered.session_file,
226
+ startedAt: registered.started_at || control.started_at || '',
227
+ fromRegistry: true,
228
+ };
229
+ }
230
+
231
+ if (sessionId && control.session_id === sessionId && control.last_session_file) {
232
+ return {
233
+ sessionId,
234
+ relPath: control.last_session_file,
235
+ startedAt: control.started_at || '',
236
+ fromRegistry: false,
237
+ };
238
+ }
239
+
240
+ return { sessionId, relPath: '', startedAt: '', fromRegistry: false };
241
+ }
242
+
243
+ function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, input, now, identity }) {
244
+ const sessionPath = join(vaultBase, relPath);
245
+ if (!existsSync(sessionPath)) return false;
246
+
247
+ reopenSessionFile(sessionPath);
248
+ const nextStartedAt = startedAt || formatLocalIso(now);
249
+ writeControl(vaultBase, {
250
+ status: 'active',
251
+ session_file: relPath,
252
+ last_session_file: relPath,
253
+ started_at: nextStartedAt,
254
+ ended_at: '',
255
+ session_id: sessionId,
256
+ last_logged_turn_id: '',
257
+ });
258
+ upsertSessionRegistry(vaultBase, sessionId, {
259
+ session_file: relPath,
260
+ status: 'active',
261
+ started_at: nextStartedAt,
262
+ ended_at: '',
263
+ transcript_path: identity.transcriptPath,
264
+ transcript_id: identity.transcriptId,
265
+ provider: identity.provider,
266
+ });
267
+ return true;
268
+ }
269
+
270
+ function createSession({ vaultBase, sessionId, input, now, identity }) {
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, sessionId }), 'utf-8');
275
+ writeControl(vaultBase, {
276
+ status: 'active',
277
+ session_file: relPath,
278
+ last_session_file: relPath,
279
+ started_at: startedAt,
280
+ ended_at: '',
281
+ session_id: sessionId,
282
+ last_logged_turn_id: '',
283
+ });
284
+ upsertSessionRegistry(vaultBase, sessionId, {
285
+ session_file: relPath,
286
+ status: 'active',
287
+ started_at: startedAt,
288
+ ended_at: '',
289
+ transcript_path: identity.transcriptPath,
290
+ transcript_id: identity.transcriptId,
291
+ provider: identity.provider,
292
+ });
293
+ return { relPath, startedAt };
294
+ }
295
+
296
+ function outputActiveContext({ relPath, startedAt, vaultBase, message }) {
297
+ writeHookOutput({
298
+ hookSpecificOutput: {
299
+ hookEventName: 'UserPromptSubmit',
300
+ additionalContext: buildAdditionalContext({ relPath, startedAt, vaultBase }),
301
+ },
302
+ systemMessage: message,
303
+ });
304
+ }
305
+
306
+ function main() {
307
+ const input = readHookInput();
308
+ const vaultBase = getVaultBase(input);
309
+ warnIfDefaultVault(input);
310
+ const now = new Date();
311
+ const identity = resolveSessionIdentity(vaultBase, input, providerMeta().id);
312
+ if (identity.state !== 'resolved') {
313
+ writeHookOutput({
314
+ hookSpecificOutput: {
315
+ hookEventName: 'UserPromptSubmit',
316
+ additionalContext: `<obsidian_session_deferred>Memória global disponível, sem escrita vinculada: ${identity.diagnostics.join('; ')}.</obsidian_session_deferred>`,
317
+ },
318
+ systemMessage: `[wendkeep] Identidade de sessão adiada: ${identity.diagnostics.join('; ')}`,
319
+ });
320
+ return;
321
+ }
322
+ const sessionId = identity.canonicalConversationId;
323
+
324
+ // Fast path: skip all writes if control file touched < 5 min ago and session matches
325
+ try {
326
+ const ctrlPath = controlPath(vaultBase);
327
+ const { mtimeMs } = statSync(ctrlPath);
328
+ if ((now.getTime() - mtimeMs) / 1000 < 300) {
329
+ const ctrl = readControl(vaultBase);
330
+ if (
331
+ ctrl.status === 'active' &&
332
+ ctrl.session_file &&
333
+ !isPlaceholderSessionFile(ctrl.session_file) &&
334
+ existsSync(join(vaultBase, ctrl.session_file)) &&
335
+ ctrl.session_id === sessionId
336
+ ) {
337
+ writeHookOutput({});
338
+ return;
339
+ }
340
+ }
341
+ } catch (err) {
342
+ debugLog('session-ensure fast-path skipped:', err);
343
+ }
344
+
345
+ const control = readControl(vaultBase);
346
+
347
+ if (control.status === 'active' && control.session_file) {
348
+ const activePath = join(vaultBase, control.session_file);
349
+ // Sem reuso-por-janela: só reaproveita a nota do control quando não há
350
+ // identidade pra checar (!sessionId) ou quando é a MESMA conversa. Conversa
351
+ // concorrente recente NÃO pode herdar a nota ativa do ponteiro global.
352
+ if (existsSync(activePath) && control.session_id === sessionId) {
353
+ const titled = maybeRetitleSession({
354
+ vaultBase,
355
+ relPath: control.session_file,
356
+ startedAt: control.started_at,
357
+ input,
358
+ });
359
+ const activeRelPath = titled.relPath;
360
+ if (titled.changed || control.session_file !== activeRelPath || control.session_id !== sessionId) {
361
+ writeControl(vaultBase, {
362
+ status: 'active',
363
+ session_file: activeRelPath,
364
+ last_session_file: activeRelPath,
365
+ started_at: control.started_at,
366
+ ended_at: '',
367
+ session_id: sessionId || control.session_id,
368
+ last_logged_turn_id: control.last_logged_turn_id || '',
369
+ });
370
+ }
371
+ upsertSessionRegistry(vaultBase, sessionId || control.session_id, {
372
+ session_file: activeRelPath,
373
+ status: 'active',
374
+ started_at: control.started_at,
375
+ ended_at: '',
376
+ transcript_path: identity.transcriptPath,
377
+ transcript_id: identity.transcriptId,
378
+ provider: identity.provider,
379
+ });
380
+ writeHookOutput({});
381
+ return;
382
+ }
383
+ }
384
+
385
+ const registered = readSessionRegistry(vaultBase).sessions?.[sessionId];
386
+ const resolvedTarget = registered?.session_file
387
+ ? { sessionId, relPath: registered.session_file, startedAt: registered.started_at || '' }
388
+ : { sessionId, relPath: '', startedAt: '' };
389
+ if (resolvedTarget.relPath && activateExistingSession({ vaultBase, relPath: resolvedTarget.relPath, startedAt: resolvedTarget.startedAt, sessionId, input, now, identity })) {
390
+ outputActiveContext({
391
+ relPath: resolvedTarget.relPath,
392
+ startedAt: resolvedTarget.startedAt || formatLocalIso(now),
393
+ vaultBase,
394
+ message: `Sessão Obsidian reaberta em ${resolvedTarget.relPath}. ${basename(controlPath(vaultBase))} atualizado.`,
395
+ });
396
+ return;
397
+ }
398
+
399
+ const created = createSession({ vaultBase, sessionId, input, now, identity });
400
+ outputActiveContext({
401
+ relPath: created.relPath,
402
+ startedAt: created.startedAt,
403
+ vaultBase,
404
+ message: `Sessão Obsidian criada em ${created.relPath}. ${basename(controlPath(vaultBase))} atualizado.`,
405
+ });
406
+ }
407
+
408
+ try {
409
+ main();
410
+ } catch (error) {
411
+ process.stderr.write(`[wendkeep] UserPromptSubmit falhou: ${error.message}\n`);
412
+ writeHookOutput({});
413
+ }