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 +51 -0
- package/hooks/import-sessions.mjs +70 -7
- package/hooks/obsidian-common.mjs +47 -1
- package/hooks/session-identity.mjs +24 -0
- package/hooks/session-stop.mjs +19 -3
- package/package.json +1 -1
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/hooks/session-stop.mjs
CHANGED
|
@@ -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
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|