wendkeep 0.46.1 → 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 +34 -0
- package/hooks/import-sessions.mjs +19 -3
- package/hooks/obsidian-common.mjs +19 -0
- package/hooks/session-observability.mjs +6 -2
- package/hooks/session-stop.mjs +6 -4
- 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,40 @@ 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
|
+
|
|
7
41
|
## [0.46.1] — 2026-07-19
|
|
8
42
|
|
|
9
43
|
### Fixed
|
|
@@ -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, isBootstrapPrompt } 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
|
|
|
@@ -192,7 +192,12 @@ export function discoverCodexTranscripts(projectPath, fromDir) {
|
|
|
192
192
|
const meta = readSessionMeta(path);
|
|
193
193
|
if (!meta || !meta.id) continue;
|
|
194
194
|
if (projectPath && !cwdMatchesProject(meta.cwd, projectPath)) continue;
|
|
195
|
-
|
|
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 });
|
|
196
201
|
}
|
|
197
202
|
return { dir, transcripts };
|
|
198
203
|
}
|
|
@@ -350,10 +355,21 @@ export function runImport(vaultBase, opts = {}) {
|
|
|
350
355
|
}
|
|
351
356
|
const notes = capturedSessionNotes(vaultBase);
|
|
352
357
|
const sinceMs = since ? Date.parse(since) : 0;
|
|
353
|
-
const report = { source: src, claudeDir, codexDir, scanned: transcripts.length, imported: 0, repaired: 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: [] };
|
|
354
359
|
|
|
355
360
|
let done = 0;
|
|
356
361
|
for (const t of transcripts) {
|
|
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
|
+
}
|
|
357
373
|
if (limit && done >= limit) break;
|
|
358
374
|
|
|
359
375
|
// Every transcript is parsed now, including already-captured ones: partial coverage is
|
|
@@ -355,6 +355,25 @@ function meaningfulPatch(patch = {}) {
|
|
|
355
355
|
}));
|
|
356
356
|
}
|
|
357
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
|
+
|
|
358
377
|
export function upsertSessionRegistry(vaultBase, sessionId, patch) {
|
|
359
378
|
if (!sessionId) return null;
|
|
360
379
|
const clean = meaningfulPatch(patch);
|
|
@@ -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)
|
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": {
|