wendkeep 0.71.1 → 0.72.1

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,6 +1,8 @@
1
1
  import { createServer } from 'node:http';
2
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
3
  import { readFileSync } from 'node:fs';
3
4
  import { fileURLToPath } from 'node:url';
5
+ import { gunzipSync } from 'node:zlib';
4
6
  import {
5
7
  appendObserverEvent,
6
8
  getObserverProject,
@@ -10,19 +12,35 @@ import {
10
12
  } from './observer-store.mjs';
11
13
  import { MAX_SNAPSHOT_BYTES, validateObserverSnapshot } from './observer-snapshot.mjs';
12
14
  import {
13
- applyMemoryEvent,
14
- exportMemoryBundle,
15
- readMemoryDocument,
16
- readMemorySync,
17
- readMemoryTree,
18
15
  setMemoryMode,
19
- searchMemory,
20
16
  validateMemoryEvent,
21
17
  } from './observer-memory.mjs';
18
+ import {
19
+ OBSERVER_SQL_FILE,
20
+ OBSERVER_SQL_SCHEMA_VERSION,
21
+ ensureObserverDatabase,
22
+ ingestObserverEvents,
23
+ migrateObserverDatabase,
24
+ readSqlProject,
25
+ listSqlProjects,
26
+ readSqlDocument,
27
+ readSqlSync,
28
+ readSqlTree,
29
+ searchSqlDocuments,
30
+ exportSqlMemoryBundle,
31
+ readTranscript,
32
+ readUsageBreakdown,
33
+ readUsageCalls,
34
+ readUsageSummary,
35
+ registerSqlProject,
36
+ } from './observer-sql-store.mjs';
37
+ import { migrateObserverContainerData } from './observer-sql-migrate.mjs';
22
38
 
23
39
  const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
24
40
  const MAX_BODY_BYTES = MAX_SNAPSHOT_BYTES + 4096;
25
41
  const MAX_MEMORY_BODY_BYTES = 8 * 1024 * 1024;
42
+ const MAX_SQL_BODY_BYTES = 64 * 1024 * 1024;
43
+ const MAX_SQL_EXPANDED_BODY_BYTES = 256 * 1024 * 1024;
26
44
  const STATIC_ROOT = fileURLToPath(new URL('../web/observer/', import.meta.url));
27
45
  const STATIC_ASSETS = new Map([
28
46
  ['/index.html', { file: 'index.html', type: 'text/html; charset=utf-8' }],
@@ -35,6 +53,39 @@ function loopbackOnly(host) {
35
53
  return LOOPBACK_HOSTS.has(String(host || '').toLowerCase());
36
54
  }
37
55
 
56
+ function safeTokenEqual(actual, expected) {
57
+ if (!actual || !expected) return false;
58
+ const left = createHash('sha256').update(String(actual)).digest();
59
+ const right = createHash('sha256').update(String(expected)).digest();
60
+ return timingSafeEqual(left, right);
61
+ }
62
+
63
+ function bearerToken(req) {
64
+ const match = String(req.headers.authorization || '').match(/^Bearer\s+(.+)$/i);
65
+ return match?.[1] || '';
66
+ }
67
+
68
+ function requestHostname(value) {
69
+ try { return new URL(`http://${String(value || '')}`).hostname.toLowerCase(); }
70
+ catch { return ''; }
71
+ }
72
+
73
+ function validateAuthority(req, { loopback }) {
74
+ const hostname = requestHostname(req.headers.host);
75
+ if (!hostname || (loopback && !LOOPBACK_HOSTS.has(hostname))) {
76
+ return { ok: false, status: 421, code: 'invalid_host', message: 'Host não corresponde ao binding do Observer.' };
77
+ }
78
+ const origin = String(req.headers.origin || '');
79
+ if (origin) {
80
+ let originHostname = '';
81
+ try { originHostname = new URL(origin).hostname.toLowerCase(); } catch { /* invalid below */ }
82
+ if (!originHostname || originHostname !== hostname) {
83
+ return { ok: false, status: 403, code: 'invalid_origin', message: 'Origin não corresponde ao Host do Observer.' };
84
+ }
85
+ }
86
+ return { ok: true };
87
+ }
88
+
38
89
  function json(res, status, body) {
39
90
  const content = JSON.stringify(body);
40
91
  res.writeHead(status, {
@@ -67,7 +118,7 @@ function serveStatic(res, pathname) {
67
118
  return true;
68
119
  }
69
120
 
70
- function readBody(req, maxBytes = MAX_BODY_BYTES) {
121
+ function readBody(req, maxBytes = MAX_BODY_BYTES, { gunzip = false, expandedMaxBytes = maxBytes } = {}) {
71
122
  return new Promise((resolve, reject) => {
72
123
  let size = 0;
73
124
  let tooLarge = false;
@@ -85,7 +136,24 @@ function readBody(req, maxBytes = MAX_BODY_BYTES) {
85
136
  chunks.push(chunk);
86
137
  });
87
138
  req.on('end', () => {
88
- if (!tooLarge) resolve(Buffer.concat(chunks).toString('utf8'));
139
+ if (tooLarge) return;
140
+ let body = Buffer.concat(chunks);
141
+ if (gunzip && String(req.headers['content-encoding'] || '').toLowerCase() === 'gzip') {
142
+ try { body = gunzipSync(body); }
143
+ catch {
144
+ const error = new Error('corpo gzip inválido.');
145
+ error.code = 'invalid_content_encoding';
146
+ reject(error);
147
+ return;
148
+ }
149
+ if (body.length > expandedMaxBytes) {
150
+ const error = new Error('corpo expandido acima do limite.');
151
+ error.code = 'payload_too_large';
152
+ reject(error);
153
+ return;
154
+ }
155
+ }
156
+ resolve(body.toString('utf8'));
89
157
  });
90
158
  req.on('error', reject);
91
159
  });
@@ -108,11 +176,41 @@ function projectIdFrom(parts) {
108
176
  return parts[0] === 'v1' && parts[1] === 'projects' && parts[2] ? parts[2] : '';
109
177
  }
110
178
 
111
- function projectKnown(dataDir, projectId) {
112
- return Boolean(
113
- getObserverProject(dataDir, projectId)
114
- || listRegisteredObserverProjects(dataDir).some((item) => item.projectId === projectId),
115
- );
179
+ function ensureSqlProjectRegistration(dataDir, sqlDb, projectId) {
180
+ try {
181
+ if (readSqlProject(sqlDb, projectId)) return true;
182
+ } catch (error) {
183
+ if (error?.code !== 'project_not_registered') throw error;
184
+ }
185
+ const legacy = getObserverProject(dataDir, projectId)
186
+ || listRegisteredObserverProjects(dataDir).find((item) => item.projectId === projectId);
187
+ if (!legacy) return false;
188
+ registerSqlProject(sqlDb, {
189
+ projectId: legacy.projectId,
190
+ projectName: legacy.projectName,
191
+ wendkeepVersion: legacy.snapshot?.wendkeep_version || legacy.wendkeepVersion || '',
192
+ });
193
+ return true;
194
+ }
195
+
196
+ function sqlMemoryEvent(projectId, event) {
197
+ return {
198
+ schema_version: 1,
199
+ event_id: event.event_id,
200
+ kind: event.operation === 'delete' ? 'document.delete' : 'document.upsert',
201
+ project_id: projectId,
202
+ occurred_at: event.captured_at || new Date().toISOString(),
203
+ payload: {
204
+ logical_path: event.logical_path,
205
+ entity_type: event.entity_type,
206
+ content: event.content || '',
207
+ content_hash: event.content_hash || '',
208
+ revision: event.revision || 1,
209
+ source_session_id: event.source_session_id || '',
210
+ source_turn_id: event.source_turn_id || '',
211
+ metadata: event.metadata || {},
212
+ },
213
+ };
116
214
  }
117
215
 
118
216
  export async function startObserverServer({
@@ -120,16 +218,57 @@ export async function startObserverServer({
120
218
  port = 8787,
121
219
  dataDir,
122
220
  allowNonLoopback = false,
221
+ token = process.env.WENDKEEP_OBSERVER_TOKEN || '',
123
222
  } = {}) {
124
223
  if (!loopbackOnly(host) && !allowNonLoopback) {
125
224
  throw new Error(`Observer HTTP aceita somente host loopback; recebido: ${host}`);
126
225
  }
226
+ if (!loopbackOnly(host) && !token) {
227
+ const error = new Error('Observer non-loopback exige --token ou WENDKEEP_OBSERVER_TOKEN.');
228
+ error.code = 'WENDKEEP_OBSERVER_TOKEN_REQUIRED';
229
+ throw error;
230
+ }
127
231
  if (!dataDir) throw new Error('dataDir é obrigatório.');
232
+ const sqlDb = ensureObserverDatabase(dataDir);
233
+ const databaseMigration = migrateObserverDatabase(sqlDb);
234
+ const legacyMigration = migrateObserverContainerData(dataDir, { database: sqlDb });
235
+ const registered = [
236
+ ...listRegisteredObserverProjects(dataDir),
237
+ ...readObserverIndex(dataDir).projects.map((item) => ({
238
+ projectId: item.projectId,
239
+ projectName: item.projectName,
240
+ wendkeepVersion: item.snapshot?.wendkeep_version || '',
241
+ })),
242
+ ];
243
+ for (const project of registered) registerSqlProject(sqlDb, project);
128
244
  const server = createServer(async (req, res) => {
129
245
  try {
130
246
  const pathname = new URL(req.url || '/', 'http://127.0.0.1').pathname;
247
+ const authority = validateAuthority(req, { loopback: loopbackOnly(host) });
248
+ if (!authority.ok) {
249
+ errorResponse(res, authority.status, authority.code, authority.message);
250
+ return;
251
+ }
252
+ const authenticated = safeTokenEqual(bearerToken(req), token);
253
+ const mutating = !['GET', 'HEAD', 'OPTIONS'].includes(String(req.method || '').toUpperCase());
254
+ if ((mutating || !loopbackOnly(host)) && !authenticated) {
255
+ errorResponse(res, 401, 'observer_auth_required', 'Bearer token válido é obrigatório para esta operação.');
256
+ return;
257
+ }
131
258
  if (req.method === 'GET' && pathname === '/healthz') {
132
- json(res, 200, { ok: true, service: 'wendkeep-observer', schema_version: 1 });
259
+ json(res, 200, {
260
+ ok: true,
261
+ service: 'wendkeep-observer',
262
+ schema_version: 1,
263
+ database: {
264
+ engine: 'sqlite',
265
+ file: OBSERVER_SQL_FILE,
266
+ schema_version: OBSERVER_SQL_SCHEMA_VERSION,
267
+ migrations: databaseMigration.applied.length,
268
+ legacy_migration: legacyMigration,
269
+ ready: true,
270
+ },
271
+ });
133
272
  return;
134
273
  }
135
274
  if (req.method === 'GET' && (pathname === '/' || STATIC_ASSETS.has(pathname))) {
@@ -148,9 +287,20 @@ export async function startObserverServer({
148
287
 
149
288
  if (parts.length === 2 && req.method === 'GET') {
150
289
  const index = readObserverIndex(dataDir);
290
+ const legacy = new Map(index.projects.map(({ snapshot, ...summary }) => [summary.projectId, summary]));
291
+ for (const project of listSqlProjects(sqlDb)) {
292
+ const current = legacy.get(project.project_id) || {};
293
+ legacy.set(project.project_id, {
294
+ ...current,
295
+ projectId: project.project_id,
296
+ projectName: current.projectName || project.project_name,
297
+ wendkeepVersion: current.wendkeepVersion || project.wendkeep_version,
298
+ registeredAt: current.registeredAt || project.registered_at,
299
+ });
300
+ }
151
301
  json(res, 200, {
152
302
  schema_version: index.schema_version,
153
- projects: index.projects.map(({ snapshot, ...summary }) => summary),
303
+ projects: [...legacy.values()].sort((a, b) => a.projectId.localeCompare(b.projectId)),
154
304
  });
155
305
  return;
156
306
  }
@@ -160,18 +310,78 @@ export async function startObserverServer({
160
310
  errorResponse(res, 404, 'not_found', 'projeto não informado.');
161
311
  return;
162
312
  }
313
+ ensureSqlProjectRegistration(dataDir, sqlDb, projectId);
314
+
315
+ if (parts.length === 4 && parts[3] === 'ingest' && req.method === 'POST') {
316
+ if (!ensureSqlProjectRegistration(dataDir, sqlDb, projectId)) {
317
+ errorResponse(res, 404, 'project_not_found', 'projeto não encontrado: ' + projectId);
318
+ return;
319
+ }
320
+ const body = parseJson(await readBody(req, MAX_SQL_BODY_BYTES, {
321
+ gunzip: true,
322
+ expandedMaxBytes: MAX_SQL_EXPANDED_BODY_BYTES,
323
+ }));
324
+ const events = Array.isArray(body.events) ? body.events : [];
325
+ if (events.length === 0) {
326
+ errorResponse(res, 400, 'invalid_ingest_batch', 'events deve conter pelo menos um evento.');
327
+ return;
328
+ }
329
+ const result = ingestObserverEvents(sqlDb, { projectId, events });
330
+ const status = result.conflicts > 0 ? 409 : result.rejected > 0 ? 400 : result.accepted > 0 ? 201 : 200;
331
+ json(res, status, result);
332
+ return;
333
+ }
334
+
335
+ if (parts.length >= 4 && parts[3] === 'usage' && req.method === 'GET') {
336
+ if (!ensureSqlProjectRegistration(dataDir, sqlDb, projectId)) {
337
+ errorResponse(res, 404, 'project_not_found', 'projeto não encontrado: ' + projectId);
338
+ return;
339
+ }
340
+ const query = new URL(req.url || '/', 'http://127.0.0.1').searchParams;
341
+ const filters = {
342
+ from: query.get('from') || '', to: query.get('to') || '', agentId: query.get('agent_id') || '', subagentId: query.get('subagent_id') || '',
343
+ sessionId: query.get('session_id') || '', changeSlug: query.get('change') || query.get('change_slug') || '', role: query.get('role') || '',
344
+ model: query.get('model') || '', provider: query.get('provider') || '', modelProvider: query.get('model_provider') || '',
345
+ limit: query.get('limit') || 100, offset: query.get('offset') || 0,
346
+ };
347
+ if (parts[4] === 'summary') {
348
+ json(res, 200, readUsageSummary(sqlDb, projectId, filters));
349
+ return;
350
+ }
351
+ if (parts[4] === 'breakdown') {
352
+ json(res, 200, readUsageBreakdown(sqlDb, projectId, filters));
353
+ return;
354
+ }
355
+ if (parts[4] === 'calls') {
356
+ json(res, 200, readUsageCalls(sqlDb, projectId, filters));
357
+ return;
358
+ }
359
+ }
360
+
361
+ if (parts.length === 5 && parts[3] === 'transcripts' && req.method === 'GET') {
362
+ if (!ensureSqlProjectRegistration(dataDir, sqlDb, projectId)) {
363
+ errorResponse(res, 404, 'project_not_found', 'projeto não encontrado: ' + projectId);
364
+ return;
365
+ }
366
+ try {
367
+ json(res, 200, readTranscript(sqlDb, projectId, parts[4]));
368
+ } catch (error) {
369
+ errorResponse(res, error?.code === 'transcript_not_found' ? 404 : 400, error?.code || 'transcript_error', error?.message || 'transcript indisponível.');
370
+ }
371
+ return;
372
+ }
163
373
 
164
374
  if (parts.length === 4 && parts[3] === 'sync' && req.method === 'GET') {
165
- if (!projectKnown(dataDir, projectId)) {
375
+ if (!ensureSqlProjectRegistration(dataDir, sqlDb, projectId)) {
166
376
  errorResponse(res, 404, 'project_not_found', 'projeto não encontrado: ' + projectId);
167
377
  return;
168
378
  }
169
- json(res, 200, readMemorySync(dataDir, projectId));
379
+ json(res, 200, { ...readSqlSync(sqlDb, projectId), mode: 'container-authority' });
170
380
  return;
171
381
  }
172
382
 
173
383
  if (parts.length === 4 && parts[3] === 'sync' && req.method === 'PUT') {
174
- if (!projectKnown(dataDir, projectId)) {
384
+ if (!ensureSqlProjectRegistration(dataDir, sqlDb, projectId)) {
175
385
  errorResponse(res, 404, 'project_not_found', 'projeto não encontrado: ' + projectId);
176
386
  return;
177
387
  }
@@ -185,20 +395,21 @@ export async function startObserverServer({
185
395
  }
186
396
 
187
397
  if (parts.length >= 4 && parts[3] === 'memory') {
188
- if (!projectKnown(dataDir, projectId)) {
398
+ if (!ensureSqlProjectRegistration(dataDir, sqlDb, projectId)) {
189
399
  errorResponse(res, 404, 'project_not_found', 'projeto não encontrado: ' + projectId);
190
400
  return;
191
401
  }
192
402
  const memoryAction = parts[4] || '';
193
403
  if (memoryAction === 'tree' && req.method === 'GET') {
194
404
  const query = new URL(req.url || '/', 'http://127.0.0.1').searchParams;
195
- json(res, 200, readMemoryTree(dataDir, projectId, query.get('prefix') || ''));
405
+ const tree = readSqlTree(sqlDb, projectId, query.get('prefix') || '');
406
+ json(res, 200, { ...tree, document_count: tree.documents.length });
196
407
  return;
197
408
  }
198
409
  if (memoryAction === 'document' && req.method === 'GET') {
199
410
  const query = new URL(req.url || '/', 'http://127.0.0.1').searchParams;
200
411
  try {
201
- json(res, 200, readMemoryDocument(dataDir, projectId, query.get('path') || ''));
412
+ json(res, 200, readSqlDocument(sqlDb, projectId, query.get('path') || ''));
202
413
  } catch (error) {
203
414
  const status = error?.code === 'memory_not_found' ? 404 : 400;
204
415
  errorResponse(res, status, error?.code || 'invalid_memory_path', error?.message || 'documento inválido.');
@@ -207,11 +418,11 @@ export async function startObserverServer({
207
418
  }
208
419
  if (memoryAction === 'search' && req.method === 'GET') {
209
420
  const query = new URL(req.url || '/', 'http://127.0.0.1').searchParams;
210
- json(res, 200, { project_id: projectId, query: query.get('q') || '', results: searchMemory(dataDir, projectId, query.get('q') || '') });
421
+ json(res, 200, { project_id: projectId, query: query.get('q') || '', results: searchSqlDocuments(sqlDb, projectId, query.get('q') || '') });
211
422
  return;
212
423
  }
213
424
  if (memoryAction === 'export' && req.method === 'GET') {
214
- json(res, 200, exportMemoryBundle(dataDir, projectId));
425
+ json(res, 200, { ...exportSqlMemoryBundle(sqlDb, projectId), mode: 'container-authority' });
215
426
  return;
216
427
  }
217
428
  if (memoryAction === 'events' && req.method === 'POST') {
@@ -221,30 +432,36 @@ export async function startObserverServer({
221
432
  errorResponse(res, 400, 'invalid_memory_batch', 'events deve conter pelo menos um evento.');
222
433
  return;
223
434
  }
224
- const results = [];
435
+ const validationErrors = [];
225
436
  for (const event of events) {
226
- if (event?.project_id !== projectId) {
227
- results.push({ accepted: false, errors: ['project_id do evento não corresponde à rota.'] });
228
- continue;
229
- }
230
437
  const validation = validateMemoryEvent(event);
231
- results.push(validation.ok ? applyMemoryEvent(dataDir, event) : { accepted: false, errors: validation.errors });
438
+ if (!validation.ok) validationErrors.push(...validation.errors);
232
439
  }
233
- const accepted = results.filter((item) => item.accepted).length;
234
- const duplicates = results.filter((item) => item.duplicate).length;
235
- const conflicts = results.filter((item) => item.conflict).length;
236
- const rejected = results.length - accepted - duplicates - conflicts;
237
- if (conflicts > 0 || rejected > 0) {
238
- json(res, conflicts > 0 ? 409 : 400, { accepted, duplicates, conflicts, rejected, results });
440
+ if (validationErrors.length) {
441
+ errorResponse(res, 400, 'invalid_memory_batch', validationErrors.join(' '));
239
442
  return;
240
443
  }
241
- json(res, accepted > 0 ? 201 : 200, { accepted, duplicates, conflicts, rejected, results });
444
+ const result = ingestObserverEvents(sqlDb, { projectId, events: events.map((event) => sqlMemoryEvent(projectId, event)) });
445
+ const status = result.conflicts > 0 ? 409 : result.rejected > 0 ? 400 : result.accepted > 0 ? 201 : 200;
446
+ json(res, status, result);
242
447
  return;
243
448
  }
244
449
  }
245
450
 
246
451
  if (parts.length === 3 && req.method === 'GET') {
247
- const project = getObserverProject(dataDir, projectId);
452
+ let project = getObserverProject(dataDir, projectId);
453
+ if (!project) {
454
+ try {
455
+ const sqlProject = readSqlProject(sqlDb, projectId);
456
+ project = {
457
+ projectId: sqlProject.project_id,
458
+ projectName: sqlProject.project_name,
459
+ wendkeepVersion: sqlProject.wendkeep_version,
460
+ registeredAt: sqlProject.registered_at,
461
+ eventCount: 0,
462
+ };
463
+ } catch { /* handled as not found below */ }
464
+ }
248
465
  if (!project) {
249
466
  errorResponse(res, 404, 'project_not_found', `projeto não encontrado: ${projectId}`);
250
467
  return;
@@ -268,6 +485,11 @@ export async function startObserverServer({
268
485
  errorResponse(res, 400, 'invalid_project', result.errors.join(' '));
269
486
  return;
270
487
  }
488
+ registerSqlProject(sqlDb, {
489
+ projectId,
490
+ projectName: body.project_name,
491
+ wendkeepVersion: body.wendkeep_version,
492
+ });
271
493
  json(res, 201, result.project);
272
494
  return;
273
495
  }
@@ -275,7 +497,15 @@ export async function startObserverServer({
275
497
  if (parts.length === 4 && parts[3] === 'changes' && req.method === 'GET') {
276
498
  const project = getObserverProject(dataDir, projectId);
277
499
  if (!project) {
278
- errorResponse(res, 404, 'project_not_found', `projeto não encontrado: ${projectId}`);
500
+ let sqlProject;
501
+ try { sqlProject = readSqlProject(sqlDb, projectId); } catch (error) {
502
+ if (error?.code !== 'project_not_registered') throw error;
503
+ }
504
+ if (!sqlProject) {
505
+ errorResponse(res, 404, 'project_not_found', `projeto não encontrado: ${projectId}`);
506
+ return;
507
+ }
508
+ json(res, 200, { project_id: projectId, changes: [] });
279
509
  return;
280
510
  }
281
511
  json(res, 200, { project_id: projectId, changes: project.snapshot?.changes || [] });
@@ -306,7 +536,7 @@ export async function startObserverServer({
306
536
  errorResponse(res, 404, 'not_found', 'rota não encontrada.');
307
537
  } catch (error) {
308
538
  if (res.headersSent) return;
309
- const status = error?.code === 'payload_too_large' ? 413 : error?.code === 'invalid_json' ? 400 : 500;
539
+ const status = error?.code === 'payload_too_large' ? 413 : ['invalid_json', 'invalid_content_encoding'].includes(error?.code) ? 400 : 500;
310
540
  errorResponse(res, status, error?.code || 'observer_error', error?.message || 'erro interno do Observer.');
311
541
  }
312
542
  });
@@ -328,6 +558,10 @@ export async function startObserverServer({
328
558
  return {
329
559
  server,
330
560
  address: () => server.address(),
331
- close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
561
+ close: () => new Promise((resolve, reject) => server.close((error) => {
562
+ try { sqlDb.close(); } catch { /* already closed */ }
563
+ if (error) reject(error);
564
+ else resolve();
565
+ })),
332
566
  };
333
567
  }