wendkeep 0.46.0 → 0.46.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,91 @@ All notable changes to **wendkeep** are documented here. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.46.2] — 2026-07-19
8
+
9
+ ### Fixed
10
+
11
+ - **Rollout de subagent do Codex virava sessão top-level no import.** Um subagent do Codex
12
+ não é um arquivo em `<transcript>/subagents/` — é um rollout **irmão** no
13
+ `~/.codex/sessions/`, cujo `session_meta` declara `source.subagent` e aponta pro pai via
14
+ `parent_thread_id`. O import ignorava o marcador e materializava o subagent como uma
15
+ "sessão" própria, com o contexto do pai inteiro replicado (subagent herda o histórico) —
16
+ no caso real, uma nota fantasma de 23 turnos duplicando a conversa da sessão-mãe. Agora a
17
+ descoberta expõe o marcador, o import conta subagents à parte no relatório (nunca em
18
+ `skipped`) e nenhuma nota é criada. A entrada de registry que o import antigo escreveu
19
+ para um subagent é removida — self-healing do nosso próprio dado errado, nunca limpeza
20
+ genérica: entrada com o mesmo id mas transcript diferente é preservada.
21
+ - **Telemetria de subagent do Codex nunca chegava à sessão-mãe — nem ao vivo.** A descoberta
22
+ (`collectSubagentUsage`) era Claude-shaped: procurava um diretório `subagents/` ao lado do
23
+ transcript, que no Codex não existe. Resultado: toda sessão Codex fechava com
24
+ `subagents_count: 0`, tanto no import quanto no hook vivo `SubagentStop` wirado na 0.46.0
25
+ — o custo dos subagents simplesmente não existia no vault. A nova
26
+ `collectCodexSubagentUsage` acha os irmãos por `parent_thread_id` (no dia do rollout pai e
27
+ no dia seguinte, cobrindo spawn que cruza a meia-noite UTC — o caso real passou a seis
28
+ minutos disso) e devolve o mesmo agregado que o writer já consome: vivo e import passam a
29
+ atribuir pelo mesmo caminho.
30
+ - **O bloco injetado ainda aparecia como fala do usuário no "Contexto conversado".** O fix
31
+ da 0.46.1 protegeu o título, mas a linha `**Usuário:**` da iteração vinha de um segundo
32
+ filtro (`shouldIgnoreUserText`) que duplicava por cópia a lista do `isBootstrapPrompt` — e
33
+ as cópias divergiram. O filtro agora delega: um lugar só para o próximo bloco que o
34
+ harness inventar.
35
+ - Bytes NUL literais em `hooks/token-usage.mjs` e `hooks/subagent-usage.mjs` escapados —
36
+ mesma classe do fix de `taxonomy.mjs` (#7): o byte cru fazia o `file` classificar o fonte
37
+ como binário e o ripgrep pulá-lo em silêncio. Entrou `tests/source-hygiene.test.mjs`
38
+ barrando byte de controle em qualquer fonte publicado; ele pegou uma quarta ocorrência
39
+ introduzida durante esta própria mudança.
40
+
41
+ ## [0.46.1] — 2026-07-19
42
+
43
+ ### Fixed
44
+
45
+ - **Turnos do Codex sumiam da nota sem nenhum aviso.** No Windows o Codex serializa o payload
46
+ do `Stop` com o campo `last_assistant_message` cortado no meio, sem fechar a string JSON —
47
+ bug upstream ainda aberto ([openai/codex#23784](https://github.com/openai/codex/issues/23784)).
48
+ Sessão em português enche esse campo de acento, então o corte é frequente. O
49
+ `readHookInput` fazia `JSON.parse` cru, lançava, e o `session-stop` saía com código 0
50
+ escrevendo só no stderr — que o Codex descarta. Resultado: a nota era criada, o summary
51
+ atualizava a cada prompt, e nenhuma iteração jamais entrava. Só o `Stop` quebrava porque
52
+ `last_assistant_message` é o único campo exclusivo dele; `SessionStart` e
53
+ `UserPromptSubmit` não o carregam.
54
+ Como esse campo é o **último** do `StopCommandInput`, tudo que o wendkeep consome
55
+ (`session_id`, `turn_id`, `transcript_path`, `cwd`) está no prefixo bem-formado. O
56
+ `readHookInput` passa a recuperar esse prefixo numa passada só, descartando o campo
57
+ truncado — nunca reconstruindo-o, porque metade de uma mensagem é dado inventado.
58
+ - **O hook parou de falhar em silêncio.** Todo caminho de bail do `session-stop` agora emite
59
+ `systemMessage`, que a UI do Codex mostra, com o motivo e o comando de recuperação. O exit
60
+ code continua 0 de propósito: hook de `Stop` que sai diferente de zero trava o turno
61
+ (openai/codex#21921), e trocar turno perdido por sessão travada é pior negócio.
62
+ - **`resolveSessionIdentity` passa a usar o `SESSION_REGISTRY` como fonte do
63
+ `transcript_path`** quando o payload não o traz. O registry já tinha o mapeamento; o lookup
64
+ é que ficava abaixo do gate, inalcançável justo no caso que resolveria. A entrada precisa
65
+ ser do mesmo provider, o que preserva o invariante do incidente de contaminação
66
+ cross-provider de 2026-07-11.
67
+ - **`wendkeep import` deixou de ser cego para a sessão danificada.** O dedup perguntava
68
+ "existe registro?", não "existe conteúdo?" — e como o `session-start` registra antes do
69
+ `session-stop` escrever, **as sessões esvaziadas pelo bug acima eram exatamente as que o
70
+ comando de recuperação se recusava a consertar.** Agora a decisão compara os turnos do
71
+ transcript com os marcadores `wk-turn` já na nota: cobertura completa pula, parcial ou
72
+ vazia completa a nota existente sem criar uma segunda. Sem flag opt-in — quem roda `import`
73
+ depois de perder sessão não tem como saber que precisaria de uma. O relatório ganhou a
74
+ categoria `repaired`, separada de `imported` (nota nova) e de `skipped` (já completa).
75
+ - **Sessões importadas ganhavam título de bloco injetado pelo harness.** Seis notas de um
76
+ mesmo projeto ficaram chamadas `<recommended_plugins> Here is a list of plugins that ar`,
77
+ no frontmatter e no nome do arquivo. Causa de uma linha: `buildIterationBlock` seleciona
78
+ `userPrompts.at(-1)` e o `deriveSummary` usava `.find(Boolean)` — o harness injeta o bloco
79
+ como **primeiro** prompt do turno e o pedido do usuário vem por **último**. Mesmo dado,
80
+ ponta oposta. As duas seleções agora são a mesma, com `isBootstrapPrompt` (que passou a
81
+ reconhecer `<recommended_plugins>`) como rede, aplicado ao prompt inteiro e não linha a
82
+ linha — filtrar por linha cairia na linha seguinte do próprio bloco injetado.
83
+
84
+ ### Recuperação
85
+
86
+ - Quem perdeu turnos de sessões Codex antes desta versão recupera com
87
+ `wendkeep import --source codex`. O rollout do Codex fica íntegro em disco, e o import agora
88
+ completa a nota existente em vez de pulá-la. Rodar mais de uma vez é no-op.
89
+ - Notas já criadas com título poluído **não** são renomeadas automaticamente: mexer em nome de
90
+ arquivo quebra wikilink e reorganiza o grafo, e isso é decisão do dono do vault.
91
+
7
92
  ## [0.46.0] — 2026-07-18
8
93
 
9
94
  ### Added
@@ -17,7 +17,7 @@ import {
17
17
  import { buildSessionContent, allocateSessionPath } from './session-start.mjs';
18
18
  import { createLinkedNotes } from './linked-notes.mjs';
19
19
  import { updateSessionObservability } from './session-observability.mjs';
20
- import { readSessionRegistry, upsertSessionRegistry, formatLocalIso, formatDate, providerMeta } from './obsidian-common.mjs';
20
+ import { readSessionRegistry, upsertSessionRegistry, removeSessionRegistryEntry, formatLocalIso, formatDate, providerMeta, isBootstrapPrompt } from './obsidian-common.mjs';
21
21
  import { getLocale } from './locale.mjs';
22
22
  import { captureProseDecisions } from './decision-capture.mjs';
23
23
 
@@ -154,6 +154,36 @@ export function capturedSessionIds(vaultBase) {
154
154
  return ids;
155
155
  }
156
156
 
157
+ // session_id -> absolute note path, for the sessions that already have a note on disk. The
158
+ // registry alone is not enough: it records a session the moment session-start runs, which is
159
+ // exactly the state a damaged session is stuck in (registered, note empty).
160
+ export function capturedSessionNotes(vaultBase) {
161
+ const notes = new Map();
162
+ const registry = readSessionRegistry(vaultBase).sessions || {};
163
+ for (const [id, entry] of Object.entries(registry)) {
164
+ if (!entry?.session_file) continue;
165
+ const abs = join(vaultBase, ...String(entry.session_file).split('/'));
166
+ if (existsSync(abs)) notes.set(id, abs);
167
+ }
168
+ try {
169
+ const sessionsDir = join(vaultBase, getLocale(vaultBase).folders.sessions);
170
+ for (const path of walkFiles(sessionsDir, /\.md$/i)) {
171
+ const id = noteSessionId(path);
172
+ if (id && !notes.has(id)) notes.set(id, path);
173
+ }
174
+ } catch { /* registry alone is enough */ }
175
+ return notes;
176
+ }
177
+
178
+ // Turn ids already memorialized in a note, read from the `wk-turn` markers insertIteration
179
+ // writes. Missing/unreadable note = nothing captured.
180
+ export function noteTurnIds(notePath) {
181
+ try {
182
+ const md = readFileSync(notePath, 'utf-8');
183
+ return new Set([...md.matchAll(/<!-- (?:wk|codex)-turn: ([^\s]+) -->/g)].map((m) => m[1]));
184
+ } catch { return new Set(); }
185
+ }
186
+
157
187
  export function discoverCodexTranscripts(projectPath, fromDir) {
158
188
  const dir = fromDir || defaultCodexSessionsDir();
159
189
  if (!dir || !existsSync(dir)) return { dir, transcripts: [] };
@@ -162,15 +192,25 @@ export function discoverCodexTranscripts(projectPath, fromDir) {
162
192
  const meta = readSessionMeta(path);
163
193
  if (!meta || !meta.id) continue;
164
194
  if (projectPath && !cwdMatchesProject(meta.cwd, projectPath)) continue;
165
- transcripts.push({ path, sessionId: meta.id, cwd: meta.cwd || '' });
195
+ // A subagent thread's rollout is a SIBLING file of its parent's — same dir, own id. The
196
+ // meta says what the file IS; ignoring it turned hierarchy into a duplicate session note.
197
+ const subagent = meta.source?.subagent
198
+ ? { parentThreadId: meta.parent_thread_id || meta.source.subagent.thread_spawn?.parent_thread_id || '', nickname: meta.source.subagent.thread_spawn?.agent_nickname || '' }
199
+ : null;
200
+ transcripts.push({ path, sessionId: meta.id, cwd: meta.cwd || '', subagent });
166
201
  }
167
202
  return { dir, transcripts };
168
203
  }
169
204
 
170
205
  // Session objective for the note title/frontmatter: first real user prompt, one line.
171
- function deriveSummary(tx) {
172
- for (const turn of tx.turns || []) {
173
- const prompt = (turn.userPrompts || []).find(Boolean);
206
+ // Mirrors buildIterationBlock's selection (`userPrompts.at(-1)`) on purpose: the harness
207
+ // injects preamble as the FIRST prompt of a turn and the user's request lands LAST, so taking
208
+ // the first titled six Vendiva sessions "<recommended_plugins> Here is a list of plugins".
209
+ // isBootstrapPrompt is the belt: it runs per whole prompt, not per line — filtering by line
210
+ // would fall through to the next line of the SAME injected block.
211
+ export function deriveSummary(tx) {
212
+ for (const turn of tx?.turns || []) {
213
+ const prompt = (turn.userPrompts || []).filter((p) => p && !isBootstrapPrompt(p)).at(-1);
174
214
  if (prompt) {
175
215
  return prompt.replace(/[\r\n#]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80) || 'session';
176
216
  }
@@ -313,15 +353,29 @@ export function runImport(vaultBase, opts = {}) {
313
353
  codexDir = d.dir;
314
354
  transcripts.push(...d.transcripts);
315
355
  }
316
- const captured = capturedSessionIds(vaultBase);
356
+ const notes = capturedSessionNotes(vaultBase);
317
357
  const sinceMs = since ? Date.parse(since) : 0;
318
- const report = { source: src, claudeDir, codexDir, scanned: transcripts.length, imported: 0, skipped: 0, errors: [], sessions: [] };
358
+ const report = { source: src, claudeDir, codexDir, scanned: transcripts.length, imported: 0, repaired: 0, skipped: 0, subagents: 0, errors: [], sessions: [] };
319
359
 
320
360
  let done = 0;
321
361
  for (const t of transcripts) {
322
- if (captured.has(t.sessionId)) { report.skipped++; continue; }
362
+ // A subagent rollout is telemetry of its PARENT session, never a session of its own —
363
+ // importing it materialized the parent's replayed context as a ghost note. Counted apart
364
+ // from `skipped` (which means "session already covered"). If import <=0.46.1 registered
365
+ // it as a top-level session, that entry is our own bad write: heal it.
366
+ if (t.subagent) {
367
+ report.subagents++;
368
+ if (!dryRun) {
369
+ try { removeSessionRegistryEntry(vaultBase, t.sessionId, t.path); } catch { /* best-effort */ }
370
+ }
371
+ continue;
372
+ }
323
373
  if (limit && done >= limit) break;
324
374
 
375
+ // Every transcript is parsed now, including already-captured ones: partial coverage is
376
+ // undetectable without the turn list. The old presence-only check was cheaper but made
377
+ // the recovery command blind to exactly the sessions it exists to repair. Narrow a large
378
+ // vault with --since / --limit.
325
379
  let tx;
326
380
  try {
327
381
  tx = parseTranscript(t.path);
@@ -332,6 +386,31 @@ export function runImport(vaultBase, opts = {}) {
332
386
  const turns = tx.turns || [];
333
387
  if (!turns.length) { report.skipped++; continue; }
334
388
 
389
+ const existingNote = notes.get(t.sessionId);
390
+ if (existingNote) {
391
+ const have = noteTurnIds(existingNote);
392
+ const missing = turns.filter((turn) => !have.has(String(turn.turnId)));
393
+ if (!missing.length) { report.skipped++; continue; }
394
+ if (dryRun) {
395
+ report.sessions.push({ sessionId: t.sessionId, turns: missing.length, repaired: true, dryRun: true });
396
+ report.repaired++;
397
+ done++;
398
+ continue;
399
+ }
400
+ try {
401
+ for (const turn of missing) {
402
+ insertIteration(existingNote, buildIterationBlock(tx, { turn_id: turn.turnId, now: turn.timestamp }), turn.turnId, tx);
403
+ }
404
+ try { updateSessionObservability({ sessionPath: existingNote, transcriptPath: t.path }); } catch { /* best-effort */ }
405
+ report.sessions.push({ sessionId: t.sessionId, turns: missing.length, repaired: true });
406
+ report.repaired++;
407
+ done++;
408
+ } catch (error) {
409
+ report.errors.push({ sessionId: t.sessionId, error: error.message });
410
+ }
411
+ continue;
412
+ }
413
+
335
414
  const startTs = turns[0].timestamp || '';
336
415
  if (sinceMs && startTs && Number.isFinite(Date.parse(startTs)) && Date.parse(startTs) < sinceMs) {
337
416
  report.skipped++;
@@ -23,10 +23,52 @@ export const VAULT_COMPLEMENT_RULES = [
23
23
  'Atualize `SHARED_MEMORY.md` somente quando a síntese mudar estado ativo que outro agente precise saber.',
24
24
  ];
25
25
 
26
+ // Codex on Windows serializes the Stop payload with `last_assistant_message` cut mid-string
27
+ // and never closed when the assistant text carries non-ASCII (openai/codex#23784). That field
28
+ // is LAST in codex-rs's StopCommandInput, so everything wendkeep consumes — session_id,
29
+ // turn_id, transcript_path, cwd — sits in the intact prefix.
30
+ //
31
+ // One pass, tracking quotes/escapes/depth, remembering the offset of the last top-level comma
32
+ // that was NOT inside a string. Re-closing there yields the well-formed prefix. Deliberately
33
+ // NOT a decreasing brute-force parse: this runs on every turn and the payload can be tens of
34
+ // KB. The truncated field is dropped, never reconstructed — half an assistant message is
35
+ // invented data, and it is the one field we do not need.
36
+ export function salvageTruncatedJson(raw) {
37
+ const text = String(raw || '');
38
+ if (text[0] !== '{') return null;
39
+ let inString = false;
40
+ let escaped = false;
41
+ let depth = 0;
42
+ let lastBoundary = -1;
43
+ for (let i = 0; i < text.length; i += 1) {
44
+ const ch = text[i];
45
+ if (escaped) { escaped = false; continue; }
46
+ if (ch === '\\') { if (inString) escaped = true; continue; }
47
+ if (ch === '"') { inString = !inString; continue; }
48
+ if (inString) continue;
49
+ if (ch === '{' || ch === '[') depth += 1;
50
+ else if (ch === '}' || ch === ']') depth -= 1;
51
+ else if (ch === ',' && depth === 1) lastBoundary = i;
52
+ }
53
+ if (lastBoundary === -1) return null;
54
+ try {
55
+ const parsed = JSON.parse(`${text.slice(0, lastBoundary)}}`);
56
+ return parsed && typeof parsed === 'object' ? parsed : null;
57
+ } catch { return null; }
58
+ }
59
+
26
60
  export function readHookInput() {
27
61
  const raw = readFileSync(0, 'utf-8').trim();
28
62
  if (!raw) return {};
29
- return JSON.parse(raw);
63
+ try {
64
+ return JSON.parse(raw);
65
+ } catch (error) {
66
+ const salvaged = salvageTruncatedJson(raw);
67
+ // `_wk` prefix: the object is the harness payload merged with our own metadata, and a
68
+ // silent key collision here would be worse than the ugly prefix.
69
+ if (salvaged) return { ...salvaged, _wkSalvaged: true };
70
+ throw error;
71
+ }
30
72
  }
31
73
 
32
74
  export function writeHookOutput(payload = {}) {
@@ -313,6 +355,25 @@ function meaningfulPatch(patch = {}) {
313
355
  }));
314
356
  }
315
357
 
358
+ // Remove one registry entry, but ONLY when its transcript matches the given path — this is
359
+ // self-healing for entries wendkeep itself mis-wrote (a subagent rollout registered as a
360
+ // top-level session by import <=0.46.1), never generic registry cleanup. An entry with the
361
+ // same id but a different transcript belongs to someone else's state and is preserved.
362
+ export function removeSessionRegistryEntry(vaultBase, sessionId, transcriptPath) {
363
+ if (!sessionId) return false;
364
+ let removed = false;
365
+ mutateSessionRegistry(vaultBase, (registry) => {
366
+ const entry = registry.sessions[sessionId];
367
+ if (!entry) return null;
368
+ const paths = [...(Array.isArray(entry.transcript_paths) ? entry.transcript_paths : []), entry.transcript_path].filter(Boolean);
369
+ if (!paths.some((p) => transcriptsMatch(p, transcriptPath))) return null;
370
+ delete registry.sessions[sessionId];
371
+ removed = true;
372
+ return null;
373
+ });
374
+ return removed;
375
+ }
376
+
316
377
  export function upsertSessionRegistry(vaultBase, sessionId, patch) {
317
378
  if (!sessionId) return null;
318
379
  const clean = meaningfulPatch(patch);
@@ -503,6 +564,10 @@ export function isBootstrapPrompt(text = '') {
503
564
  return clean.startsWith('# AGENTS.md instructions')
504
565
  || clean.startsWith('<environment_context>')
505
566
  || clean.startsWith('<permissions instructions>')
567
+ // Codex injects the available-plugins catalogue as the first userPrompt of turn 1.
568
+ // Anchored with startsWith on purpose: matching the bare substring would discard a
569
+ // legitimate prompt that merely asks about plugins.
570
+ || clean.startsWith('<recommended_plugins>')
506
571
  || clean.includes('You are Codex, a coding agent')
507
572
  || clean.startsWith('## Memory');
508
573
  }
@@ -96,6 +96,30 @@ export function resolveSessionIdentity(vaultBase, input = {}, provider = detectP
96
96
  };
97
97
  }
98
98
 
99
+ // Codex on Windows can deliver a Stop payload whose transcript_path never arrives (or is
100
+ // lost to a truncated JSON, openai/codex#23784). The registry already knows the mapping —
101
+ // the lookup below just sat under this gate, unreachable in the one case it solves. The
102
+ // comment above says we require "rollout/registry"; the registry half was never wired.
103
+ // Requiring the entry's provider to match preserves the cross-provider invariant from the
104
+ // 2026-07-11 incident: we are not minting a canonical id, we are finding an ALREADY
105
+ // REGISTERED session whose key is the hook's own id. A resume with a fresh id simply
106
+ // misses and stays deferred.
107
+ if (!transcriptPath && hookId) {
108
+ const entry = readSessionRegistry(vaultBase).sessions?.[hookId];
109
+ if (entry?.transcript_path && entry.provider === provider) {
110
+ return {
111
+ state: 'resolved',
112
+ provider,
113
+ canonicalConversationId: hookId,
114
+ hookSessionId: hookId,
115
+ transcriptPath: entry.transcript_path,
116
+ transcriptId: entry.transcript_id || basename(entry.transcript_path, '.jsonl'),
117
+ parentConversationId: '',
118
+ diagnostics: ['transcript recuperado do SESSION_REGISTRY'],
119
+ };
120
+ }
121
+ }
122
+
99
123
  if (!transcriptPath || !inspected.canonicalConversationId) {
100
124
  return { state: 'deferred', provider, transcriptPath, diagnostics: ['transcript ausente ou sem identidade canônica'] };
101
125
  }
@@ -1,7 +1,7 @@
1
1
  // Single atomic writer for session usage, models, reasoning/effort and subagents.
2
2
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { collectSessionUsage } from './token-usage.mjs';
4
- import { collectSubagentUsage, sessionDirFromTranscript } from './subagent-usage.mjs';
4
+ import { collectSubagentUsage, collectCodexSubagentUsage, sessionDirFromTranscript } from './subagent-usage.mjs';
5
5
  import { inspectTranscriptIdentity } from './session-identity.mjs';
6
6
 
7
7
  const HEADING = '## Agentes, tokens e custos';
@@ -137,7 +137,11 @@ ${renderSubagents(subagents)}`;
137
137
  export function buildSessionObservability({ sessionContent, transcriptPath }) {
138
138
  const main = collectSessionUsage({ sessionContent, transcriptPath });
139
139
  if (!main) return null;
140
- const subagents = collectSubagentUsage(sessionDirFromTranscript(transcriptPath));
140
+ // Claude layout first (<transcript>/subagents/); when absent, the Codex layout — sibling
141
+ // rollouts linked by parent_thread_id. The Codex collector self-gates: a Claude transcript
142
+ // has no session_meta line, so it returns null and this stays a strict fallback.
143
+ const subagents = collectSubagentUsage(sessionDirFromTranscript(transcriptPath))
144
+ || collectCodexSubagentUsage(transcriptPath);
141
145
  const ledger = [...mainLedger(main), ...subagentLedger(subagents)];
142
146
  const sub = subagents?.aggregate || { count: 0, tokens: 0, cost: 0, wasted: 0, tools: [] };
143
147
  let content = main.content;
@@ -18,6 +18,7 @@ import {
18
18
  formatLocalIso,
19
19
  getNextAdrNumber,
20
20
  getVaultBase,
21
+ isBootstrapPrompt,
21
22
  warnIfDefaultVault,
22
23
  listMarkdownFiles,
23
24
  readControl,
@@ -53,11 +54,12 @@ const SYNTHETIC_EVENT_TAG = /^<\/?(?:task-notification|system-reminder|local-com
53
54
 
54
55
  function shouldIgnoreUserText(text) {
55
56
  const trimmed = String(text || '').trim();
57
+ // Bootstrap detection is DELEGATED, not copied: this used to duplicate isBootstrapPrompt's
58
+ // prefix list and the copies drifted — <recommended_plugins> made it into one and not the
59
+ // other, so the title came out clean while the same block still showed as "Usuário" in the
60
+ // conversation context. One filter, one place to add the next injected block.
56
61
  return SYNTHETIC_EVENT_TAG.test(trimmed)
57
- || /^# AGENTS\.md instructions/.test(trimmed)
58
- || trimmed.startsWith('<permissions instructions>')
59
- || trimmed.includes('You are Codex, a coding agent')
60
- || trimmed.startsWith('## Memory')
62
+ || isBootstrapPrompt(trimmed)
61
63
  // Harness utility meta-prompts (title generation, classifiers) — not real user turns; they
62
64
  // were leaking into note titles/summaries on import.
63
65
  || /^Generate a concise( UI)? title/i.test(trimmed)
@@ -584,6 +586,16 @@ function formatTokenLine(usage, model) {
584
586
  return cost ? `${line} — ≈ API equivalente (não é cobrança do plano)` : line;
585
587
  }
586
588
 
589
+ // User-facing explanation for a turn that could not be memorialized. Names the upstream bug
590
+ // when the payload arrived salvaged, because "wendkeep didn't record it" reads as a wendkeep
591
+ // defect and the user would have nowhere to look.
592
+ export function bailMessage(why, input = {}) {
593
+ const truncated = input._wkSalvaged
594
+ ? ' O payload do Stop chegou truncado (openai/codex#23784).'
595
+ : '';
596
+ return `[wendkeep] Turno não registrado: ${why}.${truncated} Recupere com \`wendkeep import --source codex\`.`;
597
+ }
598
+
587
599
  export function buildIterationBlock(tx, input) {
588
600
  const turnId = input.turn_id || tx.latestTurnId || `${Date.now()}`;
589
601
  const turn = selectTurn(tx, turnId);
@@ -1033,8 +1045,11 @@ function main() {
1033
1045
  const transcriptPath = input.transcript_path || input.transcriptPath || '';
1034
1046
  const { identity, entry } = resolveSessionEntry(vaultBase, input);
1035
1047
  if (identity.state !== 'resolved' || !entry?.session_file) {
1036
- process.stderr.write(`[wendkeep] Stop sem identidade segura: ${identity.diagnostics?.join('; ') || 'sessão não registrada'}\n`);
1037
- writeHookOutput({});
1048
+ const why = identity.diagnostics?.join('; ') || 'sessão não registrada';
1049
+ process.stderr.write(`[wendkeep] Stop sem identidade segura: ${why}\n`);
1050
+ // stderr alone is a black hole here: Codex discards it, which is how an entire session of
1051
+ // lost turns produced no signal at all. systemMessage is what the UI actually shows.
1052
+ writeHookOutput({ systemMessage: bailMessage(why, input) });
1038
1053
  return;
1039
1054
  }
1040
1055
 
@@ -1160,6 +1175,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
1160
1175
  main();
1161
1176
  } catch (error) {
1162
1177
  process.stderr.write(`[wendkeep] Stop falhou: ${error.message}\n`);
1163
- writeHookOutput({});
1178
+ // Same reasoning as the identity bail: stderr is discarded by Codex. Exit stays 0 —
1179
+ // a non-zero Stop hook blocks the turn (openai/codex#21921), and trading a lost turn
1180
+ // for a stuck session is a worse deal.
1181
+ writeHookOutput({ systemMessage: bailMessage(error.message) });
1164
1182
  }
1165
1183
  }
@@ -76,6 +76,109 @@ const round4 = (n) => Math.round((Number(n) || 0) * 10000) / 10000;
76
76
 
77
77
  // Aggregate every subagent transcript under <sessionDir>/subagents. null when the dir is
78
78
  // absent (Codex / a session with no subagents) or nothing parseable.
79
+ // --- Codex discovery ----------------------------------------------------------
80
+ // A Codex subagent is not a file under `<transcript>/subagents/` — it is a SIBLING rollout
81
+ // in ~/.codex/sessions/YYYY/MM/DD/, whose session_meta declares source.subagent and points
82
+ // back via parent_thread_id. Without this, every Codex session closed with subagents_count 0,
83
+ // live (SubagentStop) and on import alike.
84
+
85
+ // First line of a rollout, bounded — Codex meta lines can be large (env, git, instructions).
86
+ function readRolloutMeta(path, maxBytes = 4 * 1024 * 1024) {
87
+ try {
88
+ const text = readFileSync(path, 'utf-8');
89
+ if (text.length > maxBytes) return null;
90
+ const line = text.slice(0, text.indexOf('\n') === -1 ? text.length : text.indexOf('\n'));
91
+ const e = JSON.parse(line);
92
+ return e.type === 'session_meta' ? (e.payload || {}) : null;
93
+ } catch { return null; }
94
+ }
95
+
96
+ // Sibling day dirs to scan: the parent rollout's own dir plus the NEXT day — a session that
97
+ // crosses midnight UTC spawns its subagent in the other day's folder, and the first real case
98
+ // (Vendiva, 23:54 UTC) missed that by six minutes.
99
+ function codexSiblingDirs(transcriptPath) {
100
+ const dir = join(transcriptPath, '..');
101
+ const m = String(dir).replace(/[\\/]+$/, '').match(/(\d{4})[\\/](\d{2})[\\/](\d{2})$/);
102
+ if (!m) return [dir];
103
+ const next = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]) + 1));
104
+ const pad = (n) => String(n).padStart(2, '0');
105
+ const nextDir = join(dir, '..', '..', '..', String(next.getUTCFullYear()), pad(next.getUTCMonth() + 1), pad(next.getUTCDate()));
106
+ return [dir, nextDir];
107
+ }
108
+
109
+ export function collectCodexSubagentUsage(transcriptPath) {
110
+ const meta = readRolloutMeta(transcriptPath);
111
+ if (!meta?.id) return null; // not a Codex rollout — the Claude path stays untouched
112
+ const canonicalId = meta.id;
113
+
114
+ const subagents = [];
115
+ const allTools = new Set();
116
+ const usageAgg = { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0, total: 0 };
117
+ const modelMap = new Map();
118
+ let count = 0;
119
+ let calls = 0;
120
+ let cost = 0;
121
+
122
+ for (const dayDir of codexSiblingDirs(transcriptPath)) {
123
+ let names;
124
+ try { names = readdirSync(dayDir); } catch { continue; }
125
+ for (const n of names) {
126
+ if (!/\.jsonl$/i.test(n)) continue;
127
+ const f = join(dayDir, n);
128
+ if (f === transcriptPath) continue;
129
+ const sib = readRolloutMeta(f);
130
+ if (!sib?.source?.subagent) continue;
131
+ const parent = sib.parent_thread_id || sib.source.subagent.thread_spawn?.parent_thread_id || '';
132
+ if (parent !== canonicalId) continue;
133
+
134
+ const summary = summarizeTokenUsage(parseTokenUsageFromTranscript(f));
135
+ if (!summary.calls) continue;
136
+ const tokens = tokensTotal(summary.totals);
137
+ for (const t of summary.tools) allTools.add(t);
138
+
139
+ subagents.push({
140
+ id: String(sib.id || basename(f, '.jsonl')).slice(0, 12),
141
+ agentType: sib.source.subagent.thread_spawn?.agent_nickname || 'codex-subagent',
142
+ workflow: null,
143
+ model: summary.models[0] || '?',
144
+ effort: summary.pensamento || '',
145
+ tools: summary.tools.length,
146
+ toolNames: summary.tools,
147
+ calls: summary.calls,
148
+ tokens,
149
+ cost: round4(summary.costs.model),
150
+ modelRows: summary.modelRows,
151
+ });
152
+
153
+ for (const row of summary.modelRows || []) {
154
+ const rowEffort = summary.pensamento || '';
155
+ const key = `${row.provider || '?'}\u0000${row.model || '?'}\u0000${rowEffort}`;
156
+ const current = modelMap.get(key) || { provider: row.provider || '?', model: row.model || '?', effort: rowEffort, calls: 0, tokens: 0, cost: 0,
157
+ usage: { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0, total: 0 } };
158
+ current.calls += row.calls || 0;
159
+ current.tokens += tokensTotal(row.usage);
160
+ current.cost += row.costs?.model || 0;
161
+ for (const k of Object.keys(current.usage)) current.usage[k] += row.usage?.[k] || 0;
162
+ modelMap.set(key, current);
163
+ }
164
+
165
+ count += 1;
166
+ calls += summary.calls;
167
+ cost += summary.costs.model;
168
+ for (const k of Object.keys(usageAgg)) usageAgg[k] += summary.totals[k] || 0;
169
+ }
170
+ }
171
+
172
+ if (!count) return null;
173
+ return {
174
+ subagents,
175
+ workflows: [],
176
+ aggregate: { count, calls, tokens: tokensTotal(usageAgg), cost: round4(cost), wasted: 0, usage: usageAgg, tools: [...allTools],
177
+ modelRows: [...modelMap.values()].map((r) => ({ ...r, cost: round4(r.cost), source: 'subagent' })),
178
+ },
179
+ };
180
+ }
181
+
79
182
  export function collectSubagentUsage(sessionDir) {
80
183
  const subDir = join(sessionDir, 'subagents');
81
184
  if (!existsSync(subDir)) return null;
@@ -612,7 +612,7 @@ const USAGE_FIELDS = ['provider', 'pensamento', 'input', 'cache_write', 'cache_r
612
612
 
613
613
  export function sameUsageData(a, b) {
614
614
  if (!a || !b) return false;
615
- const listEqual = (x, y) => (x || []).join('') === (y || []).join('');
615
+ const listEqual = (x, y) => (x || []).join('\u0000') === (y || []).join('\u0000');
616
616
  return USAGE_FIELDS.every((f) => (a[f] ?? null) === (b[f] ?? null))
617
617
  && listEqual(a.modelos, b.modelos) && listEqual(a.tools, b.tools);
618
618
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.46.0",
3
+ "version": "0.46.2",
4
4
  "description": "A persistent-memory harness for AI coding agents on your Obsidian vault: turn-by-turn session capture plus a native, zero-dependency spec→change→verify→archive loop (sensor-gated, independent verdict, mutation discrimination). Local-first, agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "bin": {