wendkeep 0.16.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,30 @@ 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
+
7
31
  ## [0.16.0] — 2026-07-06
8
32
 
9
33
  Retroactive memory.
package/README.md CHANGED
@@ -95,7 +95,7 @@ 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 sessions into the vault (deduped by `session_id`). `--from <dir>` / `--since d` / `--limit n` / `--dry-run` / `--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`. |
99
99
  | `wendkeep lesson add "t" "l"` | Record a project-local lesson (injected at the next SessionStart). |
100
100
  | `wendkeep sync-defs` | Copy `.brain/agents\|skills` into the project (`.codex/agents`, `.claude/skills`). |
101
101
  | `wendkeep validate-memory [path]` | Validate `.brain/CORE.md` (cap 25, 3 sections, no secrets/PII). |
@@ -104,17 +104,18 @@ No re‑copying, no snapshot to re‑sync — the package is the single source o
104
104
 
105
105
  ## Retroactive memory (`import`)
106
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 transcripts (`~/.claude/projects/<slug>/*.jsonl`) and rebuilds each one as a full session note in its **real** date folder — frontmatter, 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.
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
108
 
109
109
  ```bash
110
- wendkeep import --vault .myproject-vault --dry-run # preview what would be imported
110
+ wendkeep import --vault .myproject-vault --dry-run # preview what would be imported (both agents)
111
111
  wendkeep import --vault .myproject-vault # write the notes
112
+ wendkeep import --vault .myproject-vault --source codex # just Codex
112
113
  ```
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`.
114
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.
115
- - **`--from <dir>`** points at the `.claude/projects/<slug>/` folder explicitly (use it if the auto-derived path misses). Also: `--since <date>`, `--limit <n>`, `--json`.
116
- - Once imported, `wendkeep cost` aggregates your entire history — retroactively.
117
- - v1 covers Claude Code; Codex import is a follow-up.
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.
118
119
 
119
120
  ## Change lifecycle — the a2 loop (spec‑driven, native)
120
121
 
@@ -4,7 +4,7 @@
4
4
  // dated folder, deduped by session_id against the vault's SESSION_REGISTRY. This is an offline
5
5
  // replay of the live capture flow — same skeleton, same iteration blocks, same cost/subagent
6
6
  // telemetry, same finalize — so an imported note is indistinguishable from a captured one.
7
- import { existsSync, readdirSync, writeFileSync } from 'fs';
7
+ import { existsSync, readdirSync, writeFileSync, openSync, readSync, closeSync } from 'fs';
8
8
  import { basename, join } from 'path';
9
9
  import {
10
10
  parseTranscript,
@@ -48,6 +48,74 @@ export function discoverTranscripts(projectPath, fromDir) {
48
48
  return { dir, transcripts };
49
49
  }
50
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
+
51
119
  // Session objective for the note title/frontmatter: first real user prompt, one line.
52
120
  function deriveSummary(tx) {
53
121
  for (const turn of tx.turns || []) {
@@ -66,19 +134,22 @@ function toDate(ts, fallback) {
66
134
 
67
135
  // Replay one transcript into a fresh, finalized note. Returns {sessionId, relPath, turns} or
68
136
  // null when the transcript has no memorializable turns. Assumes the caller already deduped.
69
- export function importSession(vaultBase, txPath, preParsed) {
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;
70
141
  const tx = preParsed || parseTranscript(txPath);
71
142
  const turns = tx.turns || [];
72
143
  if (!turns.length) return null;
73
144
 
74
- const sessionId = tx.sessionId || basename(String(txPath), '.jsonl');
145
+ const sessionId = sessionIdOverride || tx.sessionId || basename(String(txPath), '.jsonl');
75
146
  const startDate = toDate(turns[0].timestamp, new Date());
76
147
  const endDate = toDate(turns.at(-1).timestamp, startDate);
77
148
  const summary = deriveSummary(tx);
78
149
 
79
- // Skeleton in the real dated folder.
150
+ // Skeleton in the real dated folder, tagged with the transcript's own provider.
80
151
  const { absPath, relPath } = allocateSessionPath(vaultBase, startDate, summary);
81
- writeFileSync(absPath, buildSessionContent({ relPath, now: startDate, summary }), 'utf-8');
152
+ writeFileSync(absPath, buildSessionContent({ relPath, now: startDate, summary, provider: tx.provider }), 'utf-8');
82
153
 
83
154
  // One iteration block per turn (insertIteration dedups by turn marker -> re-import safe).
84
155
  for (const turn of turns) {
@@ -115,14 +186,29 @@ export function importSession(vaultBase, txPath, preParsed) {
115
186
  return { sessionId, relPath, turns: turns.length };
116
187
  }
117
188
 
118
- // Import every not-yet-captured transcript. Deduped by session_id against the registry.
119
- // Options: { projectPath, from, since (ISO/date), limit, dryRun }.
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.
120
193
  export function runImport(vaultBase, opts = {}) {
121
- const { projectPath = process.cwd(), from = '', since = '', limit = 0, dryRun = false } = opts;
122
- const { dir, transcripts } = discoverTranscripts(projectPath, from);
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
+ }
123
209
  const captured = new Set(Object.keys(readSessionRegistry(vaultBase).sessions || {}));
124
210
  const sinceMs = since ? Date.parse(since) : 0;
125
- const report = { dir, scanned: transcripts.length, imported: 0, skipped: 0, errors: [], sessions: [] };
211
+ const report = { source: src, claudeDir, codexDir, scanned: transcripts.length, imported: 0, skipped: 0, errors: [], sessions: [] };
126
212
 
127
213
  let done = 0;
128
214
  for (const t of transcripts) {
@@ -153,7 +239,7 @@ export function runImport(vaultBase, opts = {}) {
153
239
  }
154
240
 
155
241
  try {
156
- const r = importSession(vaultBase, t.path, tx);
242
+ const r = importSession(vaultBase, t.path, { tx, sessionId: t.sessionId });
157
243
  if (r) { report.sessions.push(r); report.imported++; done++; }
158
244
  else { report.skipped++; }
159
245
  } catch (error) {
@@ -31,12 +31,14 @@ import {
31
31
  yamlQuote,
32
32
  } from './obsidian-common.mjs';
33
33
 
34
- export function buildSessionContent({ relPath, now, summary = 'session' }) {
34
+ export function buildSessionContent({ relPath, now, summary = 'session', provider: providerId }) {
35
35
  const date = formatDate(now);
36
36
  const startedAt = formatLocalIso(now);
37
37
  const titleTime = formatTime(now).slice(0, 5);
38
38
  const objective = summary === 'session' ? 'Preencher durante a sessão.' : summary;
39
- 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);
40
42
 
41
43
  return `---
