wendkeep 0.14.0 → 0.17.0

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,62 @@ 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.17.0] — 2026-07-06
8
+
9
+ Retroactive memory, now agent-agnostic (Codex).
10
+
11
+ ### Added
12
+ - **`wendkeep import --source codex|all`** — import now covers **Codex** too. Codex rollouts
13
+ (`~/.codex/sessions/**`) aren't organized by project, so they're scoped by the `cwd` recorded
14
+ in each session's `session_meta` — matched case- and separator-insensitively, including
15
+ subdirectories. `--source` defaults to **`all`** (both agents); narrow with `claude` / `codex`.
16
+ `--codex-from <dir>` overrides the sessions root.
17
+ - Transcript parsers now carry a `provider` field, so an imported note is tagged with the
18
+ transcript's **real** provider (`provider: codex` for Codex) instead of the ambient default.
19
+
20
+ ### Changed
21
+ - `wendkeep import` default source is now **`all`** (was Claude-only in 0.16.0). Still idempotent —
22
+ already-imported sessions are skipped by `session_id`, per project (Claude by slug dir, Codex by
23
+ `session_meta.cwd`).
24
+ - Import registration keys off the **discovered** `session_id` (filename for Claude,
25
+ `session_meta.id` for Codex) so the dedup key and the registry key are always identical —
26
+ closes a latent duplicate-on-reimport gap if a transcript's filename ever diverged from its
27
+ internal id.
28
+ - Validated on real data: **24** Codex sessions discovered for a production project (across
29
+ drive-case variants), 0 parse errors, notes correctly tagged `codex`.
30
+
31
+ ## [0.16.0] — 2026-07-06
32
+
33
+ Retroactive memory.
34
+
35
+ ### Added
36
+ - **`wendkeep import`** — backfill the vault with this project's *past* Claude Code sessions.
37
+ It scans `.claude/projects/<slug>/*.jsonl`, and for every session not already in the vault
38
+ (deduped by `session_id` against the `SESSION_REGISTRY`) reconstructs a full, dated session
39
+ note — frontmatter, one iteration block per turn, cost + subagent telemetry, derived
40
+ decision/bug/learning notes, and a finalized closing — placed in its **real** date folder
41
+ (`02-Sessões/<year>/<MM-MMM>/DIA <dd>/`), not today's. One command turns your whole history
42
+ into memory that `wendkeep cost` immediately aggregates.
43
+ - Offline replay of the live capture flow (same `buildSessionContent` / `insertIteration` /
44
+ `finalizeSessionFile` / usage + subagent code) so an imported note is indistinguishable
45
+ from a captured one.
46
+ - Options: `--from <dir>` (point at the `.claude/projects` folder explicitly), `--project`,
47
+ `--since <date>`, `--limit <n>`, `--dry-run` (report without writing), `--json`.
48
+ - Idempotent: re-running skips everything already imported. Never overwrites an existing note.
49
+ - v1 covers Claude Code transcripts; Codex is a follow-up.
50
+
51
+ ### Changed
52
+ - `session-start.mjs` now guards its `main()` behind the standard `import.meta.url` check (like
53
+ `session-stop.mjs`) so its note-building helpers can be imported by `import`/tests without
54
+ running the hook. No behavioral change when invoked as a hook.
55
+
56
+ ## [0.15.0] — 2026-07-06
57
+
58
+ ### Added
59
+ - **`wendkeep cost --top [N]`** — the N priciest sessions (cost incl. subagents · date · file),
60
+ most expensive first (default 10). Spot where the money went. `cost --json` now also carries
61
+ the per-session `sessions` list.
62
+
7
63
  ## [0.14.0] — 2026-07-06
8
64
 
9
65
  ### Changed
@@ -283,6 +339,7 @@ Initial release — the capture engine, extracted from a system in daily product
283
339
  - `wendkeep init` (cross-platform installer) + optional `@bitbonsai/mcpvault` MCP server.
284
340
 
285
341
  <!-- Only v0.4.0+ is tagged in git (history starts here); older versions link to npm. -->
