wendkeep 0.46.0 → 0.46.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,57 @@ 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.1] — 2026-07-19
8
+
9
+ ### Fixed
10
+
11
+ - **Turnos do Codex sumiam da nota sem nenhum aviso.** No Windows o Codex serializa o payload
12
+ do `Stop` com o campo `last_assistant_message` cortado no meio, sem fechar a string JSON —
13
+ bug upstream ainda aberto ([openai/codex#23784](https://github.com/openai/codex/issues/23784)).
14
+ Sessão em português enche esse campo de acento, então o corte é frequente. O
15
+ `readHookInput` fazia `JSON.parse` cru, lançava, e o `session-stop` saía com código 0
16
+ escrevendo só no stderr — que o Codex descarta. Resultado: a nota era criada, o summary
17
+ atualizava a cada prompt, e nenhuma iteração jamais entrava. Só o `Stop` quebrava porque
18
+ `last_assistant_message` é o único campo exclusivo dele; `SessionStart` e
19
+ `UserPromptSubmit` não o carregam.
20
+ Como esse campo é o **último** do `StopCommandInput`, tudo que o wendkeep consome
21
+ (`session_id`, `turn_id`, `transcript_path`, `cwd`) está no prefixo bem-formado. O
22
+ `readHookInput` passa a recuperar esse prefixo numa passada só, descartando o campo
23
+ truncado — nunca reconstruindo-o, porque metade de uma mensagem é dado inventado.
24
+ - **O hook parou de falhar em silêncio.** Todo caminho de bail do `session-stop` agora emite
25
+ `systemMessage`, que a UI do Codex mostra, com o motivo e o comando de recuperação. O exit
26
+ code continua 0 de propósito: hook de `Stop` que sai diferente de zero trava o turno
27
+ (openai/codex#21921), e trocar turno perdido por sessão travada é pior negócio.
28
+ - **`resolveSessionIdentity` passa a usar o `SESSION_REGISTRY` como fonte do
29
+ `transcript_path`** quando o payload não o traz. O registry já tinha o mapeamento; o lookup
30
+ é que ficava abaixo do gate, inalcançável justo no caso que resolveria. A entrada precisa
31
+ ser do mesmo provider, o que preserva o invariante do incidente de contaminação
32
+ cross-provider de 2026-07-11.
33
+ - **`wendkeep import` deixou de ser cego para a sessão danificada.** O dedup perguntava
34
+ "existe registro?", não "existe conteúdo?" — e como o `session-start` registra antes do
35
+ `session-stop` escrever, **as sessões esvaziadas pelo bug acima eram exatamente as que o
36
+ comando de recuperação se recusava a consertar.** Agora a decisão compara os turnos do
37
+ transcript com os marcadores `wk-turn` já na nota: cobertura completa pula, parcial ou
38
+ vazia completa a nota existente sem criar uma segunda. Sem flag opt-in — quem roda `import`
39
+ depois de perder sessão não tem como saber que precisaria de uma. O relatório ganhou a
40
+ categoria `repaired`, separada de `imported` (nota nova) e de `skipped` (já completa).
41
+ - **Sessões importadas ganhavam título de bloco injetado pelo harness.** Seis notas de um
42
+ mesmo projeto ficaram chamadas `<recommended_plugins> Here is a list of plugins that ar`,
43
+ no frontmatter e no nome do arquivo. Causa de uma linha: `buildIterationBlock` seleciona
44
+ `userPrompts.at(-1)` e o `deriveSummary` usava `.find(Boolean)` — o harness injeta o bloco
45
+ como **primeiro** prompt do turno e o pedido do usuário vem por **último**. Mesmo dado,
46
+ ponta oposta. As duas seleções agora são a mesma, com `isBootstrapPrompt` (que passou a
47
+ reconhecer `<recommended_plugins>`) como rede, aplicado ao prompt inteiro e não linha a
48
+ linha — filtrar por linha cairia na linha seguinte do próprio bloco injetado.
49
+
50
+ ### Recuperação
51
+
52
+ - Quem perdeu turnos de sessões Codex antes desta versão recupera com
53
+ `wendkeep import --source codex`. O rollout do Codex fica íntegro em disco, e o import agora
54
+ completa a nota existente em vez de pulá-la. Rodar mais de uma vez é no-op.
55
+ - Notas já criadas com título poluído **não** são renomeadas automaticamente: mexer em nome de
56
+ arquivo quebra wikilink e reorganiza o grafo, e isso é decisão do dono do vault.
57
+
7
58
  ## [0.46.0] — 2026-07-18
8
59
 
9
60
  ### 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, 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: [] };
@@ -168,9 +198,14 @@ export function discoverCodexTranscripts(projectPath, fromDir) {
168
198
  }
169
199
 
170
200
  // 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);
201
+ // Mirrors buildIterationBlock's selection (`userPrompts.at(-1)`) on purpose: the harness
202
+ // injects preamble as the FIRST prompt of a turn and the user's request lands LAST, so taking
203
+ // the first titled six Vendiva sessions "<recommended_plugins> Here is a list of plugins".
204
+ // isBootstrapPrompt is the belt: it runs per whole prompt, not per line — filtering by line
205
+ // would fall through to the next line of the SAME injected block.
206
+ export function deriveSummary(tx) {
207
+ for (const turn of tx?.turns || []) {
208
+ const prompt = (turn.userPrompts || []).filter((p) => p && !isBootstrapPrompt(p)).at(-1);
174
209
  if (prompt) {
175
210
  return prompt.replace(/[\r\n#]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80) || 'session';
176
211
  }
@@ -313,15 +348,18 @@ export function runImport(vaultBase, opts = {}) {
313
348
  codexDir = d.dir;
314
349
  transcripts.push(...d.transcripts);
315
350
  }
316
- const captured = capturedSessionIds(vaultBase);
351
+ const notes = capturedSessionNotes(vaultBase);
317
352
  const sinceMs = since ? Date.parse(since) : 0;
318
- const report = { source: src, claudeDir, codexDir, scanned: transcripts.length, imported: 0, skipped: 0, errors: [], sessions: [] };
353
+ const report = { source: src, claudeDir, codexDir, scanned: transcripts.length, imported: 0, repaired: 0, skipped: 0, errors: [], sessions: [] };
319
354
 
320
355
  let done = 0;
321
356
  for (const t of transcripts) {
322
- if (captured.has(t.sessionId)) { report.skipped++; continue; }
323
357
  if (limit && done >= limit) break;
324
358
 
359
+ // Every transcript is parsed now, including already-captured ones: partial coverage is
360
+ // undetectable without the turn list. The old presence-only check was cheaper but made
361
+ // the recovery command blind to exactly the sessions it exists to repair. Narrow a large
362
+ // vault with --since / --limit.
325
363
  let tx;
326
364
  try {
327
365
  tx = parseTranscript(t.path);
@@ -332,6 +370,31 @@ export function runImport(vaultBase, opts = {}) {
332
370
  const turns = tx.turns || [];
333
371
  if (!turns.length) { report.skipped++; continue; }
334
372
 
373
+ const existingNote = notes.get(t.sessionId);
374
+ if (existingNote) {
375
+ const have = noteTurnIds(existingNote);
376
+ const missing = turns.filter((turn) => !have.has(String(turn.turnId)));
377
+ if (!missing.length) { report.skipped++; continue; }
378
+ if (dryRun) {
379
+ report.sessions.push({ sessionId: t.sessionId, turns: missing.length, repaired: true, dryRun: true });
380
+ report.repaired++;
381
+ done++;
382
+ continue;
383
+ }
384
+ try {
385
+ for (const turn of missing) {
386
+ insertIteration(existingNote, buildIterationBlock(tx, { turn_id: turn.turnId, now: turn.timestamp }), turn.turnId, tx);
387
+ }
388
+ try { updateSessionObservability({ sessionPath: existingNote, transcriptPath: t.path }); } catch { /* best-effort */ }
389
+ report.sessions.push({ sessionId: t.sessionId, turns: missing.length, repaired: true });
390
+ report.repaired++;
391
+ done++;
392
+ } catch (error) {
393
+ report.errors.push({ sessionId: t.sessionId, error: error.message });
394
+ }
395
+ continue;
396
+ }
397
+
335
398
  const startTs = turns[0].timestamp || '';
336
399
  if (sinceMs && startTs && Number.isFinite(Date.parse(startTs)) && Date.parse(startTs) < sinceMs) {
337
400
  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 = {}) {
@@ -503,6 +545,10 @@ export function isBootstrapPrompt(text = '') {
503
545
  return clean.startsWith('# AGENTS.md instructions')
504
546
  || clean.startsWith('<environment_context>')
505
547
  || clean.startsWith('<permissions instructions>')
548
+ // Codex injects the available-plugins catalogue as the first userPrompt of turn 1.
549
+ // Anchored with startsWith on purpose: matching the bare substring would discard a
550
+ // legitimate prompt that merely asks about plugins.
551
+ || clean.startsWith('<recommended_plugins>')
506
552
  || clean.includes('You are Codex, a coding agent')
507
553
  || clean.startsWith('## Memory');
508
554
  }
@@ -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
  }
@@ -584,6 +584,16 @@ function formatTokenLine(usage, model) {
584
584
  return cost ? `${line} — ≈ API equivalente (não é cobrança do plano)` : line;
585
585
  }
586
586
 
587
+ // User-facing explanation for a turn that could not be memorialized. Names the upstream bug
588
+ // when the payload arrived salvaged, because "wendkeep didn't record it" reads as a wendkeep
589
+ // defect and the user would have nowhere to look.
590
+ export function bailMessage(why, input = {}) {
591
+ const truncated = input._wkSalvaged
592
+ ? ' O payload do Stop chegou truncado (openai/codex#23784).'
593
+ : '';
594
+ return `[wendkeep] Turno não registrado: ${why}.${truncated} Recupere com \`wendkeep import --source codex\`.`;
595
+ }
596
+
587
597
  export function buildIterationBlock(tx, input) {
588
598
  const turnId = input.turn_id || tx.latestTurnId || `${Date.now()}`;
589
599
  const turn = selectTurn(tx, turnId);
@@ -1033,8 +1043,11 @@ function main() {
1033
1043
  const transcriptPath = input.transcript_path || input.transcriptPath || '';
1034
1044
  const { identity, entry } = resolveSessionEntry(vaultBase, input);
1035
1045
  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({});
1046
+ const why = identity.diagnostics?.join('; ') || 'sessão não registrada';
1047
+ process.stderr.write(`[wendkeep] Stop sem identidade segura: ${why}\n`);
1048
+ // stderr alone is a black hole here: Codex discards it, which is how an entire session of
1049
+ // lost turns produced no signal at all. systemMessage is what the UI actually shows.
1050
+ writeHookOutput({ systemMessage: bailMessage(why, input) });
1038
1051
  return;
1039
1052
  }
1040
1053
 
@@ -1160,6 +1173,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
1160
1173
  main();
1161
1174
  } catch (error) {
1162
1175
  process.stderr.write(`[wendkeep] Stop falhou: ${error.message}\n`);
1163
- writeHookOutput({});
1176
+ // Same reasoning as the identity bail: stderr is discarded by Codex. Exit stays 0 —
1177
+ // a non-zero Stop hook blocks the turn (openai/codex#21921), and trading a lost turn
1178
+ // for a stuck session is a worse deal.
1179
+ writeHookOutput({ systemMessage: bailMessage(error.message) });
1164
1180
  }
1165
1181
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.46.0",
3
+ "version": "0.46.1",
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": {