42
44
  type: session
@@ -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: '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.16.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/import.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // by session_id). One command backfills your whole history: cost, subagents, iterations.
4
4
  import { existsSync } from 'node:fs';
5
5
  import { isAbsolute, resolve } from 'node:path';
6
- import { runImport, defaultClaudeProjectsDir } from '../hooks/import-sessions.mjs';
6
+ import { runImport } from '../hooks/import-sessions.mjs';
7
7
 
8
8
  function opt(argv, name) {
9
9
  const i = argv.indexOf(name);
@@ -20,25 +20,24 @@ export function runImportCli(argv) {
20
20
 
21
21
  const projectRaw = opt(argv, '--project') || process.cwd();
22
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
+ }
23
28
  const from = opt(argv, '--from') || '';
29
+ const codexFrom = opt(argv, '--codex-from') || '';
24
30
  const since = opt(argv, '--since') || '';
25
31
  const limit = Number(opt(argv, '--limit')) || 0;
26
32
  const dryRun = argv.includes('--dry-run');
27
33
 
28
- const sourceDir = from || defaultClaudeProjectsDir(projectPath);
29
- if (!from && (!sourceDir || !existsSync(sourceDir))) {
30
- process.stderr.write(`wendkeep import: no Claude transcripts for this project.\n`);
31
- process.stderr.write(` Looked in: ${sourceDir || '(home not resolved)'}\n`);
32
- process.stderr.write(` Pass --from <dir> pointing at .claude/projects/<slug>/ explicitly.\n`);
33
- process.exit(2);
34
- }
35
-
36
- const report = runImport(vaultBase, { projectPath, from, since, limit, dryRun });
34
+ const report = runImport(vaultBase, { projectPath, source, from, codexFrom, since, limit, dryRun });
37
35
 
38
36
  if (argv.includes('--json')) { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); process.exit(0); }
39
37
 
40
38
  const verb = dryRun ? 'importaria' : 'importadas';
41
- process.stdout.write(`Fonte: ${report.dir}\n`);
39
+ if (report.claudeDir) process.stdout.write(`Claude: ${report.claudeDir}\n`);
40
+ if (report.codexDir) process.stdout.write(`Codex: ${report.codexDir}\n`);
42
41
  process.stdout.write(`${report.scanned} transcript(s) · ${report.imported} ${verb} · ${report.skipped} já no vault\n`);
43
42
  for (const s of report.sessions) {
44
43
  const tag = s.dryRun ? '(dry-run)' : '→';