342
+ [0.15.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.15.0
286
343
  [0.14.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.14.0
287
344
  [0.13.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.13.0
288
345
  [0.12.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.12.0
package/README.md CHANGED
@@ -95,12 +95,28 @@ No re‑copying, no snapshot to re‑sync — the package is the single source o
95
95
  | `wendkeep spec <sub>` | Living specs: `list` / `show <capability>`. |
96
96
  | `wendkeep sensors <sub>` | `list` / `add <id> "<command>"` — view/edit `wendkeep.sensors.json` (JSON Schema shipped). |
97
97
  | `wendkeep cost [--since d]` | Aggregate AI-coding spend across the vault's sessions — total, by model, by day (`--json`). |
98
+ | `wendkeep import [opts]` | **Retroactive memory** — backfill past **Claude + Codex** sessions into the vault (deduped by `session_id`). `--source all\|claude\|codex` / `--from <dir>` / `--codex-from <dir>` / `--since d` / `--limit n` / `--dry-run` / `--json`. |
98
99
  | `wendkeep lesson add "t" "l"` | Record a project-local lesson (injected at the next SessionStart). |
99
100
  | `wendkeep sync-defs` | Copy `.brain/agents\|skills` into the project (`.codex/agents`, `.claude/skills`). |
100
101
  | `wendkeep validate-memory [path]` | Validate `.brain/CORE.md` (cap 25, 3 sections, no secrets/PII). |
101
102
  | `wendkeep doctor [--vault P]` | Run a vault health check (integrity of sessions, registry, links). |
102
103
  | `wendkeep --version` / `--help` | Version / usage. |
103
104
 
105
+ ## Retroactive memory (`import`)
106
+
107
+ Install wendkeep into an existing project and it only remembers sessions **from now on**. `wendkeep import` fixes that: it reads the project's past **Claude Code and Codex** transcripts and rebuilds each one as a full session note in its **real** date folder — frontmatter (tagged with the transcript's real provider), one iteration block per turn, cost + subagent telemetry, derived decision/bug/learning notes, finalized closing. It is an offline replay of the live capture flow, so an imported note is indistinguishable from a captured one.
108
+
109
+ ```bash
110
+ wendkeep import --vault .myproject-vault --dry-run # preview what would be imported (both agents)
111
+ wendkeep import --vault .myproject-vault # write the notes
112
+ wendkeep import --vault .myproject-vault --source codex # just Codex
113
+ ```
114
+
115
+ - **Both agents by default** (`--source all`). Claude sessions come from `~/.claude/projects/<slug>/`; Codex rollouts from `~/.codex/sessions/**`, scoped to this project by the `cwd` recorded in each session (case- and separator-insensitive, subdirs included). Narrow with `--source claude` / `--source codex`.
116
+ - **Deduped** by `session_id` against the vault's `SESSION_REGISTRY` — only sessions not already present are imported, and it never overwrites an existing note. Re-running is a no-op.
117
+ - **`--from <dir>`** / **`--codex-from <dir>`** point at the transcript folders explicitly (use if the auto-derived path misses). Also: `--since <date>`, `--limit <n>`, `--json`.
118
+ - Once imported, `wendkeep cost` aggregates your entire history — retroactively, across both agents.
119
+
104
120
  ## Change lifecycle — the a2 loop (spec‑driven, native)
105
121
 
106
122
  Beyond capturing sessions, wendkeep is a **harness**: a native, zero‑dependency loop that keeps *intent* (specs), *work* (changes) and *proof* (sensors) together in the vault, wikilinked into the Obsidian graph.
package/bin/wendkeep.mjs CHANGED
@@ -47,7 +47,9 @@ Usage:
47
47
  done <id> | undone <id> | diff | archive [--force].
48
48
  wendkeep spec <sub> Living specs: list | show <capability>.
49
49
  wendkeep sensors <sub> list | add <id> "<command>" [--severity --type --report].
50
- wendkeep cost [--since d] Aggregate AI-coding spend across the vault's sessions (--json).
50
+ wendkeep cost [opts] Aggregate AI-coding spend across the vault's sessions.
51
+ wendkeep import [opts] Backfill: import this project's past Claude sessions into the vault.
52
+ --since <date> · --top [N] (priciest sessions) · --json.
51
53
  wendkeep verify [--change s] Run a change's task sensors + record evidence (the gate).
52
54
  wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
53
55
  wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
@@ -136,6 +138,11 @@ async function main() {
136
138
  runCost(rest);
137
139
  break;
138
140
  }
141
+ case 'import': {
142
+ const { runImportCli } = await import('../src/import.mjs');
143
+ runImportCli(rest);
144
+ break;
145
+ }
139
146
  case '--version':
140
147
  case '-v':
141
148
  process.stdout.write(`${version()}\n`);
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env node
2
+ // Retroactive memory: import Claude Code transcripts that predate wendkeep into the vault.
3
+ // Each `.claude/projects/<slug>/<session_id>.jsonl` becomes a full session note in its real
4
+ // dated folder, deduped by session_id against the vault's SESSION_REGISTRY. This is an offline
5
+ // replay of the live capture flow — same skeleton, same iteration blocks, same cost/subagent
6
+ // telemetry, same finalize — so an imported note is indistinguishable from a captured one.
7
+ import { existsSync, readdirSync, writeFileSync, openSync, readSync, closeSync } from 'fs';
8
+ import { basename, join } from 'path';
9
+ import {
10
+ parseTranscript,
11
+ buildIterationBlock,
12
+ insertIteration,
13
+ finalizeSessionFile,
14
+ mergeCreatedNotes,
15
+ findLinkedDerivedNotes,
16
+ } from './session-stop.mjs';
17
+ import { buildSessionContent, allocateSessionPath } from './session-start.mjs';
18
+ import { createLinkedNotes } from './linked-notes.mjs';
19
+ import { updateSessionUsage } from './token-usage.mjs';
20
+ import { upsertSubagentUsage } from './subagent-usage.mjs';
21
+ import { readSessionRegistry, upsertSessionRegistry, formatLocalIso, formatDate } from './obsidian-common.mjs';
22
+
23
+ // Claude encodes a project's absolute path as its `.claude/projects` dir name by replacing each
24
+ // path separator and the drive colon with '-'. `C:\GitHub\WendKeep` -> `C--GitHub-WendKeep`.
25
+ // Note: each char maps 1:1 (no collapsing), so `C:\` yields `C--`.
26
+ export function claudeProjectSlug(projectPath) {
27
+ return String(projectPath || '').replace(/[\\/:]/g, '-');
28
+ }
29
+
30
+ function homeDir() {
31
+ return process.env.USERPROFILE || process.env.HOME || '';
32
+ }
33
+
34
+ export function defaultClaudeProjectsDir(projectPath) {
35
+ const home = homeDir();
36
+ if (!home) return '';
37
+ return join(home, '.claude', 'projects', claudeProjectSlug(projectPath));
38
+ }
39
+
40
+ // List candidate transcripts. The Claude session_id IS the filename, so we get it without
41
+ // parsing — cheap dedup for the (usually large) set of already-captured sessions.
42
+ export function discoverTranscripts(projectPath, fromDir) {
43
+ const dir = fromDir || defaultClaudeProjectsDir(projectPath);
44
+ if (!dir || !existsSync(dir)) return { dir, transcripts: [] };
45
+ const transcripts = readdirSync(dir)
46
+ .filter((name) => /\.jsonl$/i.test(name))
47
+ .map((name) => ({ path: join(dir, name), sessionId: basename(name, '.jsonl') }));
48
+ return { dir, transcripts };
49
+ }
50
+
51
+ // --- Codex source -----------------------------------------------------------
52
+ // Codex rollouts live in ~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl and are NOT
53
+ // organized by project, so we scope them by the `cwd` recorded in each session_meta.
54
+
55
+ export function defaultCodexSessionsDir() {
56
+ const home = homeDir();
57
+ return home ? join(home, '.codex', 'sessions') : '';
58
+ }
59
+
60
+ function normPath(p) {
61
+ return String(p || '').replace(/[\\/]+/g, '/').replace(/\/+$/, '').toLowerCase();
62
+ }
63
+
64
+ // A Codex session belongs to the project when it ran from the project root or any subdir.
65
+ function cwdMatchesProject(cwd, projectPath) {
66
+ const c = normPath(cwd);
67
+ const proj = normPath(projectPath);
68
+ if (!c || !proj) return false;
69
+ return c === proj || c.startsWith(`${proj}/`);
70
+ }
71
+
72
+ // Recursively collect *.jsonl paths (manual walk: readdir recursive lands in Node 20; floor is 18).
73
+ function walkJsonl(dir, out = []) {
74
+ let entries;
75
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
76
+ for (const e of entries) {
77
+ const p = join(dir, e.name);
78
+ if (e.isDirectory()) walkJsonl(p, out);
79
+ else if (/\.jsonl$/i.test(e.name)) out.push(p);
80
+ }
81
+ return out;
82
+ }
83
+
84
+ // Read only the leading bytes to pull the session_meta payload (id + cwd); session_meta is the
85
+ // first line of a rollout, so a bounded prefix read avoids parsing multi-MB transcripts twice.
86
+ function readSessionMeta(path) {
87
+ let fd;
88
+ try {
89
+ fd = openSync(path, 'r');
90
+ const buf = Buffer.alloc(16384);
91
+ const n = readSync(fd, buf, 0, buf.length, 0);
92
+ for (const line of buf.slice(0, n).toString('utf-8').split('\n')) {
93
+ if (!line.trim()) continue;
94
+ let e;
95
+ try { e = JSON.parse(line); } catch { return null; } // partial/oversized first line -> skip
96
+ return e.type === 'session_meta' ? (e.payload || {}) : null; // meta is always line 1
97
+ }
98
+ } catch {
99
+ return null;
100
+ } finally {
101
+ if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
102
+ }
103
+ return null;
104
+ }
105
+
106
+ export function discoverCodexTranscripts(projectPath, fromDir) {
107
+ const dir = fromDir || defaultCodexSessionsDir();
108
+ if (!dir || !existsSync(dir)) return { dir, transcripts: [] };
109
+ const transcripts = [];
110
+ for (const path of walkJsonl(dir)) {
111
+ const meta = readSessionMeta(path);
112
+ if (!meta || !meta.id) continue;
113
+ if (projectPath && !cwdMatchesProject(meta.cwd, projectPath)) continue;
114
+ transcripts.push({ path, sessionId: meta.id, cwd: meta.cwd || '' });
115
+ }
116
+ return { dir, transcripts };
117
+ }
118
+
119
+ // Session objective for the note title/frontmatter: first real user prompt, one line.
120
+ function deriveSummary(tx) {
121
+ for (const turn of tx.turns || []) {
122
+ const prompt = (turn.userPrompts || []).find(Boolean);
123
+ if (prompt) {
124
+ return prompt.replace(/[\r\n#]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80) || 'session';
125
+ }
126
+ }
127
+ return 'session';
128
+ }
129
+
130
+ function toDate(ts, fallback) {
131
+ const d = ts ? new Date(ts) : null;
132
+ return d && Number.isFinite(d.getTime()) ? d : fallback;
133
+ }
134
+
135
+ // Replay one transcript into a fresh, finalized note. Returns {sessionId, relPath, turns} or
136
+ // null when the transcript has no memorializable turns. Assumes the caller already deduped.
137
+ // opts: { tx (pre-parsed), sessionId (registry key — pass the discovered id so registration
138
+ // matches the dedup key exactly; falls back to the transcript's own id) }.
139
+ export function importSession(vaultBase, txPath, opts = {}) {
140
+ const { tx: preParsed, sessionId: sessionIdOverride } = opts;
141
+ const tx = preParsed || parseTranscript(txPath);
142
+ const turns = tx.turns || [];
143
+ if (!turns.length) return null;
144
+
145
+ const sessionId = sessionIdOverride || tx.sessionId || basename(String(txPath), '.jsonl');
146
+ const startDate = toDate(turns[0].timestamp, new Date());
147
+ const endDate = toDate(turns.at(-1).timestamp, startDate);
148
+ const summary = deriveSummary(tx);
149
+
150
+ // Skeleton in the real dated folder, tagged with the transcript's own provider.
151
+ const { absPath, relPath } = allocateSessionPath(vaultBase, startDate, summary);
152
+ writeFileSync(absPath, buildSessionContent({ relPath, now: startDate, summary, provider: tx.provider }), 'utf-8');
153
+
154
+ // One iteration block per turn (insertIteration dedups by turn marker -> re-import safe).
155
+ for (const turn of turns) {
156
+ const block = buildIterationBlock(tx, { turn_id: turn.turnId, now: turn.timestamp });
157
+ insertIteration(absPath, block, turn.turnId, tx);
158
+ }
159
+
160
+ // Cost + subagent telemetry, exactly like the live Stop hook. Fail-open.
161
+ try {
162
+ updateSessionUsage({ vaultBase, sessionRel: relPath, sessionPath: absPath, transcriptPath: txPath });
163
+ } catch { /* usage is best-effort */ }
164
+ try {
165
+ upsertSubagentUsage(absPath, txPath);
166
+ } catch { /* subagent telemetry is best-effort */ }
167
+
168
+ // Finalize: derived notes + closing section + ended_at from the last turn.
169
+ const endedAt = formatLocalIso(endDate);
170
+ const created = mergeCreatedNotes(
171
+ createLinkedNotes(vaultBase, formatDate(endDate), relPath, tx),
172
+ findLinkedDerivedNotes(vaultBase, relPath),
173
+ );
174
+ finalizeSessionFile(absPath, tx, created, endedAt);
175
+
176
+ upsertSessionRegistry(vaultBase, sessionId, {
177
+ session_file: relPath,
178
+ status: 'done',
179
+ started_at: formatLocalIso(startDate),
180
+ ended_at: endedAt,
181
+ last_turn_id: turns.at(-1).turnId,
182
+ transcript_path: txPath,
183
+ imported: true,
184
+ });
185
+
186
+ return { sessionId, relPath, turns: turns.length };
187
+ }
188
+
189
+ // Import every not-yet-captured transcript from the requested source(s). Deduped by session_id
190
+ // against the registry. Options: { projectPath, source ('all'|'claude'|'codex'), from, codexFrom,
191
+ // since (ISO/date), limit, dryRun }. importSession is provider-agnostic, so both sources share
192
+ // the same dedup + import loop.
193
+ export function runImport(vaultBase, opts = {}) {
194
+ const { projectPath = process.cwd(), source = 'all', from = '', codexFrom = '', since = '', limit = 0, dryRun = false } = opts;
195
+ const src = String(source).toLowerCase();
196
+ const transcripts = [];
197
+ let claudeDir = '';
198
+ let codexDir = '';
199
+ if (src === 'all' || src === 'claude') {
200
+ const d = discoverTranscripts(projectPath, from);
201
+ claudeDir = d.dir;
202
+ transcripts.push(...d.transcripts);
203
+ }
204
+ if (src === 'all' || src === 'codex') {
205
+ const d = discoverCodexTranscripts(projectPath, codexFrom);
206
+ codexDir = d.dir;
207
+ transcripts.push(...d.transcripts);
208
+ }
209
+ const captured = new Set(Object.keys(readSessionRegistry(vaultBase).sessions || {}));
210
+ const sinceMs = since ? Date.parse(since) : 0;
211
+ const report = { source: src, claudeDir, codexDir, scanned: transcripts.length, imported: 0, skipped: 0, errors: [], sessions: [] };
212
+
213
+ let done = 0;
214
+ for (const t of transcripts) {
215
+ if (captured.has(t.sessionId)) { report.skipped++; continue; }
216
+ if (limit && done >= limit) break;
217
+
218
+ let tx;
219
+ try {
220
+ tx = parseTranscript(t.path);
221
+ } catch (error) {
222
+ report.errors.push({ sessionId: t.sessionId, error: error.message });
223
+ continue;
224
+ }
225
+ const turns = tx.turns || [];
226
+ if (!turns.length) { report.skipped++; continue; }
227
+
228
+ const startTs = turns[0].timestamp || '';
229
+ if (sinceMs && startTs && Number.isFinite(Date.parse(startTs)) && Date.parse(startTs) < sinceMs) {
230
+ report.skipped++;
231
+ continue;
232
+ }
233
+
234
+ if (dryRun) {
235
+ report.sessions.push({ sessionId: t.sessionId, turns: turns.length, startTs, dryRun: true });
236
+ report.imported++;
237
+ done++;
238
+ continue;
239
+ }
240
+
241
+ try {
242
+ const r = importSession(vaultBase, t.path, { tx, sessionId: t.sessionId });
243
+ if (r) { report.sessions.push(r); report.imported++; done++; }
244
+ else { report.skipped++; }
245
+ } catch (error) {
246
+ report.errors.push({ sessionId: t.sessionId, error: error.message });
247
+ }
248
+ }
249
+
250
+ return report;
251
+ }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, writeFileSync } from 'fs';
3
3
  import { basename, join } from 'path';
4
+ import { pathToFileURL } from 'url';
4
5
  import {
5
6
  controlPath,
6
7
  ensureDir,
@@ -30,12 +31,14 @@ import {
30
31
  yamlQuote,
31
32
  } from './obsidian-common.mjs';
32
33
 
33
- function buildSessionContent({ relPath, now, summary = 'session' }) {
34
+ export function buildSessionContent({ relPath, now, summary = 'session', provider: providerId }) {
34
35
  const date = formatDate(now);
35
36
  const startedAt = formatLocalIso(now);
36
37
  const titleTime = formatTime(now).slice(0, 5);
37
38
  const objective = summary === 'session' ? 'Preencher durante a sessão.' : summary;
38
- const provider = providerMeta();
39
+ // Import passes the transcript's provider so a Codex note is tagged codex (not the
40
+ // ambient default); undefined falls back to providerMeta's own detection.
41
+ const provider = providerMeta(providerId);
39
42
 
40
43
  return `---
41
44
  type: session
@@ -112,7 +115,7 @@ Sessão ainda em andamento.
112
115
  `;
113
116
  }
114
117
 
115
- function allocateSessionPath(vaultBase, now, summary = 'session') {
118
+ export function allocateSessionPath(vaultBase, now, summary = 'session') {
116
119
  const folderRel = sessionFolderRel(now, vaultBase);
117
120
  const folderAbs = join(vaultBase, folderRel);
118
121
  ensureDir(folderAbs);
@@ -301,11 +304,13 @@ function main() {
301
304
  });
302
305
  }
303
306
 
304
- try {
305
- main();
306
- } catch (error) {
307
- process.stderr.write(`[codex-obsidian] SessionStart falhou: ${error.message}\n`);
308
- writeHookOutput({
309
- systemMessage: `[codex-obsidian] Não foi possível criar a sessão Obsidian: ${error.message}`,
310
- });
307
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
308
+ try {
309
+ main();
310
+ } catch (error) {
311
+ process.stderr.write(`[codex-obsidian] SessionStart falhou: ${error.message}\n`);
312
+ writeHookOutput({
313
+ systemMessage: `[codex-obsidian] Não foi possível criar a sessão Obsidian: ${error.message}`,
314
+ });
315
+ }
311
316
  }
@@ -199,6 +199,7 @@ function toolArgumentText(value) {
199
199
 
200
200
  export function parseCodexTranscript(transcriptPath) {
201
201
  const result = {
202
+ provider: 'codex',
202
203
  sessionId: '',
203
204
  model: '',
204
205
  latestTurnId: '',
@@ -380,6 +381,7 @@ function claudeUserText(content) {
380
381
  // Diferente do Codex (sem `payload`), por isso precisa de parser próprio.
381
382
  export function parseClaudeTranscript(transcriptPath) {
382
383
  const result = {
384
+ provider: 'claude',
383
385
  sessionId: '',
384
386
  model: '',
385
387
  latestTurnId: '',
@@ -781,7 +783,7 @@ function noteReferencesSession(content, sessionRel) {
781
783
  return content.includes(sessionRel) || content.includes(sessionLink);
782
784
  }
783
785
 
784
- function findLinkedDerivedNotes(vaultBase, sessionRel) {
786
+ export function findLinkedDerivedNotes(vaultBase, sessionRel) {
785
787
  const linked = { decisions: [], bugs: [], learnings: [] };
786
788
  const locF = getLocale(vaultBase).folders;
787
789
  const folders = {
@@ -808,7 +810,7 @@ function findLinkedDerivedNotes(vaultBase, sessionRel) {
808
810
  return linked;
809
811
  }
810
812
 
811
- function mergeCreatedNotes(created, linked) {
813
+ export function mergeCreatedNotes(created, linked) {
812
814
  const merged = { decisions: [], bugs: [], learnings: [] };
813
815
  for (const key of Object.keys(merged)) {
814
816
  merged[key] = [...new Set([...(created[key] || []), ...(linked[key] || [])])];
@@ -861,7 +863,7 @@ function replaceClosingSection(content, closing) {
861
863
  return `${content.slice(0, index).trimEnd()}\n\n${closing}\n`;
862
864
  }
863
865
 
864
- function finalizeSessionFile(sessionPath, tx, created, endedAt) {
866
+ export function finalizeSessionFile(sessionPath, tx, created, endedAt) {
865
867
  const pending = extractPending(tx.rawTextForDetection);
866
868
  const links = (items) => items.length ? items.map((rel) => ` - ${wikilinkFromRel(rel)}`).join('\n') : ' - Nenhuma';
867
869
  const summary = tx.latestAssistantMessage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.14.0",
3
+ "version": "0.17.0",
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": {
package/src/cost.mjs CHANGED
@@ -76,9 +76,15 @@ export function collectVaultCost(vaultBase, { since } = {}) {
76
76
  try { e = parseSessionCost(readFileSync(f, 'utf8')); } catch { continue; }
77
77
  if (!e) continue;
78
78
  if (since && e.date && e.date < since) continue;
79
+ e.file = f.slice(vaultBase.length + 1).replace(/\\/g, '/');
79
80
  entries.push(e);
80
81
  }
81
- return aggregateCosts(entries);
82
+ const agg = aggregateCosts(entries);
83
+ // Per-session list (main+sub), most expensive first — powers `cost --top`.
84
+ agg.sessions = entries
85
+ .map((e) => ({ file: e.file, date: e.date, cost: round4(e.mainCost + e.subCost) }))
86
+ .sort((a, b) => b.cost - a.cost);
87
+ return agg;
82
88
  }
83
89
 
84
90
  function opt(argv, name) {
@@ -97,6 +103,14 @@ export function runCost(argv) {
97
103
 
98
104
  if (argv.includes('--json')) { process.stdout.write(`${JSON.stringify(agg, null, 2)}\n`); process.exit(0); }
99
105
 
106
+ const topIdx = argv.indexOf('--top');
107
+ if (topIdx >= 0) {
108
+ const n = Number(argv[topIdx + 1]) || 10;
109
+ process.stdout.write(`Sessões mais caras (top ${n} de ${agg.count}):\n`);
110
+ for (const s of agg.sessions.slice(0, n)) process.stdout.write(` ${usd(s.cost).padStart(11)} ${s.date || '?'} ${s.file}\n`);
111
+ process.exit(0);
112
+ }
113
+
100
114
  process.stdout.write(`Custo total (vault): ${usd(agg.total)} — ${agg.count} sessão(ões) · ${usd(agg.avg)}/sessão\n`);
101
115
  process.stdout.write(` main: ${usd(agg.main)} · subagents: ${usd(agg.sub)}\n`);
102
116
  if (agg.wasted) process.stdout.write(` desperdiçado (runs killed/failed): ${usd(agg.wasted)}\n`);
package/src/import.mjs ADDED
@@ -0,0 +1,52 @@
1
+ // `wendkeep import` — retroactive memory. Scans this project's Claude Code transcripts and
2
+ // turns every session that isn't already in the vault into a full, dated session note (deduped
3
+ // by session_id). One command backfills your whole history: cost, subagents, iterations.
4
+ import { existsSync } from 'node:fs';
5
+ import { isAbsolute, resolve } from 'node:path';
6
+ import { runImport } from '../hooks/import-sessions.mjs';
7
+
8
+ function opt(argv, name) {
9
+ const i = argv.indexOf(name);
10
+ if (i >= 0) return argv[i + 1];
11
+ const eq = argv.find((a) => a.startsWith(`${name}=`));
12
+ return eq ? eq.slice(name.length + 1) : undefined;
13
+ }
14
+
15
+ export function runImportCli(argv) {
16
+ const vaultRaw = opt(argv, '--vault') || process.env.OBSIDIAN_VAULT_PATH;
17
+ if (!vaultRaw) { process.stderr.write('wendkeep import: no vault (--vault or OBSIDIAN_VAULT_PATH).\n'); process.exit(2); }
18
+ const vaultBase = isAbsolute(vaultRaw) ? vaultRaw : resolve(process.cwd(), vaultRaw);
19
+ if (!existsSync(vaultBase)) { process.stderr.write(`wendkeep import: vault not found: ${vaultBase}\n`); process.exit(2); }
20
+
21
+ const projectRaw = opt(argv, '--project') || process.cwd();
22
+ const projectPath = isAbsolute(projectRaw) ? projectRaw : resolve(process.cwd(), projectRaw);
23
+ const source = (opt(argv, '--source') || 'all').toLowerCase();
24
+ if (!['all', 'claude', 'codex'].includes(source)) {
25
+ process.stderr.write(`wendkeep import: --source must be all | claude | codex (got "${source}").\n`);
26
+ process.exit(2);
27
+ }
28
+ const from = opt(argv, '--from') || '';
29
+ const codexFrom = opt(argv, '--codex-from') || '';
30
+ const since = opt(argv, '--since') || '';
31
+ const limit = Number(opt(argv, '--limit')) || 0;
32
+ const dryRun = argv.includes('--dry-run');
33
+
34
+ const report = runImport(vaultBase, { projectPath, source, from, codexFrom, since, limit, dryRun });
35
+
36
+ if (argv.includes('--json')) { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); process.exit(0); }
37
+
38
+ const verb = dryRun ? 'importaria' : 'importadas';
39
+ if (report.claudeDir) process.stdout.write(`Claude: ${report.claudeDir}\n`);
40
+ if (report.codexDir) process.stdout.write(`Codex: ${report.codexDir}\n`);
41
+ process.stdout.write(`${report.scanned} transcript(s) · ${report.imported} ${verb} · ${report.skipped} já no vault\n`);
42
+ for (const s of report.sessions) {
43
+ const tag = s.dryRun ? '(dry-run)' : '→';
44
+ process.stdout.write(` ${tag} ${s.sessionId} ${s.turns} turno(s)${s.relPath ? ` ${s.relPath}` : ''}\n`);
45
+ }
46
+ if (report.errors.length) {
47
+ process.stdout.write(`\n${report.errors.length} com erro (pulados):\n`);
48
+ for (const e of report.errors) process.stdout.write(` ${e.sessionId}: ${e.error}\n`);
49
+ }
50
+ if (dryRun) process.stdout.write('\nNada foi escrito (--dry-run). Rode sem --dry-run para criar as notas.\n');
51
+ process.exit(0);
52
+ }
package/src/taxonomy.mjs CHANGED
@@ -43,6 +43,7 @@ export const HOOK_FILES = [
43
43
  'brain-recall.mjs',
44
44
  'brain-reindex.mjs',
45
45
  'session-backfill.mjs',
46
+ 'import-sessions.mjs',
46
47
  'vault-health.mjs',
47
48
  'understand-inject.mjs',
48
49
  ];