wendkeep 0.23.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,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.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
+
21
+ ## [0.24.0] — 2026-07-08
22
+
23
+ ### Changed
24
+ - **No companion is pre-selected anymore.** `context-mode` was pre-checked (and the
25
+ non-interactive default); wendkeep is a neutral harness and should not presume a third-party
26
+ plugin. The interactive picker now starts with **nothing checked**, `init --yes` (and any
27
+ non-interactive run) installs **no** companions, and `resolveCompanions({})` returns `[]`.
28
+ Opt in explicitly — interactively (Space) or `--companions context-mode`. `src/taxonomy.mjs`.
29
+ - Prompt/help/README text updated to reflect the empty default.
30
+
7
31
  ## [0.23.0] — 2026-07-08
8
32
 
9
33
  Vault structure — generated views + housekeeping (audit wave 2).
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
  ---
@@ -65,7 +77,7 @@ npx wendkeep init --no-companions --no-mcp --yes # zero companions,
65
77
  | `--vault <path>` | Vault folder. Default `<project>/.<project-name>-vault`; interactive init asks. Point it at an existing vault to install into it. |
66
78
  | `--project <path>` | Project root to wire (default: current directory). |
67
79
  | `--locale <pt-BR\|en>` | Vault language — folder names, scaffold, skills. Interactive init asks; locked at init. |
68
- | `--companions <csv>` | Companions to pin: `context-mode,caveman,understand-anything` (default: `context-mode`; `dotcontext` is legacy). |
80
+ | `--companions <csv>` | Companions to pin: `context-mode,caveman,understand-anything` (default: **none** — opt in explicitly; `dotcontext` is legacy). |
69
81
  | `--no-companions` | Pin no companions. |
70
82
  | `--no-mcp` | Skip **wendkeep's own** vault MCP (`wendkeep-vault`). Companion MCPs still follow `--companions`. |
71
83
  | `--no-colors` | Skip the Obsidian color system (`.obsidian` snippet + graph groups). |
package/bin/wendkeep.mjs CHANGED
@@ -30,7 +30,7 @@ Usage:
30
30
  --project <path> Project root to wire (default: current directory).
31
31
  --no-mcp Do not add the mcpvault MCP server to .mcp.json.
32
32
  --companions <csv> Companion plugins/MCP to pin: context-mode,caveman,understand-anything
33
- (default: context-mode). dotcontext is legacy — the native a2 loop replaces it.
33
+ (default: none — opt in explicitly). dotcontext is legacy — the native a2 loop replaces it.
34
34
  --no-companions Skip companion plugins/MCP entirely.
35
35
  --no-colors Skip the Obsidian color system (.obsidian snippet + graph groups).
36
36
  --dotcontext-mcp <v> dotcontext MCP placement: auto (default; skip project entry
@@ -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.23.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/init.mjs CHANGED
@@ -207,13 +207,13 @@ function installVaultColors(vaultPath) {
207
207
  const PROMPTS = {
208
208
  'pt-BR': {
209
209
  vault: (f) => `Caminho do vault Obsidian (Enter aceita o padrão, ou digite outro)\n [${f}]\n> `,
210
- companionsHeader: '\nCompanions (plugins/MCP opcionais — context-mode é a principal):',
210
+ companionsHeader: '\nCompanions (plugins/MCP opcionais — nenhum vem pré-marcado):',
211
211
  companionsAsk: (def) => `Digite os ids separados por vírgula (Enter aceita [${def}], "none" p/ nenhum): `,
212
212
  menu: { hint: 'Espaço marca/desmarca · ↑/↓ move · a=todos · n=nenhum · Enter confirma', header: 'Companions' },
213
213
  },
214
214
  en: {
215
215
  vault: (f) => `Obsidian vault path (Enter for the default, or type another)\n [${f}]\n> `,
216
- companionsHeader: '\nCompanions (optional plugins/MCP — context-mode is the main one):',
216
+ companionsHeader: '\nCompanions (optional plugins/MCP — none pre-selected):',
217
217
  companionsAsk: (def) => `Enter ids comma-separated (Enter for [${def}], "none" for none): `,
218
218
  menu: { hint: 'Space toggles · ↑/↓ move · a=all · n=none · Enter confirms', header: 'Companions' },
219
219
  },
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
+ }
package/src/taxonomy.mjs CHANGED
@@ -102,8 +102,10 @@ export function hookCommand(name) {
102
102
  export const COMPANIONS = [
103
103
  {
104
104
  id: 'context-mode',
105
- label: 'context-mode — otimizador de contexto + memória FTS5 (principal)',
106
- default: true,
105
+ label: 'context-mode — otimizador de contexto + memória FTS5 (opcional)',
106
+ // Not a default: wendkeep is a neutral harness and does not presume a third-party plugin.
107
+ // Opt in interactively or with `--companions context-mode`.
108
+ default: false,
107
109
  marketplace: { source: 'git', url: 'https://github.com/mksglu/context-mode.git' },
108
110
  plugin: 'context-mode',
109
111
  // Agent-agnostic: MCP server, self-updating via unpinned npx.