wendkeep 0.68.6 → 0.70.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.en.md +7 -3
  3. package/README.md +7 -3
  4. package/docs/en/commands/changes-and-verification.md +4 -0
  5. package/docs/en/commands/costs-and-observability.md +11 -2
  6. package/docs/en/commands/memory.md +10 -1
  7. package/docs/en/commands/observer.md +104 -0
  8. package/docs/pt-BR/commands/changes-and-verification.md +4 -0
  9. package/docs/pt-BR/commands/costs-and-observability.md +12 -2
  10. package/docs/pt-BR/commands/memory.md +10 -1
  11. package/docs/pt-BR/commands/observer.md +105 -0
  12. package/hooks/brain-core.mjs +46 -2
  13. package/hooks/brain-inject.mjs +3 -3
  14. package/hooks/harness-doctor.mjs +21 -7
  15. package/hooks/observer-publish.mjs +21 -0
  16. package/hooks/pricing.json +10 -1
  17. package/hooks/session-ensure.mjs +23 -0
  18. package/hooks/session-identity.mjs +4 -2
  19. package/hooks/session-stop.mjs +17 -0
  20. package/hooks/token-usage.mjs +13 -0
  21. package/hooks/vault-health.mjs +13 -0
  22. package/package.json +3 -3
  23. package/packages/cli/src/index.mjs +9 -2
  24. package/packages/integrations/src/host-hooks.mjs +4 -0
  25. package/packages/vault/src/memory-handoff.mjs +75 -9
  26. package/packages/vault/src/memory-store.mjs +34 -6
  27. package/packages/vault/src/validate-core.mjs +29 -15
  28. package/packages/vault/src/validate-memory.mjs +209 -6
  29. package/src/doctor.mjs +4 -0
  30. package/src/memory.mjs +4 -1
  31. package/src/observer-publish.mjs +122 -0
  32. package/src/observer-server.mjs +203 -0
  33. package/src/observer-snapshot.mjs +153 -0
  34. package/src/observer-store.mjs +155 -0
  35. package/src/observer.mjs +108 -0
  36. package/src/taxonomy.mjs +2 -0
