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.
- package/README.md +85 -0
- package/bin/wendkeep.mjs +98 -0
- package/hooks/brain-core.mjs +142 -0
- package/hooks/brain-inject.mjs +42 -0
- package/hooks/brain-recall.mjs +32 -0
- package/hooks/brain-reindex.mjs +13 -0
- package/hooks/linked-notes.mjs +536 -0
- package/hooks/obsidian-common.mjs +517 -0
- package/hooks/pricing.json +48 -0
- package/hooks/session-backfill.mjs +141 -0
- package/hooks/session-ensure.mjs +386 -0
- package/hooks/session-start.mjs +309 -0
- package/hooks/session-stop.mjs +1110 -0
- package/hooks/token-usage.mjs +950 -0
- package/hooks/vault-health.mjs +201 -0
- package/package.json +35 -0
- package/src/doctor.mjs +36 -0
- package/src/init.mjs +171 -0
- package/src/taxonomy.mjs +76 -0
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { request } from 'http';
|
|
5
|
+
import { pathToFileURL } from 'url';
|
|
6
|
+
import { createLinkedNotes } from './linked-notes.mjs';
|
|
7
|
+
import { addUsage, costBreakdown, emptyTokenUsage, normalizeClaudeUsage, normalizeCodexUsage, priceForModel, updateSessionUsage } from './token-usage.mjs';
|
|
8
|
+
import { buildBrainDigest, buildBrainIndex } from './brain-core.mjs';
|
|
9
|
+
import {
|
|
10
|
+
ensureDir,
|
|
11
|
+
findActiveSessionByTranscript,
|
|
12
|
+
formatDate,
|
|
13
|
+
formatHourMinute,
|
|
14
|
+
formatLocalIso,
|
|
15
|
+
getNextAdrNumber,
|
|
16
|
+
getVaultBase,
|
|
17
|
+
listMarkdownFiles,
|
|
18
|
+
readControl,
|
|
19
|
+
readHookInput,
|
|
20
|
+
redactSecrets,
|
|
21
|
+
slugify,
|
|
22
|
+
toVaultRelative,
|
|
23
|
+
truncate,
|
|
24
|
+
uniquePath,
|
|
25
|
+
upsertSessionRegistry,
|
|
26
|
+
wikilinkFromRel,
|
|
27
|
+
writeControl,
|
|
28
|
+
writeHookOutput,
|
|
29
|
+
} from './obsidian-common.mjs';
|
|
30
|
+
|
|
31
|
+
function extractContentText(content) {
|
|
32
|
+
if (typeof content === 'string') return content;
|
|
33
|
+
if (!Array.isArray(content)) return '';
|
|
34
|
+
return content
|
|
35
|
+
.map((item) => item?.text || item?.input_text || item?.output_text || '')
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.join('\n')
|
|
38
|
+
.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Tags injetadas pelo harness (não são fala humana): notificações de task,
|
|
42
|
+
// reminders do sistema, stdout de comando local, wrappers de slash-command e
|
|
43
|
+
// contexto da IDE. Nunca devem virar título/Pedido/Usuário de iteração no Vault.
|
|
44
|
+
const SYNTHETIC_EVENT_TAG = /^<\/?(?:task-notification|system-reminder|local-command-stdout|local-command-stderr|command-message|command-name|command-args|user-prompt-submit-hook|ide_selection|ide_opened_file|environment_context)\b/i;
|
|
45
|
+
|
|
46
|
+
function shouldIgnoreUserText(text) {
|
|
47
|
+
const trimmed = String(text || '').trim();
|
|
48
|
+
return SYNTHETIC_EVENT_TAG.test(trimmed)
|
|
49
|
+
|| /^# AGENTS\.md instructions/.test(trimmed)
|
|
50
|
+
|| trimmed.startsWith('<permissions instructions>')
|
|
51
|
+
|| trimmed.includes('You are Codex, a coding agent')
|
|
52
|
+
|| trimmed.startsWith('## Memory');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function addUnique(list, value) {
|
|
56
|
+
const clean = redactSecrets(String(value || '').trim());
|
|
57
|
+
if (clean && !list.includes(clean)) list.push(clean);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createTurn(turnId = '', timestamp = '') {
|
|
61
|
+
return {
|
|
62
|
+
turnId,
|
|
63
|
+
timestamp,
|
|
64
|
+
userPrompts: [],
|
|
65
|
+
assistantMessages: [],
|
|
66
|
+
tools: [],
|
|
67
|
+
consultedFiles: [],
|
|
68
|
+
changedFiles: [],
|
|
69
|
+
conversation: [],
|
|
70
|
+
usage: emptyTokenUsage(),
|
|
71
|
+
model: '',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function addConversation(turn, role, value) {
|
|
76
|
+
if (!turn) return;
|
|
77
|
+
const text = redactSecrets(String(value || '').trim());
|
|
78
|
+
if (!text) return;
|
|
79
|
+
const exists = turn.conversation.some((item) => item.role === role && item.text === text);
|
|
80
|
+
if (!exists) turn.conversation.push({ role, text });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function extractPaths(text) {
|
|
84
|
+
const paths = [];
|
|
85
|
+
const addPath = (value) => {
|
|
86
|
+
const path = normalizeExtractedPath(value);
|
|
87
|
+
if (!shouldIgnoreExtractedPath(path) && !paths.includes(path)) paths.push(path);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const windowsRegex = /[A-Za-z]:[\\/]+[^"'`\r\n{}()[\],]+\.[A-Za-z0-9]+(?::\d+)?/g;
|
|
91
|
+
let match;
|
|
92
|
+
const source = String(text || '');
|
|
93
|
+
while ((match = windowsRegex.exec(source)) !== null) {
|
|
94
|
+
addPath(match[0]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const masked = source.replace(windowsRegex, ' ');
|
|
98
|
+
const regex = /(?:^|[\s"'`(])((?:\/(?:home|mnt)\/|\.{1,2}\/|[A-Za-z0-9_.-]+\/)[A-Za-z0-9_./@+:-]+\.[A-Za-z0-9]+(?::\d+)?)/g;
|
|
99
|
+
while ((match = regex.exec(masked)) !== null) {
|
|
100
|
+
addPath(match[1]);
|
|
101
|
+
}
|
|
102
|
+
return paths.slice(0, 20);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const REPO_ROOT = String(process.cwd() || '')
|
|
106
|
+
.replace(/\\+/g, '/')
|
|
107
|
+
.replace(/\/+$/, '');
|
|
108
|
+
|
|
109
|
+
// Raiz do Vault: resolvida em call-time para que testes possam controlar
|
|
110
|
+
// process.env.OBSIDIAN_VAULT_PATH sem depender de variáveis de ambiente da máquina.
|
|
111
|
+
function vaultPathRoots() {
|
|
112
|
+
let root = '';
|
|
113
|
+
try {
|
|
114
|
+
root = String(getVaultBase() || '')
|
|
115
|
+
.replace(/\\+/g, '/')
|
|
116
|
+
.replace(/\/+$/, '')
|
|
117
|
+
.toLowerCase();
|
|
118
|
+
} catch {
|
|
119
|
+
root = '';
|
|
120
|
+
}
|
|
121
|
+
if (!root || !REPO_ROOT) return { root, rel: '' };
|
|
122
|
+
const repoLower = REPO_ROOT.toLowerCase();
|
|
123
|
+
const rel = root.startsWith(`${repoLower}/`) ? root.slice(repoLower.length + 1) : '';
|
|
124
|
+
return { root, rel };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeExtractedPath(value) {
|
|
128
|
+
const cleaned = String(value || '')
|
|
129
|
+
.replace(/\\+/g, '/')
|
|
130
|
+
.replace(/\/+/g, '/')
|
|
131
|
+
.replace(/^(?:\.\/)+/, '')
|
|
132
|
+
.replace(/[:.,;)}\]]+$/, '');
|
|
133
|
+
// Caminhos absolutos dentro do repo viram relativos para deduplicar com as
|
|
134
|
+
// formas relativas (e variações de caixa do drive no Windows).
|
|
135
|
+
if (REPO_ROOT && cleaned.toLowerCase().startsWith(`${REPO_ROOT.toLowerCase()}/`)) {
|
|
136
|
+
return cleaned.slice(REPO_ROOT.length + 1);
|
|
137
|
+
}
|
|
138
|
+
return cleaned;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function shouldIgnoreExtractedPath(path) {
|
|
142
|
+
if (!path) return true;
|
|
143
|
+
const { root: VAULT_ROOT, rel: VAULT_REL } = vaultPathRoots();
|
|
144
|
+
if (VAULT_ROOT && path.toLowerCase().startsWith(`${VAULT_ROOT}/`)) return true; // notas do Vault (abs)
|
|
145
|
+
if (VAULT_REL && path.toLowerCase().startsWith(`${VAULT_REL}/`)) return true; // notas do Vault (rel)
|
|
146
|
+
if (path.includes('/.codex/sessions/')) return true;
|
|
147
|
+
if (path.includes('/.claude/projects/')) return true; // transcripts internos do Claude
|
|
148
|
+
if (path.startsWith('../') || path.includes('/../')) return true; // relativos que escapam
|
|
149
|
+
if (/(?:^|\/)(?:CURRENT_SESSION\.md|SESSION_REGISTRY\.json)$/.test(path)) return true; // controle interno
|
|
150
|
+
if (/^[A-Za-z]:\/[A-Za-z]:\//.test(path)) return true;
|
|
151
|
+
if (/^Alves\/\.codex\//.test(path)) return true;
|
|
152
|
+
if (/\/\.[A-Za-z0-9]+(?::\d+)?$/.test(path)) return true;
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function shouldDropFileListLine(line) {
|
|
157
|
+
if (/^- Nenhum/.test(line)) return true;
|
|
158
|
+
const match = String(line || '').match(/^- `(.+)`$/);
|
|
159
|
+
return Boolean(match && shouldIgnoreExtractedPath(normalizeExtractedPath(match[1])));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Reescreve uma linha de lista `- `<path>`` com o path normalizado (absoluto do
|
|
163
|
+
// repo → relativo), para auto-reparar listas antigas com formas duplicadas.
|
|
164
|
+
function normalizeFileListLine(line) {
|
|
165
|
+
const match = String(line).match(/^- `(.+)`$/);
|
|
166
|
+
if (!match) return line;
|
|
167
|
+
return `- \`${normalizeExtractedPath(match[1])}\``;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function extractPatchFiles(text) {
|
|
171
|
+
const files = [];
|
|
172
|
+
const regex = /^\*\*\* (?:Add|Update|Delete) File:\s+(.+)$/gm;
|
|
173
|
+
let match;
|
|
174
|
+
while ((match = regex.exec(text || '')) !== null) addUnique(files, match[1]);
|
|
175
|
+
return files;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseToolArguments(args) {
|
|
179
|
+
if (!args) return {};
|
|
180
|
+
if (typeof args === 'object') return args;
|
|
181
|
+
try {
|
|
182
|
+
return JSON.parse(args);
|
|
183
|
+
} catch {
|
|
184
|
+
return { raw: String(args) };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function toolArgumentText(value) {
|
|
189
|
+
if (value == null) return '';
|
|
190
|
+
if (typeof value === 'string') return value;
|
|
191
|
+
if (Array.isArray(value)) return value.map(toolArgumentText).filter(Boolean).join('\n');
|
|
192
|
+
if (typeof value === 'object') return Object.values(value).map(toolArgumentText).filter(Boolean).join('\n');
|
|
193
|
+
return String(value);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function parseCodexTranscript(transcriptPath) {
|
|
197
|
+
const result = {
|
|
198
|
+
sessionId: '',
|
|
199
|
+
model: '',
|
|
200
|
+
latestTurnId: '',
|
|
201
|
+
latestUserPrompt: '',
|
|
202
|
+
latestAssistantMessage: '',
|
|
203
|
+
userPrompts: [],
|
|
204
|
+
assistantMessages: [],
|
|
205
|
+
tools: [],
|
|
206
|
+
consultedFiles: [],
|
|
207
|
+
changedFiles: [],
|
|
208
|
+
turns: [],
|
|
209
|
+
rawTextForDetection: '',
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return result;
|
|
213
|
+
|
|
214
|
+
const eventUserPrompts = [];
|
|
215
|
+
let currentTurn = null;
|
|
216
|
+
const ensureTurn = (turnId = '', timestamp = '') => {
|
|
217
|
+
const normalized = turnId || currentTurn?.turnId || `turn-${result.turns.length + 1}`;
|
|
218
|
+
const existing = result.turns.find((turn) => turn.turnId === normalized);
|
|
219
|
+
if (existing) {
|
|
220
|
+
currentTurn = existing;
|
|
221
|
+
return existing;
|
|
222
|
+
}
|
|
223
|
+
currentTurn = createTurn(normalized, timestamp);
|
|
224
|
+
result.turns.push(currentTurn);
|
|
225
|
+
return currentTurn;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const lines = readFileSync(transcriptPath, 'utf-8').split('\n').filter(Boolean);
|
|
229
|
+
for (const line of lines) {
|
|
230
|
+
let event;
|
|
231
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
232
|
+
|
|
233
|
+
if (event.type === 'session_meta') {
|
|
234
|
+
result.sessionId = event.payload?.id || result.sessionId;
|
|
235
|
+
result.model = event.payload?.model || event.payload?.model_provider || result.model;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (event.type === 'event_msg' && event.payload?.type === 'task_started') {
|
|
240
|
+
result.latestTurnId = event.payload.turn_id || result.latestTurnId;
|
|
241
|
+
ensureTurn(result.latestTurnId, event.timestamp);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (event.type === 'turn_context') {
|
|
246
|
+
result.latestTurnId = event.payload?.turn_id || result.latestTurnId;
|
|
247
|
+
result.model = event.payload?.model || result.model;
|
|
248
|
+
ensureTurn(result.latestTurnId, event.timestamp);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (event.type === 'event_msg' && event.payload?.type === 'user_message') {
|
|
253
|
+
const text = event.payload.message || '';
|
|
254
|
+
if (text && !shouldIgnoreUserText(text)) {
|
|
255
|
+
const turn = ensureTurn(event.payload.turn_id || result.latestTurnId, event.timestamp);
|
|
256
|
+
addUnique(eventUserPrompts, text);
|
|
257
|
+
addUnique(result.userPrompts, text);
|
|
258
|
+
addUnique(turn.userPrompts, text);
|
|
259
|
+
addConversation(turn, 'Usuário', text);
|
|
260
|
+
}
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (event.type === 'event_msg' && event.payload?.type === 'agent_message') {
|
|
265
|
+
const text = event.payload.message || event.payload.text || '';
|
|
266
|
+
if (text) {
|
|
267
|
+
const turn = ensureTurn(event.payload.turn_id || result.latestTurnId, event.timestamp);
|
|
268
|
+
addUnique(result.assistantMessages, text);
|
|
269
|
+
addUnique(turn.assistantMessages, text);
|
|
270
|
+
addConversation(turn, 'Assistente', text);
|
|
271
|
+
}
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (event.type === 'event_msg' && event.payload?.type === 'token_count') {
|
|
276
|
+
const raw = event.payload?.info?.last_token_usage;
|
|
277
|
+
if (raw) {
|
|
278
|
+
const turn = currentTurn || ensureTurn(result.latestTurnId, event.timestamp);
|
|
279
|
+
addUsage(turn.usage, normalizeCodexUsage(raw));
|
|
280
|
+
if (event.payload?.info?.model) turn.model = event.payload.info.model;
|
|
281
|
+
}
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (event.type !== 'response_item') continue;
|
|
286
|
+
const payload = event.payload || {};
|
|
287
|
+
|
|
288
|
+
if (payload.type === 'message') {
|
|
289
|
+
const text = extractContentText(payload.content);
|
|
290
|
+
if (!text) continue;
|
|
291
|
+
const turn = ensureTurn(payload.turn_id || event.turn_id || result.latestTurnId, event.timestamp);
|
|
292
|
+
if (payload.role === 'user' && !shouldIgnoreUserText(text)) {
|
|
293
|
+
addUnique(result.userPrompts, text);
|
|
294
|
+
addUnique(turn.userPrompts, text);
|
|
295
|
+
addConversation(turn, 'Usuário', text);
|
|
296
|
+
}
|
|
297
|
+
if (payload.role === 'assistant') {
|
|
298
|
+
addUnique(result.assistantMessages, text);
|
|
299
|
+
addUnique(turn.assistantMessages, text);
|
|
300
|
+
addConversation(turn, 'Assistente', text);
|
|
301
|
+
}
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (payload.type === 'function_call') {
|
|
306
|
+
addUnique(result.tools, payload.name || 'function_call');
|
|
307
|
+
const turn = ensureTurn(payload.turn_id || event.turn_id || result.latestTurnId, event.timestamp);
|
|
308
|
+
addUnique(turn.tools, payload.name || 'function_call');
|
|
309
|
+
const parsed = parseToolArguments(payload.arguments);
|
|
310
|
+
const combined = typeof parsed.raw === 'string'
|
|
311
|
+
? parsed.raw
|
|
312
|
+
: toolArgumentText(parsed);
|
|
313
|
+
|
|
314
|
+
for (const path of extractPaths(combined)) {
|
|
315
|
+
addUnique(result.consultedFiles, path);
|
|
316
|
+
addUnique(turn.consultedFiles, path);
|
|
317
|
+
}
|
|
318
|
+
for (const path of extractPatchFiles(combined)) {
|
|
319
|
+
addUnique(result.changedFiles, path);
|
|
320
|
+
addUnique(turn.changedFiles, path);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (/apply_patch|edit|write|create/i.test(payload.name || '')) {
|
|
324
|
+
for (const path of extractPaths(combined)) {
|
|
325
|
+
addUnique(result.changedFiles, path);
|
|
326
|
+
addUnique(turn.changedFiles, path);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (payload.type === 'tool_search_call') {
|
|
332
|
+
const turn = ensureTurn(payload.turn_id || event.turn_id || result.latestTurnId, event.timestamp);
|
|
333
|
+
addUnique(result.tools, 'tool_search');
|
|
334
|
+
addUnique(turn.tools, 'tool_search');
|
|
335
|
+
}
|
|
336
|
+
if (payload.type === 'web_search_call') {
|
|
337
|
+
const turn = ensureTurn(payload.turn_id || event.turn_id || result.latestTurnId, event.timestamp);
|
|
338
|
+
addUnique(result.tools, 'web_search');
|
|
339
|
+
addUnique(turn.tools, 'web_search');
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for (const prompt of eventUserPrompts) addUnique(result.userPrompts, prompt);
|
|
344
|
+
const latestTurn = result.turns.find((turn) => turn.turnId === result.latestTurnId)
|
|
345
|
+
|| result.turns.at(-1);
|
|
346
|
+
result.latestUserPrompt = latestTurn?.userPrompts.at(-1)
|
|
347
|
+
|| eventUserPrompts.at(-1)
|
|
348
|
+
|| result.userPrompts.at(-1)
|
|
349
|
+
|| '';
|
|
350
|
+
result.latestAssistantMessage = latestTurn?.assistantMessages.at(-1)
|
|
351
|
+
|| result.assistantMessages.at(-1)
|
|
352
|
+
|| '';
|
|
353
|
+
result.rawTextForDetection = redactSecrets([
|
|
354
|
+
...result.userPrompts,
|
|
355
|
+
...result.assistantMessages,
|
|
356
|
+
].join('\n\n'));
|
|
357
|
+
|
|
358
|
+
return result;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Texto humano de uma mensagem de usuário do Claude Code: mantém só blocos
|
|
362
|
+
// `text`, descartando tool_result e contexto injetado (system-reminder etc.).
|
|
363
|
+
function claudeUserText(content) {
|
|
364
|
+
if (typeof content === 'string') return content.trim();
|
|
365
|
+
if (!Array.isArray(content)) return '';
|
|
366
|
+
return content
|
|
367
|
+
.map((block) => (typeof block === 'string' ? block : (block?.type === 'text' ? block.text || '' : '')))
|
|
368
|
+
.map((text) => String(text || '').trim())
|
|
369
|
+
.filter((text) => text && !text.startsWith('<'))
|
|
370
|
+
.join('\n')
|
|
371
|
+
.trim();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Parser do transcript do Claude Code. Schema por linha:
|
|
375
|
+
// { type:'user'|'assistant', message:{ role, content:[{type:'text'|'thinking'|'tool_use'|'tool_result',...}] } }.
|
|
376
|
+
// Diferente do Codex (sem `payload`), por isso precisa de parser próprio.
|
|
377
|
+
export function parseClaudeTranscript(transcriptPath) {
|
|
378
|
+
const result = {
|
|
379
|
+
sessionId: '',
|
|
380
|
+
model: '',
|
|
381
|
+
latestTurnId: '',
|
|
382
|
+
latestUserPrompt: '',
|
|
383
|
+
latestAssistantMessage: '',
|
|
384
|
+
userPrompts: [],
|
|
385
|
+
assistantMessages: [],
|
|
386
|
+
tools: [],
|
|
387
|
+
consultedFiles: [],
|
|
388
|
+
changedFiles: [],
|
|
389
|
+
turns: [],
|
|
390
|
+
rawTextForDetection: '',
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return result;
|
|
394
|
+
|
|
395
|
+
let currentTurn = null;
|
|
396
|
+
const ensureTurn = (turnId = '', timestamp = '') => {
|
|
397
|
+
const normalized = turnId || currentTurn?.turnId || `turn-${result.turns.length + 1}`;
|
|
398
|
+
const existing = result.turns.find((turn) => turn.turnId === normalized);
|
|
399
|
+
if (existing) {
|
|
400
|
+
currentTurn = existing;
|
|
401
|
+
return existing;
|
|
402
|
+
}
|
|
403
|
+
currentTurn = createTurn(normalized, timestamp);
|
|
404
|
+
result.turns.push(currentTurn);
|
|
405
|
+
return currentTurn;
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const recordToolFiles = (turn, name, input) => {
|
|
409
|
+
const text = toolArgumentText(input);
|
|
410
|
+
for (const path of extractPaths(text)) {
|
|
411
|
+
addUnique(result.consultedFiles, path);
|
|
412
|
+
addUnique(turn.consultedFiles, path);
|
|
413
|
+
}
|
|
414
|
+
for (const path of extractPatchFiles(text)) {
|
|
415
|
+
addUnique(result.changedFiles, path);
|
|
416
|
+
addUnique(turn.changedFiles, path);
|
|
417
|
+
}
|
|
418
|
+
if (/edit|write|create|apply_patch|notebook/i.test(name)) {
|
|
419
|
+
for (const path of extractPaths(text)) {
|
|
420
|
+
addUnique(result.changedFiles, path);
|
|
421
|
+
addUnique(turn.changedFiles, path);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const lines = readFileSync(transcriptPath, 'utf-8').split('\n').filter(Boolean);
|
|
427
|
+
for (const line of lines) {
|
|
428
|
+
let event;
|
|
429
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
430
|
+
if (event.isSidechain || event.isMeta) continue;
|
|
431
|
+
if (event.sessionId && !result.sessionId) result.sessionId = event.sessionId;
|
|
432
|
+
|
|
433
|
+
if (event.type === 'user') {
|
|
434
|
+
const text = claudeUserText(event.message?.content);
|
|
435
|
+
if (!text || shouldIgnoreUserText(text)) continue;
|
|
436
|
+
const turn = ensureTurn(event.uuid || event.promptId || event.timestamp || '', event.timestamp || '');
|
|
437
|
+
result.latestTurnId = turn.turnId;
|
|
438
|
+
addUnique(result.userPrompts, text);
|
|
439
|
+
addUnique(turn.userPrompts, text);
|
|
440
|
+
addConversation(turn, 'Usuário', text);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (event.type === 'assistant') {
|
|
445
|
+
const turn = currentTurn || ensureTurn(event.uuid || event.timestamp || '', event.timestamp || '');
|
|
446
|
+
result.model = event.message?.model || result.model;
|
|
447
|
+
if (event.message?.model) turn.model = event.message.model;
|
|
448
|
+
if (event.message?.usage) addUsage(turn.usage, normalizeClaudeUsage(event.message.usage));
|
|
449
|
+
const content = Array.isArray(event.message?.content) ? event.message.content : [];
|
|
450
|
+
for (const block of content) {
|
|
451
|
+
if (!block) continue;
|
|
452
|
+
if (block.type === 'text' && block.text && block.text.trim()) {
|
|
453
|
+
addUnique(result.assistantMessages, block.text);
|
|
454
|
+
addUnique(turn.assistantMessages, block.text);
|
|
455
|
+
addConversation(turn, 'Assistente', block.text);
|
|
456
|
+
} else if (block.type === 'tool_use') {
|
|
457
|
+
const name = block.name || 'tool_use';
|
|
458
|
+
addUnique(result.tools, name);
|
|
459
|
+
addUnique(turn.tools, name);
|
|
460
|
+
recordToolFiles(turn, name, block.input);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const latestTurn = result.turns.find((turn) => turn.turnId === result.latestTurnId)
|
|
468
|
+
|| result.turns.at(-1);
|
|
469
|
+
result.latestUserPrompt = latestTurn?.userPrompts.at(-1) || result.userPrompts.at(-1) || '';
|
|
470
|
+
result.latestAssistantMessage = latestTurn?.assistantMessages.at(-1) || result.assistantMessages.at(-1) || '';
|
|
471
|
+
result.rawTextForDetection = redactSecrets([
|
|
472
|
+
...result.userPrompts,
|
|
473
|
+
...result.assistantMessages,
|
|
474
|
+
].join('\n\n'));
|
|
475
|
+
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function looksLikeCodexEvent(event) {
|
|
480
|
+
return event.payload !== undefined
|
|
481
|
+
|| event.type === 'session_meta'
|
|
482
|
+
|| event.type === 'response_item'
|
|
483
|
+
|| event.type === 'turn_context'
|
|
484
|
+
|| event.type === 'event_msg';
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function looksLikeClaudeEvent(event) {
|
|
488
|
+
return (event.type === 'user' || event.type === 'assistant') && event.message !== undefined;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Despacha para o parser certo conforme o schema do transcript (Codex x Claude),
|
|
492
|
+
// já que o mesmo hook Stop atende os dois agentes.
|
|
493
|
+
export function parseTranscript(transcriptPath) {
|
|
494
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return parseCodexTranscript(transcriptPath);
|
|
495
|
+
const lines = readFileSync(transcriptPath, 'utf-8').split('\n').filter(Boolean);
|
|
496
|
+
for (const line of lines) {
|
|
497
|
+
let event;
|
|
498
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
499
|
+
if (looksLikeCodexEvent(event)) return parseCodexTranscript(transcriptPath);
|
|
500
|
+
if (looksLikeClaudeEvent(event)) return parseClaudeTranscript(transcriptPath);
|
|
501
|
+
}
|
|
502
|
+
return parseCodexTranscript(transcriptPath);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function compactText(text, max = 600) {
|
|
506
|
+
const clean = redactSecrets(String(text || ''))
|
|
507
|
+
.replace(/\r/g, '\n')
|
|
508
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
509
|
+
.trim();
|
|
510
|
+
return truncate(clean || 'Não capturado automaticamente.', max);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function selectTurn(tx, turnId) {
|
|
514
|
+
return tx.turns.find((turn) => turn.turnId === turnId)
|
|
515
|
+
|| tx.turns.find((turn) => turn.turnId === tx.latestTurnId)
|
|
516
|
+
|| tx.turns.at(-1)
|
|
517
|
+
|| createTurn(turnId || tx.latestTurnId || 'turno');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function formatConversation(turn) {
|
|
521
|
+
const entries = (turn.conversation || [])
|
|
522
|
+
.filter((entry) => entry.text && !shouldIgnoreUserText(entry.text));
|
|
523
|
+
if (!entries.length) return '- Nenhuma mensagem útil capturada no transcript.';
|
|
524
|
+
|
|
525
|
+
const maxEntries = 12;
|
|
526
|
+
const omitted = entries.length > maxEntries ? entries.length - maxEntries + 1 : 0;
|
|
527
|
+
const visible = omitted
|
|
528
|
+
? [
|
|
529
|
+
...entries.slice(0, 2),
|
|
530
|
+
{ role: 'Resumo', text: `${omitted} mensagens intermediárias omitidas para manter a nota legível.` },
|
|
531
|
+
...entries.slice(-(maxEntries - 3)),
|
|
532
|
+
]
|
|
533
|
+
: entries;
|
|
534
|
+
|
|
535
|
+
return visible
|
|
536
|
+
.map((entry) => {
|
|
537
|
+
const limit = entry.role === 'Usuário' ? 900 : 500;
|
|
538
|
+
return `- **${entry.role}:** ${compactText(entry.text, limit)}`;
|
|
539
|
+
})
|
|
540
|
+
.join('\n');
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function formatInlineList(items, fallback = 'Nenhuma registrada.') {
|
|
544
|
+
const clean = [...new Set((items || []).filter(Boolean))].slice(0, 12);
|
|
545
|
+
return clean.length ? clean.map((item) => `\`${item}\``).join(', ') : fallback;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function fmtTokens(n) {
|
|
549
|
+
return String(Math.round(Number(n) || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function fmtUsd(n) {
|
|
553
|
+
return `$${(Math.round((Number(n) || 0) * 10000) / 10000).toFixed(4)}`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Resumo inline dos tokens do turno: contagem por tipo + custo USD entre
|
|
557
|
+
// parênteses (quando o modelo está tabelado). Texto neutro quando ausente.
|
|
558
|
+
function formatTokenLine(usage, model) {
|
|
559
|
+
const u = usage || {};
|
|
560
|
+
if (!u.total) return 'não reportados neste turno';
|
|
561
|
+
const cost = costBreakdown(u, priceForModel(model));
|
|
562
|
+
const cell = (label, n, c) => `${label} ${fmtTokens(n)}${c != null ? ` (${fmtUsd(c)})` : ''}`;
|
|
563
|
+
const parts = [cell('entrada', u.input, cost?.input), cell('cache leitura', u.cached, cost?.cached)];
|
|
564
|
+
if (u.cacheWrite) parts.push(cell('cache escrita', u.cacheWrite, cost?.cacheWrite));
|
|
565
|
+
parts.push(cell('saída', u.output, cost?.output));
|
|
566
|
+
if (u.reasoning) parts.push(`raciocínio ${fmtTokens(u.reasoning)}`);
|
|
567
|
+
parts.push(cell('total', u.total, cost?.total));
|
|
568
|
+
const line = parts.join(' · ');
|
|
569
|
+
// Custo é estimativa API-equivalente (preço da API avulsa), não cobrança do plano/assinatura.
|
|
570
|
+
return cost ? `${line} — ≈ API equivalente (não é cobrança do plano)` : line;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function buildIterationBlock(tx, input) {
|
|
574
|
+
const turnId = input.turn_id || tx.latestTurnId || `${Date.now()}`;
|
|
575
|
+
const turn = selectTurn(tx, turnId);
|
|
576
|
+
const preferredDate = input.now || turn.timestamp || '';
|
|
577
|
+
const parsedDate = preferredDate ? new Date(preferredDate) : new Date();
|
|
578
|
+
const now = Number.isFinite(parsedDate.getTime()) ? parsedDate : new Date();
|
|
579
|
+
const promptText = turn.userPrompts.at(-1) || tx.latestUserPrompt || '';
|
|
580
|
+
const latestAssistant = turn.assistantMessages.at(-1) || tx.latestAssistantMessage || '';
|
|
581
|
+
const heading = truncate(promptText.replace(/[\r\n#]+/g, ' ').replace(/\s+/g, ' ').trim() || 'Iteração', 80);
|
|
582
|
+
const files = [...new Set([...(turn.consultedFiles || []), ...(turn.changedFiles || [])])];
|
|
583
|
+
const model = turn.model || tx.model || '';
|
|
584
|
+
|
|
585
|
+
// O bloco de iteração precisa ser autoexplicativo para retomada futura:
|
|
586
|
+
// inclui recortes da conversa do turno, sem despejar outputs brutos.
|
|
587
|
+
return `
|
|
588
|
+
### ${formatTimeForHeading(now)} - ${heading}
|
|
589
|
+
<!-- codex-turn: ${turnId} -->
|
|
590
|
+
|
|
591
|
+
**Pedido:** ${compactText(promptText, 1000)}
|
|
592
|
+
|
|
593
|
+
**Contexto conversado:**
|
|
594
|
+
${formatConversation(turn)}
|
|
595
|
+
|
|
596
|
+
**Ferramentas usadas:** ${formatInlineList(turn.tools || tx.tools)}
|
|
597
|
+
|
|
598
|
+
**Tokens${model ? ` (${model})` : ''}:** ${formatTokenLine(turn.usage, model)}
|
|
599
|
+
|
|
600
|
+
**Arquivos detectados no turno:** ${formatInlineList(files, 'Nenhum arquivo detectado automaticamente.')}
|
|
601
|
+
|
|
602
|
+
**Estado ao final do turno:** ${compactText(latestAssistant || 'Checkpoint registrado pelo hook Stop do Codex.', 900)}
|
|
603
|
+
`;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function formatTimeForHeading(date) {
|
|
607
|
+
return formatHourMinute(date).replace('-', ':');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Mescla `lines` à seção dedicada `## <heading>`, deduplicando e descartando
|
|
611
|
+
// linhas que casem `dropPattern` (placeholders). No-op se a seção não existe ou
|
|
612
|
+
// não há linhas novas. Preserva o restante do arquivo.
|
|
613
|
+
function upsertListSection(content, heading, lines, dropPattern, transform) {
|
|
614
|
+
if (!lines.length) return content;
|
|
615
|
+
const shouldDrop = (line) => {
|
|
616
|
+
if (!dropPattern) return false;
|
|
617
|
+
if (typeof dropPattern === 'function') return dropPattern(line);
|
|
618
|
+
return dropPattern.test(line);
|
|
619
|
+
};
|
|
620
|
+
const marker = `\n## ${heading}\n`;
|
|
621
|
+
const start = content.indexOf(marker);
|
|
622
|
+
if (start === -1) return content;
|
|
623
|
+
const bodyStart = start + marker.length;
|
|
624
|
+
const nextRel = content.slice(bodyStart).search(/\n## /);
|
|
625
|
+
const bodyEnd = nextRel === -1 ? content.length : bodyStart + nextRel;
|
|
626
|
+
|
|
627
|
+
const existing = content.slice(bodyStart, bodyEnd)
|
|
628
|
+
.split('\n')
|
|
629
|
+
.map((l) => l.trimEnd())
|
|
630
|
+
.filter((l) => l.startsWith('- ') && !shouldDrop(l))
|
|
631
|
+
.map((l) => (transform ? transform(l) : l));
|
|
632
|
+
|
|
633
|
+
const merged = [];
|
|
634
|
+
for (const line of existing) addUnique(merged, line);
|
|
635
|
+
for (const line of lines) addUnique(merged, line);
|
|
636
|
+
|
|
637
|
+
return `${content.slice(0, bodyStart)}\n${merged.join('\n')}\n\n${content.slice(bodyEnd).replace(/^\n+/, '')}`;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const DEFAULT_PENDING_PATTERNS = [
|
|
641
|
+
/^- \[ \] Revisar resumo da sessão$/i,
|
|
642
|
+
/^- \[ \] Verificar se houve decisões a registrar$/i,
|
|
643
|
+
/^- \[ \] Verificar se houve bugs a registrar$/i,
|
|
644
|
+
/^- \[ \] Verificar se houve aprendizados a registrar$/i,
|
|
645
|
+
/^- Nenhuma pendência identificada automaticamente\.$/i,
|
|
646
|
+
/^Nenhuma pendência identificada automaticamente\.$/i,
|
|
647
|
+
];
|
|
648
|
+
|
|
649
|
+
function isDefaultPendingLine(line) {
|
|
650
|
+
const clean = String(line || '').trim();
|
|
651
|
+
return DEFAULT_PENDING_PATTERNS.some((pattern) => pattern.test(clean));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export function cleanPendingPlaceholders(content) {
|
|
655
|
+
const marker = '\n## Pendências\n';
|
|
656
|
+
const start = content.indexOf(marker);
|
|
657
|
+
if (start === -1) return content;
|
|
658
|
+
|
|
659
|
+
const bodyStart = start + marker.length;
|
|
660
|
+
const nextRel = content.slice(bodyStart).search(/\n## /);
|
|
661
|
+
const bodyEnd = nextRel === -1 ? content.length : bodyStart + nextRel;
|
|
662
|
+
const kept = content.slice(bodyStart, bodyEnd)
|
|
663
|
+
.split('\n')
|
|
664
|
+
.map((line) => line.trimEnd())
|
|
665
|
+
.filter((line) => line.trim() && !isDefaultPendingLine(line));
|
|
666
|
+
const body = kept.length
|
|
667
|
+
? kept.join('\n')
|
|
668
|
+
: 'Nenhuma pendência identificada automaticamente.';
|
|
669
|
+
|
|
670
|
+
return `${content.slice(0, bodyStart)}\n${body}\n\n${content.slice(bodyEnd).replace(/^\n+/, '')}`;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Roteia arquivos consultados/alterados e pendências detectadas para as seções
|
|
674
|
+
// dedicadas do rodapé, em vez de duplicá-los dentro de cada bloco de iteração.
|
|
675
|
+
function applyDedicatedSections(content, tx) {
|
|
676
|
+
const consulted = tx.consultedFiles.map((f) => `- \`${f}\``);
|
|
677
|
+
const changed = tx.changedFiles.map((f) => `- \`${f}\``);
|
|
678
|
+
const pending = extractPending(tx.rawTextForDetection)
|
|
679
|
+
.map((p) => `- [ ] ${p.replace(/^[-*]\s*(\[ \]\s*)?/, '').trim()}`);
|
|
680
|
+
|
|
681
|
+
let next = content;
|
|
682
|
+
next = upsertListSection(next, 'Arquivos consultados', consulted, shouldDropFileListLine, normalizeFileListLine);
|
|
683
|
+
next = upsertListSection(next, 'Arquivos criados ou alterados', changed, shouldDropFileListLine, normalizeFileListLine);
|
|
684
|
+
next = cleanPendingPlaceholders(upsertListSection(next, 'Pendências', pending, isDefaultPendingLine));
|
|
685
|
+
return next;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Insere `block` dentro da seção `## Iterações`, em ordem de preferência de âncora:
|
|
689
|
+
// antes de `## Decisões geradas nesta sessão`; senão logo após o heading `## Iterações`;
|
|
690
|
+
// senão antes de `## Encerramento`; senão no fim do arquivo.
|
|
691
|
+
function insertIntoIteracoes(content, block) {
|
|
692
|
+
const iter = content.indexOf('\n## Iterações');
|
|
693
|
+
if (iter !== -1) {
|
|
694
|
+
const anchors = [
|
|
695
|
+
'\n## Uso de tokens e custos',
|
|
696
|
+
'\n## Decisões geradas nesta sessão',
|
|
697
|
+
'\n## Bugs gerados nesta sessão',
|
|
698
|
+
'\n## Aprendizados gerados nesta sessão',
|
|
699
|
+
'\n## Arquivos consultados',
|
|
700
|
+
'\n## Arquivos criados ou alterados',
|
|
701
|
+
'\n## Pendências',
|
|
702
|
+
'\n## Encerramento',
|
|
703
|
+
]
|
|
704
|
+
.map((anchor) => content.indexOf(anchor, iter + 1))
|
|
705
|
+
.filter((index) => index !== -1)
|
|
706
|
+
.sort((a, b) => a - b);
|
|
707
|
+
if (anchors.length) {
|
|
708
|
+
const at = anchors[0];
|
|
709
|
+
return `${content.slice(0, at).trimEnd()}\n${block}\n${content.slice(at)}`;
|
|
710
|
+
}
|
|
711
|
+
const lineEnd = content.indexOf('\n', iter + 1);
|
|
712
|
+
const at = lineEnd === -1 ? content.length : lineEnd + 1;
|
|
713
|
+
return `${content.slice(0, at).trimEnd()}\n${block}\n${content.slice(at)}`;
|
|
714
|
+
}
|
|
715
|
+
const enc = content.indexOf('\n## Encerramento');
|
|
716
|
+
if (enc !== -1) {
|
|
717
|
+
return `${content.slice(0, enc).trimEnd()}\n${block}\n${content.slice(enc)}`;
|
|
718
|
+
}
|
|
719
|
+
return `${content.trimEnd()}\n${block}\n`;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Auto-reparo: realoca blocos `## ...` órfãos que o agente anexou após `## Encerramento`
|
|
723
|
+
// (iterações fora de lugar) de volta para dentro de `## Iterações`, rebaixando `##` → `###`.
|
|
724
|
+
// `## Encerramento` é sempre a última seção do template, então qualquer heading nível 2
|
|
725
|
+
// depois dela é órfão. Idempotente: no-op quando não há órfãos.
|
|
726
|
+
function relocateOrphanIterations(content) {
|
|
727
|
+
const closing = '\n## Encerramento';
|
|
728
|
+
const closingIdx = content.indexOf(closing);
|
|
729
|
+
if (closingIdx === -1) return content;
|
|
730
|
+
|
|
731
|
+
const afterClosing = closingIdx + closing.length;
|
|
732
|
+
const nextRel = content.slice(afterClosing).search(/\n## /);
|
|
733
|
+
if (nextRel === -1) return content; // Encerramento é a última seção: nada órfão.
|
|
734
|
+
|
|
735
|
+
const splitAt = afterClosing + nextRel;
|
|
736
|
+
const head = `${content.slice(0, splitAt).trimEnd()}\n`;
|
|
737
|
+
const demoted = content.slice(splitAt).replace(/^## /gm, '### ').trim();
|
|
738
|
+
if (!demoted) return content;
|
|
739
|
+
|
|
740
|
+
return insertIntoIteracoes(head, `\n${demoted}`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
export function insertIteration(sessionPath, block, turnId, tx) {
|
|
744
|
+
let content = readFileSync(sessionPath, 'utf-8');
|
|
745
|
+
if (content.includes(`<!-- codex-turn: ${turnId} -->`)) {
|
|
746
|
+
// Turno já registrado: ainda assim repara órfãos e seções dedicadas.
|
|
747
|
+
const repaired = applyDedicatedSections(relocateOrphanIterations(content), tx);
|
|
748
|
+
if (repaired !== content) writeFileSync(sessionPath, repaired, 'utf-8');
|
|
749
|
+
return false;
|
|
750
|
+
}
|
|
751
|
+
content = relocateOrphanIterations(content);
|
|
752
|
+
content = insertIntoIteracoes(content, block);
|
|
753
|
+
content = applyDedicatedSections(content, tx);
|
|
754
|
+
writeFileSync(sessionPath, content, 'utf-8');
|
|
755
|
+
return true;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function shouldFinalizeSession() {
|
|
759
|
+
// Finaliza em todo Stop por padrão; escape hatch negativo p/ debug/teste.
|
|
760
|
+
return process.env.OBSIDIAN_NO_AUTO_FINALIZE !== '1';
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Só captura checkboxes de tarefa reais (`- [ ] ...`). Antes casava as palavras
|
|
764
|
+
// `todo`/`pendência`/`pendente` em prosa (ex.: "todo" dentro de "todos"), o que
|
|
765
|
+
// despejava trechos de conversa na seção Pendências.
|
|
766
|
+
export function extractPending(text) {
|
|
767
|
+
const pending = [];
|
|
768
|
+
const lines = String(text || '').split('\n');
|
|
769
|
+
for (const line of lines) {
|
|
770
|
+
if (/^\s*[-*]\s*\[ \]\s+\S/.test(line)) addUnique(pending, truncate(line.trim(), 160));
|
|
771
|
+
}
|
|
772
|
+
return pending.slice(0, 8);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function noteReferencesSession(content, sessionRel) {
|
|
776
|
+
const sessionLink = wikilinkFromRel(sessionRel);
|
|
777
|
+
return content.includes(sessionRel) || content.includes(sessionLink);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function findLinkedDerivedNotes(vaultBase, sessionRel) {
|
|
781
|
+
const linked = { decisions: [], bugs: [], learnings: [] };
|
|
782
|
+
const folders = {
|
|
783
|
+
decisions: '04-Decisões',
|
|
784
|
+
bugs: '05-Bugs',
|
|
785
|
+
learnings: '06-Aprendizados',
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
for (const [key, folder] of Object.entries(folders)) {
|
|
789
|
+
const dir = join(vaultBase, folder);
|
|
790
|
+
for (const fileName of listMarkdownFiles(dir)) {
|
|
791
|
+
const absPath = join(dir, fileName);
|
|
792
|
+
try {
|
|
793
|
+
const content = readFileSync(absPath, 'utf-8');
|
|
794
|
+
if (noteReferencesSession(content, sessionRel)) {
|
|
795
|
+
linked[key].push(toVaultRelative(vaultBase, absPath));
|
|
796
|
+
}
|
|
797
|
+
} catch {
|
|
798
|
+
// Ignore unreadable notes; the hook must not block session shutdown.
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return linked;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function mergeCreatedNotes(created, linked) {
|
|
807
|
+
const merged = { decisions: [], bugs: [], learnings: [] };
|
|
808
|
+
for (const key of Object.keys(merged)) {
|
|
809
|
+
merged[key] = [...new Set([...(created[key] || []), ...(linked[key] || [])])];
|
|
810
|
+
}
|
|
811
|
+
return merged;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function formatPendingSection(pending) {
|
|
815
|
+
return pending.length
|
|
816
|
+
? pending.map((item) => `- ${item}`).join('\n')
|
|
817
|
+
: 'Nenhuma pendência identificada automaticamente.';
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function formatPendingClosing(pending) {
|
|
821
|
+
return pending.length
|
|
822
|
+
? pending.map((item) => ` - ${item}`).join('\n')
|
|
823
|
+
: ' - Nenhuma pendência identificada automaticamente.';
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function updateFrontmatter(content, endedAt) {
|
|
827
|
+
let next = content;
|
|
828
|
+
next = next.replace(/^ended_at:.*$/m, `ended_at: ${endedAt}`);
|
|
829
|
+
next = next.replace(/^status:.*$/m, 'status: done');
|
|
830
|
+
return next;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function replacePendingSection(content, pending) {
|
|
834
|
+
const marker = '\n## Pendências';
|
|
835
|
+
const closingMarker = '\n## Encerramento';
|
|
836
|
+
const start = content.indexOf(marker);
|
|
837
|
+
if (start === -1) return content;
|
|
838
|
+
|
|
839
|
+
const end = content.indexOf(closingMarker, start + marker.length);
|
|
840
|
+
if (end === -1) return content;
|
|
841
|
+
|
|
842
|
+
return [
|
|
843
|
+
content.slice(0, start).trimEnd(),
|
|
844
|
+
'',
|
|
845
|
+
'## Pendências',
|
|
846
|
+
'',
|
|
847
|
+
formatPendingSection(pending),
|
|
848
|
+
content.slice(end),
|
|
849
|
+
].join('\n');
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function replaceClosingSection(content, closing) {
|
|
853
|
+
const marker = '\n## Encerramento';
|
|
854
|
+
const index = content.indexOf(marker);
|
|
855
|
+
if (index === -1) return `${content.trimEnd()}\n\n${closing}\n`;
|
|
856
|
+
return `${content.slice(0, index).trimEnd()}\n\n${closing}\n`;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function finalizeSessionFile(sessionPath, tx, created, endedAt) {
|
|
860
|
+
const pending = extractPending(tx.rawTextForDetection);
|
|
861
|
+
const links = (items) => items.length ? items.map((rel) => ` - ${wikilinkFromRel(rel)}`).join('\n') : ' - Nenhuma';
|
|
862
|
+
const summary = tx.latestAssistantMessage
|
|
863
|
+
? truncate(tx.latestAssistantMessage, 500)
|
|
864
|
+
: `Sessão encerrada com ${tx.userPrompts.length} prompts e ${tx.tools.length} ferramentas registradas.`;
|
|
865
|
+
|
|
866
|
+
const closing = `## Encerramento
|
|
867
|
+
|
|
868
|
+
- **Fim:** ${endedAt}
|
|
869
|
+
- **Status:** done
|
|
870
|
+
- **Resumo final:** ${summary}
|
|
871
|
+
- **Decisões registradas:**
|
|
872
|
+
${links(created.decisions)}
|
|
873
|
+
- **Bugs registrados:**
|
|
874
|
+
${links(created.bugs)}
|
|
875
|
+
- **Aprendizados registrados:**
|
|
876
|
+
${links(created.learnings)}
|
|
877
|
+
- **Pendências:**
|
|
878
|
+
${formatPendingClosing(pending)}
|
|
879
|
+
`;
|
|
880
|
+
|
|
881
|
+
const content = readFileSync(sessionPath, 'utf-8');
|
|
882
|
+
const finalized = replaceClosingSection(
|
|
883
|
+
replacePendingSection(updateFrontmatter(content, endedAt), pending),
|
|
884
|
+
closing,
|
|
885
|
+
);
|
|
886
|
+
writeFileSync(sessionPath, finalized, 'utf-8');
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// --- Vínculo Sessão ↔ Issues Linear (03-Linear) -------------------------------
|
|
890
|
+
// Coleta IDs `NUT-\d+` citados na conversa, resolve as notas em 03-Linear e, ao
|
|
891
|
+
// ler cada nota, descobre NUTs conectadas (1 salto) mencionadas no corpo dela.
|
|
892
|
+
// Escreve os wikilinks numa seção `## Issues Linear` da própria sessão; o backlink
|
|
893
|
+
// na nota da NUT é resolvido pelo Obsidian (sem editar a nota sincronizada).
|
|
894
|
+
const LINEAR_DIR = '03-Linear';
|
|
895
|
+
|
|
896
|
+
function collectIssueIds(text) {
|
|
897
|
+
const ids = [];
|
|
898
|
+
const regex = /\bNUT-\d+\b/gi;
|
|
899
|
+
let match;
|
|
900
|
+
while ((match = regex.exec(String(text || ''))) !== null) {
|
|
901
|
+
const id = match[0].toUpperCase();
|
|
902
|
+
if (!ids.includes(id)) ids.push(id);
|
|
903
|
+
}
|
|
904
|
+
return ids;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function findLinearNote(vaultBase, issueId) {
|
|
908
|
+
const dir = join(vaultBase, LINEAR_DIR);
|
|
909
|
+
const byName = new RegExp(`^${issueId}(?![0-9])`, 'i');
|
|
910
|
+
for (const fileName of listMarkdownFiles(dir)) {
|
|
911
|
+
if (byName.test(fileName)) return toVaultRelative(vaultBase, join(dir, fileName));
|
|
912
|
+
}
|
|
913
|
+
const byFront = new RegExp(`^linear_identifier:\\s*["']?${issueId}["']?\\s*$`, 'im');
|
|
914
|
+
for (const fileName of listMarkdownFiles(dir)) {
|
|
915
|
+
try {
|
|
916
|
+
if (byFront.test(readFileSync(join(dir, fileName), 'utf-8'))) {
|
|
917
|
+
return toVaultRelative(vaultBase, join(dir, fileName));
|
|
918
|
+
}
|
|
919
|
+
} catch {
|
|
920
|
+
// Nota ilegível: ignora; o hook não pode travar o encerramento.
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function findConnectedIssueIds(vaultBase, noteRel, excludeId) {
|
|
927
|
+
try {
|
|
928
|
+
const content = readFileSync(join(vaultBase, noteRel), 'utf-8');
|
|
929
|
+
return collectIssueIds(content).filter((id) => id !== excludeId);
|
|
930
|
+
} catch {
|
|
931
|
+
return [];
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Insere uma seção `## <heading>` vazia antes de `beforeMarker` se ainda não existir.
|
|
936
|
+
function ensureSection(content, heading, beforeMarker) {
|
|
937
|
+
if (content.includes(`\n## ${heading}\n`)) return content;
|
|
938
|
+
const block = `## ${heading}\n\n`;
|
|
939
|
+
const index = content.indexOf(beforeMarker);
|
|
940
|
+
if (index === -1) return `${content.trimEnd()}\n\n${block}`;
|
|
941
|
+
return `${content.slice(0, index).trimEnd()}\n\n${block}${content.slice(index + 1)}`;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function applyLinearLinks(sessionPath, tx, vaultBase, sessionRel) {
|
|
945
|
+
const seeds = collectIssueIds(tx.rawTextForDetection);
|
|
946
|
+
if (!seeds.length) return;
|
|
947
|
+
|
|
948
|
+
const resolved = new Map(); // issueId -> relPath
|
|
949
|
+
for (const id of seeds) {
|
|
950
|
+
const rel = findLinearNote(vaultBase, id);
|
|
951
|
+
if (rel) resolved.set(id, rel);
|
|
952
|
+
}
|
|
953
|
+
// 1 salto: NUTs conectadas citadas dentro das notas semente.
|
|
954
|
+
for (const [id, rel] of [...resolved]) {
|
|
955
|
+
for (const connId of findConnectedIssueIds(vaultBase, rel, id)) {
|
|
956
|
+
if (resolved.has(connId)) continue;
|
|
957
|
+
const connRel = findLinearNote(vaultBase, connId);
|
|
958
|
+
if (connRel) resolved.set(connId, connRel);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
if (!resolved.size) return;
|
|
962
|
+
|
|
963
|
+
const lines = [...resolved.entries()]
|
|
964
|
+
.sort((a, b) => a[0].localeCompare(b[0], undefined, { numeric: true }))
|
|
965
|
+
.map(([id, rel]) => `- ${id} — ${wikilinkFromRel(rel)}`);
|
|
966
|
+
|
|
967
|
+
let content = readFileSync(sessionPath, 'utf-8');
|
|
968
|
+
content = ensureSection(content, 'Issues Linear', '\n## Encerramento');
|
|
969
|
+
content = upsertListSection(content, 'Issues Linear', lines, null);
|
|
970
|
+
writeFileSync(sessionPath, content, 'utf-8');
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Triggers Obsidian Local REST API to re-index the vault after file writes.
|
|
974
|
+
// Silent no-op if Obsidian is closed or plugin not installed.
|
|
975
|
+
function pingObsidianVault(apiKey) {
|
|
976
|
+
const key = apiKey || process.env.OBSIDIAN_API_KEY || '';
|
|
977
|
+
if (!key) return;
|
|
978
|
+
try {
|
|
979
|
+
const req = request({
|
|
980
|
+
hostname: '127.0.0.1',
|
|
981
|
+
port: 27124,
|
|
982
|
+
path: '/vault/',
|
|
983
|
+
method: 'GET',
|
|
984
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
985
|
+
timeout: 2000,
|
|
986
|
+
});
|
|
987
|
+
req.on('error', () => {});
|
|
988
|
+
req.end();
|
|
989
|
+
} catch {}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function main() {
|
|
993
|
+
const input = readHookInput();
|
|
994
|
+
if (input.stop_hook_active) {
|
|
995
|
+
writeHookOutput({});
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const vaultBase = getVaultBase(input);
|
|
1000
|
+
const control = readControl(vaultBase);
|
|
1001
|
+
const transcriptPath = input.transcript_path || input.transcriptPath || '';
|
|
1002
|
+
|
|
1003
|
+
// Roteia o turn pela sessão DO PRÓPRIO transcript (registry), não pelo
|
|
1004
|
+
// CURRENT_SESSION global — que sessões concorrentes sobrescrevem, fazendo
|
|
1005
|
+
// o turn cair na nota de outra conversa. Sem match por transcript NÃO caímos
|
|
1006
|
+
// no global (contaminaria nota alheia): pulamos e o backfill recupera depois.
|
|
1007
|
+
const matched = transcriptPath ? findActiveSessionByTranscript(vaultBase, transcriptPath) : null;
|
|
1008
|
+
const sessionRel = matched?.session_file || '';
|
|
1009
|
+
if (!sessionRel) {
|
|
1010
|
+
writeHookOutput({});
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const sessionPath = join(vaultBase, sessionRel);
|
|
1015
|
+
if (!existsSync(sessionPath)) {
|
|
1016
|
+
writeHookOutput({});
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const tx = parseTranscript(input.transcript_path || input.transcriptPath);
|
|
1021
|
+
const turnId = input.turn_id || tx.latestTurnId || String(Date.now());
|
|
1022
|
+
const sessionId = matched?.sessionId || control.session_id || tx.sessionId;
|
|
1023
|
+
const logged = insertIteration(sessionPath, buildIterationBlock(tx, input), turnId, tx);
|
|
1024
|
+
|
|
1025
|
+
try {
|
|
1026
|
+
applyLinearLinks(sessionPath, tx, vaultBase, sessionRel);
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
process.stderr.write(`[codex-obsidian] Linear link falhou: ${error.message}\n`);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
try {
|
|
1032
|
+
updateSessionUsage({
|
|
1033
|
+
vaultBase,
|
|
1034
|
+
sessionRel,
|
|
1035
|
+
sessionPath,
|
|
1036
|
+
transcriptPath,
|
|
1037
|
+
});
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
process.stderr.write(`[codex-obsidian] Token usage falhou: ${error.message}\n`);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (!shouldFinalizeSession()) {
|
|
1043
|
+
writeControl(vaultBase, {
|
|
1044
|
+
...control,
|
|
1045
|
+
status: 'active',
|
|
1046
|
+
session_file: sessionRel,
|
|
1047
|
+
last_session_file: control.last_session_file || sessionRel,
|
|
1048
|
+
session_id: sessionId,
|
|
1049
|
+
last_logged_turn_id: logged ? turnId : control.last_logged_turn_id,
|
|
1050
|
+
});
|
|
1051
|
+
upsertSessionRegistry(vaultBase, sessionId, {
|
|
1052
|
+
session_file: sessionRel,
|
|
1053
|
+
status: 'active',
|
|
1054
|
+
// started_at omitido de propósito: o merge preserva o da própria entry
|
|
1055
|
+
// (definido no SessionStart). Usar control.started_at contaminava com o
|
|
1056
|
+
// started_at de sessões concorrentes que sobrescrevem o ponteiro global.
|
|
1057
|
+
ended_at: '',
|
|
1058
|
+
last_turn_id: logged ? turnId : control.last_logged_turn_id,
|
|
1059
|
+
transcript_path: transcriptPath,
|
|
1060
|
+
});
|
|
1061
|
+
pingObsidianVault(input.obsidian_api_key);
|
|
1062
|
+
writeHookOutput({});
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const now = new Date();
|
|
1067
|
+
const endedAt = formatLocalIso(now);
|
|
1068
|
+
const created = mergeCreatedNotes(
|
|
1069
|
+
createLinkedNotes(vaultBase, formatDate(now), sessionRel, tx),
|
|
1070
|
+
findLinkedDerivedNotes(vaultBase, sessionRel),
|
|
1071
|
+
);
|
|
1072
|
+
finalizeSessionFile(sessionPath, tx, created, endedAt);
|
|
1073
|
+
writeControl(vaultBase, {
|
|
1074
|
+
status: 'inactive',
|
|
1075
|
+
session_file: '',
|
|
1076
|
+
last_session_file: sessionRel,
|
|
1077
|
+
started_at: control.started_at,
|
|
1078
|
+
ended_at: endedAt,
|
|
1079
|
+
session_id: sessionId,
|
|
1080
|
+
last_logged_turn_id: turnId,
|
|
1081
|
+
});
|
|
1082
|
+
upsertSessionRegistry(vaultBase, sessionId, {
|
|
1083
|
+
session_file: sessionRel,
|
|
1084
|
+
status: 'done',
|
|
1085
|
+
// started_at omitido: preserva o da própria entry (ver branch acima).
|
|
1086
|
+
ended_at: endedAt,
|
|
1087
|
+
last_turn_id: turnId,
|
|
1088
|
+
transcript_path: transcriptPath,
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
// Reconstrói índice (camada fria) + digest (camada quente) ao finalizar. Nunca derruba o Stop.
|
|
1092
|
+
try {
|
|
1093
|
+
const rows = buildBrainIndex(vaultBase);
|
|
1094
|
+
buildBrainDigest(vaultBase, rows);
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
process.stderr.write(`[codex-obsidian] brain index/digest falhou: ${error.message}\n`);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
pingObsidianVault(input.obsidian_api_key);
|
|
1100
|
+
writeHookOutput({});
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
1104
|
+
try {
|
|
1105
|
+
main();
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
process.stderr.write(`[codex-obsidian] Stop falhou: ${error.message}\n`);
|
|
1108
|
+
writeHookOutput({});
|
|
1109
|
+
}
|
|
1110
|
+
}
|