wendkeep 0.70.0 → 0.72.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.
@@ -1,9 +1,52 @@
1
1
  import { createServer } from 'node:http';
2
- import { appendObserverEvent, getObserverProject, readObserverIndex, registerObserverProject } from './observer-store.mjs';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { gunzipSync } from 'node:zlib';
5
+ import {
6
+ appendObserverEvent,
7
+ getObserverProject,
8
+ listRegisteredObserverProjects,
9
+ readObserverIndex,
10
+ registerObserverProject,
11
+ } from './observer-store.mjs';
3
12
  import { MAX_SNAPSHOT_BYTES, validateObserverSnapshot } from './observer-snapshot.mjs';
13
+ import {
14
+ setMemoryMode,
15
+ validateMemoryEvent,
16
+ } from './observer-memory.mjs';
17
+ import {
18
+ OBSERVER_SQL_FILE,
19
+ OBSERVER_SQL_SCHEMA_VERSION,
20
+ ensureObserverDatabase,
21
+ ingestObserverEvents,
22
+ migrateObserverDatabase,
23
+ readSqlProject,
24
+ listSqlProjects,
25
+ readSqlDocument,
26
+ readSqlSync,
27
+ readSqlTree,
28
+ searchSqlDocuments,
29
+ exportSqlMemoryBundle,
30
+ readTranscript,
31
+ readUsageBreakdown,
32
+ readUsageCalls,
33
+ readUsageSummary,
34
+ registerSqlProject,
35
+ } from './observer-sql-store.mjs';
36
+ import { migrateObserverContainerData } from './observer-sql-migrate.mjs';
4
37
 
5
38
  const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
6
39
  const MAX_BODY_BYTES = MAX_SNAPSHOT_BYTES + 4096;
40
+ const MAX_MEMORY_BODY_BYTES = 8 * 1024 * 1024;
41
+ const MAX_SQL_BODY_BYTES = 64 * 1024 * 1024;
42
+ const MAX_SQL_EXPANDED_BODY_BYTES = 256 * 1024 * 1024;
43
+ const STATIC_ROOT = fileURLToPath(new URL('../web/observer/', import.meta.url));
44
+ const STATIC_ASSETS = new Map([
45
+ ['/index.html', { file: 'index.html', type: 'text/html; charset=utf-8' }],
46
+ ['/styles.css', { file: 'styles.css', type: 'text/css; charset=utf-8' }],
47
+ ['/app.mjs', { file: 'app.mjs', type: 'text/javascript; charset=utf-8' }],
48
+ ['/favicon.svg', { file: 'favicon.svg', type: 'image/svg+xml' }],
49
+ ]);
7
50
 
