wendkeep 0.28.1 → 0.29.1
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 +29 -0
- package/bin/wendkeep.mjs +1 -0
- package/hooks/decision-capture.mjs +53 -0
- package/hooks/import-sessions.mjs +33 -1
- package/hooks/linked-notes.mjs +9 -0
- package/package.json +1 -1
- package/src/import.mjs +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ 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.29.1] — 2026-07-09
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`wendkeep import --rescan-decisions`** — re-scan **already-imported/captured** transcripts for
|
|
11
|
+
prose decisions only (no session re-import). For sessions imported before 0.29.0 whose rollouts
|
|
12
|
+
carry options-in-prose choices that were never captured. Walks the registry
|
|
13
|
+
(`session_file` + `transcript_path`), runs the same conservative extraction, dedupes by filename
|
|
14
|
+
— re-running is a no-op. `--limit N` / `--json` supported. New `rescanDecisions()` export.
|
|
15
|
+
|
|
16
|
+
## [0.29.0] — 2026-07-09
|
|
17
|
+
|
|
18
|
+
Codex decision parity — agnostic prose-decision capture.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- **Prose-decision capture** (`extractProseDecisions` / `captureProseDecisions` in
|
|
22
|
+
`hooks/decision-capture.mjs`, wired inside `createLinkedNotes`): Codex has no
|
|
23
|
+
`AskUserQuestion`-style tool — the agent asks in **prose**. A conservative pattern (assistant
|
|
24
|
+
message with ≥2 enumerated options ending in a question + a SHORT user reply) now produces the
|
|
25
|
+
**same decision note** the Claude hook writes (options + the user's choice, in `04-Decisões/`,
|
|
26
|
+
wikilinked to the session). One integration point covers **live Stop, `import` and backfill,
|
|
27
|
+
for every provider**. Validated on 144 real Codex rollouts: 6 genuine decisions extracted, no
|
|
28
|
+
visible false positives.
|
|
29
|
+
|
|
30
|
+
### Notes (investigated, decided against)
|
|
31
|
+
- **Codex subagent telemetry**: real rollouts contain **no** subagent/parallel structure — nothing
|
|
32
|
+
to map; documented as not applicable.
|
|
33
|
+
- **Codex structured events** (`thread_goal_updated`, `task_complete`): goal payload ≈ the initial
|
|
34
|
+
prompt; task events are turn markers already parsed. No extra capture worth the noise.
|
|
35
|
+
|
|
7
36
|
## [0.28.1] — 2026-07-09
|
|
8
37
|
|
|
9
38
|
Startup-contention fixes — root-caused from a real VSCode startup log where the memory injection
|
package/bin/wendkeep.mjs
CHANGED
|
@@ -54,6 +54,7 @@ Usage:
|
|
|
54
54
|
wendkeep import [opts] Backfill: import this project's past Claude + Codex sessions into
|
|
55
55
|
the vault (deduped by session_id). --source all|claude|codex (default
|
|
56
56
|
all) · --stamp-ids (backfill session_id in existing notes) ·
|
|
57
|
+
--rescan-decisions (capture prose decisions from already-imported transcripts) ·
|
|
57
58
|
--from <dir> · --codex-from <dir> · --since <date> · --limit N ·
|
|
58
59
|
--dry-run · --json.
|
|
59
60
|
wendkeep verify [--deep] [--change s] Run a change's task sensors + record evidence (the gate);
|
|
@@ -68,6 +68,59 @@ ${blocks}
|
|
|
68
68
|
`;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// --- agnostic prose decisions (Codex parity) ---------------------------------
|
|
72
|
+
// Codex has no AskUserQuestion-style tool: the agent asks in PROSE and the user answers in the
|
|
73
|
+
// next message. Conservative extraction (validated on real rollouts): an assistant message with
|
|
74
|
+
// >=2 enumerated options that ends in a question, followed by a SHORT user reply (a choice, not a
|
|
75
|
+
// new instruction). Works over the turn conversations both parsers already build — so it covers
|
|
76
|
+
// Claude and Codex, live and import, without depending on any hook event.
|
|
77
|
+
export function extractProseDecisions(tx) {
|
|
78
|
+
const flat = [];
|
|
79
|
+
for (const t of tx?.turns || []) for (const c of t.conversation || []) flat.push(c);
|
|
80
|
+
const out = [];
|
|
81
|
+
for (let i = 0; i < flat.length - 1; i++) {
|
|
82
|
+
const q = flat[i]; const a = flat[i + 1];
|
|
83
|
+
if (q.role !== 'Assistente' || a.role !== 'Usuário') continue;
|
|
84
|
+
const text = String(q.text || '');
|
|
85
|
+
const answer = String(a.text || '').trim();
|
|
86
|
+
if (!answer || answer.length > 200) continue; // long reply = new instruction, not a choice
|
|
87
|
+
// question: the message's last non-empty lines must end with '?'
|
|
88
|
+
const lines = text.trim().split('\n').map((l) => l.trim()).filter(Boolean);
|
|
89
|
+
const lastLine = lines[lines.length - 1] || '';
|
|
90
|
+
if (!/\?\s*$/.test(lastLine)) continue;
|
|
91
|
+
// options: numbered/lettered lines, or bulleted bold labels
|
|
92
|
+
let options = [...text.matchAll(/^\s*(?:\d+[\).]|[a-cA-C][\)])\s+(.{3,140})$/gm)].map((m) => m[1].trim());
|
|
93
|
+
if (options.length < 2) options = [...text.matchAll(/^\s*[-*]\s+\*\*(.{2,90}?)\*\*/gm)].map((m) => m[1].trim());
|
|
94
|
+
if (options.length < 2) continue;
|
|
95
|
+
const question = lines.filter((l) => /\?\s*$/.test(l)).pop() || lastLine;
|
|
96
|
+
out.push({ question: question.slice(0, 200), options: options.slice(0, 6), answer });
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Write one decision note per extracted prose decision (same shape as the hook capture).
|
|
102
|
+
// Deduped by filename (day + question slug). Returns the vault-relative paths written.
|
|
103
|
+
export function captureProseDecisions(vaultBase, { tx, dateStr, sessionRel, provider, localeId }) {
|
|
104
|
+
const written = [];
|
|
105
|
+
const decisions = extractProseDecisions(tx);
|
|
106
|
+
if (!decisions.length) return written;
|
|
107
|
+
const loc = getLocale(vaultBase);
|
|
108
|
+
const dir = join(vaultBase, monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase));
|
|
109
|
+
for (const d of decisions) {
|
|
110
|
+
ensureDir(dir);
|
|
111
|
+
const filePath = join(dir, `${dateStr}-escolha-${slugify(d.question, 'decisao', 50)}.md`);
|
|
112
|
+
if (existsSync(filePath)) continue;
|
|
113
|
+
writeFileSync(filePath, buildDecisionCaptureNote({
|
|
114
|
+
questions: [{ question: d.question, multiSelect: false, options: d.options.map((label) => ({ label, description: '' })) }],
|
|
115
|
+
answers: { [d.question]: d.answer },
|
|
116
|
+
dateStr, startedAt: `${dateStr}T00:00:00`, sessionRel,
|
|
117
|
+
provider: provider || providerMeta(tx?.provider), localeId: localeId || loc.id,
|
|
118
|
+
}), 'utf-8');
|
|
119
|
+
written.push(toVaultRelative(vaultBase, filePath));
|
|
120
|
+
}
|
|
121
|
+
return written;
|
|
122
|
+
}
|
|
123
|
+
|
|
71
124
|
export function captureDecision(vaultBase, input) {
|
|
72
125
|
const toolIn = input.tool_input || input.toolInput || {};
|
|
73
126
|
const questions = Array.isArray(toolIn.questions) ? toolIn.questions : [];
|
|
@@ -18,8 +18,9 @@ import { buildSessionContent, allocateSessionPath } from './session-start.mjs';
|
|
|
18
18
|
import { createLinkedNotes } from './linked-notes.mjs';
|
|
19
19
|
import { updateSessionUsage } from './token-usage.mjs';
|
|
20
20
|
import { upsertSubagentUsage } from './subagent-usage.mjs';
|
|
21
|
-
import { readSessionRegistry, upsertSessionRegistry, formatLocalIso, formatDate } from './obsidian-common.mjs';
|
|
21
|
+
import { readSessionRegistry, upsertSessionRegistry, formatLocalIso, formatDate, providerMeta } from './obsidian-common.mjs';
|
|
22
22
|
import { getLocale } from './locale.mjs';
|
|
23
|
+
import { captureProseDecisions } from './decision-capture.mjs';
|
|
23
24
|
|
|
24
25
|
// Claude encodes a project's absolute path as its `.claude/projects` dir name by replacing each
|
|
25
26
|
// path separator and the drive colon with '-'. `C:\GitHub\WendKeep` -> `C--GitHub-WendKeep`.
|
|
@@ -237,6 +238,37 @@ export function importSession(vaultBase, txPath, opts = {}) {
|
|
|
237
238
|
return { sessionId, relPath, turns: turns.length };
|
|
238
239
|
}
|
|
239
240
|
|
|
241
|
+
// Re-scan ALREADY-imported/captured transcripts for prose decisions only (no session re-import).
|
|
242
|
+
// For sessions imported before 0.29.0, whose transcripts carry options-in-prose choices that were
|
|
243
|
+
// never captured. Walks the registry (session_file + transcript_path), parses each transcript and
|
|
244
|
+
// runs captureProseDecisions — filename-deduped, so re-running is a no-op. Fail-soft per session.
|
|
245
|
+
export function rescanDecisions(vaultBase, { limit = 0 } = {}) {
|
|
246
|
+
const sessions = readSessionRegistry(vaultBase).sessions || {};
|
|
247
|
+
const report = { scanned: 0, decisions: 0, errors: [], sessions: [] };
|
|
248
|
+
let done = 0;
|
|
249
|
+
for (const [sessionId, entry] of Object.entries(sessions)) {
|
|
250
|
+
if (!entry?.transcript_path || !entry.session_file) continue;
|
|
251
|
+
if (!existsSync(entry.transcript_path)) continue;
|
|
252
|
+
if (limit && done >= limit) break;
|
|
253
|
+
report.scanned += 1;
|
|
254
|
+
done += 1;
|
|
255
|
+
try {
|
|
256
|
+
const tx = parseTranscript(entry.transcript_path);
|
|
257
|
+
const dateStr = String(entry.started_at || entry.ended_at || '').slice(0, 10) || formatDate(new Date());
|
|
258
|
+
const written = captureProseDecisions(vaultBase, {
|
|
259
|
+
tx, dateStr, sessionRel: entry.session_file, provider: providerMeta(tx.provider),
|
|
260
|
+
});
|
|
261
|
+
if (written.length) {
|
|
262
|
+
report.decisions += written.length;
|
|
263
|
+
report.sessions.push({ sessionId, notes: written });
|
|
264
|
+
}
|
|
265
|
+
} catch (error) {
|
|
266
|
+
report.errors.push({ sessionId, error: error.message });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return report;
|
|
270
|
+
}
|
|
271
|
+
|
|
240
272
|
// Stamp a missing/empty `session_id` into a note's frontmatter (only within the frontmatter
|
|
241
273
|
// block, right after `provider:`). Leaves a note that already has a non-empty id untouched.
|
|
242
274
|
function injectSessionIdFrontmatter(content, sessionId) {
|
package/hooks/linked-notes.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
wikilinkFromRel,
|
|
14
14
|
} from './obsidian-common.mjs';
|
|
15
15
|
import { getLocale } from './locale.mjs';
|
|
16
|
+
import { captureProseDecisions } from './decision-capture.mjs';
|
|
16
17
|
|
|
17
18
|
function yamlQuote(value) {
|
|
18
19
|
return `"${String(value || '').replaceAll('"', '\\"')}"`;
|
|
@@ -568,6 +569,14 @@ export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options =
|
|
|
568
569
|
}
|
|
569
570
|
}
|
|
570
571
|
|
|
572
|
+
// Agnostic prose decisions (Codex parity): options-in-prose + short answer -> decision note.
|
|
573
|
+
// One integration point covers live Stop, import and backfill, for every provider. Fail-quiet.
|
|
574
|
+
try {
|
|
575
|
+
for (const rel of captureProseDecisions(vaultBase, { tx, dateStr, sessionRel, provider, localeId: loc.id })) {
|
|
576
|
+
linked.decisions.push(rel);
|
|
577
|
+
}
|
|
578
|
+
} catch { /* prose capture é bônus — nunca derruba a captura principal */ }
|
|
579
|
+
|
|
571
580
|
const learnings = extractLearningDetails(tx, bugDetails);
|
|
572
581
|
if (learnings) {
|
|
573
582
|
const vaultLearningKeys = collectLearningKeys(vaultBase); // vault-wide dedup
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.1",
|
|
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, stampSessionIds } from '../hooks/import-sessions.mjs';
|
|
6
|
+
import { runImport, stampSessionIds, rescanDecisions } from '../hooks/import-sessions.mjs';
|
|
7
7
|
|
|
8
8
|
function opt(argv, name) {
|
|
9
9
|
const i = argv.indexOf(name);
|
|
@@ -18,6 +18,16 @@ 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: re-scan already-imported transcripts for prose decisions only, then stop.
|
|
22
|
+
if (argv.includes('--rescan-decisions')) {
|
|
23
|
+
const r = rescanDecisions(vaultBase, { limit: Number(opt(argv, '--limit')) || 0 });
|
|
24
|
+
if (argv.includes('--json')) { process.stdout.write(`${JSON.stringify(r, null, 2)}\n`); process.exit(0); }
|
|
25
|
+
process.stdout.write(`${r.scanned} transcript(s) varridos · ${r.decisions} decisão(ões) capturadas\n`);
|
|
26
|
+
for (const s of r.sessions) for (const n of s.notes) process.stdout.write(` → ${n}\n`);
|
|
27
|
+
if (r.errors.length) for (const e of r.errors) process.stderr.write(` erro ${e.sessionId}: ${e.error}\n`);
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
// Repair mode: backfill session_id into existing notes from the registry, then stop.
|
|
22
32
|
if (argv.includes('--stamp-ids')) {
|
|
23
33
|
const r = stampSessionIds(vaultBase);
|