wendkeep 0.86.0 → 0.88.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 (55) hide show
  1. package/.githooks/commit-msg +16 -0
  2. package/.githooks/prepare-commit-msg +16 -0
  3. package/CHANGELOG.md +32 -0
  4. package/README.en.md +3 -1
  5. package/README.md +3 -1
  6. package/docs/en/commands/commit.md +159 -0
  7. package/docs/en/commands/observer-security.md +154 -0
  8. package/docs/en/commands/observer.md +30 -12
  9. package/docs/pt-BR/commands/commit.md +159 -0
  10. package/docs/pt-BR/commands/observer-security.md +154 -0
  11. package/docs/pt-BR/commands/observer.md +30 -12
  12. package/hooks/observer-publish.mjs +3 -1
  13. package/package.json +6 -2
  14. package/packages/cli/src/index.mjs +11 -1
  15. package/packages/commit/package.json +6 -0
  16. package/packages/commit/src/cli.mjs +89 -0
  17. package/packages/commit/src/commit-input.mjs +181 -0
  18. package/packages/commit/src/commit-message.mjs +51 -0
  19. package/packages/commit/src/commit-policy.mjs +144 -0
  20. package/packages/commit/src/git-runtime.mjs +428 -0
  21. package/packages/commit/src/index.mjs +28 -0
  22. package/packages/commit/src/proof-validation.mjs +443 -0
  23. package/packages/mcp/src/executor.mjs +35 -2
  24. package/packages/observer/package.json +16 -0
  25. package/packages/observer/src/audit.mjs +1 -0
  26. package/packages/observer/src/authz.mjs +38 -0
  27. package/packages/observer/src/encryption.mjs +75 -0
  28. package/packages/observer/src/index.mjs +7 -0
  29. package/packages/observer/src/policy.mjs +305 -0
  30. package/packages/observer/src/purge.mjs +100 -0
  31. package/packages/observer/src/redaction.mjs +54 -0
  32. package/packages/observer/src/retention.mjs +39 -0
  33. package/packages/observer/src/token-registry.mjs +122 -0
  34. package/schema/commit-message-v1.schema.json +75 -0
  35. package/schema/observer/006-observer-security.sql +64 -0
  36. package/schema/observer-policy-v1.schema.json +63 -0
  37. package/schema/sync-event-v1.schema.json +10 -0
  38. package/scripts/validate-commit-range.mjs +244 -0
  39. package/src/doctor.mjs +7 -0
  40. package/src/git-commit-hooks.mjs +112 -0
  41. package/src/init.mjs +13 -0
  42. package/src/observer-auth.mjs +8 -0
  43. package/src/observer-privacy.mjs +7 -3
  44. package/src/observer-publish.mjs +31 -0
  45. package/src/observer-server.mjs +179 -20
  46. package/src/observer-sql-migrate.mjs +5 -2
  47. package/src/observer-sql-publish.mjs +114 -39
  48. package/src/observer-sql-store.mjs +299 -45
  49. package/src/observer-transcript-store.mjs +23 -8
  50. package/src/observer.mjs +145 -12
  51. package/src/skills-seed.mjs +79 -0
  52. package/src/sync-protocol.mjs +20 -0
  53. package/web/observer/app.mjs +107 -31
  54. package/web/observer/index.html +7 -0
  55. package/web/observer/styles.css +5 -0
@@ -1,15 +1,17 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import { join, basename, dirname } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { decodeTranscript, encodeTranscript } from './observer-transcript-store.mjs';
7
+ import { decryptObserverValue, encryptObserverValue } from '../packages/observer/src/encryption.mjs';
8
+ import { protectObserverEvent, readObserverPolicy } from '../packages/observer/src/policy.mjs';
7
9
  import {
8
10
  chunkMarkdownDocument, recallEvidence, recallTerms,
9
11
  } from '../packages/vault/src/evidence-recall.mjs';
10
12
 
11
13
  export const OBSERVER_SQL_FILE = 'observer.sqlite';
12
- export const OBSERVER_SQL_SCHEMA_VERSION = 5;
14
+ export const OBSERVER_SQL_SCHEMA_VERSION = 6;
13
15
  export const OBSERVER_EVENT_SCHEMA_VERSION = 1;
14
16
 
15
17
  const SCHEMA_DIR = fileURLToPath(new URL('../schema/observer/', import.meta.url));
@@ -23,6 +25,29 @@ const OBSERVER_SQL_MINIMUM_NODE = '22.13.0';
23
25
  const require = createRequire(import.meta.url);
24
26
  let DatabaseSync;
25
27
  const DATABASE_PATHS = new WeakMap();
28
+ const DATABASE_SECURITY = new WeakMap();
29
+
30
+ export function configureObserverDatabaseSecurity(db, { policy = null, encryption = null, enforcePolicy = false } = {}) {
31
+ if (!db) throw new Error('db é obrigatório.');
32
+ DATABASE_SECURITY.set(db, { policy, encryption, enforcePolicy: Boolean(enforcePolicy) });
33
+ return { policy: Boolean(policy) || Boolean(enforcePolicy), encryption: Boolean(encryption) };
34
+ }
35
+
36
+ function databaseSecurity(db) {
37
+ return DATABASE_SECURITY.get(db) || { policy: null, encryption: null, enforcePolicy: false };
38
+ }
39
+
40
+ function encryptedJson(encryption, value, aad) {
41
+ return encryption ? json(encryptObserverValue(encryption, json(value), { aad })) : '';
42
+ }
43
+
44
+ function encryptedText(encryption, value, aad) {
45
+ return encryption ? json(encryptObserverValue(encryption, text(value), { aad })) : '';
46
+ }
47
+
48
+ function decryptedJson(encryption, envelope, fallback, aad) {
49
+ return envelope ? parseJson(decryptObserverValue(encryption, parseJson(envelope), { aad }), fallback) : fallback;
50
+ }
26
51
 