8
51
  function loopbackOnly(host) {
9
52
  return LOOPBACK_HOSTS.has(String(host || '').toLowerCase());
@@ -23,7 +66,25 @@ function errorResponse(res, status, code, message) {
23
66
  json(res, status, { error: { code, message } });
24
67
  }
25
68
 
26
- function readBody(req) {
69
+ function serveStatic(res, pathname) {
70
+ const asset = STATIC_ASSETS.get(pathname === '/' ? '/index.html' : pathname);
71
+ if (!asset) return false;
72
+ try {
73
+ const body = readFileSync(new URL(asset.file, `file://${STATIC_ROOT.replace(/\\/g, '/')}/`));
74
+ res.writeHead(200, {
75
+ 'content-type': asset.type,
76
+ 'cache-control': 'no-store',
77
+ 'x-content-type-options': 'nosniff',
78
+ 'content-length': body.byteLength,
79
+ });
80
+ res.end(body);
81
+ } catch {
82
+ errorResponse(res, 500, 'dashboard_asset_error', 'asset do dashboard indisponível.');
83
+ }
84
+ return true;
85
+ }
86
+
87
+ function readBody(req, maxBytes = MAX_BODY_BYTES, { gunzip = false, expandedMaxBytes = maxBytes } = {}) {
27
88
  return new Promise((resolve, reject) => {
28
89
  let size = 0;
29
90
  let tooLarge = false;
@@ -31,7 +92,7 @@ function readBody(req) {
31
92
  req.on('data', (chunk) => {
32
93
  if (tooLarge) return;
33
94
  size += chunk.length;
34
- if (size > MAX_BODY_BYTES) {
95
+ if (size > maxBytes) {
35
96
  tooLarge = true;
36
97
  const error = new Error('corpo acima do limite.');
37
98
  error.code = 'payload_too_large';
@@ -41,7 +102,24 @@ function readBody(req) {
41
102
  chunks.push(chunk);
42
103
  });
43
104
  req.on('end', () => {
44
- if (!tooLarge) resolve(Buffer.concat(chunks).toString('utf8'));
105
+ if (tooLarge) return;
106
+ let body = Buffer.concat(chunks);
107
+ if (gunzip && String(req.headers['content-encoding'] || '').toLowerCase() === 'gzip') {
108
+ try { body = gunzipSync(body); }
109
+ catch {
110
+ const error = new Error('corpo gzip inválido.');
111
+ error.code = 'invalid_content_encoding';
112
+ reject(error);
113
+ return;
114
+ }
115
+ if (body.length > expandedMaxBytes) {
116
+ const error = new Error('corpo expandido acima do limite.');
117
+ error.code = 'payload_too_large';
118
+ reject(error);
119
+ return;
120
+ }
121
+ }
122
+ resolve(body.toString('utf8'));
45
123
  });
46
124
  req.on('error', reject);
47
125
  });
@@ -56,13 +134,6 @@ function parseJson(text) {
56
134
  }
57
135
  }
58
136
 
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
137
  function pathParts(url) {
67
138
  return new URL(url, 'http://127.0.0.1').pathname.split('/').filter(Boolean).map((part) => decodeURIComponent(part));
68
139
  }
@@ -71,28 +142,93 @@ function projectIdFrom(parts) {
71
142
  return parts[0] === 'v1' && parts[1] === 'projects' && parts[2] ? parts[2] : '';
72
143
  }
73
144
 
145
+ function ensureSqlProjectRegistration(dataDir, sqlDb, projectId) {
146
+ try {
147
+ if (readSqlProject(sqlDb, projectId)) return true;
148
+ } catch (error) {
149
+ if (error?.code !== 'project_not_registered') throw error;
150
+ }
151
+ const legacy = getObserverProject(dataDir, projectId)
152
+ || listRegisteredObserverProjects(dataDir).find((item) => item.projectId === projectId);
153
+ if (!legacy) return false;
154
+ registerSqlProject(sqlDb, {
155
+ projectId: legacy.projectId,
156
+ projectName: legacy.projectName,
157
+ wendkeepVersion: legacy.snapshot?.wendkeep_version || legacy.wendkeepVersion || '',
158
+ });
159
+ return true;
160
+ }
161
+
162
+ function sqlMemoryEvent(projectId, event) {
163
+ return {
164
+ schema_version: 1,
165
+ event_id: event.event_id,
166
+ kind: event.operation === 'delete' ? 'document.delete' : 'document.upsert',
167
+ project_id: projectId,
168
+ occurred_at: event.captured_at || new Date().toISOString(),
169
+ payload: {
170
+ logical_path: event.logical_path,
171
+ entity_type: event.entity_type,
172
+ content: event.content || '',
173
+ content_hash: event.content_hash || '',
174
+ revision: event.revision || 1,
175
+ source_session_id: event.source_session_id || '',
176
+ source_turn_id: event.source_turn_id || '',
177
+ metadata: event.metadata || {},
178
+ },
179
+ };
180
+ }
181
+
74
182
  export async function startObserverServer({
75
183
  host = '127.0.0.1',
76
184
  port = 8787,
77
185
  dataDir,
78
- token = process.env.WENDKEEP_OBSERVER_TOKEN || '',
79
186
  allowNonLoopback = false,
80
187
  } = {}) {
81
188
  if (!loopbackOnly(host) && !allowNonLoopback) {
82
189
  throw new Error(`Observer HTTP aceita somente host loopback; recebido: ${host}`);
83
190
  }
84
191
  if (!dataDir) throw new Error('dataDir é obrigatório.');
192
+ const sqlDb = ensureObserverDatabase(dataDir);
193
+ const databaseMigration = migrateObserverDatabase(sqlDb);
194
+ const legacyMigration = migrateObserverContainerData(dataDir, { database: sqlDb });
195
+ const registered = [
196
+ ...listRegisteredObserverProjects(dataDir),
197
+ ...readObserverIndex(dataDir).projects.map((item) => ({
198
+ projectId: item.projectId,
199
+ projectName: item.projectName,
200
+ wendkeepVersion: item.snapshot?.wendkeep_version || '',
201
+ })),
202
+ ];
203
+ for (const project of registered) registerSqlProject(sqlDb, project);
85
204
  const server = createServer(async (req, res) => {
86
205
  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 });
206
+ const pathname = new URL(req.url || '/', 'http://127.0.0.1').pathname;
207
+ if (req.method === 'GET' && pathname === '/healthz') {
208
+ json(res, 200, {
209
+ ok: true,
210
+ service: 'wendkeep-observer',
211
+ schema_version: 1,
212
+ database: {
213
+ engine: 'sqlite',
214
+ file: OBSERVER_SQL_FILE,
215
+ schema_version: OBSERVER_SQL_SCHEMA_VERSION,
216
+ migrations: databaseMigration.applied.length,
217
+ legacy_migration: legacyMigration,
218
+ ready: true,
219
+ },
220
+ });
221
+ return;
222
+ }
223
+ if (req.method === 'GET' && (pathname === '/' || STATIC_ASSETS.has(pathname))) {
224
+ serveStatic(res, pathname);
90
225
  return;
91
226
  }
92
- if (!authorized(req, token)) {
93
- errorResponse(res, 401, 'unauthorized', 'token local ausente ou inválido.');
227
+ if (req.method === 'GET' && !pathname.startsWith('/v1/')) {
228
+ errorResponse(res, 404, 'not_found', 'rota não encontrada.');
94
229
  return;
95
230
  }
231
+ const parts = pathParts(req.url || '/');
96
232
  if (parts[0] !== 'v1' || parts[1] !== 'projects') {
97
233
  errorResponse(res, 404, 'not_found', 'rota não encontrada.');
98
234
  return;
@@ -100,9 +236,20 @@ export async function startObserverServer({
100
236
 
101
237
  if (parts.length === 2 && req.method === 'GET') {
102
238
  const index = readObserverIndex(dataDir);
239
+ const legacy = new Map(index.projects.map(({ snapshot, ...summary }) => [summary.projectId, summary]));
240
+ for (const project of listSqlProjects(sqlDb)) {
241
+ const current = legacy.get(project.project_id) || {};
242
+ legacy.set(project.project_id, {
243
+ ...current,
244
+ projectId: project.project_id,
245
+ projectName: current.projectName || project.project_name,
246
+ wendkeepVersion: current.wendkeepVersion || project.wendkeep_version,
247
+ registeredAt: current.registeredAt || project.registered_at,
248
+ });
249
+ }
103
250
  json(res, 200, {
104
251
  schema_version: index.schema_version,
105
- projects: index.projects.map(({ snapshot, ...summary }) => summary),
252
+ projects: [...legacy.values()].sort((a, b) => a.projectId.localeCompare(b.projectId)),
106
253
  });
107
254
  return;
108
255
  }
@@ -112,9 +259,158 @@ export async function startObserverServer({
112
259
  errorResponse(res, 404, 'not_found', 'projeto não informado.');
113
260
  return;
114
261
  }
262
+ ensureSqlProjectRegistration(dataDir, sqlDb, projectId);
263
+
264
+ if (parts.length === 4 && parts[3] === 'ingest' && req.method === 'POST') {
265
+ if (!ensureSqlProjectRegistration(dataDir, sqlDb, projectId)) {
266
+ errorResponse(res, 404, 'project_not_found', 'projeto não encontrado: ' + projectId);
267
+ return;
268
+ }
269
+ const body = parseJson(await readBody(req, MAX_SQL_BODY_BYTES, {
270
+ gunzip: true,
271
+ expandedMaxBytes: MAX_SQL_EXPANDED_BODY_BYTES,
272
+ }));
273
+ const events = Array.isArray(body.events) ? body.events : [];
274
+ if (events.length === 0) {
275
+ errorResponse(res, 400, 'invalid_ingest_batch', 'events deve conter pelo menos um evento.');
276
+ return;
277
+ }
278
+ const result = ingestObserverEvents(sqlDb, { projectId, events });
279
+ const status = result.conflicts > 0 ? 409 : result.rejected > 0 ? 400 : result.accepted > 0 ? 201 : 200;
280
+ json(res, status, result);
281
+ return;
282
+ }
283
+
284
+ if (parts.length >= 4 && parts[3] === 'usage' && req.method === 'GET') {
285
+ if (!ensureSqlProjectRegistration(dataDir, sqlDb, projectId)) {
286
+ errorResponse(res, 404, 'project_not_found', 'projeto não encontrado: ' + projectId);
287
+ return;
288
+ }
289
+ const query = new URL(req.url || '/', 'http://127.0.0.1').searchParams;
290
+ const filters = {
291
+ from: query.get('from') || '', to: query.get('to') || '', agentId: query.get('agent_id') || '', subagentId: query.get('subagent_id') || '',
292
+ sessionId: query.get('session_id') || '', changeSlug: query.get('change') || query.get('change_slug') || '', role: query.get('role') || '',
293
+ model: query.get('model') || '', provider: query.get('provider') || '', modelProvider: query.get('model_provider') || '',
294
+ limit: query.get('limit') || 100, offset: query.get('offset') || 0,
295
+ };
296
+ if (parts[4] === 'summary') {
297
+ json(res, 200, readUsageSummary(sqlDb, projectId, filters));
298
+ return;
299
+ }
300
+ if (parts[4] === 'breakdown') {
301
+ json(res, 200, readUsageBreakdown(sqlDb, projectId, filters));
302
+ return;
303
+ }
304
+ if (parts[4] === 'calls') {
305
+ json(res, 200, readUsageCalls(sqlDb, projectId, filters));
306
+ return;
307
+ }
308
+ }
309
+
310
+ if (parts.length === 5 && parts[3] === 'transcripts' && req.method === 'GET') {
311
+ if (!ensureSqlProjectRegistration(dataDir, sqlDb, projectId)) {
312
+ errorResponse(res, 404, 'project_not_found', 'projeto não encontrado: ' + projectId);
313
+ return;
314
+ }
315
+ try {
316
+ json(res, 200, readTranscript(sqlDb, projectId, parts[4]));
317
+ } catch (error) {
318
+ errorResponse(res, error?.code === 'transcript_not_found' ? 404 : 400, error?.code || 'transcript_error', error?.message || 'transcript indisponível.');
319
+ }
320
+ return;
321
+ }
322
+
323
+ if (parts.length === 4 && parts[3] === 'sync' && req.method === 'GET') {
324
+ if (!ensureSqlProjectRegistration(dataDir, sqlDb, projectId)) {
325
+ errorResponse(res, 404, 'project_not_found', 'projeto não encontrado: ' + projectId);
326
+ return;
327
+ }
328
+ json(res, 200, { ...readSqlSync(sqlDb, projectId), mode: 'container-authority' });
329
+ return;
330
+ }
331
+
332
+ if (parts.length === 4 && parts[3] === 'sync' && req.method === 'PUT') {
333
+ if (!ensureSqlProjectRegistration(dataDir, sqlDb, projectId)) {
334
+ errorResponse(res, 404, 'project_not_found', 'projeto não encontrado: ' + projectId);
335
+ return;
336
+ }
337
+ const body = parseJson(await readBody(req));
338
+ try {
339
+ json(res, 200, setMemoryMode(dataDir, projectId, body.mode));
340
+ } catch (error) {
341
+ errorResponse(res, 400, error?.code || 'invalid_memory_mode', error?.message || 'modo inválido.');
342
+ }
343
+ return;
344
+ }
345
+
346
+ if (parts.length >= 4 && parts[3] === 'memory') {
347
+ if (!ensureSqlProjectRegistration(dataDir, sqlDb, projectId)) {
348
+ errorResponse(res, 404, 'project_not_found', 'projeto não encontrado: ' + projectId);
349
+ return;
350
+ }
351
+ const memoryAction = parts[4] || '';
352
+ if (memoryAction === 'tree' && req.method === 'GET') {
353
+ const query = new URL(req.url || '/', 'http://127.0.0.1').searchParams;
354
+ const tree = readSqlTree(sqlDb, projectId, query.get('prefix') || '');
355
+ json(res, 200, { ...tree, document_count: tree.documents.length });
356
+ return;
357
+ }
358
+ if (memoryAction === 'document' && req.method === 'GET') {
359
+ const query = new URL(req.url || '/', 'http://127.0.0.1').searchParams;
360
+ try {
361
+ json(res, 200, readSqlDocument(sqlDb, projectId, query.get('path') || ''));
362
+ } catch (error) {
363
+ const status = error?.code === 'memory_not_found' ? 404 : 400;
364
+ errorResponse(res, status, error?.code || 'invalid_memory_path', error?.message || 'documento inválido.');
365
+ }
366
+ return;
367
+ }
368
+ if (memoryAction === 'search' && req.method === 'GET') {
369
+ const query = new URL(req.url || '/', 'http://127.0.0.1').searchParams;
370
+ json(res, 200, { project_id: projectId, query: query.get('q') || '', results: searchSqlDocuments(sqlDb, projectId, query.get('q') || '') });
371
+ return;
372
+ }
373
+ if (memoryAction === 'export' && req.method === 'GET') {
374
+ json(res, 200, { ...exportSqlMemoryBundle(sqlDb, projectId), mode: 'container-authority' });
375
+ return;
376
+ }
377
+ if (memoryAction === 'events' && req.method === 'POST') {
378
+ const body = parseJson(await readBody(req, MAX_MEMORY_BODY_BYTES));
379
+ const events = Array.isArray(body.events) ? body.events : [];
380
+ if (events.length === 0) {
381
+ errorResponse(res, 400, 'invalid_memory_batch', 'events deve conter pelo menos um evento.');
382
+ return;
383
+ }
384
+ const validationErrors = [];
385
+ for (const event of events) {
386
+ const validation = validateMemoryEvent(event);
387
+ if (!validation.ok) validationErrors.push(...validation.errors);
388
+ }
389
+ if (validationErrors.length) {
390
+ errorResponse(res, 400, 'invalid_memory_batch', validationErrors.join(' '));
391
+ return;
392
+ }
393
+ const result = ingestObserverEvents(sqlDb, { projectId, events: events.map((event) => sqlMemoryEvent(projectId, event)) });
394
+ const status = result.conflicts > 0 ? 409 : result.rejected > 0 ? 400 : result.accepted > 0 ? 201 : 200;
395
+ json(res, status, result);
396
+ return;
397
+ }
398
+ }
115
399
 
116
400
  if (parts.length === 3 && req.method === 'GET') {
117
- const project = getObserverProject(dataDir, projectId);
401
+ let project = getObserverProject(dataDir, projectId);
402
+ if (!project) {
403
+ try {
404
+ const sqlProject = readSqlProject(sqlDb, projectId);
405
+ project = {
406
+ projectId: sqlProject.project_id,
407
+ projectName: sqlProject.project_name,
408
+ wendkeepVersion: sqlProject.wendkeep_version,
409
+ registeredAt: sqlProject.registered_at,
410
+ eventCount: 0,
411
+ };
412
+ } catch { /* handled as not found below */ }
413
+ }
118
414
  if (!project) {
119
415
  errorResponse(res, 404, 'project_not_found', `projeto não encontrado: ${projectId}`);
120
416
  return;
@@ -138,6 +434,11 @@ export async function startObserverServer({
138
434
  errorResponse(res, 400, 'invalid_project', result.errors.join(' '));
139
435
  return;
140
436
  }
437
+ registerSqlProject(sqlDb, {
438
+ projectId,
439
+ projectName: body.project_name,
440
+ wendkeepVersion: body.wendkeep_version,
441
+ });
141
442
  json(res, 201, result.project);
142
443
  return;
143
444
  }
@@ -145,7 +446,15 @@ export async function startObserverServer({
145
446
  if (parts.length === 4 && parts[3] === 'changes' && req.method === 'GET') {
146
447
  const project = getObserverProject(dataDir, projectId);
147
448
  if (!project) {
148
- errorResponse(res, 404, 'project_not_found', `projeto não encontrado: ${projectId}`);
449
+ let sqlProject;
450
+ try { sqlProject = readSqlProject(sqlDb, projectId); } catch (error) {
451
+ if (error?.code !== 'project_not_registered') throw error;
452
+ }
453
+ if (!sqlProject) {
454
+ errorResponse(res, 404, 'project_not_found', `projeto não encontrado: ${projectId}`);
455
+ return;
456
+ }
457
+ json(res, 200, { project_id: projectId, changes: [] });
149
458
  return;
150
459
  }
151
460
  json(res, 200, { project_id: projectId, changes: project.snapshot?.changes || [] });
@@ -176,7 +485,7 @@ export async function startObserverServer({
176
485
  errorResponse(res, 404, 'not_found', 'rota não encontrada.');
177
486
  } catch (error) {
178
487
  if (res.headersSent) return;
179
- const status = error?.code === 'payload_too_large' ? 413 : error?.code === 'invalid_json' ? 400 : 500;
488
+ const status = error?.code === 'payload_too_large' ? 413 : ['invalid_json', 'invalid_content_encoding'].includes(error?.code) ? 400 : 500;
180
489
  errorResponse(res, status, error?.code || 'observer_error', error?.message || 'erro interno do Observer.');
181
490
  }
182
491
  });
@@ -198,6 +507,10 @@ export async function startObserverServer({
198
507
  return {
199
508
  server,
200
509
  address: () => server.address(),
201
- close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
510
+ close: () => new Promise((resolve, reject) => server.close((error) => {
511
+ try { sqlDb.close(); } catch { /* already closed */ }
512
+ if (error) reject(error);
513
+ else resolve();
514
+ })),
202
515
  };
203
516
  }