wendkeep 0.17.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 +19 -0
- package/README.md +2 -1
- package/hooks/import-sessions.mjs +76 -21
- package/hooks/session-start.mjs +5 -4
- package/package.json +1 -1
- package/src/import.mjs +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ 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
|
+
|
|
7
26
|
## [0.17.0] — 2026-07-06
|
|
8
27
|
|
|
9
28
|
Retroactive memory, now agent-agnostic (Codex).
|
package/README.md
CHANGED
|
@@ -113,7 +113,8 @@ wendkeep import --vault .myproject-vault --source codex # just Codex
|
|
|
113
113
|
```
|
|
114
114
|
|
|
115
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
|
-
-
|
|
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.
|
|
117
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`.
|
|
118
119
|
- Once imported, `wendkeep cost` aggregates your entire history — retroactively, across both agents.
|
|
119
120
|
|
|
@@ -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, openSync, readSync, closeSync } 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`.
|
|
@@ -69,45 +70,71 @@ function cwdMatchesProject(cwd, projectPath) {
|
|
|
69
70
|
return c === proj || c.startsWith(`${proj}/`);
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
// Recursively collect
|
|
73
|
-
function
|
|
73
|
+
// Recursively collect paths matching `re` (manual walk: readdir recursive lands in Node 20; floor is 18).
|
|
74
|
+
function walkFiles(dir, re, out = []) {
|
|
74
75
|
let entries;
|
|
75
76
|
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
76
77
|
for (const e of entries) {
|
|
77
78
|
const p = join(dir, e.name);
|
|
78
|
-
if (e.isDirectory())
|
|
79
|
-
else if (
|
|
79
|
+
if (e.isDirectory()) walkFiles(p, re, out);
|
|
80
|
+
else if (re.test(e.name)) out.push(p);
|
|
80
81
|
}
|
|
81
82
|
return out;
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
// Read only the leading bytes
|
|
85
|
-
|
|
86
|
-
function readSessionMeta(path) {
|
|
85
|
+
// Read only the leading bytes of a file (avoids slurping multi-MB transcripts / notes).
|
|
86
|
+
function readPrefix(path, bytes = 4096) {
|
|
87
87
|
let fd;
|
|
88
88
|
try {
|
|
89
89
|
fd = openSync(path, 'r');
|
|
90
|
-
const buf = Buffer.alloc(
|
|
91
|
-
const n = readSync(fd, buf, 0,
|
|
92
|
-
|
|
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
|
-
}
|
|
90
|
+
const buf = Buffer.alloc(bytes);
|
|
91
|
+
const n = readSync(fd, buf, 0, bytes, 0);
|
|
92
|
+
return buf.slice(0, n).toString('utf-8');
|
|
98
93
|
} catch {
|
|
99
|
-
return
|
|
94
|
+
return '';
|
|
100
95
|
} finally {
|
|
101
96
|
if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
|
|
102
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
|
+
}
|
|
103
108
|
return null;
|
|
104
109
|
}
|
|
105
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
|
+
|
|
106
133
|
export function discoverCodexTranscripts(projectPath, fromDir) {
|
|
107
134
|
const dir = fromDir || defaultCodexSessionsDir();
|
|
108
135
|
if (!dir || !existsSync(dir)) return { dir, transcripts: [] };
|
|
109
136
|
const transcripts = [];
|
|
110
|
-
for (const path of
|
|
137
|
+
for (const path of walkFiles(dir, /\.jsonl$/i)) {
|
|
111
138
|
const meta = readSessionMeta(path);
|
|
112
139
|
if (!meta || !meta.id) continue;
|
|
113
140
|
if (projectPath && !cwdMatchesProject(meta.cwd, projectPath)) continue;
|
|
@@ -147,9 +174,9 @@ export function importSession(vaultBase, txPath, opts = {}) {
|
|
|
147
174
|
const endDate = toDate(turns.at(-1).timestamp, startDate);
|
|
148
175
|
const summary = deriveSummary(tx);
|
|
149
176
|
|
|
150
|
-
// Skeleton in the real dated folder, tagged with the transcript's own provider.
|
|
177
|
+
// Skeleton in the real dated folder, tagged with the transcript's own provider + session id.
|
|
151
178
|
const { absPath, relPath } = allocateSessionPath(vaultBase, startDate, summary);
|
|
152
|
-
writeFileSync(absPath, buildSessionContent({ relPath, now: startDate, summary, provider: tx.provider }), 'utf-8');
|
|
179
|
+
writeFileSync(absPath, buildSessionContent({ relPath, now: startDate, summary, provider: tx.provider, sessionId }), 'utf-8');
|
|
153
180
|
|
|
154
181
|
// One iteration block per turn (insertIteration dedups by turn marker -> re-import safe).
|
|
155
182
|
for (const turn of turns) {
|
|
@@ -186,6 +213,34 @@ export function importSession(vaultBase, txPath, opts = {}) {
|
|
|
186
213
|
return { sessionId, relPath, turns: turns.length };
|
|
187
214
|
}
|
|
188
215
|
|
|
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
|
+
|
|
189
244
|
// Import every not-yet-captured transcript from the requested source(s). Deduped by session_id
|
|
190
245
|
// against the registry. Options: { projectPath, source ('all'|'claude'|'codex'), from, codexFrom,
|
|
191
246
|
// since (ISO/date), limit, dryRun }. importSession is provider-agnostic, so both sources share
|
|
@@ -206,7 +261,7 @@ export function runImport(vaultBase, opts = {}) {
|
|
|
206
261
|
codexDir = d.dir;
|
|
207
262
|
transcripts.push(...d.transcripts);
|
|
208
263
|
}
|
|
209
|
-
const captured =
|
|
264
|
+
const captured = capturedSessionIds(vaultBase);
|
|
210
265
|
const sinceMs = since ? Date.parse(since) : 0;
|
|
211
266
|
const report = { source: src, claudeDir, codexDir, scanned: transcripts.length, imported: 0, skipped: 0, errors: [], sessions: [] };
|
|
212
267
|
|
package/hooks/session-start.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
yamlQuote,
|
|
32
32
|
} from './obsidian-common.mjs';
|
|
33
33
|
|
|
34
|
-
export function buildSessionContent({ relPath, now, summary = 'session', provider: providerId }) {
|
|
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);
|
|
@@ -46,6 +46,7 @@ date: ${date}
|
|
|
46
46
|
started_at: ${startedAt}
|
|
47
47
|
ended_at:
|
|
48
48
|
provider: ${provider.id}
|
|
49
|
+
session_id: ${sessionId ? yamlQuote(sessionId) : ''}
|
|
49
50
|
status: active
|
|
50
51
|
summary: ${yamlQuote(summary)}
|
|
51
52
|
cssclasses:
|
|
@@ -202,7 +203,7 @@ function main() {
|
|
|
202
203
|
const knownAbs = join(vaultBase, known.session_file);
|
|
203
204
|
if (!existsSync(knownAbs)) {
|
|
204
205
|
ensureDir(join(vaultBase, known.session_file.split('/').slice(0, -1).join('/')));
|
|
205
|
-
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');
|
|
206
207
|
}
|
|
207
208
|
const startedAt = known.started_at || control.started_at || formatLocalIso(now);
|
|
208
209
|
writeControl(vaultBase, {
|
|
@@ -244,7 +245,7 @@ function main() {
|
|
|
244
245
|
const matchAbs = join(vaultBase, match.session_file);
|
|
245
246
|
if (!existsSync(matchAbs)) {
|
|
246
247
|
ensureDir(join(vaultBase, match.session_file.split('/').slice(0, -1).join('/')));
|
|
247
|
-
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');
|
|
248
249
|
}
|
|
249
250
|
const startedAt = match.started_at || control.started_at || formatLocalIso(now);
|
|
250
251
|
writeControl(vaultBase, {
|
|
@@ -276,7 +277,7 @@ function main() {
|
|
|
276
277
|
const summary = sessionSummaryFromInput(input);
|
|
277
278
|
const { absPath, relPath } = allocateSessionPath(vaultBase, now, summary);
|
|
278
279
|
const startedAt = formatLocalIso(now);
|
|
279
|
-
writeFileSync(absPath, buildSessionContent({ relPath, now, summary }), 'utf-8');
|
|
280
|
+
writeFileSync(absPath, buildSessionContent({ relPath, now, summary, sessionId }), 'utf-8');
|
|
280
281
|
writeControl(vaultBase, {
|
|
281
282
|
status: 'active',
|
|
282
283
|
session_file: relPath,
|
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 } from '../hooks/import-sessions.mjs';
|
|
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,6 +18,14 @@ 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);
|
|
23
31
|
const source = (opt(argv, '--source') || 'all').toLowerCase();
|