27
52
  export function observerSqlRuntimeSupport(version = process.versions.node) {
28
53
  const current = String(version || '0.0.0');
@@ -109,7 +134,50 @@ export function openObserverDatabase(dataDir) {
109
134
  return db;
110
135
  }
111
136
 
112
- export function migrateObserverDatabase(db) {
137
+ function encryptedStructuralBackup(db, databasePath, version, encryption) {
138
+ if (!encryption) throw Object.assign(new Error('Migração estrutural exige chave para backup protegido.'), { code: 'observer_encryption_required' });
139
+ const prefix = `${databasePath}.pre-${String(version).padStart(3, '0')}-${Date.now()}.bak`;
140
+ const temporaryPath = `${prefix}.tmp`;
141
+ const encryptedPath = `${prefix}.enc`;
142
+ const manifestPath = `${encryptedPath}.manifest.json`;
143
+ const aad = `observer-backup:${basename(databasePath)}:${version}`;
144
+ try {
145
+ db.exec(`VACUUM INTO '${temporaryPath.replaceAll("'", "''")}'`);
146
+ try { chmodSync(temporaryPath, 0o600); } catch { /* best effort on Windows */ }
147
+ const plaintext = readFileSync(temporaryPath);
148
+ const envelope = encryptObserverValue(encryption, plaintext.toString('base64'), { aad });
149
+ writeFileSync(encryptedPath, `${JSON.stringify(envelope)}\n`, { mode: 0o600 });
150
+ writeFileSync(manifestPath, `${JSON.stringify({
151
+ schema_version: 1,
152
+ encrypted_file: basename(encryptedPath),
153
+ algorithm: envelope.algorithm,
154
+ key_id: envelope.key_id,
155
+ aad,
156
+ plaintext_sha256: createHash('sha256').update(plaintext).digest('hex'),
157
+ source_database: basename(databasePath),
158
+ target_schema_version: version,
159
+ created_at: now(),
160
+ }, null, 2)}\n`, { mode: 0o600 });
161
+ try { chmodSync(encryptedPath, 0o600); chmodSync(manifestPath, 0o600); } catch { /* best effort on Windows */ }
162
+ return encryptedPath;
163
+ } finally {
164
+ rmSync(temporaryPath, { force: true });
165
+ }
166
+ }
167
+
168
+ export function restoreObserverEncryptedBackup({ backupPath, destinationPath, encryption } = {}) {
169
+ if (!backupPath || !destinationPath || !encryption) throw new Error('backupPath, destinationPath e encryption são obrigatórios.');
170
+ const manifest = parseJson(readFileSync(`${backupPath}.manifest.json`, 'utf8'), null);
171
+ if (!manifest || manifest.encrypted_file !== basename(backupPath)) throw Object.assign(new Error('Manifest do backup inválido.'), { code: 'observer_backup_manifest_invalid' });
172
+ const envelope = parseJson(readFileSync(backupPath, 'utf8'), null);
173
+ const plaintext = Buffer.from(decryptObserverValue(encryption, envelope, { aad: manifest.aad }), 'base64');
174
+ if (createHash('sha256').update(plaintext).digest('hex') !== manifest.plaintext_sha256) throw Object.assign(new Error('Integridade do backup inválida.'), { code: 'observer_backup_integrity_invalid' });
175
+ writeFileSync(destinationPath, plaintext, { mode: 0o600 });
176
+ try { chmodSync(destinationPath, 0o600); } catch { /* best effort on Windows */ }
177
+ return { restored: true, destination_path: destinationPath, sha256: manifest.plaintext_sha256 };
178
+ }
179
+
180
+ export function migrateObserverDatabase(db, { backupEncryption = null, requireEncryptedBackup = false } = {}) {
113
181
  if (!db) throw new Error('db é obrigatório.');
114
182
  db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)');
115
183
  const migrationColumns = db.prepare('PRAGMA table_info(schema_migrations)').all().map((row) => row.name);
@@ -136,9 +204,13 @@ export function migrateObserverDatabase(db) {
136
204
  if (/^\s*--\s*wendkeep:structural\b/m.test(sql) && applied.size > 0) {
137
205
  const databasePath = DATABASE_PATHS.get(db);
138
206
  if (!databasePath) throw new Error('Caminho do Observer desconhecido para backup estrutural.');
139
- const backupPath = `${databasePath}.pre-${String(version).padStart(3, '0')}-${Date.now()}.bak`;
140
- const escaped = backupPath.replaceAll("'", "''");
141
- db.exec(`VACUUM INTO '${escaped}'`);
207
+ const backupPath = requireEncryptedBackup
208
+ ? encryptedStructuralBackup(db, databasePath, version, backupEncryption)
209
+ : `${databasePath}.pre-${String(version).padStart(3, '0')}-${Date.now()}.bak`;
210
+ if (!requireEncryptedBackup) {
211
+ const escaped = backupPath.replaceAll("'", "''");
212
+ db.exec(`VACUUM INTO '${escaped}'`);
213
+ }
142
214
  backups.push(backupPath);
143
215
  }
144
216
  db.exec('BEGIN IMMEDIATE');
@@ -229,6 +301,7 @@ export function rebuildSqlEvidenceIndex(db, { missingOnly = false } = {}) {
229
301
  const documents = db.prepare(`SELECT d.project_id, d.logical_path, d.content, d.metadata_json, d.entity_type, d.captured_at
230
302
  FROM documents d
231
303
  WHERE d.deleted_at IS NULL
304
+ AND COALESCE(d.content_envelope, '') = ''
232
305
  AND (? = 0 OR NOT EXISTS (
233
306
  SELECT 1 FROM document_chunks c
234
307
  WHERE c.project_id = d.project_id AND c.logical_path = d.logical_path
@@ -252,17 +325,98 @@ export function rebuildSqlEvidenceIndex(db, { missingOnly = false } = {}) {
252
325
  return { documents: documents.length, chunks, fts };
253
326
  }
254
327
 
255
- export function ensureObserverDatabase(dataDir) {
328
+ export function bootstrapObserverDatabase(dataDir, { security = {} } = {}) {
256
329
  const db = openObserverDatabase(dataDir);
257
330
  try {
258
- migrateObserverDatabase(db);
259
- return db;
331
+ configureObserverDatabaseSecurity(db, security);
332
+ const databaseMigration = migrateObserverDatabase(db, {
333
+ backupEncryption: security?.encryption || null,
334
+ requireEncryptedBackup: Boolean(security?.encryption?.required),
335
+ });
336
+ const protectedDataMigration = security.encryption
337
+ ? migrateObserverProtectedData(db, { encryption: security.encryption })
338
+ : { protected_rows: 0, projects: 0 };
339
+ return { db, databaseMigration, protectedDataMigration };
260
340
  } catch (error) {
261
341
  db.close();
262
342
  throw error;
263
343
  }
264
344
  }
265
345
 
346
+ export function ensureObserverDatabase(dataDir, { security = null } = {}) {
347
+ return bootstrapObserverDatabase(dataDir, { security: security || {} }).db;
348
+ }
349
+
350
+ export function migrateObserverProtectedData(db, { projectId = '', encryption } = {}) {
351
+ if (!encryption) return { protected_rows: 0, projects: 0 };
352
+ const projects = projectId
353
+ ? [requireProject(db, projectId)]
354
+ : db.prepare('SELECT project_id FROM projects ORDER BY project_id').all();
355
+ let protectedRows = 0;
356
+ for (const project of projects) {
357
+ const id = project.project_id;
358
+ db.exec('BEGIN IMMEDIATE');
359
+ try {
360
+ const documents = db.prepare("SELECT logical_path, content, metadata_json, content_envelope, metadata_envelope FROM documents WHERE project_id = ? AND (content <> '' OR metadata_json <> '{}' OR content_envelope = '' OR metadata_envelope = '')").all(id);
361
+ for (const row of documents) {
362
+ const contentEnvelope = row.content_envelope || encryptedText(encryption, row.content, `${id}:document:${row.logical_path}`);
363
+ const metadataEnvelope = row.metadata_envelope || encryptedJson(encryption, parseJson(row.metadata_json), `${id}:document:${row.logical_path}:metadata`);
364
+ db.prepare("UPDATE documents SET content = '', content_envelope = ?, metadata_json = '{}', metadata_envelope = ? WHERE project_id = ? AND logical_path = ?")
365
+ .run(contentEnvelope, metadataEnvelope, id, row.logical_path);
366
+ db.prepare('DELETE FROM document_chunks WHERE project_id = ? AND logical_path = ?').run(id, row.logical_path);
367
+ if (observerFts5Support(db).supported) db.prepare('DELETE FROM evidence_chunks_fts WHERE project_id = ? AND logical_path = ?').run(id, row.logical_path);
368
+ protectedRows += 1;
369
+ }
370
+ const calls = db.prepare("SELECT call_id, prompt_text, response_text, metadata_json, prompt_envelope, response_envelope, metadata_envelope FROM llm_calls WHERE project_id = ? AND (prompt_text <> '' OR response_text <> '' OR metadata_json <> '{}')").all(id);
371
+ for (const row of calls) {
372
+ db.prepare("UPDATE llm_calls SET prompt_text = '', response_text = '', metadata_json = '{}', prompt_envelope = ?, response_envelope = ?, metadata_envelope = ? WHERE project_id = ? AND call_id = ?").run(
373
+ row.prompt_envelope || encryptedText(encryption, row.prompt_text, `${id}:call:${row.call_id}:prompt`),
374
+ row.response_envelope || encryptedText(encryption, row.response_text, `${id}:call:${row.call_id}:response`),
375
+ row.metadata_envelope || encryptedJson(encryption, parseJson(row.metadata_json), `${id}:call:${row.call_id}:metadata`), id, row.call_id,
376
+ );
377
+ protectedRows += 1;
378
+ }
379
+ const transcripts = db.prepare("SELECT * FROM transcripts WHERE project_id = ? AND codec <> 'aes-256-gcm+gzip'").all(id);
380
+ for (const row of transcripts) {
381
+ const decoded = decodeTranscript(row);
382
+ const encoded = encodeTranscript(decoded.content, { encryption, aad: `${id}:transcript:${row.transcript_id}` });
383
+ db.prepare("UPDATE transcripts SET codec = ?, content_gzip = ?, compressed_bytes = ?, metadata_json = '{}', metadata_envelope = ? WHERE project_id = ? AND transcript_id = ?").run(
384
+ encoded.codec, encoded.content_gzip, encoded.compressed_bytes,
385
+ encryptedJson(encryption, parseJson(row.metadata_json), `${id}:transcript:${row.transcript_id}:metadata`), id, row.transcript_id,
386
+ );
387
+ protectedRows += 1;
388
+ }
389
+ const snapshots = db.prepare("SELECT project_id, snapshot_json, snapshot_envelope FROM project_snapshots WHERE project_id = ? AND snapshot_json <> '{}'").all(id);
390
+ for (const row of snapshots) {
391
+ db.prepare("UPDATE project_snapshots SET snapshot_json = '{}', snapshot_envelope = ? WHERE project_id = ?").run(
392
+ row.snapshot_envelope || encryptedJson(encryption, parseJson(row.snapshot_json), `${id}:snapshot`), id,
393
+ );
394
+ protectedRows += 1;
395
+ }
396
+ for (const table of ['ingest_events', 'memory_events']) {
397
+ const rows = db.prepare(`SELECT event_id, payload_json FROM ${table} WHERE project_id = ?`).all(id);
398
+ for (const row of rows) {
399
+ const source = parseJson(row.payload_json);
400
+ const protectedPayload = { ...source };
401
+ for (const field of ['content', 'prompt', 'promptText', 'prompt_text', 'response', 'responseText', 'response_text']) {
402
+ if (Object.hasOwn(protectedPayload, field)) protectedPayload[field] = '[PROTECTED]';
403
+ }
404
+ if (Object.hasOwn(protectedPayload, 'metadata')) protectedPayload.metadata = '[PROTECTED]';
405
+ db.prepare(`UPDATE ${table} SET payload_json = ? WHERE event_id = ?`).run(json(protectedPayload), row.event_id);
406
+ }
407
+ }
408
+ db.prepare(`INSERT INTO observer_security_backfill(project_id, status, protected_rows, updated_at)
409
+ VALUES (?, 'complete', ?, ?) ON CONFLICT(project_id) DO UPDATE SET status = 'complete', protected_rows = excluded.protected_rows, updated_at = excluded.updated_at`)
410
+ .run(id, protectedRows, now());
411
+ db.exec('COMMIT');
412
+ } catch (error) {
413
+ db.exec('ROLLBACK');
414
+ throw error;
415
+ }
416
+ }
417
+ return { protected_rows: protectedRows, projects: projects.length };
418
+ }
419
+
266
420
  export function registerSqlProject(db, { projectId, projectName = projectId, wendkeepVersion = '', registeredAt = now() } = {}) {
267
421
  if (!validProjectId(projectId)) return { registered: false, errors: ['project_id inválido.'] };
268
422
  const timestamp = now();
@@ -348,6 +502,7 @@ function ensureAgent(db, projectId, payload) {
348
502
  function applyDocument(db, event) {
349
503
  const p = event.payload;
350
504
  const content = text(p.content);
505
+ const { encryption } = databaseSecurity(db);
351
506
  const logicalPath = text(p.logical_path || p.logicalPath);
352
507
  if (!logicalPath || logicalPath.includes('..') || /^[A-Za-z]:[\\/]/.test(logicalPath)) throw new Error('logical_path inválido.');
353
508
  const current = db.prepare('SELECT revision FROM documents WHERE project_id = ? AND logical_path = ?').get(event.project_id, logicalPath);
@@ -371,15 +526,24 @@ function applyDocument(db, event) {
371
526
  }
372
527
  const title = text(p.title || basename(logicalPath).replace(/\.md$/i, ''));
373
528
  const documentId = text(p.document_id || p.documentId) || `${event.project_id}:${logicalPath}`;
529
+ const contentEnvelope = encryption
530
+ ? encryptObserverValue(encryption, content, { aad: `${event.project_id}:document:${logicalPath}` })
531
+ : null;
532
+ const metadataEnvelope = encryption
533
+ ? encryptedJson(encryption, p.metadata || {}, `${event.project_id}:document:${logicalPath}:metadata`)
534
+ : '';
535
+ const storedContent = contentEnvelope ? '' : content;
374
536
  db.prepare(`
375
- INSERT INTO documents(document_id, project_id, logical_path, entity_type, title, content, metadata_json, content_hash, revision, source_session_id, source_turn_id, captured_at, deleted_at)
376
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
537
+ INSERT INTO documents(document_id, project_id, logical_path, entity_type, title, content, content_envelope, metadata_json, metadata_envelope, content_hash, revision, source_session_id, source_turn_id, captured_at, deleted_at)
538
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
377
539
  ON CONFLICT(project_id, logical_path) DO UPDATE SET
378
540
  document_id = excluded.document_id,
379
541
  entity_type = excluded.entity_type,
380
542
  title = excluded.title,
381
543
  content = excluded.content,
544
+ content_envelope = excluded.content_envelope,
382
545
  metadata_json = excluded.metadata_json,
546
+ metadata_envelope = excluded.metadata_envelope,
383
547
  content_hash = excluded.content_hash,
384
548
  revision = excluded.revision,
385
549
  source_session_id = excluded.source_session_id,
@@ -387,22 +551,27 @@ function applyDocument(db, event) {
387
551
  captured_at = excluded.captured_at,
388
552
  deleted_at = NULL
389
553
  `).run(
390
- documentId, event.project_id, logicalPath, text(p.entity_type || p.entityType, 'memory'), title, content, json(p.metadata), contentHash,
391
- revision, text(p.source_session_id || p.sourceSessionId), text(p.source_turn_id || p.sourceTurnId), text(p.captured_at || event.occurred_at),
554
+ documentId, event.project_id, logicalPath, text(p.entity_type || p.entityType, 'memory'), title, storedContent, contentEnvelope ? json(contentEnvelope) : '', encryption ? '{}' : json(p.metadata), metadataEnvelope, contentHash,
555
+ revision, text(p.source_session_id || p.sourceSessionId), text(p.source_turn_id || p.sourceTurnId), text(p.captured_at || p.capturedAt || event.occurred_at),
392
556
  );
393
557
  db.prepare(`
394
558
  INSERT INTO memory_events(event_id, project_id, entity_type, logical_path, operation, revision, content_hash, source_session_id, source_turn_id, occurred_at, payload_json)
395
559
  VALUES (?, ?, ?, ?, 'upsert', ?, ?, ?, ?, ?, ?)
396
560
  `).run(event.event_id, event.project_id, text(p.entity_type || p.entityType, 'memory'), logicalPath, revision, contentHash,
397
- text(p.source_session_id || p.sourceSessionId), text(p.source_turn_id || p.sourceTurnId), event.occurred_at, json(p));
398
- writeSqlDocumentChunks(db, {
399
- projectId: event.project_id,
400
- logicalPath,
401
- content,
402
- metadata: p.metadata,
403
- entityType: text(p.entity_type || p.entityType, 'memory'),
404
- capturedAt: text(p.captured_at || event.occurred_at),
405
- });
561
+ text(p.source_session_id || p.sourceSessionId), text(p.source_turn_id || p.sourceTurnId), event.occurred_at,
562
+ json(ledgerPayload(event, encryption)));
563
+ if (contentEnvelope) {
564
+ db.prepare('DELETE FROM document_chunks WHERE project_id = ? AND logical_path = ?').run(event.project_id, logicalPath);
565
+ } else {
566
+ writeSqlDocumentChunks(db, {
567
+ projectId: event.project_id,
568
+ logicalPath,
569
+ content,
570
+ metadata: p.metadata,
571
+ entityType: text(p.entity_type || p.entityType, 'memory'),
572
+ capturedAt: text(p.captured_at || p.capturedAt || event.occurred_at),
573
+ });
574
+ }
406
575
  return { stale: false };
407
576
  }
408
577
 
@@ -461,13 +630,26 @@ function applyCall(db, event) {
461
630
  throw error;
462
631
  }
463
632
  const [input, cacheWrite, cacheRead, output, reasoning, total] = tokenFields(p.tokens);
633
+ const { encryption } = databaseSecurity(db);
634
+ const prompt = text(p.prompt_text || p.promptText || p.prompt);
635
+ const response = text(p.response_text || p.responseText || p.response);
636
+ const promptEnvelope = encryption
637
+ ? encryptObserverValue(encryption, prompt, { aad: `${event.project_id}:call:${callId}:prompt` })
638
+ : null;
639
+ const responseEnvelope = encryption
640
+ ? encryptObserverValue(encryption, response, { aad: `${event.project_id}:call:${callId}:response` })
641
+ : null;
642
+ const metadataEnvelope = encryption
643
+ ? encryptedJson(encryption, p.metadata || {}, `${event.project_id}:call:${callId}:metadata`)
644
+ : '';
464
645
  db.prepare(`
465
- INSERT INTO llm_calls(call_pk, call_id, project_id, session_id, agent_id, role, provider, model_provider, model, effort, sequence, occurred_at, tokens_input, tokens_cache_write, tokens_cache_read, tokens_output, tokens_reasoning, tokens_total, cost_usd, cost_status, transcript_id, prompt_text, response_text, status, metadata_json)
466
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
646
+ INSERT INTO llm_calls(call_pk, call_id, project_id, session_id, agent_id, role, provider, model_provider, model, effort, sequence, occurred_at, tokens_input, tokens_cache_write, tokens_cache_read, tokens_output, tokens_reasoning, tokens_total, cost_usd, cost_status, transcript_id, prompt_text, response_text, prompt_envelope, response_envelope, status, metadata_json, metadata_envelope)
647
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
467
648
  `).run(
468
649
  scopedIdentity(event.project_id, callId), callId, event.project_id, sessionId, agentId, text(p.role, 'main'), text(p.provider), text(p.model_provider || p.modelProvider), text(p.model), text(p.effort), integer(p.sequence),
469
650
  text(p.occurred_at || event.occurred_at), input, cacheWrite, cacheRead, output, reasoning, total, number(p.cost_usd ?? p.costUsd), text(p.cost_status || p.costStatus, 'unknown'),
470
- p.transcript_id || p.transcriptId || null, text(p.prompt_text || p.promptText || p.prompt), text(p.response_text || p.responseText || p.response), text(p.status, 'complete'), json(p.metadata),
651
+ p.transcript_id || p.transcriptId || null, promptEnvelope ? '' : prompt, responseEnvelope ? '' : response,
652
+ promptEnvelope ? json(promptEnvelope) : '', responseEnvelope ? json(responseEnvelope) : '', text(p.status, 'complete'), encryption ? '{}' : json(p.metadata), metadataEnvelope,
471
653
  );
472
654
  return { stale: false };
473
655
  }
@@ -478,17 +660,22 @@ function applyTranscript(db, event) {
478
660
  const agentId = ensureAgent(db, event.project_id, p);
479
661
  const transcriptId = text(p.transcript_id || p.transcriptId);
480
662
  if (!transcriptId) throw new Error('transcript_id ausente.');
481
- const encoded = encodeTranscript(p.content);
663
+ const { encryption } = databaseSecurity(db);
664
+ const encoded = encodeTranscript(p.content, { encryption, aad: `${event.project_id}:transcript:${transcriptId}` });
665
+ const metadataEnvelope = encryption
666
+ ? encryptedJson(encryption, p.metadata || {}, `${event.project_id}:transcript:${transcriptId}:metadata`)
667
+ : '';
482
668
  db.prepare(`
483
- INSERT INTO transcripts(transcript_pk, transcript_id, project_id, session_id, agent_id, coverage, codec, content_gzip, content_sha256, original_bytes, compressed_bytes, source, occurred_at, metadata_json)
484
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
669
+ INSERT INTO transcripts(transcript_pk, transcript_id, project_id, session_id, agent_id, coverage, codec, content_gzip, content_sha256, original_bytes, compressed_bytes, source, occurred_at, metadata_json, metadata_envelope)
670
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
485
671
  ON CONFLICT(project_id, transcript_id) DO UPDATE SET
486
672
  coverage = excluded.coverage, codec = excluded.codec, content_gzip = excluded.content_gzip,
487
673
  content_sha256 = excluded.content_sha256, original_bytes = excluded.original_bytes, compressed_bytes = excluded.compressed_bytes,
488
- source = excluded.source, occurred_at = excluded.occurred_at, metadata_json = excluded.metadata_json
674
+ source = excluded.source, occurred_at = excluded.occurred_at, metadata_json = excluded.metadata_json,
675
+ metadata_envelope = excluded.metadata_envelope
489
676
  `).run(
490
677
  scopedIdentity(event.project_id, transcriptId), transcriptId, event.project_id, sessionId, agentId, text(p.coverage, 'complete'), encoded.codec, encoded.content_gzip, encoded.content_sha256,
491
- encoded.original_bytes, encoded.compressed_bytes, text(p.source), event.occurred_at, json(p.metadata),
678
+ encoded.original_bytes, encoded.compressed_bytes, text(p.source), event.occurred_at, encryption ? '{}' : json(p.metadata), metadataEnvelope,
492
679
  );
493
680
  return { stale: false };
494
681
  }
@@ -504,10 +691,36 @@ function applyEvent(db, event) {
504
691
  throw new Error('kind não implementado.');
505
692
  }
506
693
 
694
+ function ledgerPayload(event, encryption) {
695
+ if (!encryption) return event.payload;
696
+ const payload = structuredClone(event.payload || {});
697
+ if (event.kind === 'document.upsert' || event.kind === 'transcript.upsert') payload.content = '[PROTECTED]';
698
+ if (event.kind === 'llm_call') {
699
+ for (const key of ['prompt', 'promptText', 'prompt_text', 'response', 'responseText', 'response_text']) {
700
+ if (Object.hasOwn(payload, key)) payload[key] = '[PROTECTED]';
701
+ }
702
+ }
703
+ if (['document.upsert', 'transcript.upsert', 'llm_call'].includes(event.kind)
704
+ && Object.hasOwn(payload, 'metadata')) payload.metadata = '[PROTECTED]';
705
+ return payload;
706
+ }
707
+
507
708
  export function ingestObserverEvents(db, { projectId, events = [] } = {}) {
508
709
  requireProject(db, projectId);
509
- const result = { accepted: 0, duplicates: 0, conflicts: 0, stale: 0, rejected: 0, results: [] };
510
- for (const event of events) {
710
+ const security = databaseSecurity(db);
711
+ const storedPolicy = readObserverPolicy(db, projectId);
712
+ const effectivePolicy = security.policy || (security.enforcePolicy ? storedPolicy : null);
713
+ if ((security.policy?.encryption_required || storedPolicy.encryption_required) && !security.encryption) {
714
+ throw Object.assign(new Error('A policy exige criptografia antes da ingestão.'), { code: 'observer_encryption_required' });
715
+ }
716
+ const result = { accepted: 0, duplicates: 0, conflicts: 0, stale: 0, rejected: 0, dropped: 0, results: [] };
717
+ for (const incoming of events) {
718
+ const event = effectivePolicy ? protectObserverEvent(incoming, { policy: effectivePolicy }) : incoming;
719
+ if (!event) {
720
+ result.dropped += 1;
721
+ result.results.push({ accepted: false, dropped: true, event_id: incoming?.event_id || '' });
722
+ continue;
723
+ }
511
724
  const validation = validateEvent(event, projectId);
512
725
  if (!validation.ok) {
513
726
  result.rejected += 1;
@@ -537,7 +750,7 @@ export function ingestObserverEvents(db, { projectId, events = [] } = {}) {
537
750
  let savepointOpen = true;
538
751
  try {
539
752
  db.prepare('INSERT INTO ingest_events(event_id, project_id, kind, payload_hash, payload_json, occurred_at, ingested_at, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
540
- .run(event.event_id, projectId, event.kind, payloadHash, json(event.payload), event.occurred_at, now(), 'accepted');
753
+ .run(event.event_id, projectId, event.kind, payloadHash, json(ledgerPayload(event, security.encryption)), event.occurred_at, now(), 'accepted');
541
754
  const applied = applyEvent(db, event);
542
755
  if (applied.stale) {
543
756
  db.prepare('UPDATE ingest_events SET status = ? WHERE event_id = ?').run('stale', event.event_id);
@@ -647,12 +860,23 @@ export function readUsageCalls(db, projectId, filters = {}) {
647
860
  offset,
648
861
  limit,
649
862
  total: integer(rows[0]?.total_count),
650
- calls: rows.map((row) => ({
863
+ calls: rows.map((row) => {
864
+ const { encryption } = databaseSecurity(db);
865
+ const prompt = row.prompt_envelope
866
+ ? decryptObserverValue(encryption, parseJson(row.prompt_envelope), { aad: `${projectId}:call:${row.call_id}:prompt` })
867
+ : row.prompt_text;
868
+ const response = row.response_envelope
869
+ ? decryptObserverValue(encryption, parseJson(row.response_envelope), { aad: `${projectId}:call:${row.call_id}:response` })
870
+ : row.response_text;
871
+ return ({
651
872
  call_id: row.call_id, session_id: row.session_id, agent_id: row.agent_id, role: row.role, provider: row.provider,
652
873
  model_provider: row.model_provider, model: row.model, effort: row.effort, sequence: integer(row.sequence), occurred_at: row.occurred_at,
653
874
  tokens: tokenObject(row), cost_usd: Number(number(row.cost_usd).toFixed(4)), cost_status: row.cost_status,
654
- transcript_id: row.transcript_id, prompt: row.prompt_text, response: row.response_text, status: row.status, metadata: parseJson(row.metadata_json),
655
- })),
875
+ transcript_id: row.transcript_id, prompt, response, status: row.status,
876
+ metadata: row.metadata_envelope
877
+ ? decryptedJson(encryption, row.metadata_envelope, {}, `${projectId}:call:${row.call_id}:metadata`)
878
+ : parseJson(row.metadata_json),
879
+ }); }),
656
880
  };
657
881
  }
658
882
 
@@ -664,7 +888,14 @@ export function readTranscript(db, projectId, transcriptId) {
664
888
  error.code = 'transcript_not_found';
665
889
  throw error;
666
890
  }
667
- return decodeTranscript(row);
891
+ const decoded = decodeTranscript(row, {
892
+ encryption: databaseSecurity(db).encryption,
893
+ aad: `${projectId}:transcript:${transcriptId}`,
894
+ });
895
+ decoded.metadata = row.metadata_envelope
896
+ ? decryptedJson(databaseSecurity(db).encryption, row.metadata_envelope, {}, `${projectId}:transcript:${transcriptId}:metadata`)
897
+ : parseJson(row.metadata_json);
898
+ return decoded;
668
899
  }
669
900
 
670
901
  export function readSqlDocument(db, projectId, logicalPath) {
@@ -680,7 +911,13 @@ export function readSqlDocument(db, projectId, logicalPath) {
680
911
  error.code = 'memory_not_found';
681
912
  throw error;
682
913
  }
683
- return { ...row, metadata: parseJson(row.metadata_json) };
914
+ const content = row.content_envelope
915
+ ? decryptObserverValue(databaseSecurity(db).encryption, parseJson(row.content_envelope), { aad: `${projectId}:document:${logicalPath}` })
916
+ : row.content;
917
+ const metadata = row.metadata_envelope
918
+ ? decryptedJson(databaseSecurity(db).encryption, row.metadata_envelope, {}, `${projectId}:document:${logicalPath}:metadata`)
919
+ : parseJson(row.metadata_json);
920
+ return { ...row, content, metadata };
684
921
  }
685
922
 
686
923
  export function readSqlTree(db, projectId, prefix = '') {
@@ -731,10 +968,16 @@ export function readSqlSync(db, projectId) {
731
968
  return { mode: 'container-authority', project_id: projectId, document_count: integer(documents.count), event_count: integer(events.count), pending_count: integer(pending.count), database: OBSERVER_SQL_FILE, schema_version: OBSERVER_SQL_SCHEMA_VERSION };
732
969
  }
733
970
 
734
- export function exportSqlMemoryBundle(db, projectId) {
971
+ export function exportSqlMemoryBundle(db, projectId, { includeContent = false } = {}) {
735
972
  requireProject(db, projectId);
736
973
  const rows = db.prepare('SELECT logical_path, entity_type, content, content_hash, revision, captured_at FROM documents WHERE project_id = ? AND deleted_at IS NULL ORDER BY logical_path').all(projectId);
737
- return { schema_version: OBSERVER_SQL_SCHEMA_VERSION, project_id: projectId, generated_at: now(), documents: rows };
974
+ return {
975
+ schema_version: OBSERVER_SQL_SCHEMA_VERSION,
976
+ project_id: projectId,
977
+ generated_at: now(),
978
+ sanitized: !includeContent,
979
+ documents: rows.map((row) => ({ ...row, content: includeContent ? row.content : '' })),
980
+ };
738
981
  }
739
982
 
740
983
  export function readSqlProject(db, projectId) {
@@ -745,6 +988,11 @@ export function readSqlProject(db, projectId) {
745
988
  export function upsertSqlProjectSnapshot(db, snapshot) {
746
989
  const projectId = text(snapshot?.project_id || snapshot?.projectId);
747
990
  requireProject(db, projectId);
991
+ const security = databaseSecurity(db);
992
+ const storedPolicy = readObserverPolicy(db, projectId);
993
+ if ((security.policy?.encryption_required || storedPolicy.encryption_required) && !security.encryption) {
994
+ throw Object.assign(new Error('A policy exige criptografia antes de persistir snapshots.'), { code: 'observer_encryption_required' });
995
+ }
748
996
  const eventId = text(snapshot?.event_id);
749
997
  const capturedAt = text(snapshot?.captured_at);
750
998
  if (!eventId || !capturedAt || Number.isNaN(Date.parse(capturedAt))) throw new Error('snapshot SQL inválido.');
@@ -754,19 +1002,25 @@ export function upsertSqlProjectSnapshot(db, snapshot) {
754
1002
  || (current.captured_at === capturedAt && current.event_id >= eventId))) {
755
1003
  return { accepted: false, stale: true, event_id: eventId };
756
1004
  }
757
- db.prepare(`INSERT INTO project_snapshots(project_id, event_id, captured_at, snapshot_json)
758
- VALUES (?, ?, ?, ?)
1005
+ const { encryption } = security;
1006
+ const snapshotEnvelope = encryption ? encryptedJson(encryption, snapshot, `${projectId}:snapshot`) : '';
1007
+ db.prepare(`INSERT INTO project_snapshots(project_id, event_id, captured_at, snapshot_json, snapshot_envelope)
1008
+ VALUES (?, ?, ?, ?, ?)
759
1009
  ON CONFLICT(project_id) DO UPDATE SET
760
1010
  event_id = excluded.event_id,
761
1011
  captured_at = excluded.captured_at,
762
- snapshot_json = excluded.snapshot_json`).run(projectId, eventId, capturedAt, json(snapshot));
1012
+ snapshot_json = excluded.snapshot_json,
1013
+ snapshot_envelope = excluded.snapshot_envelope`).run(projectId, eventId, capturedAt, encryption ? '{}' : json(snapshot), snapshotEnvelope);
763
1014
  return { accepted: true, duplicate: false, event_id: eventId };
764
1015
  }
765
1016
 
766
1017
  export function readSqlProjectSnapshot(db, projectId) {
767
1018
  requireProject(db, projectId);
768
- const row = db.prepare('SELECT snapshot_json FROM project_snapshots WHERE project_id = ?').get(projectId);
769
- return row ? parseJson(row.snapshot_json, null) : null;
1019
+ const row = db.prepare('SELECT snapshot_json, snapshot_envelope FROM project_snapshots WHERE project_id = ?').get(projectId);
1020
+ if (!row) return null;
1021
+ return row.snapshot_envelope
1022
+ ? decryptedJson(databaseSecurity(db).encryption, row.snapshot_envelope, null, `${projectId}:snapshot`)
1023
+ : parseJson(row.snapshot_json, null);
770
1024
  }
771
1025
 
772
1026
  export function readSqlProjectOverview(db, projectId) {
@@ -1,25 +1,40 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { gunzipSync, gzipSync } from 'node:zlib';
3
+ import { decryptObserverValue, encryptObserverValue } from '../packages/observer/src/encryption.mjs';
3
4
 
4
5
  export const TRANSCRIPT_CODEC = 'gzip';
5
6
 
6
- export function encodeTranscript(content) {
7
+ export function encodeTranscript(content, { encryption = null, aad = '' } = {}) {
7
8
  const text = String(content ?? '');
8
9
  const raw = Buffer.from(text, 'utf8');
9
10
  const compressed = gzipSync(raw, { mtime: 0 });
11
+ const envelope = encryption
12
+ ? encryptObserverValue(encryption, compressed.toString('base64'), { aad })
13
+ : null;
14
+ const stored = envelope ? Buffer.from(JSON.stringify(envelope), 'utf8') : compressed;
10
15
  return {
11
- codec: TRANSCRIPT_CODEC,
12
- content_gzip: compressed,
16
+ codec: envelope ? 'aes-256-gcm+gzip' : TRANSCRIPT_CODEC,
17
+ content_gzip: stored,
13
18
  content_sha256: createHash('sha256').update(raw).digest('hex'),
14
19
  original_bytes: raw.byteLength,
15
- compressed_bytes: compressed.byteLength,
20
+ compressed_bytes: stored.byteLength,
16
21
  };
17
22
  }
18
23
 
19
- export function decodeTranscript(row) {
24
+ export function decodeTranscript(row, { encryption = null, aad = '' } = {}) {
20
25
  if (!row) return null;
21
- const compressed = Buffer.from(row.content_gzip || []);
22
- const raw = row.codec === TRANSCRIPT_CODEC ? gunzipSync(compressed) : compressed;
26
+ const stored = Buffer.from(row.content_gzip || []);
27
+ let compressed = stored;
28
+ if (row.codec === 'aes-256-gcm+gzip') {
29
+ if (!encryption) {
30
+ const error = new Error('Chave do transcript protegido indisponível.');
31
+ error.code = 'observer_encryption_key_unavailable';
32
+ throw error;
33
+ }
34
+ const envelope = JSON.parse(stored.toString('utf8'));
35
+ compressed = Buffer.from(decryptObserverValue(encryption, envelope, { aad }), 'base64');
36
+ }
37
+ const raw = [TRANSCRIPT_CODEC, 'aes-256-gcm+gzip'].includes(row.codec) ? gunzipSync(compressed) : compressed;
23
38
  const content = raw.toString('utf8');
24
39
  const contentSha256 = createHash('sha256').update(raw).digest('hex');
25
40
  if (row.content_sha256 && row.content_sha256 !== contentSha256) {
@@ -37,7 +52,7 @@ export function decodeTranscript(row) {
37
52
  content,
38
53
  content_sha256: contentSha256,
39
54
  original_bytes: Number(row.original_bytes) || raw.byteLength,
40
- compressed_bytes: Number(row.compressed_bytes) || compressed.byteLength,
55
+ compressed_bytes: Number(row.compressed_bytes) || stored.byteLength,
41
56
  source: row.source || '',
42
57
  occurred_at: row.occurred_at,
43
58
  metadata: parseJson(row.metadata_json),