wendkeep 0.16.0 → 0.18.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 +43 -0
- package/README.md +9 -7
- package/hooks/import-sessions.mjs +153 -12
- package/hooks/session-start.mjs +8 -5
- package/hooks/session-stop.mjs +2 -0
- package/package.json +1 -1
- package/src/import.mjs +18 -11
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,49 @@ 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.18.0] — 2026-07-06
|
|
8
|
+
|
|
9
|
+
Session identity in the note.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Session notes now carry **`session_id`** in their frontmatter — both live capture and import,
|
|
13
|
+
Claude and Codex. Pairs with the existing `provider:` field so every note self-identifies
|
|
14
|
+
(which conversation, which agent) without consulting the registry.
|
|
15
|
+
- **`wendkeep import --stamp-ids`** — backfill `session_id` into existing notes from the
|
|
16
|
+
`SESSION_REGISTRY` (for notes captured or imported before the field existed). Idempotent;
|
|
17
|
+
only touches notes missing the field.
|
|
18
|
+
- Import dedup now also scans existing notes' `session_id` (`capturedSessionIds` = registry ∪
|
|
19
|
+
note frontmatter), so a session that already has a note on disk is never re-imported even if
|
|
20
|
+
the registry was reset or lost.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- `buildSessionContent` accepts a `sessionId`; the SessionStart hook (all three create/recreate
|
|
24
|
+
paths) and `importSession` thread the id through, so a note records its identity at creation.
|
|
25
|
+
|
|
26
|
+
## [0.17.0] — 2026-07-06
|
|
27
|
+
|
|
28
|
+
Retroactive memory, now agent-agnostic (Codex).
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- **`wendkeep import --source codex|all`** — import now covers **Codex** too. Codex rollouts
|
|
32
|
+
(`~/.codex/sessions/**`) aren't organized by project, so they're scoped by the `cwd` recorded
|
|
33
|
+
in each session's `session_meta` — matched case- and separator-insensitively, including
|
|
34
|
+
subdirectories. `--source` defaults to **`all`** (both agents); narrow with `claude` / `codex`.
|
|
35
|
+
`--codex-from <dir>` overrides the sessions root.
|
|
36
|
+
- Transcript parsers now carry a `provider` field, so an imported note is tagged with the
|
|
37
|
+
transcript's **real** provider (`provider: codex` for Codex) instead of the ambient default.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
- `wendkeep import` default source is now **`all`** (was Claude-only in 0.16.0). Still idempotent —
|
|
41
|
+
already-imported sessions are skipped by `session_id`, per project (Claude by slug dir, Codex by
|
|
42
|
+
`session_meta.cwd`).
|
|
43
|
+
- Import registration keys off the **discovered** `session_id` (filename for Claude,
|
|
44
|
+
`session_meta.id` for Codex) so the dedup key and the registry key are always identical —
|
|
45
|
+
closes a latent duplicate-on-reimport gap if a transcript's filename ever diverged from its
|
|
46
|
+
internal id.
|
|
47
|
+
- Validated on real data: **24** Codex sessions discovered for a production project (across
|
|
48
|
+
drive-case variants), 0 parse errors, notes correctly tagged `codex`.
|
|
49
|
+
|
|
7
50
|
## [0.16.0] — 2026-07-06
|
|
8
51
|
|
|
9
52
|
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,19 @@ 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
|
|
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
|
|
|
114
|
-
- **
|
|
115
|
-
-
|
|
116
|
-
-
|
|
117
|
-
-
|
|
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
|
+
- Every note records its **`session_id`** and **`provider`** in frontmatter (live capture and import alike). Backfill older notes with `wendkeep import --stamp-ids` (fills the id from the registry; idempotent).
|
|
117
|
+
- **Deduped** by `session_id` against the vault's `SESSION_REGISTRY` **and** existing notes' frontmatter — only sessions not already present are imported, and it never overwrites an existing note. Re-running is a no-op.
|
|
118
|
+
- **`--from <dir>`** / **`--codex-from <dir>`** point at the transcript folders explicitly (use if the auto-derived path misses). Also: `--since <date>`, `--limit <n>`, `--json`.
|
|
119
|
+
- Once imported, `wendkeep cost` aggregates your entire history — retroactively, across both agents.
|
|
118
120
|
|
|
119
121
|
## Change lifecycle — the a2 loop (spec‑driven, native)
|
|
120
122
|
|
|
@@ -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, readFileSync, openSync, readSync, closeSync } from 'fs';
|
|
8
8
|
import { basename, join } from 'path';
|
|
9
9
|
import {
|
|
10
10
|
parseTranscript,
|
|
@@ -19,6 +19,7 @@ import { createLinkedNotes } from './linked-notes.mjs';
|
|
|
19
19
|
import { updateSessionUsage } from './token-usage.mjs';
|
|
20
20
|
import { upsertSubagentUsage } from './subagent-usage.mjs';
|
|
21
21
|
import { readSessionRegistry, upsertSessionRegistry, formatLocalIso, formatDate } from './obsidian-common.mjs';
|
|
22
|
+
import { getLocale } from './locale.mjs';
|
|
22
23
|
|
|
23
24
|
// Claude encodes a project's absolute path as its `.claude/projects` dir name by replacing each
|
|
24
25
|
// path separator and the drive colon with '-'. `C:\GitHub\WendKeep` -> `C--GitHub-WendKeep`.
|
|
@@ -48,6 +49,100 @@ export function discoverTranscripts(projectPath, fromDir) {
|
|
|
48
49
|
return { dir, transcripts };
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
// --- Codex source -----------------------------------------------------------
|
|
53
|
+
// Codex rollouts live in ~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl and are NOT
|
|
54
|
+
// organized by project, so we scope them by the `cwd` recorded in each session_meta.
|
|
55
|
+
|
|
56
|
+
export function defaultCodexSessionsDir() {
|
|
57
|
+
const home = homeDir();
|
|
58
|
+
return home ? join(home, '.codex', 'sessions') : '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normPath(p) {
|
|
62
|
+
return String(p || '').replace(/[\\/]+/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A Codex session belongs to the project when it ran from the project root or any subdir.
|
|
66
|
+
function cwdMatchesProject(cwd, projectPath) {
|
|
67
|
+
const c = normPath(cwd);
|
|
68
|
+
const proj = normPath(projectPath);
|
|
69
|
+
if (!c || !proj) return false;
|
|
70
|
+
return c === proj || c.startsWith(`${proj}/`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Recursively collect paths matching `re` (manual walk: readdir recursive lands in Node 20; floor is 18).
|
|
74
|
+
function walkFiles(dir, re, out = []) {
|
|
75
|
+
let entries;
|
|
76
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
77
|
+
for (const e of entries) {
|
|
78
|
+
const p = join(dir, e.name);
|
|
79
|
+
if (e.isDirectory()) walkFiles(p, re, out);
|
|
80
|
+
else if (re.test(e.name)) out.push(p);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Read only the leading bytes of a file (avoids slurping multi-MB transcripts / notes).
|
|
86
|
+
function readPrefix(path, bytes = 4096) {
|
|
87
|
+
let fd;
|
|
88
|
+
try {
|
|
89
|
+
fd = openSync(path, 'r');
|
|
90
|
+
const buf = Buffer.alloc(bytes);
|
|
91
|
+
const n = readSync(fd, buf, 0, bytes, 0);
|
|
92
|
+
return buf.slice(0, n).toString('utf-8');
|
|
93
|
+
} catch {
|
|
94
|
+
return '';
|
|
95
|
+
} finally {
|
|
96
|
+
if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Pull the session_meta payload (id + cwd). session_meta is line 1 of a rollout.
|
|
101
|
+
function readSessionMeta(path) {
|
|
102
|
+
for (const line of readPrefix(path, 16384).split('\n')) {
|
|
103
|
+
if (!line.trim()) continue;
|
|
104
|
+
let e;
|
|
105
|
+
try { e = JSON.parse(line); } catch { return null; } // partial/oversized first line -> skip
|
|
106
|
+
return e.type === 'session_meta' ? (e.payload || {}) : null;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The `session_id` recorded in a note's frontmatter (empty when absent).
|
|
112
|
+
function noteSessionId(path) {
|
|
113
|
+
const m = readPrefix(path, 2048).match(/^session_id:\s*["']?([^"'\r\n]+)["']?\s*$/m);
|
|
114
|
+
return m ? m[1].trim() : '';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Every session_id the vault already has a record of: the SESSION_REGISTRY plus every session
|
|
118
|
+
// note's frontmatter id. The registry is authoritative for wendkeep-native vaults, but scanning
|
|
119
|
+
// notes too keeps import safe even if the registry was reset/lost — a session with a note on
|
|
120
|
+
// disk is never re-imported.
|
|
121
|
+
export function capturedSessionIds(vaultBase) {
|
|
122
|
+
const ids = new Set(Object.keys(readSessionRegistry(vaultBase).sessions || {}));
|
|
123
|
+
try {
|
|
124
|
+
const sessionsDir = join(vaultBase, getLocale(vaultBase).folders.sessions);
|
|
125
|
+
for (const path of walkFiles(sessionsDir, /\.md$/i)) {
|
|
126
|
+
const id = noteSessionId(path);
|
|
127
|
+
if (id) ids.add(id);
|
|
128
|
+
}
|
|
129
|
+
} catch { /* registry alone is enough */ }
|
|
130
|
+
return ids;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function discoverCodexTranscripts(projectPath, fromDir) {
|
|
134
|
+
const dir = fromDir || defaultCodexSessionsDir();
|
|
135
|
+
if (!dir || !existsSync(dir)) return { dir, transcripts: [] };
|
|
136
|
+
const transcripts = [];
|
|
137
|
+
for (const path of walkFiles(dir, /\.jsonl$/i)) {
|
|
138
|
+
const meta = readSessionMeta(path);
|
|
139
|
+
if (!meta || !meta.id) continue;
|
|
140
|
+
if (projectPath && !cwdMatchesProject(meta.cwd, projectPath)) continue;
|
|
141
|
+
transcripts.push({ path, sessionId: meta.id, cwd: meta.cwd || '' });
|
|
142
|
+
}
|
|
143
|
+
return { dir, transcripts };
|
|
144
|
+
}
|
|
145
|
+
|
|
51
146
|
// Session objective for the note title/frontmatter: first real user prompt, one line.
|
|
52
147
|
function deriveSummary(tx) {
|
|
53
148
|
for (const turn of tx.turns || []) {
|
|
@@ -66,19 +161,22 @@ function toDate(ts, fallback) {
|
|
|
66
161
|
|
|
67
162
|
// Replay one transcript into a fresh, finalized note. Returns {sessionId, relPath, turns} or
|
|
68
163
|
// null when the transcript has no memorializable turns. Assumes the caller already deduped.
|
|
69
|
-
|
|
164
|
+
// opts: { tx (pre-parsed), sessionId (registry key — pass the discovered id so registration
|
|
165
|
+
// matches the dedup key exactly; falls back to the transcript's own id) }.
|
|
166
|
+
export function importSession(vaultBase, txPath, opts = {}) {
|
|
167
|
+
const { tx: preParsed, sessionId: sessionIdOverride } = opts;
|
|
70
168
|
const tx = preParsed || parseTranscript(txPath);
|
|
71
169
|
const turns = tx.turns || [];
|
|
72
170
|
if (!turns.length) return null;
|
|
73
171
|
|
|
74
|
-
const sessionId = tx.sessionId || basename(String(txPath), '.jsonl');
|
|
172
|
+
const sessionId = sessionIdOverride || tx.sessionId || basename(String(txPath), '.jsonl');
|
|
75
173
|
const startDate = toDate(turns[0].timestamp, new Date());
|
|
76
174
|
const endDate = toDate(turns.at(-1).timestamp, startDate);
|
|
77
175
|
const summary = deriveSummary(tx);
|
|
78
176
|
|
|
79
|
-
// Skeleton in the real dated folder.
|
|
177
|
+
// Skeleton in the real dated folder, tagged with the transcript's own provider + session id.
|
|
80
178
|
const { absPath, relPath } = allocateSessionPath(vaultBase, startDate, summary);
|
|
81
|
-
writeFileSync(absPath, buildSessionContent({ relPath, now: startDate, summary }), 'utf-8');
|
|
179
|
+
writeFileSync(absPath, buildSessionContent({ relPath, now: startDate, summary, provider: tx.provider, sessionId }), 'utf-8');
|
|
82
180
|
|
|
83
181
|
// One iteration block per turn (insertIteration dedups by turn marker -> re-import safe).
|
|
84
182
|
for (const turn of turns) {
|
|
@@ -115,14 +213,57 @@ export function importSession(vaultBase, txPath, preParsed) {
|
|
|
115
213
|
return { sessionId, relPath, turns: turns.length };
|
|
116
214
|
}
|
|
117
215
|
|
|
118
|
-
//
|
|
119
|
-
//
|
|
216
|
+
// Stamp a missing/empty `session_id` into a note's frontmatter (only within the frontmatter
|
|
217
|
+
// block, right after `provider:`). Leaves a note that already has a non-empty id untouched.
|
|
218
|
+
function injectSessionIdFrontmatter(content, sessionId) {
|
|
219
|
+
const q = `"${String(sessionId).replace(/"/g, '\\"')}"`;
|
|
220
|
+
if (/^session_id:\s*$/m.test(content)) return content.replace(/^session_id:\s*$/m, `session_id: ${q}`);
|
|
221
|
+
if (/^session_id:\s*\S/m.test(content)) return content; // already has a value
|
|
222
|
+
if (/^provider:.*$/m.test(content)) return content.replace(/^(provider:.*)$/m, `$1\nsession_id: ${q}`);
|
|
223
|
+
return content.replace(/^---\s*$/m, `---\nsession_id: ${q}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Backfill `session_id` into existing session notes from the SESSION_REGISTRY (session_file -> id).
|
|
227
|
+
// For notes captured/imported before the field existed. Idempotent; only touches notes missing it.
|
|
228
|
+
export function stampSessionIds(vaultBase) {
|
|
229
|
+
const reg = readSessionRegistry(vaultBase).sessions || {};
|
|
230
|
+
const report = { total: Object.keys(reg).length, stamped: 0, alreadyOk: 0, missingFile: 0 };
|
|
231
|
+
for (const [sessionId, entry] of Object.entries(reg)) {
|
|
232
|
+
if (!entry?.session_file) continue;
|
|
233
|
+
const abs = join(vaultBase, entry.session_file);
|
|
234
|
+
if (!existsSync(abs)) { report.missingFile++; continue; }
|
|
235
|
+
let content;
|
|
236
|
+
try { content = readFileSync(abs, 'utf-8'); } catch { report.missingFile++; continue; }
|
|
237
|
+
const next = injectSessionIdFrontmatter(content, sessionId);
|
|
238
|
+
if (next !== content) { writeFileSync(abs, next, 'utf-8'); report.stamped++; }
|
|
239
|
+
else { report.alreadyOk++; }
|
|
240
|
+
}
|
|
241
|
+
return report;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Import every not-yet-captured transcript from the requested source(s). Deduped by session_id
|
|
245
|
+
// against the registry. Options: { projectPath, source ('all'|'claude'|'codex'), from, codexFrom,
|
|
246
|
+
// since (ISO/date), limit, dryRun }. importSession is provider-agnostic, so both sources share
|
|
247
|
+
// the same dedup + import loop.
|
|
120
248
|
export function runImport(vaultBase, opts = {}) {
|
|
121
|
-
const { projectPath = process.cwd(), from = '', since = '', limit = 0, dryRun = false } = opts;
|
|
122
|
-
const
|
|
123
|
-
const
|
|
249
|
+
const { projectPath = process.cwd(), source = 'all', from = '', codexFrom = '', since = '', limit = 0, dryRun = false } = opts;
|
|
250
|
+
const src = String(source).toLowerCase();
|
|
251
|
+
const transcripts = [];
|
|
252
|
+
let claudeDir = '';
|
|
253
|
+
let codexDir = '';
|
|
254
|
+
if (src === 'all' || src === 'claude') {
|
|
255
|
+
const d = discoverTranscripts(projectPath, from);
|
|
256
|
+
claudeDir = d.dir;
|
|
257
|
+
transcripts.push(...d.transcripts);
|
|
258
|
+
}
|
|
259
|
+
if (src === 'all' || src === 'codex') {
|
|
260
|
+
const d = discoverCodexTranscripts(projectPath, codexFrom);
|
|
261
|
+
codexDir = d.dir;
|
|
262
|
+
transcripts.push(...d.transcripts);
|
|
263
|
+
}
|
|
264
|
+
const captured = capturedSessionIds(vaultBase);
|
|
124
265
|
const sinceMs = since ? Date.parse(since) : 0;
|
|
125
|
-
const report = {
|
|
266
|
+
const report = { source: src, claudeDir, codexDir, scanned: transcripts.length, imported: 0, skipped: 0, errors: [], sessions: [] };
|
|
126
267
|
|
|
127
268
|
let done = 0;
|
|
128
269
|
for (const t of transcripts) {
|
|
@@ -153,7 +294,7 @@ export function runImport(vaultBase, opts = {}) {
|
|
|
153
294
|
}
|
|
154
295
|
|
|
155
296
|
try {
|
|
156
|
-
const r = importSession(vaultBase, t.path, tx);
|
|
297
|
+
const r = importSession(vaultBase, t.path, { tx, sessionId: t.sessionId });
|
|
157
298
|
if (r) { report.sessions.push(r); report.imported++; done++; }
|
|
158
299
|
else { report.skipped++; }
|
|
159
300
|
} catch (error) {
|
package/hooks/session-start.mjs
CHANGED
|
@@ -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, sessionId = '' }) {
|
|
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
|
-
|
|
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
|
|
@@ -44,6 +46,7 @@ date: ${date}
|
|
|
44
46
|
started_at: ${startedAt}
|
|
45
47
|
ended_at:
|
|
46
48
|
provider: ${provider.id}
|
|
49
|
+
session_id: ${sessionId ? yamlQuote(sessionId) : ''}
|
|
47
50
|
status: active
|
|
48
51
|
summary: ${yamlQuote(summary)}
|
|
49
52
|
cssclasses:
|
|
@@ -200,7 +203,7 @@ function main() {
|
|
|
200
203
|
const knownAbs = join(vaultBase, known.session_file);
|
|
201
204
|
if (!existsSync(knownAbs)) {
|
|
202
205
|
ensureDir(join(vaultBase, known.session_file.split('/').slice(0, -1).join('/')));
|
|
203
|
-
writeFileSync(knownAbs, buildSessionContent({ relPath: known.session_file, now, summary: sessionSummaryFromInput(input) }), 'utf-8');
|
|
206
|
+
writeFileSync(knownAbs, buildSessionContent({ relPath: known.session_file, now, summary: sessionSummaryFromInput(input), sessionId }), 'utf-8');
|
|
204
207
|
}
|
|
205
208
|
const startedAt = known.started_at || control.started_at || formatLocalIso(now);
|
|
206
209
|
writeControl(vaultBase, {
|
|
@@ -242,7 +245,7 @@ function main() {
|
|
|
242
245
|
const matchAbs = join(vaultBase, match.session_file);
|
|
243
246
|
if (!existsSync(matchAbs)) {
|
|
244
247
|
ensureDir(join(vaultBase, match.session_file.split('/').slice(0, -1).join('/')));
|
|
245
|
-
writeFileSync(matchAbs, buildSessionContent({ relPath: match.session_file, now, summary: sessionSummaryFromInput(input) }), 'utf-8');
|
|
248
|
+
writeFileSync(matchAbs, buildSessionContent({ relPath: match.session_file, now, summary: sessionSummaryFromInput(input), sessionId: sessionId || match.sessionId }), 'utf-8');
|
|
246
249
|
}
|
|
247
250
|
const startedAt = match.started_at || control.started_at || formatLocalIso(now);
|
|
248
251
|
writeControl(vaultBase, {
|
|
@@ -274,7 +277,7 @@ function main() {
|
|
|
274
277
|
const summary = sessionSummaryFromInput(input);
|
|
275
278
|
const { absPath, relPath } = allocateSessionPath(vaultBase, now, summary);
|
|
276
279
|
const startedAt = formatLocalIso(now);
|
|
277
|
-
writeFileSync(absPath, buildSessionContent({ relPath, now, summary }), 'utf-8');
|
|
280
|
+
writeFileSync(absPath, buildSessionContent({ relPath, now, summary, sessionId }), 'utf-8');
|
|
278
281
|
writeControl(vaultBase, {
|
|
279
282
|
status: 'active',
|
|
280
283
|
session_file: relPath,
|
package/hooks/session-stop.mjs
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.18.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,
|
|
6
|
+
import { runImport, stampSessionIds } from '../hooks/import-sessions.mjs';
|
|
7
7
|
|
|
8
8
|
function opt(argv, name) {
|
|
9
9
|
const i = argv.indexOf(name);
|
|
@@ -18,27 +18,34 @@ export function runImportCli(argv) {
|
|
|
18
18
|
const vaultBase = isAbsolute(vaultRaw) ? vaultRaw : resolve(process.cwd(), vaultRaw);
|
|
19
19
|
if (!existsSync(vaultBase)) { process.stderr.write(`wendkeep import: vault not found: ${vaultBase}\n`); process.exit(2); }
|
|
20
20
|
|
|
21
|
+
// Repair mode: backfill session_id into existing notes from the registry, then stop.
|
|
22
|
+
if (argv.includes('--stamp-ids')) {
|
|
23
|
+
const r = stampSessionIds(vaultBase);
|
|
24
|
+
if (argv.includes('--json')) { process.stdout.write(`${JSON.stringify(r, null, 2)}\n`); process.exit(0); }
|
|
25
|
+
process.stdout.write(`session_id carimbado em ${r.stamped} nota(s) · ${r.alreadyOk} já ok · ${r.missingFile} sem arquivo (de ${r.total} no registry)\n`);
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
const projectRaw = opt(argv, '--project') || process.cwd();
|
|
22
30
|
const projectPath = isAbsolute(projectRaw) ? projectRaw : resolve(process.cwd(), projectRaw);
|
|
31
|
+
const source = (opt(argv, '--source') || 'all').toLowerCase();
|
|
32
|
+
if (!['all', 'claude', 'codex'].includes(source)) {
|
|
33
|
+
process.stderr.write(`wendkeep import: --source must be all | claude | codex (got "${source}").\n`);
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
23
36
|
const from = opt(argv, '--from') || '';
|
|
37
|
+
const codexFrom = opt(argv, '--codex-from') || '';
|
|
24
38
|
const since = opt(argv, '--since') || '';
|
|
25
39
|
const limit = Number(opt(argv, '--limit')) || 0;
|
|
26
40
|
const dryRun = argv.includes('--dry-run');
|
|
27
41
|
|
|
28
|
-
const
|
|
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 });
|
|
42
|
+
const report = runImport(vaultBase, { projectPath, source, from, codexFrom, since, limit, dryRun });
|
|
37
43
|
|
|
38
44
|
if (argv.includes('--json')) { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); process.exit(0); }
|
|
39
45
|
|
|
40
46
|
const verb = dryRun ? 'importaria' : 'importadas';
|
|
41
|
-
process.stdout.write(`
|
|
47
|
+
if (report.claudeDir) process.stdout.write(`Claude: ${report.claudeDir}\n`);
|
|
48
|
+
if (report.codexDir) process.stdout.write(`Codex: ${report.codexDir}\n`);
|
|
42
49
|
process.stdout.write(`${report.scanned} transcript(s) · ${report.imported} ${verb} · ${report.skipped} já no vault\n`);
|
|
43
50
|
for (const s of report.sessions) {
|
|
44
51
|
const tag = s.dryRun ? '(dry-run)' : '→';
|