wendkeep 0.73.0 → 0.75.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 (38) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.en.md +13 -9
  3. package/README.md +13 -9
  4. package/docs/en/commands/memory.md +16 -1
  5. package/docs/en/commands/observer.md +29 -11
  6. package/docs/en/commands/operating-profiles.md +1 -1
  7. package/docs/pt-BR/commands/memory.md +16 -1
  8. package/docs/pt-BR/commands/observer.md +28 -11
  9. package/docs/pt-BR/commands/operating-profiles.md +1 -1
  10. package/hooks/brain-core.mjs +2 -0
  11. package/hooks/brain-recall.mjs +5 -1
  12. package/hooks/evidence-context.mjs +41 -0
  13. package/hooks/evidence-recall.mjs +1 -0
  14. package/hooks/memory-scope.mjs +1 -0
  15. package/package.json +2 -2
  16. package/packages/cli/src/index.mjs +2 -2
  17. package/packages/integrations/src/host-hooks.mjs +1 -0
  18. package/packages/vault/src/evidence-recall.mjs +343 -0
  19. package/packages/vault/src/index.mjs +2 -0
  20. package/packages/vault/src/memory-handoff.mjs +58 -3
  21. package/packages/vault/src/memory-schema.mjs +12 -2
  22. package/packages/vault/src/memory-scope.mjs +119 -0
  23. package/packages/vault/src/memory-store.mjs +86 -24
  24. package/schema/observer/004-evidence-recall.sql +25 -0
  25. package/schema/observer/005-project-scoped-identities.sql +217 -0
  26. package/src/change.mjs +41 -1
  27. package/src/doctor.mjs +5 -0
  28. package/src/init.mjs +2 -2
  29. package/src/memory.mjs +95 -2
  30. package/src/note.mjs +8 -1
  31. package/src/observer-publish.mjs +9 -34
  32. package/src/observer-server.mjs +38 -63
  33. package/src/observer-sql-migrate.mjs +1 -1
  34. package/src/observer-sql-publish.mjs +372 -12
  35. package/src/observer-sql-store.mjs +248 -32
  36. package/src/observer-store.mjs +15 -3
  37. package/src/observer.mjs +104 -14
  38. package/src/taxonomy.mjs +4 -0
@@ -4,9 +4,12 @@ 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 {
8
+ chunkMarkdownDocument, recallEvidence, recallTerms,
9
+ } from '../packages/vault/src/evidence-recall.mjs';
7
10
 
8
11
  export const OBSERVER_SQL_FILE = 'observer.sqlite';
9
- export const OBSERVER_SQL_SCHEMA_VERSION = 3;
12
+ export const OBSERVER_SQL_SCHEMA_VERSION = 5;
10
13
  export const OBSERVER_EVENT_SCHEMA_VERSION = 1;
11
14
 
12
15
  const SCHEMA_DIR = fileURLToPath(new URL('../schema/observer/', import.meta.url));