@@ -1,6 +1,7 @@
1
- import { readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { validateMemoryEvent, validateSharedMemory } from './memory-schema.mjs';
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { basename, join, relative } from 'node:path';
3
+ import { sanitizeMemoryText, validateMemoryEvent, validateSharedMemory } from './memory-schema.mjs';
4
+ import { deriveMemoryProjection } from './memory-store.mjs';
4
5
  import { assertVaultPathSafe } from './vault-path-safety.mjs';
5
6
  import { validateCore } from './validate-core.mjs';
6
7
 
@@ -104,8 +105,208 @@ function validateSharedArtifact(vaultBase, eventIds) {
104
105
  return { ...validateSharedMemory(read.content, { eventIds }), path, content: read.content };
105
106
  }
106
107
 
107
- export function combineMemoryResults({ project, core, ledger, shared }) {
108
- const components = { project, core, ledger, shared };
108
+ const TERMINAL_CANDIDATE_STATUSES = new Set(['resolved', 'rejected', 'superseded']);
109
+ const DECISION_LINK_RE = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g;
110
+
111
+ function readCandidateInventory(vaultBase) {
112
+ const path = join(vaultBase, '.brain', 'MEMORY_CANDIDATES.jsonl');
113
+ let checked;
114
+ try {
115
+ checked = assertVaultPathSafe(vaultBase, path, {
116
+ allowMissing: true, expectedType: 'file', label: 'MEMORY_CANDIDATES.jsonl',
117
+ });
118
+ } catch (error) {
119
+ return failedComponent(`MEMORY_CANDIDATES.jsonl inseguro: ${error?.message || error}`, {
120
+ count: 0, activeCount: 0, path,
121
+ });
122
+ }
123
+ if (!checked.exists) return { ok: true, errors: [], warnings: [], count: 0, activeCount: 0, path };
124
+
125
+ try {
126
+ checked = assertVaultPathSafe(vaultBase, checked.target, {
127
+ allowMissing: false, expectedType: 'file', label: 'MEMORY_CANDIDATES.jsonl',
128
+ });
129
+ const lines = readFileSync(checked.target, 'utf8').replace(/\r\n/g, '\n')
130
+ .split('\n').filter((line) => line.trim());
131
+ const errors = [];
132
+ let count = 0;
133
+ let activeCount = 0;
134
+ for (const [index, line] of lines.entries()) {
135
+ try {
136
+ const item = JSON.parse(line);
137
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
138
+ errors.push(`MEMORY_CANDIDATES.jsonl linha ${index + 1} deve conter um objeto.`);
139
+ continue;
140
+ }
141
+ count += 1;
142
+ if (!TERMINAL_CANDIDATE_STATUSES.has(item.status || 'active')) activeCount += 1;
143
+ } catch (error) {
144
+ errors.push(`MEMORY_CANDIDATES.jsonl linha ${index + 1} contém JSON inválido: ${error?.message || error}`);
145
+ }
146
+ }
147
+ return { ok: errors.length === 0, errors, warnings: [], count, activeCount, path };
148
+ } catch (error) {
149
+ return failedComponent(`MEMORY_CANDIDATES.jsonl ilegível: ${error?.message || error}`, {
150
+ count: 0, activeCount: 0, path,
151
+ });
152
+ }
153
+ }
154
+
155
+ function sharedEventIds(content) {
156
+ const ids = new Set();
157
+ for (const line of String(content || '').split('\n')) {
158
+ const match = line.match(/^\s*-\s+\[([^\]]+)\]/);
159
+ if (match?.[1]) ids.add(match[1]);
160
+ }
161
+ return ids;
162
+ }
163
+
164
+ function walkDecisionFiles(root, vaultBase, output = []) {
165
+ let entries;
166
+ try { entries = readdirSync(root, { withFileTypes: true }); } catch { return output; }
167
+ for (const entry of entries) {
168
+ const path = join(root, entry.name);
169
+ if (entry.isDirectory()) walkDecisionFiles(path, vaultBase, output);
170
+ else if (entry.isFile() && /^ADR-\d+.*\.md$/i.test(entry.name)) {
171
+ const rel = relative(vaultBase, path).replace(/\\/g, '/').replace(/\.md$/i, '');
172
+ output.push(rel);
173
+ }
174
+ }
175
+ return output;
176
+ }
177
+
178
+ function decisionInventory(vaultBase) {
179
+ const paths = [];
180
+ for (const folder of ['04-Decisões', '04-Decisions']) {
181
+ walkDecisionFiles(join(vaultBase, folder), vaultBase, paths);
182
+ }
183
+ const byPath = new Set(paths);
184
+ const byBasename = new Set(paths.map((path) => basename(path)));
185
+ return { byPath, byBasename };
186
+ }
187
+
188
+ function unresolvedDecisionLinks(vaultBase, content) {
189
+ const inventory = decisionInventory(vaultBase);
190
+ const unresolved = new Set();
191
+ DECISION_LINK_RE.lastIndex = 0;
192
+ let match;
193
+ while ((match = DECISION_LINK_RE.exec(String(content || '')))) {
194
+ const target = String(match[1] || '').trim().replace(/\\/g, '/').replace(/\.md$/i, '');
195
+ const targetBase = basename(target);
196
+ const isDecision = /^ADR-\d+/i.test(targetBase)
197
+ || /(^|\/)(?:04-Decis(?:õ|o)es|04-Decisions)(?:\/|$)/i.test(target);
198
+ if (!isDecision) continue;
199
+ if (!target || target.includes('...') || target.includes('…')
200
+ || (!inventory.byPath.has(target) && !inventory.byBasename.has(targetBase))) {
201
+ unresolved.add(sanitizeMemoryText(target).slice(0, 180));
202
+ }
203
+ }
204
+ return [...unresolved].sort();
205
+ }
206
+
207
+ function semanticMemoryHealth(vaultBase, { ledger, shared, candidates }) {
208
+ const base = {
209
+ ok: true,
210
+ status: 'unavailable',
211
+ code: 'MEMORY_SEMANTIC_STRUCTURAL_UNAVAILABLE',
212
+ errors: [],
213
+ warnings: [],
214
+ activeKeys: [],
215
+ projectedKeys: [],
216
+ missingKeys: [],
217
+ unresolvedDecisionLinks: [],
218
+ placeholderOnly: false,
219
+ counts: { activeKeys: 0, projectedKeys: 0, missingKeys: 0, candidates: candidates?.activeCount || 0, placeholderSections: 0, unresolvedDecisionLinks: 0 },
220
+ };
221
+ if (!ledger?.ok || !shared?.ok) return base;
222
+
223
+ let replay;
224
+ try { replay = deriveMemoryProjection(vaultBase, ledger.events); }
225
+ catch (error) {
226
+ return {
227
+ ...base,
228
+ ok: false,
229
+ status: 'degraded',
230
+ code: 'MEMORY_SEMANTIC_REPLAY_UNAVAILABLE',
231
+ errors: ['[MEMORY_SEMANTIC_REPLAY_UNAVAILABLE] não foi possível rederivar as chaves ativas do ledger.'],
232
+ };
233
+ }
234
+
235
+ const active = Object.entries(replay.records || {}).map(([memoryKey, record]) => ({
236
+ memoryKey: sanitizeMemoryText(memoryKey),
237
+ eventId: record?.source?.event_id,
238
+ }));
239
+ const activeKeys = active.map(({ memoryKey }) => memoryKey).sort();
240
+ const projectedIds = sharedEventIds(shared.content);
241
+ const projectedKeys = active
242
+ .filter(({ eventId }) => projectedIds.has(eventId))
243
+ .map(({ memoryKey }) => memoryKey)
244
+ .sort();
245
+ const missingKeys = active
246
+ .filter(({ eventId }) => !projectedIds.has(eventId))
247
+ .map(({ memoryKey }) => memoryKey)
248
+ .sort();
249
+ const sectionValues = [...(shared.sections?.values?.() || [])].flat();
250
+ const placeholderSections = sectionValues.filter((line) => /^-\s*\(vazio\)\s*$/i.test(line)).length;
251
+ const nonPlaceholderLines = sectionValues.filter((line) => !/^-\s*\(vazio\)\s*$/i.test(line));
252
+ const placeholderOnly = sectionValues.length > 0 && nonPlaceholderLines.length === 0;
253
+ const unresolvedLinks = unresolvedDecisionLinks(vaultBase, shared.content);
254
+ const candidateCount = Math.max(candidates?.activeCount || 0, replay.candidates?.length || 0);
255
+ const errors = [];
256
+ const warnings = [];
257
+ const codes = [];
258
+
259
+ if (missingKeys.length && placeholderOnly) {
260
+ codes.push('MEMORY_SEMANTIC_PLACEHOLDER_ONLY');
261
+ errors.push(`[MEMORY_SEMANTIC_PLACEHOLDER_ONLY] SHARED contém somente placeholders para ${activeKeys.length} chave(s) ativa(s); candidates=${candidateCount}.`);
262
+ } else if (missingKeys.length) {
263
+ codes.push('MEMORY_SEMANTIC_COVERAGE_MISSING');
264
+ errors.push(`[MEMORY_SEMANTIC_COVERAGE_MISSING] SHARED não cobre ${missingKeys.length} chave(s) ativa(s): ${missingKeys.join(', ')}.`);
265
+ }
266
+ if (unresolvedLinks.length) {
267
+ codes.push('MEMORY_SEMANTIC_DECISION_LINK_UNRESOLVED');
268
+ errors.push(`[MEMORY_SEMANTIC_DECISION_LINK_UNRESOLVED] ${unresolvedLinks.length} link(s) de decisão não resolvido(s).`);
269
+ }
270
+
271
+ let status = 'healthy';
272
+ let code = 'MEMORY_SEMANTIC_COVERAGE_OK';
273
+ if (!ledger.events.length && !candidateCount) {
274
+ status = 'neutral';
275
+ code = 'MEMORY_SEMANTIC_EMPTY_NEUTRAL';
276
+ } else if (!activeKeys.length && candidateCount) {
277
+ status = 'degraded';
278
+ code = 'MEMORY_SEMANTIC_CANDIDATES_PENDING';
279
+ warnings.push(`[MEMORY_SEMANTIC_CANDIDATES_PENDING] ${candidateCount} candidate(s) preservado(s); o estado não é memória vazia.`);
280
+ } else if (codes.length) {
281
+ status = 'degraded';
282
+ code = codes[0];
283
+ }
284
+
285
+ return {
286
+ ok: errors.length === 0,
287
+ status,
288
+ code,
289
+ codes,
290
+ errors,
291
+ warnings,
292
+ activeKeys,
293
+ projectedKeys,
294
+ missingKeys,
295
+ unresolvedDecisionLinks: unresolvedLinks,
296
+ placeholderOnly,
297
+ counts: {
298
+ activeKeys: activeKeys.length,
299
+ projectedKeys: projectedKeys.length,
300
+ missingKeys: missingKeys.length,
301
+ candidates: candidateCount,
302
+ placeholderSections,
303
+ unresolvedDecisionLinks: unresolvedLinks.length,
304
+ },
305
+ };
306
+ }
307
+
308
+ export function combineMemoryResults({ project, core, ledger, shared, candidates, semantic }) {
309
+ const components = { project, core, ledger, shared, candidates, semantic };
109
310
  const errors = [];
110
311
  const warnings = [];
111
312
  for (const [name, result] of Object.entries(components)) {
@@ -124,5 +325,7 @@ export function validateMemoryBundle(vaultBase) {
124
325
  const core = validateCoreArtifact(vaultBase);
125
326
  const ledger = readLedgerForValidation(vaultBase, { projectId: project.projectId });
126
327
  const shared = validateSharedArtifact(vaultBase, ledger.eventIds);
127
- return combineMemoryResults({ project, core, ledger, shared });
328
+ const candidates = readCandidateInventory(vaultBase);
329
+ const semantic = semanticMemoryHealth(vaultBase, { ledger, shared, candidates });
330
+ return combineMemoryResults({ project, core, ledger, shared, candidates, semantic });
128
331
  }
package/src/doctor.mjs CHANGED
@@ -39,6 +39,10 @@ export function renderVaultHealthLines(result) {
39
39
  `[memória] ${healthStatusLabel(result.memoryStatus)} — schema: ${metricValue(memory.schemaVersion)} · revisão: ${metricValue(memory.revision)} · cursor: ${metricValue(memory.eventCursor)} · hash: ${metricValue(memory.stateHash)}`,
40
40
  );
41
41
  lines.push(` ledger: ${metricValue(memory.ledgerEvents)} evento(s) · outbox: ${metricValue(memory.pendingOutbox)} · candidates: ${metricValue(memory.candidates)} · conflitos: ${metricValue(memory.activeConflicts)}`);
42
+ const semanticKeys = memory.semanticActiveKeys || [];
43
+ const semanticProjected = memory.semanticProjectedKeys || [];
44
+ const semanticMissing = memory.semanticMissingKeys || [];
45
+ lines.push(` semântica: ${metricValue(memory.semanticCode)} · ativas: ${semanticKeys.length} [${semanticKeys.join(', ')}] · projetadas: ${semanticProjected.length} · ausentes: ${semanticMissing.length}`);
42
46
  for (const failure of memoryFailures) lines.push(` ✗ ${failure}`);
43
47
  for (const warning of memoryWarnings) lines.push(` ! ${warning}`);
44
48
  if (result.memoryStatus === 'healthy' && !memoryFailures.length && !memoryWarnings.length) {
package/src/memory.mjs CHANGED
@@ -1874,13 +1874,16 @@ export function runValidateMemoryBundle(argv) {
1874
1874
  return;
1875
1875
  }
1876
1876
  const result = validateMemoryBundle(vault);
1877
+ const semantic = result.semantic || {};
1878
+ const semanticSummary = `semântica ${semantic.code || 'n/a'} · ativas: ${semantic.counts?.activeKeys ?? 0} · projetadas: ${semantic.counts?.projectedKeys ?? 0} · ausentes: ${semantic.counts?.missingKeys ?? 0}`;
1877
1879
  if (!result.ok) {
1878
1880
  process.stderr.write(`❌ bundle de memória inválido (${result.errors.length} erro(s)):\n`);
1881
+ process.stderr.write(` ${semanticSummary}\n`);
1879
1882
  for (const error of result.errors) process.stderr.write(` - ${error}\n`);
1880
1883
  process.exitCode = 1;
1881
1884
  return;
1882
1885
  }
1883
- process.stdout.write('✅ bundle de memória v2 OK (CORE + ledger + SHARED).\n');
1886
+ process.stdout.write(`✅ bundle de memória v2 OK (CORE + ledger + SHARED; ${semanticSummary}).\n`);
1884
1887
  process.exitCode = 0;
1885
1888
  }
1886
1889
 
@@ -0,0 +1,122 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { buildProjectSnapshot } from './observer-snapshot.mjs';
4
+
5
+ const OUTBOX_REL = join('.brain', 'observer-outbox');
6
+ const REQUEST_TIMEOUT_MS = 500;
7
+
8
+ function outboxDir(vaultBase) {
9
+ return join(vaultBase, OUTBOX_REL);
10
+ }
11
+
12
+ function eventPath(vaultBase, eventId) {
13
+ if (!/^obs-[a-f0-9]{24}$/.test(eventId)) throw new Error('event_id inválido para outbox.');
14
+ return join(outboxDir(vaultBase), `${eventId}.json`);
15
+ }
16
+
17
+ function atomicWrite(path, value) {
18
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
19
+ writeFileSync(temp, `${JSON.stringify(value)}\n`, 'utf8');
20
+ renameSync(temp, path);
21
+ }
22
+
23
+ export function listOutbox(vaultBase) {
24
+ const dir = outboxDir(vaultBase);
25
+ if (!existsSync(dir)) return [];
26
+ return readdirSync(dir)
27
+ .filter((name) => /^obs-[a-f0-9]{24}\.json$/.test(name))
28
+ .sort()
29
+ .flatMap((name) => {
30
+ try { return [JSON.parse(readFileSync(join(dir, name), 'utf8'))]; }
31
+ catch { return []; }
32
+ });
33
+ }
34
+
35
+ function queueOutbox(vaultBase, event) {
36
+ const dir = outboxDir(vaultBase);
37
+ mkdirSync(dir, { recursive: true });
38
+ const path = eventPath(vaultBase, event.event_id);
39
+ if (!existsSync(path)) atomicWrite(path, event);
40
+ return path;
41
+ }
42
+
43
+ function removeOutbox(vaultBase, eventId) {
44
+ const path = eventPath(vaultBase, eventId);
45
+ if (existsSync(path)) unlinkSync(path);
46
+ }
47
+
48
+ async function postSnapshot(url, token, event) {
49
+ const controller = new AbortController();
50
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
51
+ try {
52
+ const response = await fetch(`${String(url).replace(/\/$/, '')}/v1/projects/${encodeURIComponent(event.project_id)}/snapshot`, {
53
+ method: 'POST',
54
+ headers: {
55
+ authorization: `Bearer ${token}`,
56
+ 'content-type': 'application/json',
57
+ },
58
+ body: JSON.stringify(event),
59
+ signal: controller.signal,
60
+ });
61
+ const text = await response.text();
62
+ let body = {};
63
+ try { body = JSON.parse(text); } catch { /* server error remains deterministic below */ }
64
+ if (!response.ok || !(body.accepted === true || body.duplicate === true)) {
65
+ throw new Error(`Observer respondeu HTTP ${response.status}.`);
66
+ }
67
+ return body;
68
+ } finally {
69
+ clearTimeout(timer);
70
+ }
71
+ }
72
+
73
+ export async function retryObserverOutbox({ vaultBase, url, token } = {}) {
74
+ if (!url || !token) return { attempted: 0, confirmed: 0, pending: listOutbox(vaultBase).length };
75
+ let attempted = 0;
76
+ let confirmed = 0;
77
+ for (const event of listOutbox(vaultBase)) {
78
+ attempted += 1;
79
+ try {
80
+ await postSnapshot(url, token, event);
81
+ removeOutbox(vaultBase, event.event_id);
82
+ confirmed += 1;
83
+ } catch { /* preserve the event for a later retry */ }
84
+ }
85
+ return { attempted, confirmed, pending: listOutbox(vaultBase).length };
86
+ }
87
+
88
+ export async function publishObserverSnapshot({
89
+ vaultBase,
90
+ projectRoot,
91
+ url = process.env.WENDKEEP_OBSERVER_URL || '',
92
+ token = process.env.WENDKEEP_OBSERVER_TOKEN || '',
93
+ now = new Date(),
94
+ } = {}) {
95
+ try {
96
+ const event = buildProjectSnapshot({ vaultBase, projectRoot, now });
97
+ if (!url) return { ok: true, skipped: true, queued: false, hookExitCode: 0, event_id: event.event_id };
98
+
99
+ await retryObserverOutbox({ vaultBase, url, token });
100
+ try {
101
+ const response = await postSnapshot(url, token, event);
102
+ return {
103
+ ok: true,
104
+ queued: false,
105
+ hookExitCode: 0,
106
+ event_id: event.event_id,
107
+ duplicate: response.duplicate === true,
108
+ };
109
+ } catch (error) {
110
+ queueOutbox(vaultBase, event);
111
+ return {
112
+ ok: false,
113
+ queued: true,
114
+ hookExitCode: 0,
115
+ event_id: event.event_id,
116
+ error: error.message,
117
+ };
118
+ }
119
+ } catch (error) {
120
+ return { ok: false, queued: false, hookExitCode: 0, error: error.message };
121
+ }
122
+ }
@@ -0,0 +1,203 @@
1
+ import { createServer } from 'node:http';
2
+ import { appendObserverEvent, getObserverProject, readObserverIndex, registerObserverProject } from './observer-store.mjs';
3
+ import { MAX_SNAPSHOT_BYTES, validateObserverSnapshot } from './observer-snapshot.mjs';
4
+
5
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
6
+ const MAX_BODY_BYTES = MAX_SNAPSHOT_BYTES + 4096;
7
+
8
+ function loopbackOnly(host) {
9
+ return LOOPBACK_HOSTS.has(String(host || '').toLowerCase());
10
+ }
11
+
12
+ function json(res, status, body) {
13
+ const content = JSON.stringify(body);
14
+ res.writeHead(status, {
15
+ 'content-type': 'application/json; charset=utf-8',
16
+ 'cache-control': 'no-store',
17
+ 'content-length': Buffer.byteLength(content),
18
+ });
19
+ res.end(content);
20
+ }
21
+
22
+ function errorResponse(res, status, code, message) {
23
+ json(res, status, { error: { code, message } });
24
+ }
25
+
26
+ function readBody(req) {
27
+ return new Promise((resolve, reject) => {
28
+ let size = 0;
29
+ let tooLarge = false;
30
+ const chunks = [];
31
+ req.on('data', (chunk) => {
32
+ if (tooLarge) return;
33
+ size += chunk.length;
34
+ if (size > MAX_BODY_BYTES) {
35
+ tooLarge = true;
36
+ const error = new Error('corpo acima do limite.');
37
+ error.code = 'payload_too_large';
38
+ reject(error);
39
+ return;
40
+ }
41
+ chunks.push(chunk);
42
+ });
43
+ req.on('end', () => {
44
+ if (!tooLarge) resolve(Buffer.concat(chunks).toString('utf8'));
45
+ });
46
+ req.on('error', reject);
47
+ });
48
+ }
49
+
50
+ function parseJson(text) {
51
+ try { return JSON.parse(text || '{}'); }
52
+ catch {
53
+ const error = new Error('JSON inválido.');
54
+ error.code = 'invalid_json';
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ function authorized(req, token) {
60
+ if (!token) return false;
61
+ const header = String(req.headers.authorization || '');
62
+ return header === `Bearer ${token}`
63
+ || req.headers['x-wendkeep-observer-token'] === token;
64
+ }
65
+
66
+ function pathParts(url) {
67
+ return new URL(url, 'http://127.0.0.1').pathname.split('/').filter(Boolean).map((part) => decodeURIComponent(part));
68
+ }
69
+
70
+ function projectIdFrom(parts) {
71
+ return parts[0] === 'v1' && parts[1] === 'projects' && parts[2] ? parts[2] : '';
72
+ }
73
+
74
+ export async function startObserverServer({
75
+ host = '127.0.0.1',
76
+ port = 8787,
77
+ dataDir,
78
+ token = process.env.WENDKEEP_OBSERVER_TOKEN || '',
79
+ allowNonLoopback = false,
80
+ } = {}) {
81
+ if (!loopbackOnly(host) && !allowNonLoopback) {
82
+ throw new Error(`Observer HTTP aceita somente host loopback; recebido: ${host}`);
83
+ }
84
+ if (!dataDir) throw new Error('dataDir é obrigatório.');
85
+ const server = createServer(async (req, res) => {
86
+ try {
87
+ const parts = pathParts(req.url || '/');
88
+ if (req.method === 'GET' && parts.length === 1 && parts[0] === 'healthz') {
89
+ json(res, 200, { ok: true, service: 'wendkeep-observer', schema_version: 1 });
90
+ return;
91
+ }
92
+ if (!authorized(req, token)) {
93
+ errorResponse(res, 401, 'unauthorized', 'token local ausente ou inválido.');
94
+ return;
95
+ }
96
+ if (parts[0] !== 'v1' || parts[1] !== 'projects') {
97
+ errorResponse(res, 404, 'not_found', 'rota não encontrada.');
98
+ return;
99
+ }
100
+
101
+ if (parts.length === 2 && req.method === 'GET') {
102
+ const index = readObserverIndex(dataDir);
103
+ json(res, 200, {
104
+ schema_version: index.schema_version,
105
+ projects: index.projects.map(({ snapshot, ...summary }) => summary),
106
+ });
107
+ return;
108
+ }
109
+
110
+ const projectId = projectIdFrom(parts);
111
+ if (!projectId) {
112
+ errorResponse(res, 404, 'not_found', 'projeto não informado.');
113
+ return;
114
+ }
115
+
116
+ if (parts.length === 3 && req.method === 'GET') {
117
+ const project = getObserverProject(dataDir, projectId);
118
+ if (!project) {
119
+ errorResponse(res, 404, 'project_not_found', `projeto não encontrado: ${projectId}`);
120
+ return;
121
+ }
122
+ json(res, 200, project);
123
+ return;
124
+ }
125
+
126
+ if (parts.length === 3 && req.method === 'PUT') {
127
+ const body = parseJson(await readBody(req));
128
+ if (body.project_id !== projectId) {
129
+ errorResponse(res, 400, 'project_mismatch', 'project_id do corpo não corresponde à rota.');
130
+ return;
131
+ }
132
+ const result = registerObserverProject(dataDir, {
133
+ projectId,
134
+ projectName: body.project_name,
135
+ wendkeepVersion: body.wendkeep_version,
136
+ });
137
+ if (!result.registered) {
138
+ errorResponse(res, 400, 'invalid_project', result.errors.join(' '));
139
+ return;
140
+ }
141
+ json(res, 201, result.project);
142
+ return;
143
+ }
144
+
145
+ if (parts.length === 4 && parts[3] === 'changes' && req.method === 'GET') {
146
+ const project = getObserverProject(dataDir, projectId);
147
+ if (!project) {
148
+ errorResponse(res, 404, 'project_not_found', `projeto não encontrado: ${projectId}`);
149
+ return;
150
+ }
151
+ json(res, 200, { project_id: projectId, changes: project.snapshot?.changes || [] });
152
+ return;
153
+ }
154
+
155
+ if (parts.length === 4 && ['snapshot', 'snapshots'].includes(parts[3]) && req.method === 'POST') {
156
+ const body = parseJson(await readBody(req));
157
+ const validation = validateObserverSnapshot(body, { projectId });
158
+ if (!validation.ok) {
159
+ errorResponse(res, 400, 'invalid_snapshot', validation.errors.join(' '));
160
+ return;
161
+ }
162
+ const result = appendObserverEvent(dataDir, body);
163
+ if (!result.accepted && result.duplicate) {
164
+ json(res, 200, { accepted: false, duplicate: true, event_id: body.event_id });
165
+ return;
166
+ }
167
+ if (!result.accepted) {
168
+ const unregistered = result.errors.some((item) => /não registrado/.test(item));
169
+ errorResponse(res, unregistered ? 409 : 400, unregistered ? 'project_not_registered' : 'invalid_event', result.errors.join(' '));
170
+ return;
171
+ }
172
+ json(res, 201, { accepted: true, duplicate: false, event_id: body.event_id });
173
+ return;
174
+ }
175
+
176
+ errorResponse(res, 404, 'not_found', 'rota não encontrada.');
177
+ } catch (error) {
178
+ if (res.headersSent) return;
179
+ const status = error?.code === 'payload_too_large' ? 413 : error?.code === 'invalid_json' ? 400 : 500;
180
+ errorResponse(res, status, error?.code || 'observer_error', error?.message || 'erro interno do Observer.');
181
+ }
182
+ });
183
+
184
+ await new Promise((resolve, reject) => {
185
+ const onError = (error) => {
186
+ server.off('listening', onListening);
187
+ reject(error);
188
+ };
189
+ const onListening = () => {
190
+ server.off('error', onError);
191
+ resolve();
192
+ };
193
+ server.once('error', onError);
194
+ server.once('listening', onListening);
195
+ server.listen(Number(port), host);
196
+ });
197
+
198
+ return {
199
+ server,
200
+ address: () => server.address(),
201
+ close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
202
+ };
203
+ }