wendkeep 0.66.3 → 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.
@@ -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
  }