@@ -19,6 +22,7 @@ const EVENT_KINDS = new Set([
19
22
  const OBSERVER_SQL_MINIMUM_NODE = '22.13.0';
20
23
  const require = createRequire(import.meta.url);
21
24
  let DatabaseSync;
25
+ const DATABASE_PATHS = new WeakMap();
22
26
 
23
27
  export function observerSqlRuntimeSupport(version = process.versions.node) {
24
28
  const current = String(version || '0.0.0');
@@ -71,6 +75,10 @@ function hash(value) {
71
75
  return createHash('sha256').update(typeof value === 'string' ? value : json(value)).digest('hex');
72
76
  }
73
77
 
78
+ function scopedIdentity(projectId, externalId) {
79
+ return `${projectId}\u001f${externalId}`;
80
+ }
81
+
74
82
  function validProjectId(value) { return typeof value === 'string' && PROJECT_ID_RE.test(value); }
75
83
 
76
84
  function requireProject(db, projectId) {
@@ -94,7 +102,9 @@ export function openObserverDatabase(dataDir) {
94
102
  if (!dataDir) throw new Error('dataDir é obrigatório.');
95
103
  mkdirSync(dataDir, { recursive: true });
96
104
  const SqliteDatabase = observerDatabaseSync();
97
- const db = new SqliteDatabase(join(dataDir, OBSERVER_SQL_FILE));
105
+ const databasePath = join(dataDir, OBSERVER_SQL_FILE);
106
+ const db = new SqliteDatabase(databasePath);
107
+ DATABASE_PATHS.set(db, databasePath);
98
108
  db.exec('PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;');
99
109
  return db;
100
110
  }
@@ -102,27 +112,146 @@ export function openObserverDatabase(dataDir) {
102
112
  export function migrateObserverDatabase(db) {
103
113
  if (!db) throw new Error('db é obrigatório.');
104
114
  db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)');
105
- const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version)));
115
+ const migrationColumns = db.prepare('PRAGMA table_info(schema_migrations)').all().map((row) => row.name);
116
+ if (!migrationColumns.includes('checksum')) db.exec("ALTER TABLE schema_migrations ADD COLUMN checksum TEXT NOT NULL DEFAULT ''");
117
+ const appliedRows = db.prepare('SELECT version, name, checksum FROM schema_migrations ORDER BY version').all();
118
+ const applied = new Map(appliedRows.map((row) => [Number(row.version), row]));
119
+ const backups = [];
106
120
  for (const file of migrationFiles()) {
107
121
  const version = Number(file.split('-')[0]);
108
- if (applied.has(version)) continue;
109
122
  const sql = readFileSync(join(SCHEMA_DIR, file), 'utf8');
123
+ const checksum = hash(sql);
124
+ const previous = applied.get(version);
125
+ if (previous) {
126
+ if (previous.name !== file || (previous.checksum && previous.checksum !== checksum)) {
127
+ const error = new Error(`Checksum da migração ${version} não corresponde ao arquivo ${file}.`);
128
+ error.code = 'WENDKEEP_OBSERVER_MIGRATION_CHECKSUM_MISMATCH';
129
+ throw error;
130
+ }
131
+ if (!previous.checksum) {
132
+ db.prepare('UPDATE schema_migrations SET checksum = ? WHERE version = ?').run(checksum, version);
133
+ }
134
+ continue;
135
+ }
136
+ if (/^\s*--\s*wendkeep:structural\b/m.test(sql) && applied.size > 0) {
137
+ const databasePath = DATABASE_PATHS.get(db);
138
+ 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}'`);
142
+ backups.push(backupPath);
143
+ }
110
144
  db.exec('BEGIN IMMEDIATE');
111
145
  try {
112
146
  db.exec(sql);
113
- db.prepare('INSERT INTO schema_migrations(version, name, applied_at) VALUES (?, ?, ?)').run(version, file, now());
147
+ db.prepare('INSERT INTO schema_migrations(version, name, applied_at, checksum) VALUES (?, ?, ?, ?)').run(version, file, now(), checksum);
114
148
  db.exec('COMMIT');
115
149
  } catch (error) {
116
150
  db.exec('ROLLBACK');
117
151
  throw error;
118
152
  }
119
153
  }
154
+ rebuildSqlEvidenceIndex(db, { missingOnly: true });
120
155
  return {
121
156
  version: Number(db.prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations').get().version || 0),
122
- applied: db.prepare('SELECT version, name, applied_at FROM schema_migrations ORDER BY version').all(),
157
+ applied: db.prepare('SELECT version, name, applied_at, checksum FROM schema_migrations ORDER BY version').all(),
158
+ backups,
123
159
  };
124
160
  }
125
161
 
162
+ export function observerFts5Support(db) {
163
+ try {
164
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS evidence_chunks_fts USING fts5(
165
+ chunk_id UNINDEXED,
166
+ project_id UNINDEXED,
167
+ logical_path,
168
+ title,
169
+ heading,
170
+ content,
171
+ tokenize = 'unicode61 remove_diacritics 2'
172
+ )`);
173
+ return { supported: true, engine: 'fts5' };
174
+ } catch (error) {
175
+ return { supported: false, engine: 'lexical-fallback', reason: text(error?.message || error) };
176
+ }
177
+ }
178
+
179
+ function synchronizeSqlFts(db) {
180
+ const support = observerFts5Support(db);
181
+ if (!support.supported) return support;
182
+ const chunks = Number(db.prepare('SELECT COUNT(*) AS count FROM document_chunks').get().count || 0);
183
+ const indexed = Number(db.prepare('SELECT COUNT(*) AS count FROM evidence_chunks_fts').get().count || 0);
184
+ if (chunks === indexed) return support;
185
+ db.exec(`DELETE FROM evidence_chunks_fts;
186
+ INSERT INTO evidence_chunks_fts(chunk_id, project_id, logical_path, title, heading, content)
187
+ SELECT chunk_id, project_id, logical_path, title, heading, content
188
+ FROM document_chunks;`);
189
+ return { ...support, rebuilt: true, chunks };
190
+ }
191
+
192
+ function writeSqlDocumentChunks(db, {
193
+ projectId, logicalPath, content, metadata = {}, entityType = 'document', capturedAt = '',
194
+ } = {}) {
195
+ const chunks = chunkMarkdownDocument({
196
+ projectId,
197
+ logicalPath,
198
+ content,
199
+ metadata: { ...metadata, observed_at: metadata.observed_at || capturedAt },
200
+ entityType,
201
+ });
202
+ db.prepare('DELETE FROM document_chunks WHERE project_id = ? AND logical_path = ?').run(projectId, logicalPath);
203
+ const fts = observerFts5Support(db);
204
+ if (fts.supported) {
205
+ db.prepare('DELETE FROM evidence_chunks_fts WHERE project_id = ? AND logical_path = ?').run(projectId, logicalPath);
206
+ }
207
+ const insert = db.prepare(`INSERT INTO document_chunks(
208
+ chunk_id, project_id, logical_path, title, heading, entity_type, change_slug,
209
+ session_id, work_session_id, authority, observed_at, validity, content_hash, ordinal, content
210
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
211
+ const insertFts = fts.supported
212
+ ? db.prepare('INSERT INTO evidence_chunks_fts(chunk_id, project_id, logical_path, title, heading, content) VALUES (?, ?, ?, ?, ?, ?)')
213
+ : null;
214
+ for (const chunk of chunks) {
215
+ insert.run(
216
+ chunk.chunk_id, chunk.project_id, chunk.logical_path, chunk.title, chunk.heading,
217
+ chunk.entity_type, chunk.change_slug, chunk.session_id, chunk.work_session_id,
218
+ chunk.authority, chunk.observed_at, chunk.validity, chunk.content_hash,
219
+ chunk.ordinal, chunk.content,
220
+ );
221
+ insertFts?.run(chunk.chunk_id, chunk.project_id, chunk.logical_path, chunk.title, chunk.heading, chunk.content);
222
+ }
223
+ return { chunks: chunks.length, fts };
224
+ }
225
+
226
+ export function rebuildSqlEvidenceIndex(db, { missingOnly = false } = {}) {
227
+ const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'document_chunks'").get();
228
+ if (!table) return { documents: 0, chunks: 0, fts: { supported: false, engine: 'unavailable' } };
229
+ const documents = db.prepare(`SELECT d.project_id, d.logical_path, d.content, d.metadata_json, d.entity_type, d.captured_at
230
+ FROM documents d
231
+ WHERE d.deleted_at IS NULL
232
+ AND (? = 0 OR NOT EXISTS (
233
+ SELECT 1 FROM document_chunks c
234
+ WHERE c.project_id = d.project_id AND c.logical_path = d.logical_path
235
+ ))
236
+ ORDER BY d.project_id, d.logical_path`).all(missingOnly ? 1 : 0);
237
+ let chunks = 0;
238
+ let fts = observerFts5Support(db);
239
+ for (const document of documents) {
240
+ const result = writeSqlDocumentChunks(db, {
241
+ projectId: document.project_id,
242
+ logicalPath: document.logical_path,
243
+ content: document.content,
244
+ metadata: parseJson(document.metadata_json),
245
+ entityType: document.entity_type,
246
+ capturedAt: document.captured_at,
247
+ });
248
+ chunks += result.chunks;
249
+ fts = result.fts;
250
+ }
251
+ fts = synchronizeSqlFts(db);
252
+ return { documents: documents.length, chunks, fts };
253
+ }
254
+
126
255
  export function ensureObserverDatabase(dataDir) {
127
256
  const db = openObserverDatabase(dataDir);
128
257
  try {
@@ -171,9 +300,9 @@ function ensureSession(db, projectId, payload) {
171
300
  const sessionId = text(payload.session_id || payload.sessionId);
172
301
  if (!sessionId) throw new Error('session_id ausente.');
173
302
  db.prepare(`
174
- INSERT INTO sessions(session_id, project_id, provider, status, summary, change_slug, started_at, ended_at, updated_at, metadata_json)
175
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
176
- ON CONFLICT(session_id) DO UPDATE SET
303
+ INSERT INTO sessions(session_pk, session_id, project_id, provider, status, summary, change_slug, started_at, ended_at, updated_at, metadata_json)
304
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
305
+ ON CONFLICT(project_id, session_id) DO UPDATE SET
177
306
  provider = CASE WHEN excluded.provider <> '' THEN excluded.provider ELSE sessions.provider END,
178
307
  status = CASE WHEN excluded.status <> '' THEN excluded.status ELSE sessions.status END,
179
308
  summary = CASE WHEN excluded.summary <> '' THEN excluded.summary ELSE sessions.summary END,
@@ -183,7 +312,7 @@ function ensureSession(db, projectId, payload) {
183
312
  updated_at = excluded.updated_at,
184
313
  metadata_json = CASE WHEN excluded.metadata_json <> '{}' THEN excluded.metadata_json ELSE sessions.metadata_json END
185
314
  `).run(
186
- sessionId, projectId, text(payload.provider), text(payload.status, 'unknown'), text(payload.summary), text(payload.change_slug || payload.changeSlug),
315
+ scopedIdentity(projectId, sessionId), sessionId, projectId, text(payload.provider), text(payload.status, 'unknown'), text(payload.summary), text(payload.change_slug || payload.changeSlug),
187
316
  payload.started_at || payload.startedAt || null, payload.ended_at || payload.endedAt || null, now(), json(payload.metadata),
188
317
  );
189
318
  return sessionId;
@@ -194,9 +323,9 @@ function ensureAgent(db, projectId, payload) {
194
323
  if (!agentId) throw new Error('agent_id ausente.');
195
324
  const sessionId = ensureSession(db, projectId, payload);
196
325
  db.prepare(`
197
- INSERT INTO agent_runs(agent_id, project_id, session_id, parent_agent_id, role, agent_name, agent_type, workflow, status, model, effort, started_at, ended_at, metadata_json)
198
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
199
- ON CONFLICT(agent_id) DO UPDATE SET
326
+ INSERT INTO agent_runs(agent_pk, agent_id, project_id, session_id, parent_agent_id, role, agent_name, agent_type, workflow, status, model, effort, started_at, ended_at, metadata_json)
327
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
328
+ ON CONFLICT(project_id, agent_id) DO UPDATE SET
200
329
  parent_agent_id = COALESCE(excluded.parent_agent_id, agent_runs.parent_agent_id),
201
330
  role = excluded.role,
202
331
  agent_name = CASE WHEN excluded.agent_name <> '' THEN excluded.agent_name ELSE agent_runs.agent_name END,
@@ -209,7 +338,7 @@ function ensureAgent(db, projectId, payload) {
209
338
  ended_at = COALESCE(excluded.ended_at, agent_runs.ended_at),
210
339
  metadata_json = CASE WHEN excluded.metadata_json <> '{}' THEN excluded.metadata_json ELSE agent_runs.metadata_json END
211
340
  `).run(
212
- agentId, projectId, sessionId, payload.parent_agent_id || payload.parentAgentId || null, text(payload.role, 'main'),
341
+ scopedIdentity(projectId, agentId), agentId, projectId, sessionId, payload.parent_agent_id || payload.parentAgentId || null, text(payload.role, 'main'),
213
342
  text(payload.agent_name || payload.agentName), text(payload.agent_type || payload.agentType), text(payload.workflow),
214
343
  text(payload.status, 'unknown'), text(payload.model), text(payload.effort), payload.started_at || null, payload.ended_at || null, json(payload.metadata),
215
344
  );
@@ -266,6 +395,14 @@ function applyDocument(db, event) {
266
395
  VALUES (?, ?, ?, ?, 'upsert', ?, ?, ?, ?, ?, ?)
267
396
  `).run(event.event_id, event.project_id, text(p.entity_type || p.entityType, 'memory'), logicalPath, revision, contentHash,
268
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
+ });
269
406
  return { stale: false };
270
407
  }
271
408
 
@@ -279,6 +416,10 @@ function applyDocumentDelete(db, event) {
279
416
  VALUES (?, ?, ?, ?, 'delete', ?, '', ?, ?, ?, ?)
280
417
  `).run(event.event_id, event.project_id, text(event.payload.entity_type || event.payload.entityType, 'memory'), logicalPath,
281
418
  integer(event.payload.revision, 1), text(event.payload.source_session_id || event.payload.sourceSessionId), text(event.payload.source_turn_id || event.payload.sourceTurnId), event.occurred_at, json(event.payload));
419
+ db.prepare('DELETE FROM document_chunks WHERE project_id = ? AND logical_path = ?').run(event.project_id, logicalPath);
420
+ if (observerFts5Support(db).supported) {
421
+ db.prepare('DELETE FROM evidence_chunks_fts WHERE project_id = ? AND logical_path = ?').run(event.project_id, logicalPath);
422
+ }
282
423
  return { stale: false };
283
424
  }
284
425
 
@@ -289,19 +430,19 @@ function applyUsageRollup(db, event) {
289
430
  const [input, cacheWrite, cacheRead, output, reasoning, total] = tokenFields(p.tokens);
290
431
  const rollupKey = text(p.rollup_key || p.rollupKey) || [event.project_id, sessionId, agentId, p.model_provider || p.modelProvider || '', p.model || '', p.effort || ''].join(':');
291
432
  const revision = integer(p.revision, 1);
292
- const current = db.prepare('SELECT revision FROM usage_rollups WHERE rollup_key = ?').get(rollupKey);
433
+ const current = db.prepare('SELECT revision FROM usage_rollups WHERE project_id = ? AND rollup_key = ?').get(event.project_id, rollupKey);
293
434
  if (current && revision < Number(current.revision)) return { stale: true };
294
435
  db.prepare(`
295
- INSERT INTO usage_rollups(rollup_key, project_id, session_id, agent_id, role, provider, model_provider, model, effort, calls, tokens_input, tokens_cache_write, tokens_cache_read, tokens_output, tokens_reasoning, tokens_total, cost_usd, cost_status, pricing_source, pricing_version, wasted_usd, revision, occurred_at, source_event_id, metadata_json)
296
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
297
- ON CONFLICT(rollup_key) DO UPDATE SET
436
+ INSERT INTO usage_rollups(rollup_pk, rollup_key, project_id, session_id, agent_id, role, provider, model_provider, model, effort, calls, tokens_input, tokens_cache_write, tokens_cache_read, tokens_output, tokens_reasoning, tokens_total, cost_usd, cost_status, pricing_source, pricing_version, wasted_usd, revision, occurred_at, source_event_id, metadata_json)
437
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
438
+ ON CONFLICT(project_id, rollup_key) DO UPDATE SET
298
439
  calls = excluded.calls, tokens_input = excluded.tokens_input, tokens_cache_write = excluded.tokens_cache_write,
299
440
  tokens_cache_read = excluded.tokens_cache_read, tokens_output = excluded.tokens_output, tokens_reasoning = excluded.tokens_reasoning,
300
441
  tokens_total = excluded.tokens_total, cost_usd = excluded.cost_usd, cost_status = excluded.cost_status,
301
442
  pricing_source = excluded.pricing_source, pricing_version = excluded.pricing_version, wasted_usd = excluded.wasted_usd,
302
443
  revision = excluded.revision, occurred_at = excluded.occurred_at, source_event_id = excluded.source_event_id, metadata_json = excluded.metadata_json
303
444
  `).run(
304
- rollupKey, event.project_id, sessionId, agentId, text(p.role, 'main'), text(p.provider), text(p.model_provider || p.modelProvider), text(p.model), text(p.effort),
445
+ scopedIdentity(event.project_id, rollupKey), rollupKey, event.project_id, sessionId, agentId, text(p.role, 'main'), text(p.provider), text(p.model_provider || p.modelProvider), text(p.model), text(p.effort),
305
446
  integer(p.calls), input, cacheWrite, cacheRead, output, reasoning, total, number(p.cost_usd ?? p.costUsd), text(p.cost_status || p.costStatus, 'unknown'),
306
447
  text(p.pricing_source || p.pricingSource), text(p.pricing_version || p.pricingVersion), number(p.wasted_usd ?? p.wastedUsd), revision, event.occurred_at, event.event_id, json(p.metadata),
307
448
  );
@@ -314,17 +455,17 @@ function applyCall(db, event) {
314
455
  const agentId = ensureAgent(db, event.project_id, p);
315
456
  const callId = text(p.call_id || p.callId);
316
457
  if (!callId) throw new Error('call_id ausente.');
317
- if (db.prepare('SELECT call_id FROM llm_calls WHERE call_id = ?').get(callId)) {
458
+ if (db.prepare('SELECT call_id FROM llm_calls WHERE project_id = ? AND call_id = ?').get(event.project_id, callId)) {
318
459
  const error = new Error(`call_id já existe: ${callId}`);
319
460
  error.code = 'call_conflict';
320
461
  throw error;
321
462
  }
322
463
  const [input, cacheWrite, cacheRead, output, reasoning, total] = tokenFields(p.tokens);
323
464
  db.prepare(`
324
- INSERT INTO llm_calls(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)
325
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
326
467
  `).run(
327
- 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),
468
+ 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),
328
469
  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'),
329
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),
330
471
  );
@@ -339,14 +480,14 @@ function applyTranscript(db, event) {
339
480
  if (!transcriptId) throw new Error('transcript_id ausente.');
340
481
  const encoded = encodeTranscript(p.content);
341
482
  db.prepare(`
342
- INSERT INTO transcripts(transcript_id, project_id, session_id, agent_id, coverage, codec, content_gzip, content_sha256, original_bytes, compressed_bytes, source, occurred_at, metadata_json)
343
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
344
- ON CONFLICT(transcript_id) DO UPDATE SET
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
485
+ ON CONFLICT(project_id, transcript_id) DO UPDATE SET
345
486
  coverage = excluded.coverage, codec = excluded.codec, content_gzip = excluded.content_gzip,
346
487
  content_sha256 = excluded.content_sha256, original_bytes = excluded.original_bytes, compressed_bytes = excluded.compressed_bytes,
347
488
  source = excluded.source, occurred_at = excluded.occurred_at, metadata_json = excluded.metadata_json
348
489
  `).run(
349
- transcriptId, event.project_id, sessionId, agentId, text(p.coverage, 'complete'), encoded.codec, encoded.content_gzip, encoded.content_sha256,
490
+ scopedIdentity(event.project_id, transcriptId), transcriptId, event.project_id, sessionId, agentId, text(p.coverage, 'complete'), encoded.codec, encoded.content_gzip, encoded.content_sha256,
350
491
  encoded.original_bytes, encoded.compressed_bytes, text(p.source), event.occurred_at, json(p.metadata),
351
492
  );
352
493
  return { stale: false };
@@ -392,6 +533,8 @@ export function ingestObserverEvents(db, { projectId, events = [] } = {}) {
392
533
  }
393
534
  continue;
394
535
  }
536
+ db.exec('SAVEPOINT ingest_one');
537
+ let savepointOpen = true;
395
538
  try {
396
539
  db.prepare('INSERT INTO ingest_events(event_id, project_id, kind, payload_hash, payload_json, occurred_at, ingested_at, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
397
540
  .run(event.event_id, projectId, event.kind, payloadHash, json(event.payload), event.occurred_at, now(), 'accepted');
@@ -404,8 +547,12 @@ export function ingestObserverEvents(db, { projectId, events = [] } = {}) {
404
547
  result.accepted += 1;
405
548
  result.results.push({ accepted: true, event_id: event.event_id });
406
549
  }
550
+ db.exec('RELEASE ingest_one');
551
+ savepointOpen = false;
407
552
  } catch (error) {
408
- db.prepare('DELETE FROM ingest_events WHERE event_id = ?').run(event.event_id);
553
+ if (savepointOpen) {
554
+ try { db.exec('ROLLBACK TO ingest_one'); } finally { db.exec('RELEASE ingest_one'); }
555
+ }
409
556
  if (error?.code === 'call_conflict' || error?.code === 'document_conflict') {
410
557
  result.conflicts += 1;
411
558
  result.results.push({ accepted: false, conflict: true, event_id: event.event_id, errors: [error.message] });
@@ -542,11 +689,38 @@ export function readSqlTree(db, projectId, prefix = '') {
542
689
  return { schema_version: OBSERVER_SQL_SCHEMA_VERSION, project_id: projectId, prefix, documents: rows };
543
690
  }
544
691
 
545
- export function searchSqlDocuments(db, projectId, query = '') {
692
+ export function searchSqlDocuments(db, projectId, query = '', { forceLexical = false } = {}) {
546
693
  requireProject(db, projectId);
547
- const q = `%${text(query).replace(/[%_]/g, '\\$&')}%`;
548
- const rows = db.prepare(`SELECT logical_path, entity_type, title, content_hash, revision, captured_at, source_session_id, content FROM documents WHERE project_id = ? AND deleted_at IS NULL AND (logical_path LIKE ? ESCAPE '\\' OR title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\') ORDER BY captured_at DESC, logical_path LIMIT 100`).all(projectId, q, q, q);
549
- return rows.map((row) => ({ ...row, excerpt: row.content.slice(0, 240) }));
694
+ const terms = recallTerms(query);
695
+ if (!terms.length) return [];
696
+ const fts = forceLexical
697
+ ? { supported: false, engine: 'lexical-fallback' }
698
+ : observerFts5Support(db);
699
+ const columns = `c.chunk_id, c.project_id, c.logical_path, c.title, c.heading,
700
+ c.entity_type, c.change_slug, c.session_id, c.work_session_id, c.authority,
701
+ c.observed_at, c.validity, c.content_hash AS chunk_content_hash, c.ordinal, c.content,
702
+ d.content_hash, d.revision, d.captured_at, d.source_session_id`;
703
+ let rows;
704
+ if (fts.supported) {
705
+ const expression = [...new Set(terms)]
706
+ .map((term) => `"${term.replaceAll('"', '""')}"`)
707
+ .join(' OR ');
708
+ rows = db.prepare(`SELECT ${columns} FROM evidence_chunks_fts f
709
+ JOIN document_chunks c ON c.chunk_id = f.chunk_id
710
+ JOIN documents d ON d.project_id = c.project_id AND d.logical_path = c.logical_path
711
+ WHERE evidence_chunks_fts MATCH ? AND c.project_id = ? AND d.deleted_at IS NULL
712
+ ORDER BY bm25(evidence_chunks_fts, 0, 0, 1.5, 3.0, 2.5, 1.0), c.observed_at DESC
713
+ LIMIT 200`).all(expression, projectId);
714
+ } else {
715
+ // FTS5 may be unavailable in a valid Observer runtime. Rank the complete project corpus
716
+ // instead of truncating by recency before matching, which would make old exact evidence
717
+ // permanently unreachable.
718
+ rows = db.prepare(`SELECT ${columns} FROM document_chunks c
719
+ JOIN documents d ON d.project_id = c.project_id AND d.logical_path = c.logical_path
720
+ WHERE c.project_id = ? AND d.deleted_at IS NULL
721
+ ORDER BY c.observed_at DESC, c.logical_path, c.ordinal`).all(projectId);
722
+ }
723
+ return recallEvidence(rows, query, { topK: 5 });
550
724
  }
551
725
 
552
726
  export function readSqlSync(db, projectId) {
@@ -568,6 +742,48 @@ export function readSqlProject(db, projectId) {
568
742
  return db.prepare('SELECT * FROM projects WHERE project_id = ?').get(projectId);
569
743
  }
570
744
 
745
+ export function upsertSqlProjectSnapshot(db, snapshot) {
746
+ const projectId = text(snapshot?.project_id || snapshot?.projectId);
747
+ requireProject(db, projectId);
748
+ const eventId = text(snapshot?.event_id);
749
+ const capturedAt = text(snapshot?.captured_at);
750
+ if (!eventId || !capturedAt || Number.isNaN(Date.parse(capturedAt))) throw new Error('snapshot SQL inválido.');
751
+ const current = db.prepare('SELECT event_id, captured_at FROM project_snapshots WHERE project_id = ?').get(projectId);
752
+ if (current?.event_id === eventId) return { accepted: false, duplicate: true, event_id: eventId };
753
+ if (current && (current.captured_at > capturedAt
754
+ || (current.captured_at === capturedAt && current.event_id >= eventId))) {
755
+ return { accepted: false, stale: true, event_id: eventId };
756
+ }
757
+ db.prepare(`INSERT INTO project_snapshots(project_id, event_id, captured_at, snapshot_json)
758
+ VALUES (?, ?, ?, ?)
759
+ ON CONFLICT(project_id) DO UPDATE SET
760
+ event_id = excluded.event_id,
761
+ captured_at = excluded.captured_at,
762
+ snapshot_json = excluded.snapshot_json`).run(projectId, eventId, capturedAt, json(snapshot));
763
+ return { accepted: true, duplicate: false, event_id: eventId };
764
+ }
765
+
766
+ export function readSqlProjectSnapshot(db, projectId) {
767
+ 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;
770
+ }
771
+
772
+ export function readSqlProjectOverview(db, projectId) {
773
+ const project = readSqlProject(db, projectId);
774
+ const snapshot = readSqlProjectSnapshot(db, projectId);
775
+ const events = db.prepare('SELECT COUNT(*) AS count FROM ingest_events WHERE project_id = ?').get(projectId);
776
+ return {
777
+ projectId: project.project_id,
778
+ projectName: project.project_name,
779
+ wendkeepVersion: project.wendkeep_version,
780
+ registeredAt: project.registered_at,
781
+ updatedAt: project.updated_at,
782
+ eventCount: integer(events.count),
783
+ snapshot,
784
+ };
785
+ }
786
+
571
787
  export function listSqlProjects(db) {
572
788
  return db.prepare('SELECT * FROM projects ORDER BY project_name, project_id').all();
573
789
  }
@@ -86,8 +86,7 @@ function newer(left, right) {
86
86
  return leftTime > rightTime || (leftTime === rightTime && String(left?.event_id).localeCompare(String(right?.event_id)) > 0);
87
87
  }
88
88
 
89
- export function rebuildObserverIndex(dataDir) {
90
- ensureDataDir(dataDir);
89
+ function deriveObserverIndex(dataDir) {
91
90
  const byProject = new Map();
92
91
  for (const event of readEvents(dataDir)) {
93
92
  const current = byProject.get(event.project_id);
@@ -106,15 +105,28 @@ export function rebuildObserverIndex(dataDir) {
106
105
  const item = byProject.get(event.project_id);
107
106
  if (item) item.eventCount += 1;
108
107
  }
109
- const index = {
108
+ return {
110
109
  schema_version: OBSERVER_DATA_SCHEMA_VERSION,
111
110
  generated_at: new Date().toISOString(),
112
111
  projects: [...byProject.values()].sort((a, b) => a.projectId.localeCompare(b.projectId)),
113
112
  };
113
+ }
114
+
115
+ export function rebuildObserverIndex(dataDir) {
116
+ ensureDataDir(dataDir);
117
+ const index = deriveObserverIndex(dataDir);
114
118
  atomicJson(join(dataDir, OBSERVER_INDEX_FILE), index);
115
119
  return index;
116
120
  }
117
121
 
122
+ /** Read a legacy source without creating or refreshing legacy authority files. */
123
+ export function readObserverIndexSource(dataDir) {
124
+ ensureDataDir(dataDir);
125
+ const index = readJson(join(dataDir, OBSERVER_INDEX_FILE), null);
126
+ if (index?.schema_version === OBSERVER_DATA_SCHEMA_VERSION && Array.isArray(index.projects)) return index;
127
+ return deriveObserverIndex(dataDir);
128
+ }
129
+
118
130
  export function readObserverIndex(dataDir) {
119
131
  ensureDataDir(dataDir);
120
132
  const path = join(dataDir, OBSERVER_INDEX_FILE);
package/src/observer.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { isAbsolute, resolve } from 'node:path';
3
- import { appendObserverEvent, readObserverIndex, registerObserverProject } from './observer-store.mjs';
3
+ import { readObserverIndexSource } from './observer-store.mjs';
4
4
  import { buildProjectSnapshot } from './observer-snapshot.mjs';
5
5
  import { compareMemoryParity } from './observer-memory-publish.mjs';
6
6
  import { publishObserverSql } from './observer-sql-publish.mjs';
7
+ import { migrateObserverData } from './observer-sql-migrate.mjs';
7
8
  import { startObserverServer } from './observer-server.mjs';
8
- import { ensureObserverDatabase, migrateObserverDatabase, listSqlProjects, OBSERVER_SQL_FILE, OBSERVER_SQL_SCHEMA_VERSION } from './observer-sql-store.mjs';
9
+ import { ensureObserverDatabase, migrateObserverDatabase, listSqlProjects, registerSqlProject, upsertSqlProjectSnapshot, OBSERVER_SQL_FILE, OBSERVER_SQL_SCHEMA_VERSION } from './observer-sql-store.mjs';
9
10
  import { resolveProjectVault } from '../packages/vault/src/project-vault.mjs';
10
11
  import { observerAuthHeaders, resolveObserverToken } from './observer-auth.mjs';
11
12
 
@@ -16,12 +17,15 @@ Uso:
16
17
  [--allow-non-loopback] [--token TOKEN]
17
18
  wendkeep observer register --project P [--vault V] [--data-dir D] [--json]
18
19
  wendkeep observer publish --project P [--vault V] [--data-dir D] [--json]
20
+ wendkeep observer reconcile --project P [--vault V] [--data-dir D] [--url U]
21
+ [--capture-level metadata|messages|full-transcript] [--json]
19
22
  wendkeep observer memory import --project P [--vault V] [--url U] [--token TOKEN]
20
23
  [--capture-level metadata|messages|full-transcript] [--json]
21
24
  wendkeep observer status [--data-dir D] [--json]
22
25
 
23
26
  O Observer local pode manter snapshots operacionais e uma cópia completa da memória em volume
24
27
  Docker. O comando memory import faz a primeira migração de um vault para o container.
28
+ Hooks apenas enfileiram/drenam alterações incrementais; reconcile é a varredura integral explícita.
25
29
  `;
26
30
 
27
31
  function optionValue(argv, name) {
@@ -73,6 +77,19 @@ function databaseSummary(dir) {
73
77
  } finally { db.close(); }
74
78
  }
75
79
 
80
+ function sqlProjectsSummary(dir) {
81
+ const db = ensureObserverDatabase(dir);
82
+ try {
83
+ return listSqlProjects(db).map((project) => ({
84
+ projectId: project.project_id,
85
+ projectName: project.project_name,
86
+ wendkeepVersion: project.wendkeep_version,
87
+ updatedAt: project.updated_at,
88
+ authority: 'sqlite',
89
+ }));
90
+ } finally { db.close(); }
91
+ }
92
+
76
93
  export async function runObserver(argv = [], { write = (chunk) => process.stdout.write(chunk) } = {}) {
77
94
  const [sub] = argv;
78
95
  const asJson = argv.includes('--json');
@@ -84,7 +101,8 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
84
101
  const token = resolveObserverToken(optionValue(argv, '--token'));
85
102
 
86
103
  if (sub === 'status') {
87
- print({ ...summary(readObserverIndex(dir)), database: databaseSummary(dir) }, asJson, write);
104
+ const legacy = summary(readObserverIndexSource(dir));
105
+ print({ ...legacy, projects: sqlProjectsSummary(dir), legacy_projects: legacy.projects, database: databaseSummary(dir) }, asJson, write);
88
106
  return 0;
89
107
  }
90
108
 
@@ -131,6 +149,12 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
131
149
  token,
132
150
  captureLevel: optionValue(argv, '--capture-level') || process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata',
133
151
  });
152
+ const snapshotResponse = await fetch(`${String(url).replace(/\/$/, '')}/v1/projects/${encodeURIComponent(snapshot.project_id)}/snapshot`, {
153
+ method: 'POST',
154
+ headers,
155
+ body: JSON.stringify(snapshot),
156
+ });
157
+ if (!snapshotResponse.ok) throw new Error(`Observer não importou o snapshot: HTTP ${snapshotResponse.status}.`);
134
158
  const parity = await compareMemoryParity({
135
159
  vaultBase: vault,
136
160
  projectId: snapshot.project_id,
@@ -148,24 +172,90 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
148
172
  return result.ok ? 0 : 1;
149
173
  }
150
174
 
175
+ if (sub === 'reconcile') {
176
+ const root = projectRoot(argv);
177
+ const vault = vaultBase(argv, root);
178
+ const snapshot = buildProjectSnapshot({ vaultBase: vault, projectRoot: root });
179
+ const url = optionValue(argv, '--url') || process.env.WENDKEEP_OBSERVER_URL || '';
180
+ if (!url) {
181
+ const db = ensureObserverDatabase(dir);
182
+ let migration;
183
+ try {
184
+ migration = migrateObserverData({
185
+ dataDir: dir,
186
+ vaultBase: vault,
187
+ projectId: snapshot.project_id,
188
+ projectName: snapshot.project_name,
189
+ database: db,
190
+ });
191
+ upsertSqlProjectSnapshot(db, snapshot);
192
+ } finally { db.close(); }
193
+ const result = { ok: migration.rejected === 0 && migration.conflicts === 0, project_id: snapshot.project_id, mode: 'local-sqlite', migration };
194
+ print(result, asJson, write);
195
+ return result.ok ? 0 : 1;
196
+ }
197
+ const registration = await fetch(`${String(url).replace(/\/$/, '')}/v1/projects/${encodeURIComponent(snapshot.project_id)}`, {
198
+ method: 'PUT',
199
+ headers: observerAuthHeaders(token, { 'content-type': 'application/json', accept: 'application/json' }),
200
+ body: JSON.stringify({
201
+ project_id: snapshot.project_id,
202
+ project_name: snapshot.project_name,
203
+ wendkeep_version: snapshot.wendkeep_version,
204
+ }),
205
+ });
206
+ if (!registration.ok) throw new Error(`Observer não registrou o projeto: HTTP ${registration.status}.`);
207
+ const sql = await publishObserverSql({
208
+ vaultBase: vault,
209
+ projectId: snapshot.project_id,
210
+ url,
211
+ token,
212
+ captureLevel: optionValue(argv, '--capture-level') || process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata',
213
+ });
214
+ const snapshotResponse = await fetch(`${String(url).replace(/\/$/, '')}/v1/projects/${encodeURIComponent(snapshot.project_id)}/snapshot`, {
215
+ method: 'POST',
216
+ headers: observerAuthHeaders(token, { 'content-type': 'application/json', accept: 'application/json' }),
217
+ body: JSON.stringify(snapshot),
218
+ });
219
+ if (!snapshotResponse.ok) throw new Error(`Observer não reconciliou o snapshot: HTTP ${snapshotResponse.status}.`);
220
+ const parity = await compareMemoryParity({ vaultBase: vault, projectId: snapshot.project_id, url, token });
221
+ const result = { ok: sql.ok && parity.missing === 0 && parity.mismatched === 0, project_id: snapshot.project_id, mode: 'remote-sqlite', sql, parity };
222
+ print(result, asJson, write);
223
+ return result.ok ? 0 : 1;
224
+ }
225
+
151
226
  if (!['register', 'publish'].includes(sub)) throw new Error('observer: subcomando desconhecido: ' + sub);
152
227
  const root = projectRoot(argv);
153
228
  const vault = vaultBase(argv, root);
154
229
  const snapshot = buildProjectSnapshot({ vaultBase: vault, projectRoot: root });
155
230
 
156
231
  if (sub === 'register') {
157
- const result = registerObserverProject(dir, {
158
- projectId: snapshot.project_id,
159
- projectName: snapshot.project_name,
160
- wendkeepVersion: snapshot.wendkeep_version,
161
- });
162
- if (!result.registered) throw new Error(result.errors.join(' '));
163
- print(result, asJson, write);
232
+ const db = ensureObserverDatabase(dir);
233
+ let sql;
234
+ try {
235
+ sql = registerSqlProject(db, {
236
+ projectId: snapshot.project_id,
237
+ projectName: snapshot.project_name,
238
+ wendkeepVersion: snapshot.wendkeep_version,
239
+ });
240
+ } finally { db.close(); }
241
+ if (!sql.registered) throw new Error(sql.errors.join(' '));
242
+ print({ ...sql, authority: 'sqlite' }, asJson, write);
164
243
  return 0;
165
244
  }
166
245
 
167
- const result = appendObserverEvent(dir, snapshot);
168
- if (!result.accepted && !result.duplicate) throw new Error(result.errors.join(' '));
169
- print({ ok: true, ...result }, asJson, write);
170
- return 0;
246
+ const db = ensureObserverDatabase(dir);
247
+ let migration;
248
+ try {
249
+ migration = migrateObserverData({
250
+ dataDir: dir,
251
+ vaultBase: vault,
252
+ projectId: snapshot.project_id,
253
+ projectName: snapshot.project_name,
254
+ database: db,
255
+ });
256
+ upsertSqlProjectSnapshot(db, snapshot);
257
+ } finally { db.close(); }
258
+ const result = { ok: migration.rejected === 0 && migration.conflicts === 0, authority: 'sqlite', migration };
259
+ print(result, asJson, write);
260
+ return result.ok ? 0 : 1;
171
261
  }