wendkeep 0.74.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.
@@ -5,6 +5,8 @@ import {
5
5
  readdirSync,
6
6
  readFileSync,
7
7
  renameSync,
8
+ rmSync,
9
+ statSync,
8
10
  unlinkSync,
9
11
  writeFileSync,
10
12
  } from 'node:fs';
@@ -18,6 +20,7 @@ import { sanitizeObserverContent, sanitizeObserverMetadata } from './observer-pr
18
20
 
19
21
  export const SQL_OUTBOX_REL = '.brain/observer-sql-outbox';
20
22
  export const SQL_STATE_REL = '.brain/observer-sql-state.json';
23
+ export const SQL_PUBLISHER_LEASE_REL = '.brain/observer-sql-publisher.lock';
21
24
  const SQL_SCHEMA_VERSION = 1;
22
25
  export const SQL_EVENT_BATCH_SIZE = 64;
23
26
  export const SQL_EVENT_BATCH_BYTES = 8 * 1024 * 1024;
@@ -158,7 +161,11 @@ function transcriptCalls({ projectId, sessionId, agentId, role, provider, modelF
158
161
  const tokens = tokenPayload(turn.usage);
159
162
  if (!prompt && !response && !tokens.total) return [];
160
163
  const model = text(turn.model || parsed.model || modelFallback, '?');
161
- const callId = eventId('call', projectId, `${transcriptId}:${agentId}:${turn.turnId || index + 1}:${hash(content).slice(0, 16)}`);
164
+ const stableTurn = turn.turnId || index + 1;
165
+ const turnRevision = turn.status === 'complete'
166
+ ? 'complete'
167
+ : hash({ prompt, response, tokens, status: turn.status }).slice(0, 16);
168
+ const callId = eventId('call', projectId, `${transcriptId}:${agentId}:${stableTurn}:${turnRevision}`);
162
169
  return [{
163
170
  schema_version: 1,
164
171
  event_id: eventId('call-event', projectId, callId),
@@ -321,6 +328,136 @@ export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, n
321
328
  return { events: dedupeEvents(events), nextState, scanned: Object.keys(nextState.files).length, changed };
322
329
  }
323
330
 
331
+ function hookEventName(input = {}) {
332
+ return text(input.hook_event_name || input.hookEventName || input.event_name || input.event).toLowerCase();
333
+ }
334
+
335
+ function incrementalSessionContext(vaultBase, input = {}) {
336
+ const registry = readRegistry(vaultBase);
337
+ const requestedId = text(
338
+ input.session_id || input.sessionId || input.thread_id || input.threadId
339
+ || input.conversation_id || input.conversationId,
340
+ );
341
+ const pair = Object.entries(registry.sessions || {}).find(([sessionId, entry]) => (
342
+ (requestedId && sessionId === requestedId)
343
+ || (input.session_file && normalizePath(entry?.session_file) === normalizePath(input.session_file))
344
+ ));
345
+ if (!pair?.[1]?.session_file) return null;
346
+ const logicalPath = normalizePath(pair[1].session_file);
347
+ if (!logicalPath || logicalPath.startsWith('/') || logicalPath.includes('..')) return null;
348
+ const absolute = join(vaultBase, logicalPath);
349
+ if (!existsSync(absolute)) return null;
350
+ const content = readFileSync(absolute, 'utf8');
351
+ return {
352
+ sessionId: pair[0],
353
+ entry: pair[1],
354
+ file: { logicalPath, absolute },
355
+ content,
356
+ fm: parseFrontmatter(content),
357
+ };
358
+ }
359
+
360
+ /** Build only the session/subagent evidence named by the current hook payload. */
361
+ export function buildObserverSqlIncrementalBatch({
362
+ vaultBase,
363
+ projectId,
364
+ input = {},
365
+ now = new Date(),
366
+ state = readState(vaultBase),
367
+ captureLevel = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata',
368
+ } = {}) {
369
+ if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
370
+ const eventName = hookEventName(input);
371
+ const nextState = {
372
+ schema_version: SQL_SCHEMA_VERSION,
373
+ files: { ...(state.files || {}) },
374
+ transcripts: { ...(state.transcripts || {}) },
375
+ };
376
+ if (eventName.includes('sessionstart')) {
377
+ return { events: [], nextState, scanned: 0, changed: 0, scope: 'drain-only' };
378
+ }
379
+ const context = incrementalSessionContext(vaultBase, input);
380
+ if (!context) return { events: [], nextState, scanned: 0, changed: 0, scope: 'drain-only' };
381
+
382
+ const occurredAt = isoNow(now);
383
+ const resolvedCaptureLevel = normalizeObserverCaptureLevel(captureLevel);
384
+ const events = [];
385
+ let changed = 0;
386
+ const safeContent = sanitizeObserverContent(context.content);
387
+ const contentHash = hash(safeContent);
388
+ const previous = state.files?.[context.file.logicalPath];
389
+ const revision = Math.max(1, Number(previous?.revision || 0) + (previous?.content_hash === contentHash ? 0 : 1));
390
+ nextState.files[context.file.logicalPath] = { content_hash: contentHash, revision };
391
+ if (previous?.content_hash !== contentHash) {
392
+ changed += 1;
393
+ events.push(documentEvent({
394
+ projectId,
395
+ logicalPath: context.file.logicalPath,
396
+ content: context.content,
397
+ metadata: context.fm,
398
+ revision,
399
+ occurredAt,
400
+ }));
401
+ const cost = parseSessionCost(context.content)
402
+ || { model: '?', mainCost: 0, subCost: 0, tokens: 0, subTokens: 0, ledger: [] };
403
+ const sessionProjection = sessionEvents({
404
+ projectId,
405
+ logicalPath: context.file.logicalPath,
406
+ content: context.content,
407
+ cost,
408
+ revision,
409
+ sessionId: context.sessionId,
410
+ }).events;
411
+ events.push(...sessionProjection.filter((event) => !(
412
+ eventName.includes('subagentstop') && event.kind === 'transcript.upsert'
413
+ )));
414
+ }
415
+
416
+ const mainAgentId = `${projectId}:${context.sessionId}:main`;
417
+ const provider = text(context.fm.provider);
418
+ const model = text(context.fm.modelo || context.fm.custo_modelo_label, '?');
419
+ let sources = sourceCandidates({
420
+ vaultBase,
421
+ logicalPath: context.file.logicalPath,
422
+ fm: context.fm,
423
+ input,
424
+ });
425
+ if (input.agent_transcript_path || input.agentTranscriptPath || eventName.includes('subagentstop')) {
426
+ sources = sources.filter((source) => source.role === 'subagent').slice(0, 1);
427
+ } else {
428
+ sources = sources.filter((source) => source.role === 'main').slice(0, 1);
429
+ }
430
+ for (const source of sources) {
431
+ const transcriptContent = readFileSync(source.path, 'utf8');
432
+ const fingerprint = hash(transcriptContent);
433
+ const previousTranscript = state.transcripts?.[source.transcriptId];
434
+ if (previousTranscript?.content_hash === fingerprint && previousTranscript?.coverage === resolvedCaptureLevel) continue;
435
+ const complete = completeTranscriptEvents({
436
+ projectId,
437
+ sessionId: context.sessionId,
438
+ mainAgentId,
439
+ provider,
440
+ model,
441
+ source,
442
+ now: occurredAt,
443
+ captureLevel: resolvedCaptureLevel,
444
+ });
445
+ nextState.transcripts[source.transcriptId] = {
446
+ content_hash: complete.fingerprint,
447
+ coverage: resolvedCaptureLevel,
448
+ };
449
+ events.push(...complete.events);
450
+ changed += 1;
451
+ }
452
+ return {
453
+ events: dedupeEvents(events),
454
+ nextState,
455
+ scanned: 1,
456
+ changed,
457
+ scope: `session:${context.sessionId}`,
458
+ };
459
+ }
460
+
324
461
  function queueOutbox(vaultBase, batch) {
325
462
  mkdirSync(outboxDir(vaultBase), { recursive: true });
326
463
  const path = outboxPath(vaultBase, batch);
@@ -328,14 +465,184 @@ function queueOutbox(vaultBase, batch) {
328
465
  return path;
329
466
  }
330
467
 
468
+ function eventCoalesceKey(event) {
469
+ const payload = event.payload || {};
470
+ return [
471
+ event.project_id,
472
+ event.kind,
473
+ payload.logical_path || payload.session_id || payload.agent_id || payload.rollup_key
474
+ || payload.call_id || payload.transcript_id || event.event_id,
475
+ ].join('\u001f');
476
+ }
477
+
478
+ const WAIT_ARRAY = new Int32Array(new SharedArrayBuffer(4));
479
+ function waitSync(milliseconds) { Atomics.wait(WAIT_ARRAY, 0, 0, milliseconds); }
480
+
481
+ function acquireBatchFileLease(path, waitMs = 0) {
482
+ const lock = `${path}.lock`;
483
+ const deadline = Date.now() + Math.max(0, waitMs);
484
+ do {
485
+ try {
486
+ mkdirSync(lock);
487
+ return lock;
488
+ } catch (error) {
489
+ if (error?.code !== 'EEXIST') throw error;
490
+ try {
491
+ if (Date.now() - statSync(lock).mtimeMs > 60_000) {
492
+ const stale = `${lock}.stale-${process.pid}-${Date.now()}`;
493
+ renameSync(lock, stale);
494
+ rmSync(stale, { recursive: true, force: true });
495
+ continue;
496
+ }
497
+ } catch { /* another process recovered or released it */ }
498
+ if (Date.now() >= deadline) return null;
499
+ waitSync(Math.min(5, Math.max(1, deadline - Date.now())));
500
+ }
501
+ } while (Date.now() <= deadline);
502
+ return null;
503
+ }
504
+
505
+ function releaseBatchFileLease(lock) {
506
+ if (lock) rmSync(lock, { recursive: true, force: true });
507
+ }
508
+
509
+ /** Queue a precise writer batch and replace older pending state for the same logical scope. */
510
+ export function enqueueObserverSqlBatch(vaultBase, batch, { scope = 'incremental', now = new Date() } = {}) {
511
+ if (!batch?.project_id || !Array.isArray(batch.events)) throw new Error('Batch incremental do Observer inválido.');
512
+ if (!batch.events.length) return { queued: false, events: 0, path: '' };
513
+ mkdirSync(outboxDir(vaultBase), { recursive: true });
514
+ const path = join(outboxDir(vaultBase), `sql-live-${hash(`${batch.project_id}:${scope}`).slice(0, 24)}.json`);
515
+ const lease = acquireBatchFileLease(path, 200);
516
+ if (!lease) {
517
+ const fallback = queueOutbox(vaultBase, { ...batch, enqueued_at: isoNow(now), scope });
518
+ return { queued: true, events: batch.events.length, path: fallback, coalesced: false };
519
+ }
520
+ try {
521
+ const existing = readJson(path, { events: [] });
522
+ const merged = new Map();
523
+ for (const event of [...(existing.events || []), ...batch.events]) merged.set(eventCoalesceKey(event), event);
524
+ const timestamp = isoNow(now);
525
+ atomicJson(path, {
526
+ schema_version: SQL_SCHEMA_VERSION,
527
+ project_id: batch.project_id,
528
+ scope,
529
+ enqueued_at: existing.enqueued_at || timestamp,
530
+ updated_at: timestamp,
531
+ events: [...merged.values()],
532
+ });
533
+ return { queued: true, events: merged.size, path, coalesced: true };
534
+ } finally {
535
+ releaseBatchFileLease(lease);
536
+ }
537
+ }
538
+
539
+ /** Writer seam for note/archive commands: enqueue exactly one known logical document. */
540
+ export function enqueueObserverDocumentChange({
541
+ vaultBase,
542
+ projectId,
543
+ logicalPath,
544
+ deleted = false,
545
+ now = new Date(),
546
+ } = {}) {
547
+ const normalized = normalizePath(logicalPath);
548
+ if (!vaultBase || !projectId || !normalized || normalized.startsWith('/') || normalized.includes('..')) {
549
+ throw new Error('Documento incremental do Observer inválido.');
550
+ }
551
+ const state = readState(vaultBase);
552
+ const nextState = {
553
+ schema_version: SQL_SCHEMA_VERSION,
554
+ files: { ...(state.files || {}) },
555
+ transcripts: { ...(state.transcripts || {}) },
556
+ };
557
+ const previous = state.files?.[normalized];
558
+ const revision = Math.max(1, Number(previous?.revision || 0) + 1);
559
+ const occurredAt = isoNow(now);
560
+ let event;
561
+ if (deleted) {
562
+ if (!previous) return { queued: false, unchanged: true, events: 0 };
563
+ event = {
564
+ schema_version: 1,
565
+ event_id: eventId('document-delete', projectId, `${normalized}:${revision}`),
566
+ kind: 'document.delete',
567
+ project_id: projectId,
568
+ occurred_at: occurredAt,
569
+ payload: { logical_path: normalized, revision },
570
+ };
571
+ nextState.files[normalized] = { ...previous, revision, deleted: true };
572
+ } else {
573
+ const absolute = join(vaultBase, normalized);
574
+ if (!existsSync(absolute)) return { queued: false, missing: true, events: 0 };
575
+ const content = readFileSync(absolute, 'utf8');
576
+ const metadata = parseFrontmatter(content);
577
+ const contentHash = hash(sanitizeObserverContent(content));
578
+ if (previous?.content_hash === contentHash && previous?.deleted !== true) {
579
+ return { queued: false, unchanged: true, events: 0 };
580
+ }
581
+ event = documentEvent({ projectId, logicalPath: normalized, content, metadata, revision, occurredAt });
582
+ nextState.files[normalized] = { content_hash: contentHash, revision };
583
+ }
584
+ const queued = enqueueObserverSqlBatch(vaultBase, {
585
+ schema_version: SQL_SCHEMA_VERSION,
586
+ project_id: projectId,
587
+ events: [event],
588
+ }, { scope: `document:${normalized}`, now });
589
+ atomicJson(statePath(vaultBase), nextState);
590
+ return { ...queued, event_id: event.event_id, deleted };
591
+ }
592
+
331
593
  export function listSqlOutbox(vaultBase) {
332
594
  const dir = outboxDir(vaultBase);
333
595
  if (!existsSync(dir)) return [];
334
- return readdirSync(dir).filter((name) => /^sql-[a-f0-9]{24}\.json$/.test(name)).sort().flatMap((name) => {
596
+ return readdirSync(dir).filter((name) => /^sql-(?:live-)?[a-f0-9]{24}\.json$/.test(name)).sort().flatMap((name) => {
335
597
  try { return [{ path: join(dir, name), ...JSON.parse(readFileSync(join(dir, name), 'utf8')) }]; } catch { return []; }
336
598
  });
337
599
  }
338
600
 
601
+ export function inspectObserverSqlOutbox(vaultBase, currentTime = Date.now()) {
602
+ const batches = listSqlOutbox(vaultBase);
603
+ let bytes = 0;
604
+ let oldestAt = '';
605
+ let events = 0;
606
+ for (const batch of batches) {
607
+ try { bytes += statSync(batch.path).size; } catch { /* raced with a drain */ }
608
+ events += Array.isArray(batch.events) ? batch.events.length : 0;
609
+ const candidate = text(batch.enqueued_at || batch.updated_at);
610
+ if (candidate && (!oldestAt || candidate < oldestAt)) oldestAt = candidate;
611
+ }
612
+ const oldestAgeMs = oldestAt ? Math.max(0, Number(currentTime) - Date.parse(oldestAt)) : 0;
613
+ return { batches: batches.length, events, bytes, oldest_at: oldestAt, oldest_age_ms: oldestAgeMs };
614
+ }
615
+
616
+ function leasePath(vaultBase) { return join(vaultBase, SQL_PUBLISHER_LEASE_REL); }
617
+
618
+ function acquirePublisherLease(vaultBase, currentTime = Date.now()) {
619
+ const path = leasePath(vaultBase);
620
+ mkdirSync(join(path, '..'), { recursive: true });
621
+ const token = `${process.pid}-${currentTime}-${Math.random().toString(16).slice(2)}`;
622
+ try {
623
+ mkdirSync(path);
624
+ } catch (error) {
625
+ if (error?.code !== 'EEXIST') throw error;
626
+ let age = 0;
627
+ try { age = currentTime - statSync(path).mtimeMs; } catch { return null; }
628
+ if (age <= 60_000) return null;
629
+ const stale = `${path}.stale-${token}`;
630
+ try {
631
+ renameSync(path, stale);
632
+ rmSync(stale, { recursive: true, force: true });
633
+ mkdirSync(path);
634
+ } catch { return null; }
635
+ }
636
+ atomicJson(join(path, 'owner.json'), { token, pid: process.pid, acquired_at: new Date(currentTime).toISOString() });
637
+ return { path, token };
638
+ }
639
+
640
+ function releasePublisherLease(lease) {
641
+ if (!lease) return;
642
+ const owner = readJson(join(lease.path, 'owner.json'), {});
643
+ if (owner.token === lease.token) rmSync(lease.path, { recursive: true, force: true });
644
+ }
645
+
339
646
  async function postSqlChunk({ url, projectId, events, fetchImpl = globalThis.fetch, token = '' }) {
340
647
  const controller = new AbortController();
341
648
  const rawBody = Buffer.from(JSON.stringify({ events }), 'utf8');
@@ -402,17 +709,65 @@ async function postSqlBatch({ url, projectId, events, fetchImpl = globalThis.fet
402
709
  export async function retryObserverSqlOutbox({ vaultBase, projectId, url, fetchImpl = globalThis.fetch, token = process.env.WENDKEEP_OBSERVER_TOKEN || '' } = {}) {
403
710
  const pending = listSqlOutbox(vaultBase);
404
711
  if (!url) return { attempted: 0, confirmed: 0, pending: pending.length };
712
+ const lease = acquirePublisherLease(vaultBase);
713
+ if (!lease) return { attempted: 0, confirmed: 0, pending: pending.length, busy: true };
405
714
  let attempted = 0;
406
715
  let confirmed = 0;
407
- for (const batch of pending) {
408
- attempted += 1;
409
- try {
410
- await postSqlBatch({ url, projectId, events: batch.events, fetchImpl, token });
411
- unlinkSync(batch.path);
412
- confirmed += 1;
413
- } catch { break; }
716
+ try {
717
+ for (const batch of pending) {
718
+ if (batch.project_id && batch.project_id !== projectId) continue;
719
+ const batchLease = acquireBatchFileLease(batch.path);
720
+ if (!batchLease) continue;
721
+ attempted += 1;
722
+ try {
723
+ const current = readJson(batch.path, null);
724
+ if (!current?.events?.length) continue;
725
+ await postSqlBatch({ url, projectId, events: current.events, fetchImpl, token });
726
+ if (existsSync(batch.path)) unlinkSync(batch.path);
727
+ confirmed += 1;
728
+ } catch { break; }
729
+ finally { releaseBatchFileLease(batchLease); }
730
+ }
731
+ return { attempted, confirmed, pending: listSqlOutbox(vaultBase).length, busy: false };
732
+ } finally {
733
+ releasePublisherLease(lease);
414
734
  }
415
- return { attempted, confirmed, pending: listSqlOutbox(vaultBase).length };
735
+ }
736
+
737
+ /** Hook path: enqueue at most one touched session/subagent, then drain under one lease. */
738
+ export async function publishObserverSqlIncremental({
739
+ vaultBase,
740
+ projectId,
741
+ url = process.env.WENDKEEP_OBSERVER_URL || '',
742
+ input = {},
743
+ now = new Date(),
744
+ fetchImpl = globalThis.fetch,
745
+ token = process.env.WENDKEEP_OBSERVER_TOKEN || '',
746
+ captureLevel = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata',
747
+ } = {}) {
748
+ if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
749
+ const state = readState(vaultBase);
750
+ const batch = buildObserverSqlIncrementalBatch({
751
+ vaultBase, projectId, input, now, state, captureLevel,
752
+ });
753
+ const queued = enqueueObserverSqlBatch(vaultBase, {
754
+ schema_version: SQL_SCHEMA_VERSION,
755
+ project_id: projectId,
756
+ events: batch.events,
757
+ }, { scope: batch.scope, now });
758
+ if (batch.events.length) atomicJson(statePath(vaultBase), batch.nextState);
759
+ const replay = await retryObserverSqlOutbox({ vaultBase, projectId, url, fetchImpl, token });
760
+ const pending = listSqlOutbox(vaultBase).length;
761
+ return {
762
+ ok: pending === 0,
763
+ queued: pending > 0,
764
+ scanned: batch.scanned,
765
+ changed: batch.changed,
766
+ enqueued_events: queued.events || 0,
767
+ pending,
768
+ replay,
769
+ hookExitCode: 0,
770
+ };
416
771
  }
417
772
 
418
773
  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' } = {}) {
@@ -423,17 +778,22 @@ export async function publishObserverSql({ vaultBase, projectId, url = process.e
423
778
  ? await readRemoteDocuments({ url, projectId, fetchImpl, token })
424
779
  : {};
425
780
  const batch = buildObserverSqlEventBatch({ vaultBase, projectId, input, now, state, remoteDocuments, captureLevel });
426
- atomicJson(statePath(vaultBase), batch.nextState);
427
- if (!batch.events.length) return { ok: true, queued: false, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay };
781
+ if (!batch.events.length) {
782
+ atomicJson(statePath(vaultBase), batch.nextState);
783
+ return { ok: true, queued: false, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay };
784
+ }
428
785
  if (!url) {
429
786
  queueOutbox(vaultBase, { schema_version: SQL_SCHEMA_VERSION, project_id: projectId, events: batch.events });
787
+ atomicJson(statePath(vaultBase), batch.nextState);
430
788
  return { ok: false, queued: true, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay, hookExitCode: 0 };
431
789
  }
432
790
  try {
433
791
  const response = await postSqlBatch({ url, projectId, events: batch.events, fetchImpl, token });
792
+ atomicJson(statePath(vaultBase), batch.nextState);
434
793
  return { ok: true, queued: false, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay, response };
435
794
  } catch (error) {
436
795
  queueOutbox(vaultBase, { schema_version: SQL_SCHEMA_VERSION, project_id: projectId, events: batch.events });
796
+ atomicJson(statePath(vaultBase), batch.nextState);
437
797
  return { ok: false, queued: true, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay, hookExitCode: 0, error: error.message };
438
798
  }
439
799
  }