wendkeep 0.24.0 → 0.25.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,20 @@ 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.25.0] — 2026-07-08
8
+
9
+ Cost trend/projection + shareable stats + launch assets.
10
+
11
+ ### Added
12
+ - **`wendkeep cost --trend [day|week|month]`** — cost bucketed over time plus a run-rate
13
+ **projection** (recent-window daily average × horizon). `wendkeep cost --write` generates a
14
+ `00-Custo.md` trend note in the vault (by-month table + projection + top models). `src/cost.mjs`.
15
+ - **`wendkeep stats`** — one shareable line: sessions · prompts · spend · date span · models
16
+ (`--json` too). For the npm page, a README badge line, or a post. `src/stats.mjs`.
17
+ - **Launch assets** (`docs/`): README hero (tagline, badges, quickstart, screenshot slot),
18
+ Show HN / r/ObsidianMD / X post drafts (`docs/20-launch-posts.md`), and a repeatable
19
+ graph-screenshot guide (`docs/21-graph-screenshot.md`).
20
+
7
21
  ## [0.24.0] — 2026-07-08
8
22
 
9
23
  ### Changed
package/README.md CHANGED
@@ -1,9 +1,21 @@
1
1
  # wendkeep
2
2
 
3
+ > **Your AI coding agent forgets every session. wendkeep makes it remember — in the Obsidian vault you already use.**
4
+
5
+ [![npm](https://img.shields.io/npm/v/wendkeep.svg)](https://www.npmjs.com/package/wendkeep)
3
6
  ![test](https://github.com/rogersialves/wendkeep/actions/workflows/test.yml/badge.svg)
7
+ ![zero deps](https://img.shields.io/badge/runtime%20deps-0-brightgreen)
8
+ ![node](https://img.shields.io/badge/node-%E2%89%A518-blue)
4
9
 
5
10
  **A persistent‑memory harness for AI coding agents, built on your Obsidian vault.** Every Claude Code / Codex session is captured turn‑by‑turn into local Markdown — with token/cost tracking, auto‑extracted decisions, bugs and learnings, and a curated memory layer injected back at the start of the next session. On top of that memory core sits a native, zero‑dependency **change lifecycle** (spec → change → TDD → sensor‑gated archive) that keeps intent, work and proof wikilinked in one graph. 100% local, open‑core.
6
11
 
12
+ ```bash
13
+ npm i -D wendkeep && npx wendkeep init # captures from the next session on
14
+ npx wendkeep import # backfill past Claude + Codex sessions
15
+ ```
16
+
17
+ <!-- SCREENSHOT: Obsidian graph of a wendkeep vault (sessions ↔ decisions ↔ bugs ↔ changes). Drop a PNG/GIF at docs/assets/graph.png and reference it here. -->
18
+
7
19
  > Extracted from a system in daily production use: the capture engine, cost tracking and graph wiring are battle‑tested; the cross‑platform installer (`wendkeep init`) and the native change loop are the newer parts. See [`docs/`](https://github.com/rogersialves/wendkeep/tree/main/docs) for the project's strategy and decision log.
8
20
 
9
21
  ---
package/bin/wendkeep.mjs CHANGED
@@ -48,7 +48,9 @@ Usage:
48
48
  wendkeep spec <sub> Living specs: list | show <capability>.
49
49
  wendkeep sensors <sub> list | add <id> "<command>" [--severity --type --report].
50
50
  wendkeep cost [opts] Aggregate AI-coding spend across the vault's sessions.
51
- --since <date> · --top [N] (priciest sessions) · --json.
51
+ --since <date> · --top [N] (priciest) · --trend [day|week|month]
52
+ (+ run-rate projection) · --write (generate 00-Custo.md) · --json.
53
+ wendkeep stats [--vault P] One shareable line: sessions · prompts · spend · span · models (--json).
52
54
  wendkeep import [opts] Backfill: import this project's past Claude + Codex sessions into
53
55
  the vault (deduped by session_id). --source all|claude|codex (default
54
56
  all) · --stamp-ids (backfill session_id in existing notes) ·
@@ -144,6 +146,11 @@ async function main() {
144
146
  runCost(rest);
145
147
  break;
146
148
  }
149
+ case 'stats': {
150
+ const { runStats } = await import('../src/stats.mjs');
151
+ runStats(rest);
152
+ break;
153
+ }
147
154
  case 'import': {
148
155
  const { runImportCli } = await import('../src/import.mjs');
149
156
  runImportCli(rest);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.24.0",
3
+ "version": "0.25.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
@@ -1,12 +1,16 @@
1
1
  // `wendkeep cost` — aggregate AI-coding spend across every session note in the vault.
2
2
  // Each session note carries cost in its frontmatter (main + subagents since 0.10.0); this
3
3
  // rolls the whole vault up: total, by day, by model. Pure aggregation + a thin CLI.
4
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
4
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
5
5
  import { isAbsolute, join, resolve } from 'node:path';
6
6
  import { getLocale } from '../hooks/locale.mjs';
7
7
 
8
8
  const round4 = (n) => Math.round((Number(n) || 0) * 10000) / 10000;
9
9
  const usd = (n) => `$${(Number(n) || 0).toFixed(4)}`;
10
+ const today = () => {
11
+ const d = new Date();
12
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
13
+ };
10
14
 
11
15
  function fmValue(content, key) {
12
16
  const m = content.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'));
@@ -24,6 +28,7 @@ export function parseSessionCost(content) {
24
28
  wasted: Number(fmValue(content, 'subagents_wasted_usd')) || 0,
25
29
  tokens: Number(fmValue(content, 'tokens_total')) || 0,
26
30
  subTokens: Number(fmValue(content, 'subagents_tokens_total')) || 0,
31
+ prompts: Number(fmValue(content, 'prompts')) || 0,
27
32
  };
28
33
  }
29
34
 
@@ -35,8 +40,9 @@ export function aggregateCosts(entries) {
35
40
  let wasted = 0;
36
41
  let tokens = 0;
37
42
  let subTokens = 0;
43
+ let prompts = 0;
38
44
  for (const e of entries) {
39
- main += e.mainCost; sub += e.subCost; wasted += e.wasted || 0; tokens += e.tokens; subTokens += e.subTokens;
45
+ main += e.mainCost; sub += e.subCost; wasted += e.wasted || 0; tokens += e.tokens; subTokens += e.subTokens; prompts += e.prompts || 0;
40
46
  const d = e.date || '?';
41
47
  (byDay[d] = byDay[d] || { cost: 0, count: 0 }).cost += e.mainCost + e.subCost;
42
48
  byDay[d].count += 1;
@@ -48,12 +54,93 @@ export function aggregateCosts(entries) {
48
54
  count: entries.length,
49
55
  main: round4(main), sub: round4(sub), total: round4(total), wasted: round4(wasted),
50
56
  avg: round4(entries.length ? total / entries.length : 0),
51
- tokens, subTokens,
57
+ tokens, subTokens, prompts,
52
58
  byDay: Object.entries(byDay).sort().map(([date, v]) => ({ date, cost: round4(v.cost), count: v.count })),
53
59
  byModel: Object.entries(byModel).sort((a, b) => b[1].cost - a[1].cost).map(([model, v]) => ({ model, cost: round4(v.cost), count: v.count })),
54
60
  };
55
61
  }
56
62
 
63
+ // Shift a 'YYYY-MM-DD' string by `delta` days (UTC, pure).
64
+ function shiftDate(dateStr, delta) {
65
+ const d = new Date(`${dateStr}T00:00:00Z`);
66
+ d.setUTCDate(d.getUTCDate() + delta);
67
+ return d.toISOString().slice(0, 10);
68
+ }
69
+
70
+ // ISO-ish week key 'YYYY-Www' for a 'YYYY-MM-DD' string (pure).
71
+ function isoWeek(dateStr) {
72
+ const d = new Date(`${dateStr}T00:00:00Z`);
73
+ const day = (d.getUTCDay() + 6) % 7; // Mon=0
74
+ d.setUTCDate(d.getUTCDate() - day + 3); // nearest Thursday
75
+ const firstThu = new Date(Date.UTC(d.getUTCFullYear(), 0, 4));
76
+ const week = 1 + Math.round(((d - firstThu) / 86400000 - 3 + ((firstThu.getUTCDay() + 6) % 7)) / 7);
77
+ return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
78
+ }
79
+
80
+ // Group the per-day cost series (agg.byDay) into day|week|month buckets. Pure.
81
+ export function trendBuckets(byDay, bucket = 'month') {
82
+ const keyOf = (date) => (bucket === 'day' ? date : bucket === 'week' ? isoWeek(date) : String(date).slice(0, 7));
83
+ const map = {};
84
+ for (const d of byDay || []) {
85
+ if (!d.date || d.date === '?') continue;
86
+ const k = keyOf(d.date);
87
+ (map[k] = map[k] || { period: k, cost: 0, count: 0 }).cost += d.cost;
88
+ map[k].count += d.count;
89
+ }
90
+ return Object.values(map).sort((a, b) => a.period.localeCompare(b.period)).map((b) => ({ ...b, cost: round4(b.cost) }));
91
+ }
92
+
93
+ // Run-rate projection from the last `windowDays` of activity. Pure; nowStr = 'YYYY-MM-DD'
94
+ // (defaults to the latest day seen). Honest: a flat run-rate, not a fitted forecast.
95
+ export function projectSpend(byDay, { nowStr, windowDays = 30, horizonDays = 30 } = {}) {
96
+ const days = (byDay || []).filter((d) => d.date && d.date !== '?');
97
+ if (!days.length) return { dailyRate: 0, projected: 0, windowDays, horizonDays, basisDays: 0, basisTotal: 0 };
98
+ const now = nowStr || days[days.length - 1].date;
99
+ const cutoff = shiftDate(now, -windowDays);
100
+ const recent = days.filter((d) => d.date > cutoff);
101
+ const basisTotal = recent.reduce((s, d) => s + d.cost, 0);
102
+ const dailyRate = basisTotal / windowDays;
103
+ return { dailyRate: round4(dailyRate), projected: round4(dailyRate * horizonDays), windowDays, horizonDays, basisDays: recent.length, basisTotal: round4(basisTotal) };
104
+ }
105
+
106
+ // A generated vault note: cost by month + projection + top models. Overwrites (it is generated).
107
+ export function renderTrendNote(agg, proj, dateStr) {
108
+ const rows = trendBuckets(agg.byDay, 'month').map((b) => `| ${b.period} | ${usd(b.cost)} | ${b.count} |`).join('\n');
109
+ const models = agg.byModel.slice(0, 8).map((m) => `| ${m.model} | ${usd(m.cost)} | ${m.count} |`).join('\n');
110
+ return `---
111
+ type: cost-trend
112
+ date: ${dateStr}
113
+ cssclasses:
114
+ - topic-dashboard
115
+ tags:
116
+ - custo
117
+ ---
118
+
119
+ # Custo — tendência
120
+
121
+ > Gerado por \`wendkeep cost --write\`. ${agg.count} sessão(ões) · total ${usd(agg.total)} · ${usd(agg.avg)}/sessão.
122
+
123
+ ## Por mês
124
+
125
+ | Mês | Custo | Sessões |
126
+ |---|---|---|
127
+ ${rows || '| — | — | — |'}
128
+
129
+ ## Projeção (run-rate ${proj.windowDays}d)
130
+
131
+ Base: ${usd(proj.basisTotal)} nos últimos ${proj.windowDays} dias (${proj.basisDays} dia(s) com atividade) → **${usd(proj.dailyRate)}/dia**.
132
+ Projeção próximos ${proj.horizonDays} dias: **${usd(proj.projected)}**.
133
+
134
+ > Run-rate simples (média diária × horizonte), não previsão ajustada.
135
+
136
+ ## Por modelo
137
+
138
+ | Modelo | Custo | Sessões |
139
+ |---|---|---|
140
+ ${models || '| — | — | — |'}
141
+ `;
142
+ }
143
+
57
144
  function walkNotes(dir) {
58
145
  const out = [];
59
146
  let names;
@@ -111,6 +198,24 @@ export function runCost(argv) {
111
198
  process.exit(0);
112
199
  }
113
200
 
201
+ const trendIdx = argv.indexOf('--trend');
202
+ if (trendIdx >= 0) {
203
+ const bucket = ['day', 'week', 'month'].includes(argv[trendIdx + 1]) ? argv[trendIdx + 1] : 'month';
204
+ const proj = projectSpend(agg.byDay);
205
+ process.stdout.write(`Tendência de custo (por ${bucket}):\n`);
206
+ for (const b of trendBuckets(agg.byDay, bucket)) process.stdout.write(` ${b.period} ${usd(b.cost).padStart(11)} (${b.count})\n`);
207
+ process.stdout.write(`\nRun-rate ${proj.windowDays}d: ${usd(proj.dailyRate)}/dia · projeção ${proj.horizonDays}d: ${usd(proj.projected)} (base ${usd(proj.basisTotal)} em ${proj.basisDays} dia(s))\n`);
208
+ process.exit(0);
209
+ }
210
+
211
+ if (argv.includes('--write')) {
212
+ const proj = projectSpend(agg.byDay);
213
+ const rel = '00-Custo.md';
214
+ writeFileSync(join(vaultBase, rel), renderTrendNote(agg, proj, today()), 'utf8');
215
+ process.stdout.write(`cost --write: ${rel} (nota de tendência gerada)\n`);
216
+ process.exit(0);
217
+ }
218
+
114
219
  process.stdout.write(`Custo total (vault): ${usd(agg.total)} — ${agg.count} sessão(ões) · ${usd(agg.avg)}/sessão\n`);
115
220
  process.stdout.write(` main: ${usd(agg.main)} · subagents: ${usd(agg.sub)}\n`);
116
221
  if (agg.wasted) process.stdout.write(` desperdiçado (runs killed/failed): ${usd(agg.wasted)}\n`);
package/src/stats.mjs ADDED
@@ -0,0 +1,45 @@
1
+ // `wendkeep stats` — one shareable line about what the vault has captured. For the npm page,
2
+ // a README badge line, or a tweet. Read-only; reuses the cost aggregation.
3
+ import { existsSync } from 'node:fs';
4
+ import { isAbsolute, resolve } from 'node:path';
5
+ import { collectVaultCost } from './cost.mjs';
6
+
7
+ const usd = (n) => `$${(Number(n) || 0).toFixed(2)}`;
8
+ const num = (n) => String(Math.round(Number(n) || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, '.');
9
+
10
+ // Pure: derive the shareable stats from a collectVaultCost aggregate.
11
+ export function statsFrom(agg) {
12
+ const days = (agg.byDay || []).map((d) => d.date).filter((d) => d && d !== '?').sort();
13
+ return {
14
+ sessions: agg.count,
15
+ prompts: agg.prompts || 0,
16
+ cost: agg.total,
17
+ models: (agg.byModel || []).length,
18
+ firstDay: days[0] || '',
19
+ lastDay: days[days.length - 1] || '',
20
+ spanDays: days.length,
21
+ };
22
+ }
23
+
24
+ export function statsLine(s) {
25
+ const span = s.firstDay && s.lastDay ? ` · ${s.spanDays} dia(s) (${s.firstDay}→${s.lastDay})` : '';
26
+ return `wendkeep: ${num(s.sessions)} sessão(ões) · ${num(s.prompts)} prompts · ${usd(s.cost)} capturado${span} · ${s.models} modelo(s)`;
27
+ }
28
+
29
+ function opt(argv, name) {
30
+ const i = argv.indexOf(name);
31
+ if (i >= 0) return argv[i + 1];
32
+ const eq = argv.find((a) => a.startsWith(`${name}=`));
33
+ return eq ? eq.slice(name.length + 1) : undefined;
34
+ }
35
+
36
+ export function runStats(argv) {
37
+ const vaultRaw = opt(argv, '--vault') || process.env.OBSIDIAN_VAULT_PATH;
38
+ if (!vaultRaw) { process.stderr.write('wendkeep stats: no vault (--vault or OBSIDIAN_VAULT_PATH).\n'); process.exit(2); }
39
+ const vaultBase = isAbsolute(vaultRaw) ? vaultRaw : resolve(process.cwd(), vaultRaw);
40
+ if (!existsSync(vaultBase)) { process.stderr.write(`wendkeep stats: vault not found: ${vaultBase}\n`); process.exit(2); }
41
+ const s = statsFrom(collectVaultCost(vaultBase));
42
+ if (argv.includes('--json')) { process.stdout.write(`${JSON.stringify(s, null, 2)}\n`); process.exit(0); }
43
+ process.stdout.write(`${statsLine(s)}\n`);
44
+ process.exit(0);
45
+ }