wendkeep 0.66.4 → 0.66.5

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.
@@ -510,16 +510,19 @@ function detectTranscriptFormat(lines) {
510
510
  return 'codex';
511
511
  }
512
512
 
513
- export function parseTokenUsageFromTranscript(transcriptPath) {
513
+ export function parseTokenUsageFromContent(content, { transcriptPath = '' } = {}) {
514
514
  const result = emptyParseResult(transcriptPath);
515
- if (!transcriptPath || !existsSync(transcriptPath)) return result;
516
-
517
- const lines = readFileSync(transcriptPath, 'utf-8').split('\n').filter(Boolean);
515
+ const lines = String(content || '').split('\n').filter(Boolean);
518
516
  return detectTranscriptFormat(lines) === 'claude'
519
517
  ? parseClaudeLines(lines, result)
520
518
  : parseCodexLines(lines, result);
521
519
  }
522
520
 
521
+ export function parseTokenUsageFromTranscript(transcriptPath) {
522
+ if (!transcriptPath || !existsSync(transcriptPath)) return emptyParseResult(transcriptPath);
523
+ return parseTokenUsageFromContent(readFileSync(transcriptPath, 'utf-8'), { transcriptPath });
524
+ }
525
+
523
526
  function modelCost(usage, model) {
524
527
  const normalized = normalizeModelName(model);
525
528
  const price = PRICE_REFERENCE[normalized];
@@ -935,6 +938,80 @@ export function collectSessionUsage({ sessionContent, transcriptPath }) {
935
938
  };
936
939
  }
937
940
 
941
+ function normalizedTranscriptKey(value) {
942
+ return String(value || '').replace(/\.jsonl?$/i, '').trim().toLowerCase();
943
+ }
944
+
945
+ // Rebuild the main bucket from every validated top-level rollout. Existing history is used
946
+ // only to preserve stable timestamps; an entry that is neither a known root nor a proven
947
+ // descendant makes the reconstruction fail closed instead of silently deleting a legitimate
948
+ // reopening.
949
+ export function collectSessionUsageForRoots({
950
+ sessionContent,
951
+ rootPaths = [],
952
+ descendantIds = [],
953
+ } = {}) {
954
+ const fmMatch = String(sessionContent || '').match(/^---\n([\s\S]*?)\n---/);
955
+ if (!fmMatch) return null;
956
+
957
+ const roots = [...new Set(rootPaths.filter(Boolean))].sort((a, b) => String(a).localeCompare(String(b)));
958
+ const rootKeys = new Set(roots.map((path) => normalizedTranscriptKey(transcriptIdFromPath(path))));
959
+ const descendantKeys = new Set(descendantIds.map(normalizedTranscriptKey).filter(Boolean));
960
+ const existingEntries = parseUsageHistory(fmMatch[1]);
961
+ const unresolved = existingEntries.filter((entry) => {
962
+ const key = normalizedTranscriptKey(entry.transcript_id);
963
+ return key && !rootKeys.has(key) && !descendantKeys.has(key);
964
+ });
965
+ if (unresolved.length) {
966
+ return {
967
+ state: 'degraded',
968
+ diagnostics: [{ code: 'MAIN_TRANSCRIPT_UNRESOLVED', count: unresolved.length }],
969
+ entries: existingEntries,
970
+ content: sessionContent,
971
+ };
972
+ }
973
+
974
+ const entries = [];
975
+ const summaries = [];
976
+ for (const transcriptPath of roots) {
977
+ if (!existsSync(transcriptPath)) {
978
+ return {
979
+ state: 'degraded',
980
+ diagnostics: [{ code: 'MAIN_TRANSCRIPT_UNRESOLVED', count: 1 }],
981
+ entries: existingEntries,
982
+ content: sessionContent,
983
+ };
984
+ }
985
+ const summary = summarizeTokenUsage(parseTokenUsageFromTranscript(transcriptPath));
986
+ if (!summary.calls) {
987
+ return {
988
+ state: 'degraded',
989
+ diagnostics: [{ code: 'MAIN_TRANSCRIPT_UNRESOLVED', count: 1 }],
990
+ entries: existingEntries,
991
+ content: sessionContent,
992
+ };
993
+ }
994
+ const transcriptId = transcriptIdFromPath(transcriptPath);
995
+ const current = entryFromSummary(summary, transcriptId);
996
+ const previous = existingEntries.find((entry) => normalizedTranscriptKey(entry.transcript_id) === normalizedTranscriptKey(transcriptId));
997
+ if (previous && sameUsageData(previous, current)) current.atualizado_em = previous.atualizado_em;
998
+ entries.push(current);
999
+ summaries.push(summary);
1000
+ }
1001
+
1002
+ const agg = aggregateEntries(entries);
1003
+ const content = upsertSessionFrontmatter(sessionContent, agg, entries);
1004
+ if (content === null) return null;
1005
+ return {
1006
+ state: 'complete',
1007
+ diagnostics: [],
1008
+ summaries,
1009
+ aggregate: agg,
1010
+ entries,
1011
+ content,
1012
+ };
1013
+ }
1014
+
938
1015
  export function updateSessionUsage({ vaultBase, sessionRel, sessionPath, transcriptPath, lockTimeoutMs }) {
939
1016
  if (!sessionPath || !existsSync(sessionPath)) return null;
940
1017
  let result = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.66.4",
3
+ "version": "0.66.5",
4
4
  "description": "Vault-first persistent memory for AI coding agents, with an optional profile-aware governance runtime: OFF, FLOW, GUIDE, GOVERN, or ASSURE. Local-first and agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "workspaces": [
package/src/cost.mjs CHANGED
@@ -196,19 +196,53 @@ function opt(argv, name) {
196
196
  return eq ? eq.slice(name.length + 1) : undefined;
197
197
  }
198
198
 
199
+ const REBUILD_LIMIT_FLAGS = [
200
+ ['--max-graph-nodes', 'maxGraphNodes'],
201
+ ['--max-fallback-days', 'maxFallbackDays'],
202
+ ['--max-fallback-candidates', 'maxFallbackCandidates'],
203
+ ];
204
+
205
+ export function parseRebuildOptions(argv = []) {
206
+ const session = opt(argv, '--session') || '';
207
+ const overrides = {};
208
+ for (const [flag, key] of REBUILD_LIMIT_FLAGS) {
209
+ const present = argv.includes(flag) || argv.some((arg) => arg.startsWith(`${flag}=`));
210
+ if (!present) continue;
211
+ const value = Number(opt(argv, flag));
212
+ if (!Number.isSafeInteger(value) || value <= 0) {
213
+ return { ok: false, exitCode: 2, code: 'INVALID_LIMIT_OVERRIDE' };
214
+ }
215
+ overrides[key] = value;
216
+ }
217
+ if (Object.keys(overrides).length > 0 && !session) {
218
+ return { ok: false, exitCode: 2, code: 'TARGET_REQUIRED_FOR_LIMIT_OVERRIDE' };
219
+ }
220
+ return {
221
+ ok: true,
222
+ options: {
223
+ apply: argv.includes('--apply'),
224
+ session,
225
+ limit: Number(opt(argv, '--limit')) || 0,
226
+ limits: { ...overrides },
227
+ overrides: { ...overrides },
228
+ },
229
+ };
230
+ }
231
+
199
232
  export function runCost(argv) {
200
233
  const vaultRaw = opt(argv, '--vault') || process.env.OBSIDIAN_VAULT_PATH;
201
234
  if (!vaultRaw) { process.stderr.write('wendkeep cost: no vault (--vault or OBSIDIAN_VAULT_PATH).\n'); process.exit(2); }
202
235
  const vaultBase = isAbsolute(vaultRaw) ? vaultRaw : resolve(process.cwd(), vaultRaw);
203
236
  if (!existsSync(vaultBase)) { process.stderr.write(`wendkeep cost: vault not found: ${vaultBase}\n`); process.exit(2); }
204
237
  if (argv[0] === 'rebuild') {
205
- const report = rebuildSessionCosts(vaultBase, {
206
- apply: argv.includes('--apply'),
207
- session: opt(argv, '--session') || '',
208
- limit: Number(opt(argv, '--limit')) || 0,
209
- });
238
+ const parsed = parseRebuildOptions(argv);
239
+ if (!parsed.ok) {
240
+ process.stderr.write(`wendkeep cost rebuild: ${parsed.code}\n`);
241
+ process.exit(parsed.exitCode);
242
+ }
243
+ const report = rebuildSessionCosts(vaultBase, parsed.options);
210
244
  if (argv.includes('--json')) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
211
- else process.stdout.write(`cost rebuild (${report.mode}): ${report.scanned} lidas · ${report.changed} alteradas · ${report.unchanged} iguais · ${report.missing.length} sem fonte · ${report.errors.length} erros\n${report.mode === 'apply' ? 'Relatório: .brain/COST_REBUILD.json\n' : 'Nenhum arquivo foi alterado; use --apply para gravar.\n'}`);
245
+ else process.stdout.write(`cost rebuild (${report.mode}): ${report.scanned} lidas · ${report.changed} alteradas · ${report.unchanged} iguais · ${report.degraded} degradadas · ${report.stale} stale · ${report.missing} sem fonte · ${report.errors} erros\n${report.mode === 'apply' ? 'Relatório: .brain/COST_REBUILD.json\n' : 'Nenhum arquivo foi alterado; use --apply para gravar.\n'}`);
212
246
  process.exit(report.ok ? 0 : 1);
213
247
  }
214
248
  const agg = collectVaultCost(vaultBase, { since: opt(argv, '--since') });
package/src/doctor.mjs CHANGED
@@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process';
4
4
  import { existsSync } from 'node:fs';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { checkHarness, checkVaultLinks, checkSessionActivity, checkStackedFrontmatter, renderStackedFrontmatterLines, checkUnpricedModels, renderUnpricedModelLines, checkStaleDerivedSections, renderStaleDerivedSectionLines } from '../hooks/harness-doctor.mjs';
7
+ import { checkHarness, checkVaultLinks, checkSessionActivity, checkStackedFrontmatter, renderStackedFrontmatterLines, checkUnpricedModels, renderUnpricedModelLines, checkStaleDerivedSections, renderStaleDerivedSectionLines, checkSessionObservability, renderSessionObservabilityLines } from '../hooks/harness-doctor.mjs';
8
8
  import { checkSyncDefs } from './sync-defs.mjs';
9
9
  import { resolveProjectVault } from './project-vault.mjs';
10
10
 
@@ -79,6 +79,9 @@ export function runDoctor(argv) {
79
79
  // 3d. Seções derivadas do corpo que ficaram para trás do Encerramento (notas pré-0.53.0).
80
80
  process.stdout.write(`\n${renderStaleDerivedSectionLines(checkStaleDerivedSections(vaultBase)).join('\n')}\n`);
81
81
 
82
+ // 3e. Observabilidade materializada: schema vigente não basta sem frontier + manifest frescos.
83
+ process.stdout.write(`\n${renderSessionObservabilityLines(checkSessionObservability(vaultBase)).join('\n')}\n`);
84
+
82
85
  // 4. Sessão: não mente "inativa" quando há atividade recente (workflow/subagente em background).
83
86
  const act = checkSessionActivity(vaultBase);
84
87
  if (act.lastSession) {
@@ -1,45 +1,231 @@
1
- // Deterministic cost reconstruction for historical sessions.
2
- // Registry is authoritative: session_file <-> transcript_path. Dry-run restores every note.
3
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { join } from 'node:path';
1
+ // Deterministic, causal reconstruction for historical session observability.
2
+ // Dry-run is a pure composition pass; apply delegates all note mutation to the CAS publisher.
3
+ import { createHash } from 'node:crypto';
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
5
6
  import { readSessionRegistry } from '../hooks/obsidian-common.mjs';
6
- import { updateSessionObservability } from '../hooks/session-observability.mjs';
7
+ import * as sessionObservability from '../hooks/session-observability.mjs';
8
+ import {
9
+ mutateObservabilityStore,
10
+ readObservabilityStore,
11
+ } from '../hooks/session-observability-store.mjs';
12
+ import { sanitizeObservabilityDiagnostics } from '../hooks/session-observability-state.mjs';
7
13
  import { assertVaultPathSafe } from '../hooks/vault-path-safety.mjs';
8
14
 
9
- export function rebuildSessionCosts(vaultBase, { apply = false, session = '', limit = 0 } = {}) {
10
- const registry = readSessionRegistry(vaultBase);
11
- const report = { version: 1, generatedAt: new Date().toISOString(), mode: apply ? 'apply' : 'dry-run', scanned: 0, changed: 0, unchanged: 0, missing: [], errors: [], sessions: [] };
12
- const entries = Object.entries(registry.sessions || {}).map(([sessionId, value]) => ({ sessionId, ...value }))
13
- .filter((e) => e.session_file)
14
- .filter((e) => !session || e.sessionId === session || e.session_file === session);
15
- for (const entry of entries) {
15
+ function sortedEntries(registry, target) {
16
+ return Object.entries(registry?.sessions || {})
17
+ .map(([sessionId, value]) => ({ sessionId, ...value }))
18
+ .filter((entry) => entry.session_file)
19
+ .filter((entry) => !target || entry.sessionId === target || entry.session_file === target)
20
+ .sort((a, b) => a.sessionId.localeCompare(b.sessionId));
21
+ }
22
+
23
+ function transcriptCandidates(entry) {
24
+ const paths = new Set();
25
+ if (entry.transcript_path) paths.add(entry.transcript_path);
26
+ for (const path of entry.transcript_paths || []) if (path) paths.add(path);
27
+ const activations = Array.isArray(entry.activations)
28
+ ? entry.activations
29
+ : Object.values(entry.activations || {});
30
+ for (const activation of activations) {
31
+ if (activation?.transcript_path) paths.add(activation.transcript_path);
32
+ for (const path of activation?.transcript_paths || []) if (path) paths.add(path);
33
+ }
34
+ return [...paths];
35
+ }
36
+
37
+ function candidateContent(candidate, fallback) {
38
+ return typeof candidate?.content === 'string' ? candidate.content : fallback;
39
+ }
40
+
41
+ function candidateHash(candidate, fallback) {
42
+ return createHash('sha256').update(candidateContent(candidate, fallback)).digest('hex');
43
+ }
44
+
45
+ function safeDiagnostics(input, fallback = []) {
46
+ try {
47
+ return sanitizeObservabilityDiagnostics(input || fallback);
48
+ } catch {
49
+ return sanitizeObservabilityDiagnostics(fallback);
50
+ }
51
+ }
52
+
53
+ export function semanticRebuildReport(report) {
54
+ const {
55
+ generatedAt: _generatedAt,
56
+ changed = 0,
57
+ unchanged = 0,
58
+ sessions = [],
59
+ ...semantic
60
+ } = report || {};
61
+ return {
62
+ ...semantic,
63
+ converged: Number(changed || 0) + Number(unchanged || 0),
64
+ sessions: sessions.map((entry) => ({
65
+ ...entry,
66
+ status: entry.status === 'published' || entry.status === 'unchanged'
67
+ ? 'converged'
68
+ : entry.status,
69
+ })),
70
+ };
71
+ }
72
+
73
+ export function writeRebuildReportIfChanged(reportPath, report) {
74
+ if (existsSync(reportPath)) {
75
+ try {
76
+ const previous = JSON.parse(readFileSync(reportPath, 'utf8'));
77
+ if (JSON.stringify(semanticRebuildReport(previous))
78
+ === JSON.stringify(semanticRebuildReport(report))) return false;
79
+ } catch {
80
+ // Invalid prior reports are replaced by the sanitized current schema.
81
+ }
82
+ }
83
+ mkdirSync(dirname(reportPath), { recursive: true });
84
+ writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
85
+ return true;
86
+ }
87
+
88
+ function markDirtyDefault(vaultBase, sessionId, diagnostics) {
89
+ mutateObservabilityStore(vaultBase, sessionId, (state) => ({
90
+ ...state,
91
+ observability_dirty: true,
92
+ diagnostics: safeDiagnostics(diagnostics, [{ code: 'STALE_FRONTIER', count: 1 }]),
93
+ }));
94
+ }
95
+
96
+ export function rebuildSessionCosts(
97
+ vaultBase,
98
+ {
99
+ apply = false,
100
+ session = '',
101
+ limit = 0,
102
+ limits = {},
103
+ overrides = {},
104
+ } = {},
105
+ effects = {},
106
+ ) {
107
+ const readRegistry = effects.readRegistry || readSessionRegistry;
108
+ const compose = effects.compose || sessionObservability.composeSessionObservability;
109
+ const publish = effects.publish || sessionObservability.publishSessionObservability;
110
+ const readStore = effects.readStore || readObservabilityStore;
111
+ const markDirty = effects.markDirty || markDirtyDefault;
112
+ const writeReport = effects.writeReport || writeRebuildReportIfChanged;
113
+ const now = effects.now || (() => new Date().toISOString());
114
+ if (typeof compose !== 'function') throw new TypeError('composeSessionObservability indisponível');
115
+ if (apply && typeof publish !== 'function') throw new TypeError('publishSessionObservability indisponível');
116
+
117
+ const registry = readRegistry(vaultBase);
118
+ const report = {
119
+ version: 2,
120
+ generatedAt: now(),
121
+ mode: apply ? 'apply' : 'dry-run',
122
+ targeted: Boolean(session),
123
+ overrides: { ...overrides },
124
+ scanned: 0,
125
+ changed: 0,
126
+ unchanged: 0,
127
+ degraded: 0,
128
+ stale: 0,
129
+ missing: 0,
130
+ errors: 0,
131
+ ok: true,
132
+ sessions: [],
133
+ };
134
+
135
+ for (const entry of sortedEntries(registry, session)) {
16
136
  if (limit && report.scanned >= limit) break;
17
137
  report.scanned += 1;
18
- const checkedNote = assertVaultPathSafe(vaultBase, join(vaultBase, entry.session_file), {
19
- expectedType: 'file', label: 'nota de sessão do rebuild de custos',
20
- });
21
- const note = checkedNote.target;
22
- if (!entry.transcript_path || !checkedNote.exists || !existsSync(entry.transcript_path)) {
23
- report.missing.push({ sessionId: entry.sessionId, session: entry.session_file, note: checkedNote.exists, transcript: !!entry.transcript_path && existsSync(entry.transcript_path), transcriptPath: entry.transcript_path || '' });
24
- continue;
25
- }
26
- const before = readFileSync(note, 'utf8');
138
+ let note;
27
139
  try {
28
- updateSessionObservability({
29
- vaultBase, sessionPath: note, transcriptPath: entry.transcript_path,
30
- caller: 'cost-rebuild', canonicalConversationId: entry.sessionId,
140
+ const checked = assertVaultPathSafe(vaultBase, join(vaultBase, entry.session_file), {
141
+ expectedType: 'file', label: 'nota de sessão do rebuild de custos',
31
142
  });
32
- const after = readFileSync(note, 'utf8');
33
- const changed = before !== after;
34
- if (changed) report.changed += 1; else report.unchanged += 1;
35
- report.sessions.push({ sessionId: entry.sessionId, session: entry.session_file, transcript: entry.transcript_path, changed });
36
- if (!apply && changed) writeFileSync(note, before, 'utf8');
37
- } catch (error) {
38
- if (!apply) writeFileSync(note, before, 'utf8');
39
- report.errors.push({ sessionId: entry.sessionId, session: entry.session_file, error: error.message });
143
+ const hasTranscript = transcriptCandidates(entry).some((path) => existsSync(path));
144
+ if (!checked.exists || !hasTranscript) {
145
+ report.missing += 1;
146
+ report.sessions.push({
147
+ sessionId: entry.sessionId, status: 'missing', diagnostics: [],
148
+ });
149
+ continue;
150
+ }
151
+ note = checked.target;
152
+ const before = readFileSync(note, 'utf8');
153
+ const runtimeState = readStore(vaultBase, entry.sessionId);
154
+ const candidate = compose({
155
+ vaultBase,
156
+ sessionContent: before,
157
+ sessionEntry: entry,
158
+ canonicalConversationId: entry.sessionId,
159
+ caller: 'cost-rebuild',
160
+ mode: 'offline',
161
+ limits,
162
+ runtimeState,
163
+ });
164
+ const diagnostics = safeDiagnostics(candidate?.diagnostics);
165
+ const contentHash = candidateHash(candidate, before);
166
+ if (candidate?.state === 'degraded') {
167
+ report.degraded += 1;
168
+ report.sessions.push({ sessionId: entry.sessionId, status: 'degraded', diagnostics });
169
+ if (apply) markDirty(vaultBase, entry.sessionId, diagnostics);
170
+ continue;
171
+ }
172
+ if (candidate?.state !== 'complete' && candidate?.state !== 'none') {
173
+ report.degraded += 1;
174
+ const invalidDiagnostics = [{ code: 'PARENT_META_INVALID', count: 1 }];
175
+ report.sessions.push({
176
+ sessionId: entry.sessionId, status: 'degraded', diagnostics: invalidDiagnostics,
177
+ });
178
+ if (apply) markDirty(vaultBase, entry.sessionId, invalidDiagnostics);
179
+ continue;
180
+ }
181
+
182
+ if (!apply) {
183
+ const changed = candidateContent(candidate, before) !== before;
184
+ if (changed) report.changed += 1;
185
+ else report.unchanged += 1;
186
+ report.sessions.push({
187
+ sessionId: entry.sessionId,
188
+ status: changed ? 'would-change' : 'unchanged',
189
+ candidateHash: contentHash,
190
+ diagnostics,
191
+ });
192
+ continue;
193
+ }
194
+
195
+ const outcome = publish({
196
+ vaultBase,
197
+ sessionPath: note,
198
+ canonicalConversationId: entry.sessionId,
199
+ candidate,
200
+ caller: 'cost-rebuild',
201
+ mode: 'offline',
202
+ allowSourceRefresh: true,
203
+ }) || { status: 'degraded' };
204
+ if (outcome.status === 'published') report.changed += 1;
205
+ else if (outcome.status === 'unchanged') report.unchanged += 1;
206
+ else if (outcome.status === 'stale' || outcome.status === 'conflict') {
207
+ report.stale += 1;
208
+ markDirty(vaultBase, entry.sessionId, [{ code: 'STALE_FRONTIER', count: 1 }]);
209
+ } else {
210
+ report.degraded += 1;
211
+ markDirty(vaultBase, entry.sessionId, [{ code: 'PARENT_META_INVALID', count: 1 }]);
212
+ }
213
+ report.sessions.push({
214
+ sessionId: entry.sessionId,
215
+ status: outcome.status || 'degraded',
216
+ candidateHash: contentHash,
217
+ diagnostics: safeDiagnostics(outcome.diagnostics, diagnostics),
218
+ });
219
+ } catch {
220
+ report.errors += 1;
221
+ const diagnostics = [{ code: 'PARENT_META_INVALID', count: 1 }];
222
+ report.sessions.push({ sessionId: entry.sessionId, status: 'degraded', diagnostics });
223
+ if (apply) markDirty(vaultBase, entry.sessionId, diagnostics);
40
224
  }
41
225
  }
42
- report.ok = report.errors.length === 0 && report.missing.length === 0;
43
- if (apply) writeFileSync(join(vaultBase, '.brain', 'COST_REBUILD.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
226
+
227
+ report.ok = report.degraded === 0 && report.stale === 0
228
+ && report.missing === 0 && report.errors === 0;
229
+ if (apply) writeReport(join(vaultBase, '.brain', 'COST_REBUILD.json'), report);
44
230
  return report;
45
231
  }