wendkeep 0.72.0 → 0.73.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 (44) hide show
  1. package/CHANGELOG.md +66 -3
  2. package/README.en.md +28 -11
  3. package/README.md +28 -11
  4. package/docs/en/commands/changes-and-verification.md +10 -5
  5. package/docs/en/commands/maintenance-and-diagnostics.md +17 -9
  6. package/docs/en/commands/observer.md +18 -12
  7. package/docs/en/commands/operating-profiles.md +28 -3
  8. package/docs/en/commands/sessions-and-import.md +4 -4
  9. package/docs/pt-BR/commands/changes-and-verification.md +10 -5
  10. package/docs/pt-BR/commands/maintenance-and-diagnostics.md +12 -5
  11. package/docs/pt-BR/commands/observer.md +18 -12
  12. package/docs/pt-BR/commands/operating-profiles.md +28 -3
  13. package/docs/pt-BR/commands/sessions-and-import.md +4 -4
  14. package/hooks/brain-inject.mjs +6 -6
  15. package/hooks/change-context.mjs +11 -0
  16. package/hooks/change-core.mjs +53 -21
  17. package/hooks/change-warn.mjs +2 -0
  18. package/hooks/harness-doctor.mjs +13 -5
  19. package/hooks/understand-inject.mjs +1 -1
  20. package/hooks/vault-health.mjs +2 -2
  21. package/package.json +5 -4
  22. package/packages/cli/src/index.mjs +12 -2
  23. package/packages/integrations/src/host-hooks.mjs +1 -1
  24. package/packages/vault/src/memory-store.mjs +17 -4
  25. package/src/change.mjs +10 -4
  26. package/src/delivery.mjs +303 -0
  27. package/src/doctor.mjs +47 -10
  28. package/src/init.mjs +2 -2
  29. package/src/observer-auth.mjs +10 -0
  30. package/src/observer-memory-publish.mjs +13 -8
  31. package/src/observer-privacy.mjs +23 -0
  32. package/src/observer-publish.mjs +10 -7
  33. package/src/observer-server.mjs +51 -0
  34. package/src/observer-sql-publish.mjs +72 -31
  35. package/src/observer-sql-store.mjs +33 -2
  36. package/src/observer.mjs +10 -3
  37. package/src/release-changelog.mjs +1 -1
  38. package/src/release-provenance.mjs +115 -0
  39. package/src/skills-seed.mjs +25 -9
  40. package/src/sync-defs.mjs +5 -2
  41. package/src/sync.mjs +2 -2
  42. package/src/taxonomy.mjs +1 -1
  43. package/src/vault-readme.mjs +2 -2
  44. package/src/work-kind.mjs +62 -0
@@ -0,0 +1,23 @@
1
+ import { basename } from 'node:path';
2
+
3
+ const TRANSCRIPT_PATH_KEY = /^(?:transcript_path|agent_transcript_path|transcriptPath|agentTranscriptPath)$/i;
4
+ const TRANSCRIPT_PATH_LINE = /^(\s*["']?(?:transcript_path|agent_transcript_path|transcriptPath|agentTranscriptPath)["']?\s*:\s*)(["']?)(.*?)(\2)(\s*,?\s*)$/gmi;
5
+
6
+ function sourceLabel(value) {
7
+ return basename(String(value || '').replaceAll('\\', '/'));
8
+ }
9
+
10
+ export function sanitizeObserverContent(content) {
11
+ return String(content || '').replace(TRANSCRIPT_PATH_LINE, (_line, prefix, quote, value, _closing, suffix) => (
12
+ `${prefix}${quote}${sourceLabel(value)}${quote}${suffix}`
13
+ ));
14
+ }
15
+
16
+ export function sanitizeObserverMetadata(value) {
17
+ if (Array.isArray(value)) return value.map(sanitizeObserverMetadata);
18
+ if (!value || typeof value !== 'object') return value;
19
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
20
+ key,
21
+ TRANSCRIPT_PATH_KEY.test(key) ? sourceLabel(item) : sanitizeObserverMetadata(item),
22
+ ]));
23
+ }
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSyn
2
2
  import { join } from 'node:path';
3
3
  import { buildProjectSnapshot } from './observer-snapshot.mjs';
4
4
  import { publishObserverSql } from './observer-sql-publish.mjs';
5
+ import { observerAuthHeaders, resolveObserverToken } from './observer-auth.mjs';
5
6
 
6
7
  const OUTBOX_REL = join('.brain', 'observer-outbox');
7
8
  const REQUEST_TIMEOUT_MS = 500;
@@ -46,15 +47,15 @@ function removeOutbox(vaultBase, eventId) {
46
47
  if (existsSync(path)) unlinkSync(path);
47
48
  }
48
49
 
