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 +85 -0
- package/hooks/import-sessions.mjs +87 -8
- package/hooks/obsidian-common.mjs +66 -1
- package/hooks/session-identity.mjs +24 -0
- package/hooks/session-observability.mjs +6 -2
- package/hooks/session-stop.mjs +25 -7
- package/hooks/subagent-usage.mjs +103 -0
- package/hooks/token-usage.mjs +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/hooks/session-stop.mjs
CHANGED
|
@@ -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
|
-
||
|
|
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
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
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
|
}
|
package/hooks/subagent-usage.mjs
CHANGED
|
@@ -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;
|
package/hooks/token-usage.mjs
CHANGED
|
@@ -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('
|
|
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.
|
|
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": {
|