49
- async function postSnapshot(url, event) {
50
+ async function postSnapshot(url, event, token) {
50
51
  const controller = new AbortController();
51
52
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
52
53
  try {
53
54
  const response = await fetch(`${String(url).replace(/\/$/, '')}/v1/projects/${encodeURIComponent(event.project_id)}/snapshot`, {
54
55
  method: 'POST',
55
- headers: {
56
+ headers: observerAuthHeaders(token, {
56
57
  'content-type': 'application/json',
57
- },
58
+ }),
58
59
  body: JSON.stringify(event),
59
60
  signal: controller.signal,
60
61
  });
@@ -70,14 +71,14 @@ async function postSnapshot(url, event) {
70
71
  }
71
72
  }
72
73
 
73
- export async function retryObserverOutbox({ vaultBase, url } = {}) {
74
+ export async function retryObserverOutbox({ vaultBase, url, token = process.env.WENDKEEP_OBSERVER_TOKEN || '' } = {}) {
74
75
  if (!url) return { attempted: 0, confirmed: 0, pending: listOutbox(vaultBase).length };
75
76
  let attempted = 0;
76
77
  let confirmed = 0;
77
78
  for (const event of listOutbox(vaultBase)) {
78
79
  attempted += 1;
79
80
  try {
80
- await postSnapshot(url, event);
81
+ await postSnapshot(url, event, token);
81
82
  removeOutbox(vaultBase, event.event_id);
82
83
  confirmed += 1;
83
84
  } catch { /* preserve the event for a later retry */ }
@@ -91,6 +92,7 @@ export async function publishObserverSnapshot({
91
92
  url = process.env.WENDKEEP_OBSERVER_URL || '',
92
93
  now = new Date(),
93
94
  input = {},
95
+ token = process.env.WENDKEEP_OBSERVER_TOKEN || '',
94
96
  } = {}) {
95
97
  try {
96
98
  const event = buildProjectSnapshot({ vaultBase, projectRoot, now });
@@ -100,15 +102,16 @@ export async function publishObserverSnapshot({
100
102
  url,
101
103
  input,
102
104
  now,
105
+ token,
103
106
  });
104
107
  if (!url) return { ok: sql.ok, skipped: true, queued: sql.queued, hookExitCode: 0, event_id: event.event_id, sql };
105
108
 
106
- await retryObserverOutbox({ vaultBase, url });
109
+ await retryObserverOutbox({ vaultBase, url, token });
107
110
  // SQL is the live authority. Keep the legacy-shaped `memory` field for
108
111
  // older integrations while reporting the real SQL publication separately.
109
112
  const memory = { ok: sql.ok, queued: sql.queued, changed: sql.changed, pending: sql.pending, authority: 'sqlite' };
110
113
  try {
111
- const response = await postSnapshot(url, event);
114
+ const response = await postSnapshot(url, event, resolveObserverToken(token));
112
115
  return {
113
116
  ok: true,
114
117
  queued: false,
@@ -1,4 +1,5 @@
1
1
  import { createServer } from 'node:http';
2
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
3
  import { readFileSync } from 'node:fs';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { gunzipSync } from 'node:zlib';
@@ -52,6 +53,39 @@ function loopbackOnly(host) {
52
53
  return LOOPBACK_HOSTS.has(String(host || '').toLowerCase());
53
54
  }
54
55
 
56
+ function safeTokenEqual(actual, expected) {
57
+ if (!actual || !expected) return false;
58
+ const left = createHash('sha256').update(String(actual)).digest();
59
+ const right = createHash('sha256').update(String(expected)).digest();
60
+ return timingSafeEqual(left, right);
61
+ }
62
+
63
+ function bearerToken(req) {
64
+ const match = String(req.headers.authorization || '').match(/^Bearer\s+(.+)$/i);
65
+ return match?.[1] || '';
66
+ }
67
+
68
+ function requestHostname(value) {
69
+ try { return new URL(`http://${String(value || '')}`).hostname.toLowerCase(); }
70
+ catch { return ''; }
71
+ }
72
+
73
+ function validateAuthority(req, { loopback }) {
74
+ const hostname = requestHostname(req.headers.host);
75
+ if (!hostname || (loopback && !LOOPBACK_HOSTS.has(hostname))) {
76
+ return { ok: false, status: 421, code: 'invalid_host', message: 'Host não corresponde ao binding do Observer.' };
77
+ }
78
+ const origin = String(req.headers.origin || '');
79
+ if (origin) {
80
+ let originHostname = '';
81
+ try { originHostname = new URL(origin).hostname.toLowerCase(); } catch { /* invalid below */ }
82
+ if (!originHostname || originHostname !== hostname) {
83
+ return { ok: false, status: 403, code: 'invalid_origin', message: 'Origin não corresponde ao Host do Observer.' };
84
+ }
85
+ }
86
+ return { ok: true };
87
+ }
88
+
55
89
  function json(res, status, body) {
56
90
  const content = JSON.stringify(body);
57
91
  res.writeHead(status, {
@@ -184,10 +218,16 @@ export async function startObserverServer({
184
218
  port = 8787,
185
219
  dataDir,
186
220
  allowNonLoopback = false,
221
+ token = process.env.WENDKEEP_OBSERVER_TOKEN || '',
187
222
  } = {}) {
188
223
  if (!loopbackOnly(host) && !allowNonLoopback) {
189
224
  throw new Error(`Observer HTTP aceita somente host loopback; recebido: ${host}`);
190
225
  }
226
+ if (!loopbackOnly(host) && !token) {
227
+ const error = new Error('Observer non-loopback exige --token ou WENDKEEP_OBSERVER_TOKEN.');
228
+ error.code = 'WENDKEEP_OBSERVER_TOKEN_REQUIRED';
229
+ throw error;
230
+ }
191
231
  if (!dataDir) throw new Error('dataDir é obrigatório.');
192
232
  const sqlDb = ensureObserverDatabase(dataDir);
193
233
  const databaseMigration = migrateObserverDatabase(sqlDb);
@@ -204,6 +244,17 @@ export async function startObserverServer({
204
244
  const server = createServer(async (req, res) => {
205
245
  try {
206
246
  const pathname = new URL(req.url || '/', 'http://127.0.0.1').pathname;
247
+ const authority = validateAuthority(req, { loopback: loopbackOnly(host) });
248
+ if (!authority.ok) {
249
+ errorResponse(res, authority.status, authority.code, authority.message);
250
+ return;
251
+ }
252
+ const authenticated = safeTokenEqual(bearerToken(req), token);
253
+ const mutating = !['GET', 'HEAD', 'OPTIONS'].includes(String(req.method || '').toUpperCase());
254
+ if ((mutating || !loopbackOnly(host)) && !authenticated) {
255
+ errorResponse(res, 401, 'observer_auth_required', 'Bearer token válido é obrigatório para esta operação.');
256
+ return;
257
+ }
207
258
  if (req.method === 'GET' && pathname === '/healthz') {
208
259
  json(res, 200, {
209
260
  ok: true,
@@ -13,6 +13,8 @@ import { gzipSync } from 'node:zlib';
13
13
  import { parseTranscriptContent } from '../packages/integrations/src/transcripts.mjs';
14
14
  import { parseSessionCost, } from './cost.mjs';
15
15
  import { buildSessionIdentityMap, listMigrationDocuments, parseFrontmatter, sessionEvents } from './observer-sql-migrate.mjs';
16
+ import { observerAuthHeaders } from './observer-auth.mjs';
17
+ import { sanitizeObserverContent, sanitizeObserverMetadata } from './observer-privacy.mjs';
16
18
 
17
19
  export const SQL_OUTBOX_REL = '.brain/observer-sql-outbox';
18
20
  export const SQL_STATE_REL = '.brain/observer-sql-state.json';
@@ -20,6 +22,28 @@ const SQL_SCHEMA_VERSION = 1;
20
22
  export const SQL_EVENT_BATCH_SIZE = 64;
21
23
  export const SQL_EVENT_BATCH_BYTES = 8 * 1024 * 1024;
22
24
  const REQUEST_TIMEOUT_MS = 15000;
25
+ const MAX_REQUEST_TIMEOUT_MS = 120000;
26
+ const REQUEST_TIMEOUT_BYTES_STEP = 1024 * 1024;
27
+ const CAPTURE_LEVELS = new Set(['metadata', 'messages', 'full-transcript']);
28
+
29
+ export function observerSqlRequestTimeoutMs(rawBytes) {
30
+ const size = Math.max(0, Number(rawBytes) || 0);
31
+ const oversizedBytes = Math.max(0, size - SQL_EVENT_BATCH_BYTES);
32
+ return Math.min(
33
+ MAX_REQUEST_TIMEOUT_MS,
34
+ REQUEST_TIMEOUT_MS + Math.ceil(oversizedBytes / REQUEST_TIMEOUT_BYTES_STEP) * 1000,
35
+ );
36
+ }
37
+
38
+ export function normalizeObserverCaptureLevel(value = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata') {
39
+ const level = String(value || 'metadata').trim().toLowerCase();
40
+ if (!CAPTURE_LEVELS.has(level)) {
41
+ const error = new Error(`Nível de captura inválido: ${level}. Use metadata, messages ou full-transcript.`);
42
+ error.code = 'WENDKEEP_OBSERVER_CAPTURE_LEVEL_INVALID';
43
+ throw error;
44
+ }
45
+ return level;
46
+ }
23
47
 
24
48
  function text(value, fallback = '') { return String(value ?? fallback); }
25
49
  function hash(value) { return createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex'); }
@@ -73,7 +97,9 @@ function tokenPayload(usage = {}) {
73
97
  }
74
98
 
75
99
  function documentEvent({ projectId, logicalPath, content, metadata, revision, occurredAt }) {
76
- const contentHash = hash(content);
100
+ const safeContent = sanitizeObserverContent(content);
101
+ const safeMetadata = sanitizeObserverMetadata(metadata);
102
+ const contentHash = hash(safeContent);
77
103
  return {
78
104
  schema_version: 1,
79
105
  event_id: eventId('document', projectId, `${logicalPath}:${revision}:${contentHash}`),
@@ -89,11 +115,11 @@ function documentEvent({ projectId, logicalPath, content, metadata, revision, oc
89
115
  : logicalPath.startsWith('07-Specs/') ? 'spec'
90
116
  : logicalPath.startsWith('08-Mudanças/') ? 'change' : 'memory',
91
117
  title: basename(logicalPath).replace(/\.md$/i, ''),
92
- content,
118
+ content: safeContent,
93
119
  content_hash: contentHash,
94
120
  revision,
95
- metadata,
96
- source_session_id: text(metadata?.session_id),
121
+ metadata: safeMetadata,
122
+ source_session_id: text(safeMetadata?.session_id),
97
123
  },
98
124
  };
99
125
  }
@@ -123,7 +149,7 @@ function agentEvent({ projectId, sessionId, agentId, parentAgentId = '', role =
123
149
  };
124
150
  }
125
151
 
126
- function transcriptCalls({ projectId, sessionId, agentId, role, provider, modelFallback, transcriptId, content, occurredAt }) {
152
+ function transcriptCalls({ projectId, sessionId, agentId, role, provider, modelFallback, transcriptId, content, occurredAt, includeMessages }) {
127
153
  let parsed;
128
154
  try { parsed = parseTranscriptContent(content); } catch { return []; }
129
155
  return (parsed.turns || []).flatMap((turn, index) => {
@@ -154,8 +180,8 @@ function transcriptCalls({ projectId, sessionId, agentId, role, provider, modelF
154
180
  cost_usd: 0,
155
181
  cost_status: 'unknown',
156
182
  transcript_id: transcriptId,
157
- prompt_text: prompt,
158
- response_text: response,
183
+ prompt_text: includeMessages ? prompt : '',
184
+ response_text: includeMessages ? response : '',
159
185
  status: turn.status === 'aborted' ? 'aborted' : 'complete',
160
186
  metadata: { tools: turn.tools || [], source: 'transcript-parser' },
161
187
  },
@@ -180,7 +206,7 @@ function sourceCandidates({ vaultBase, logicalPath, fm, input }) {
180
206
  return candidates;
181
207
  }
182
208
 
183
- function completeTranscriptEvents({ projectId, sessionId, mainAgentId, provider, model, source, now }) {
209
+ function completeTranscriptEvents({ projectId, sessionId, mainAgentId, provider, model, source, now, captureLevel }) {
184
210
  const content = readFileSync(source.path, 'utf8');
185
211
  const agentId = source.role === 'subagent'
186
212
  ? `${projectId}:${sessionId}:subagent:${hash(source.path).slice(0, 16)}`
@@ -189,7 +215,7 @@ function completeTranscriptEvents({ projectId, sessionId, mainAgentId, provider,
189
215
  ? agentEvent({ projectId, sessionId, agentId, parentAgentId: mainAgentId, role: 'subagent', provider, model, input: source.agentInput, occurredAt: now })
190
216
  : null;
191
217
  const fingerprint = hash(content);
192
- const transcript = {
218
+ const transcript = captureLevel === 'full-transcript' ? {
193
219
  schema_version: 1,
194
220
  event_id: eventId('transcript', projectId, `${source.transcriptId}:${fingerprint}`),
195
221
  kind: 'transcript.upsert',
@@ -202,10 +228,21 @@ function completeTranscriptEvents({ projectId, sessionId, mainAgentId, provider,
202
228
  coverage: 'complete',
203
229
  content,
204
230
  source: 'hook-transcript',
205
- metadata: { original_path: source.path.replaceAll('\\', '/') },
231
+ metadata: { source_label: basename(source.path), source_hash: hash(normalizePath(source.path)) },
206
232
  },
207
- };
208
- const calls = transcriptCalls({ projectId, sessionId, agentId, role: source.role, provider, modelFallback: model, transcriptId: source.transcriptId, content, occurredAt: now });
233
+ } : null;
234
+ const calls = transcriptCalls({
235
+ projectId,
236
+ sessionId,
237
+ agentId,
238
+ role: source.role,
239
+ provider,
240
+ modelFallback: model,
241
+ transcriptId: source.transcriptId,
242
+ content,
243
+ occurredAt: now,
244
+ includeMessages: captureLevel !== 'metadata',
245
+ });
209
246
  return { events: [agent, transcript, ...calls].filter(Boolean), fingerprint, transcriptId: source.transcriptId };
210
247
  }
211
248
 
@@ -218,9 +255,10 @@ function dedupeEvents(events) {
218
255
  });
219
256
  }
220
257
 
221
- export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, now = new Date(), state = readState(vaultBase), remoteDocuments = {} } = {}) {
258
+ export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, now = new Date(), state = readState(vaultBase), remoteDocuments = {}, captureLevel = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata' } = {}) {
222
259
  if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
223
260
  const occurredAt = isoNow(now);
261
+ const resolvedCaptureLevel = normalizeObserverCaptureLevel(captureLevel);
224
262
  const nextState = { schema_version: SQL_SCHEMA_VERSION, files: { ...(state.files || {}) }, transcripts: { ...(state.transcripts || {}) } };
225
263
  const events = [];
226
264
  const sessionContexts = [];
@@ -235,7 +273,7 @@ export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, n
235
273
  let changed = 0;
236
274
  for (const file of files) {
237
275
  const content = readFileSync(file.absolute, 'utf8');
238
- const contentHash = hash(content);
276
+ const contentHash = hash(sanitizeObserverContent(content));
239
277
  const previous = state.files?.[file.logicalPath];
240
278
  const remote = remoteDocuments?.[file.logicalPath];
241
279
  const baseRevision = Math.max(Number(previous?.revision || 0), Number(remote?.revision || 0));
@@ -263,9 +301,10 @@ export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, n
263
301
  for (const source of sources) {
264
302
  const content = readFileSync(source.path, 'utf8');
265
303
  const fingerprint = hash(content);
266
- if (state.transcripts?.[source.transcriptId]?.content_hash === fingerprint) continue;
267
- const complete = completeTranscriptEvents({ projectId, sessionId, mainAgentId, provider, model, source, now: occurredAt });
268
- nextState.transcripts[source.transcriptId] = { content_hash: complete.fingerprint, coverage: 'complete' };
304
+ const previousTranscript = state.transcripts?.[source.transcriptId];
305
+ if (previousTranscript?.content_hash === fingerprint && previousTranscript?.coverage === resolvedCaptureLevel) continue;
306
+ const complete = completeTranscriptEvents({ projectId, sessionId, mainAgentId, provider, model, source, now: occurredAt, captureLevel: resolvedCaptureLevel });
307
+ nextState.transcripts[source.transcriptId] = { content_hash: complete.fingerprint, coverage: resolvedCaptureLevel };
269
308
  const summaryId = complete.transcriptId;
270
309
  for (const event of complete.events) {
271
310
  if (event.kind === 'agent.upsert' && event.payload.agent_id !== mainAgentId) events.push(event);
@@ -297,14 +336,15 @@ export function listSqlOutbox(vaultBase) {
297
336
  });
298
337
  }
299
338
 
300
- async function postSqlChunk({ url, projectId, events, fetchImpl = globalThis.fetch }) {
339
+ async function postSqlChunk({ url, projectId, events, fetchImpl = globalThis.fetch, token = '' }) {
301
340
  const controller = new AbortController();
302
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
303
- const wireBody = gzipSync(Buffer.from(JSON.stringify({ events }), 'utf8'));
341
+ const rawBody = Buffer.from(JSON.stringify({ events }), 'utf8');
342
+ const timer = setTimeout(() => controller.abort(), observerSqlRequestTimeoutMs(rawBody.byteLength));
343
+ const wireBody = gzipSync(rawBody);
304
344
  try {
305
345
  const response = await fetchImpl(`${String(url).replace(/\/$/, '')}/v1/projects/${encodeURIComponent(projectId)}/ingest`, {
306
346
  method: 'POST',
307
- headers: { 'content-type': 'application/json', 'content-encoding': 'gzip', accept: 'application/json' },
347
+ headers: observerAuthHeaders(token, { 'content-type': 'application/json', 'content-encoding': 'gzip', accept: 'application/json' }),
308
348
  body: wireBody,
309
349
  signal: controller.signal,
310
350
  });
@@ -314,11 +354,11 @@ async function postSqlChunk({ url, projectId, events, fetchImpl = globalThis.fet
314
354
  } finally { clearTimeout(timer); }
315
355
  }
316
356
 
317
- async function readRemoteDocuments({ url, projectId, fetchImpl = globalThis.fetch }) {
357
+ async function readRemoteDocuments({ url, projectId, fetchImpl = globalThis.fetch, token = '' }) {
318
358
  if (!url) return {};
319
359
  try {
320
360
  const response = await fetchImpl(`${String(url).replace(/\/$/, '')}/v1/projects/${encodeURIComponent(projectId)}/memory/tree`, {
321
- headers: { accept: 'application/json' },
361
+ headers: observerAuthHeaders(token, { accept: 'application/json' }),
322
362
  });
323
363
  if (!response.ok) return {};
324
364
  const body = await response.json().catch(() => ({}));
@@ -328,7 +368,7 @@ async function readRemoteDocuments({ url, projectId, fetchImpl = globalThis.fetc
328
368
  }
329
369
  }
330
370
 
331
- async function postSqlBatch({ url, projectId, events, fetchImpl = globalThis.fetch }) {
371
+ async function postSqlBatch({ url, projectId, events, fetchImpl = globalThis.fetch, token = '' }) {
332
372
  const aggregate = { accepted: 0, rejected: 0, conflicts: 0, stale: 0, duplicates: 0 };
333
373
  let chunk = [];
334
374
  let chunkBytes = 0;
@@ -339,6 +379,7 @@ async function postSqlBatch({ url, projectId, events, fetchImpl = globalThis.fet
339
379
  projectId,
340
380
  events: items,
341
381
  fetchImpl,
382
+ token,
342
383
  });
343
384
  for (const key of Object.keys(aggregate)) aggregate[key] += Number(response?.[key]) || 0;
344
385
  };
@@ -358,7 +399,7 @@ async function postSqlBatch({ url, projectId, events, fetchImpl = globalThis.fet
358
399
  return aggregate;
359
400
  }
360
401
 
361
- export async function retryObserverSqlOutbox({ vaultBase, projectId, url, fetchImpl = globalThis.fetch } = {}) {
402
+ export async function retryObserverSqlOutbox({ vaultBase, projectId, url, fetchImpl = globalThis.fetch, token = process.env.WENDKEEP_OBSERVER_TOKEN || '' } = {}) {
362
403
  const pending = listSqlOutbox(vaultBase);
363
404
  if (!url) return { attempted: 0, confirmed: 0, pending: pending.length };
364
405
  let attempted = 0;
@@ -366,7 +407,7 @@ export async function retryObserverSqlOutbox({ vaultBase, projectId, url, fetchI
366
407
  for (const batch of pending) {
367
408
  attempted += 1;
368
409
  try {
369
- await postSqlBatch({ url, projectId, events: batch.events, fetchImpl });
410
+ await postSqlBatch({ url, projectId, events: batch.events, fetchImpl, token });
370
411
  unlinkSync(batch.path);
371
412
  confirmed += 1;
372
413
  } catch { break; }
@@ -374,14 +415,14 @@ export async function retryObserverSqlOutbox({ vaultBase, projectId, url, fetchI
374
415
  return { attempted, confirmed, pending: listSqlOutbox(vaultBase).length };
375
416
  }
376
417
 
377
- export async function publishObserverSql({ vaultBase, projectId, url = process.env.WENDKEEP_OBSERVER_URL || '', input = {}, now = new Date(), fetchImpl = globalThis.fetch } = {}) {
418
+ export async function publishObserverSql({ vaultBase, projectId, url = process.env.WENDKEEP_OBSERVER_URL || '', input = {}, now = new Date(), fetchImpl = globalThis.fetch, token = process.env.WENDKEEP_OBSERVER_TOKEN || '', captureLevel = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata' } = {}) {
378
419
  if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
379
- const replay = await retryObserverSqlOutbox({ vaultBase, projectId, url, fetchImpl });
420
+ const replay = await retryObserverSqlOutbox({ vaultBase, projectId, url, fetchImpl, token });
380
421
  const state = readState(vaultBase);
381
422
  const remoteDocuments = Object.keys(state.files || {}).length === 0
382
- ? await readRemoteDocuments({ url, projectId, fetchImpl })
423
+ ? await readRemoteDocuments({ url, projectId, fetchImpl, token })
383
424
  : {};
384
- const batch = buildObserverSqlEventBatch({ vaultBase, projectId, input, now, state, remoteDocuments });
425
+ const batch = buildObserverSqlEventBatch({ vaultBase, projectId, input, now, state, remoteDocuments, captureLevel });
385
426
  atomicJson(statePath(vaultBase), batch.nextState);
386
427
  if (!batch.events.length) return { ok: true, queued: false, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay };
387
428
  if (!url) {
@@ -389,7 +430,7 @@ export async function publishObserverSql({ vaultBase, projectId, url = process.e
389
430
  return { ok: false, queued: true, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay, hookExitCode: 0 };
390
431
  }
391
432
  try {
392
- const response = await postSqlBatch({ url, projectId, events: batch.events, fetchImpl });
433
+ const response = await postSqlBatch({ url, projectId, events: batch.events, fetchImpl, token });
393
434
  return { ok: true, queued: false, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay, response };
394
435
  } catch (error) {
395
436
  queueOutbox(vaultBase, { schema_version: SQL_SCHEMA_VERSION, project_id: projectId, events: batch.events });
@@ -1,8 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
3
4
  import { join, basename, dirname } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
- import { DatabaseSync } from 'node:sqlite';
6
6
  import { decodeTranscript, encodeTranscript } from './observer-transcript-store.mjs';
7
7
 
8
8
  export const OBSERVER_SQL_FILE = 'observer.sqlite';
@@ -16,6 +16,36 @@ const EVENT_KINDS = new Set([
16
16
  'usage.rollup', 'llm_call', 'transcript.upsert',
17
17
  ]);
18
18
 
19
+ const OBSERVER_SQL_MINIMUM_NODE = '22.13.0';
20
+ const require = createRequire(import.meta.url);
21
+ let DatabaseSync;
22
+
23
+ export function observerSqlRuntimeSupport(version = process.versions.node) {
24
+ const current = String(version || '0.0.0');
25
+ const [major = 0, minor = 0] = current.split('.').map((part) => Number(part) || 0);
26
+ return {
27
+ supported: major > 22 || (major === 22 && minor >= 13),
28
+ minimum: OBSERVER_SQL_MINIMUM_NODE,
29
+ current,
30
+ };
31
+ }
32
+
33
+ function observerSqlRuntimeError(support = observerSqlRuntimeSupport()) {
34
+ const error = new Error(`Observer SQL requer Node.js >= ${support.minimum}; atual: ${support.current}. O Keep Core continua compatível com Node.js >= 18.`);
35
+ error.code = 'WENDKEEP_OBSERVER_NODE_UNSUPPORTED';
36
+ return error;
37
+ }
38
+
39
+ function observerDatabaseSync() {
40
+ const support = observerSqlRuntimeSupport();
41
+ if (!support.supported) throw observerSqlRuntimeError(support);
42
+ if (!DatabaseSync) {
43
+ try { ({ DatabaseSync } = require('node:sqlite')); }
44
+ catch { throw observerSqlRuntimeError(support); }
45
+ }
46
+ return DatabaseSync;
47
+ }
48
+
19
49
  function now() { return new Date().toISOString(); }
20
50
 
21
51
  function text(value, fallback = '') { return String(value ?? fallback); }
@@ -63,7 +93,8 @@ function migrationFiles() {
63
93
  export function openObserverDatabase(dataDir) {
64
94
  if (!dataDir) throw new Error('dataDir é obrigatório.');
65
95
  mkdirSync(dataDir, { recursive: true });
66
- const db = new DatabaseSync(join(dataDir, OBSERVER_SQL_FILE));
96
+ const SqliteDatabase = observerDatabaseSync();
97
+ const db = new SqliteDatabase(join(dataDir, OBSERVER_SQL_FILE));
67
98
  db.exec('PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;');
68
99
  return db;
69
100
  }
package/src/observer.mjs CHANGED
@@ -7,15 +7,17 @@ import { publishObserverSql } from './observer-sql-publish.mjs';
7
7
  import { startObserverServer } from './observer-server.mjs';
8
8
  import { ensureObserverDatabase, migrateObserverDatabase, listSqlProjects, OBSERVER_SQL_FILE, OBSERVER_SQL_SCHEMA_VERSION } from './observer-sql-store.mjs';
9
9
  import { resolveProjectVault } from '../packages/vault/src/project-vault.mjs';
10
+ import { observerAuthHeaders, resolveObserverToken } from './observer-auth.mjs';
10
11
 
11
12
  export const OBSERVER_HELP = `wendkeep observer — Observer local multi-projeto
12
13
 
13
14
  Uso:
14
15
  wendkeep observer serve [--data-dir P] [--host 127.0.0.1] [--port 8787]
15
- [--allow-non-loopback]
16
+ [--allow-non-loopback] [--token TOKEN]
16
17
  wendkeep observer register --project P [--vault V] [--data-dir D] [--json]
17
18
  wendkeep observer publish --project P [--vault V] [--data-dir D] [--json]
18
- wendkeep observer memory import --project P [--vault V] [--url U] [--json]
19
+ wendkeep observer memory import --project P [--vault V] [--url U] [--token TOKEN]
20
+ [--capture-level metadata|messages|full-transcript] [--json]
19
21
  wendkeep observer status [--data-dir D] [--json]
20
22
 
21
23
  O Observer local pode manter snapshots operacionais e uma cópia completa da memória em volume
@@ -79,6 +81,7 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
79
81
  return 0;
80
82
  }
81
83
  const dir = dataDir(argv);
84
+ const token = resolveObserverToken(optionValue(argv, '--token'));
82
85
 
83
86
  if (sub === 'status') {
84
87
  print({ ...summary(readObserverIndex(dir)), database: databaseSummary(dir) }, asJson, write);
@@ -92,6 +95,7 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
92
95
  host,
93
96
  port: Number(optionValue(argv, '--port') || 8787),
94
97
  allowNonLoopback: argv.includes('--allow-non-loopback'),
98
+ token,
95
99
  });
96
100
  const address = server.address();
97
101
  process.stdout.write(`wendkeep observer listening: http://${address.address}:${address.port}\n`);
@@ -106,7 +110,7 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
106
110
  const snapshot = buildProjectSnapshot({ vaultBase: vault, projectRoot: root });
107
111
  const url = optionValue(argv, '--url') || process.env.WENDKEEP_OBSERVER_URL || '';
108
112
  if (!url) throw new Error('observer memory import: --url ou WENDKEEP_OBSERVER_URL é obrigatório.');
109
- const headers = { 'content-type': 'application/json', accept: 'application/json' };
113
+ const headers = observerAuthHeaders(token, { 'content-type': 'application/json', accept: 'application/json' });
110
114
  const registration = await fetch(
111
115
  String(url).replace(/\/$/, '') + '/v1/projects/' + encodeURIComponent(snapshot.project_id),
112
116
  {
@@ -124,11 +128,14 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
124
128
  vaultBase: vault,
125
129
  projectId: snapshot.project_id,
126
130
  url,
131
+ token,
132
+ captureLevel: optionValue(argv, '--capture-level') || process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata',
127
133
  });
128
134
  const parity = await compareMemoryParity({
129
135
  vaultBase: vault,
130
136
  projectId: snapshot.project_id,
131
137
  url,
138
+ token,
132
139
  });
133
140
  const result = {
134
141
  ok: sql.ok && parity.missing === 0 && parity.mismatched === 0,
@@ -1,5 +1,5 @@
1
1
  // Pure helper: extract a single version's release notes from a Keep-a-Changelog
2
- // file. Reused by scripts/release.mjs and .github/workflows/release.yml so the
2
+ // file. Reused by scripts/release.mjs and .github/workflows/auto-tag.yml so the
3
3
  // GitHub Release body always matches the committed CHANGELOG.
4
4
 
5
5
  const HEADER_RE = /^##\s*\[([^\]]+)\]\s*[—–-]\s*(.+?)\s*$/;
@@ -0,0 +1,115 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { cpSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { basename, join } from 'node:path';
5
+
6
+ const DEPENDENCY_FIELDS = Object.freeze([
7
+ 'dependencies',
8
+ 'devDependencies',
9
+ 'optionalDependencies',
10
+ 'peerDependencies',
11
+ ]);
12
+
13
+ export function parsePackIntegrity(raw) {
14
+ const text = String(raw || '');
15
+ const start = text.indexOf('[');
16
+ const end = text.lastIndexOf(']');
17
+ if (start < 0 || end < start) return '';
18
+ try {
19
+ return String(JSON.parse(text.slice(start, end + 1))[0]?.integrity || '');
20
+ } catch {
21
+ return '';
22
+ }
23
+ }
24
+
25
+ export function packIntegrityInIsolatedCopy(root, { execute = execFileSync } = {}) {
26
+ const tempRoot = mkdtempSync(join(tmpdir(), 'wendkeep-release-pack-'));
27
+ const packageRoot = join(tempRoot, 'package');
28
+ const ignored = new Set(['.git', 'node_modules']);
29
+ try {
30
+ const binding = JSON.parse(readFileSync(join(root, '.wendkeep.json'), 'utf8'));
31
+ const vault = String(binding.vault || '');
32
+ if (vault && !vault.includes('/') && !vault.includes('\\')) ignored.add(vault);
33
+ } catch { /* unbound package: nothing else to exclude */ }
34
+ try {
35
+ cpSync(root, packageRoot, {
36
+ recursive: true,
37
+ filter(source) {
38
+ if (source === root) return true;
39
+ const relativeName = basename(source);
40
+ return !ignored.has(relativeName);
41
+ },
42
+ });
43
+ const command = process.platform === 'win32' ? 'npm.cmd' : 'npm';
44
+ const raw = execute(command, ['pack', '--dry-run', '--json'], {
45
+ cwd: packageRoot,
46
+ encoding: 'utf8',
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ });
49
+ return parsePackIntegrity(raw);
50
+ } finally {
51
+ rmSync(tempRoot, { recursive: true, force: true });
52
+ }
53
+ }
54
+
55
+ export function packageHasSelfDependency(pkg = {}) {
56
+ const name = String(pkg.name || '');
57
+ if (!name) return false;
58
+ return DEPENDENCY_FIELDS.some((field) => Object.hasOwn(pkg[field] || {}, name));
59
+ }
60
+
61
+ export function evaluateReleaseProvenance({
62
+ name,
63
+ version,
64
+ headCommit,
65
+ tagCommit = '',
66
+ publishedIntegrity = '',
67
+ localIntegrity = '',
68
+ requirePublished = false,
69
+ } = {}) {
70
+ const tag = `v${version}`;
71
+ if (tagCommit && tagCommit !== headCommit) {
72
+ return {
73
+ ok: false,
74
+ code: 'tag_commit_mismatch',
75
+ message: `${tag} aponta para ${tagCommit.slice(0, 7)}, não para ${headCommit.slice(0, 7)}. Bump a versão antes de alterar a árvore publicada.`,
76
+ };
77
+ }
78
+ if (publishedIntegrity && !tagCommit) {
79
+ return {
80
+ ok: false,
81
+ code: 'published_tag_missing',
82
+ message: `${name}@${version} está publicado, mas ${tag} não comprova o commit correspondente.`,
83
+ };
84
+ }
85
+ if (requirePublished && !publishedIntegrity) {
86
+ return {
87
+ ok: false,
88
+ code: 'published_artifact_missing',
89
+ message: `${name}@${version} ainda não possui integridade consultável no npm.`,
90
+ };
91
+ }
92
+ if (publishedIntegrity && localIntegrity && publishedIntegrity !== localIntegrity) {
93
+ return {
94
+ ok: false,
95
+ code: 'tarball_integrity_mismatch',
96
+ message: `o tarball de ${tag} diverge do artefato publicado no npm.`,
97
+ };
98
+ }
99
+ if (publishedIntegrity && !localIntegrity) {
100
+ return {
101
+ ok: false,
102
+ code: 'local_integrity_missing',
103
+ message: `não foi possível calcular a integridade do tarball de ${tag}.`,
104
+ };
105
+ }
106
+ return {
107
+ ok: true,
108
+ code: publishedIntegrity ? 'verified' : 'release_candidate',
109
+ name,
110
+ version,
111
+ tag,
112
+ commit: headCommit,
113
+ integrity: publishedIntegrity || localIntegrity || '',
114
+ };
115
+ }