wendkeep 0.73.0 → 0.74.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.
@@ -14,6 +14,12 @@ import {
14
14
  sanitizeMemoryText,
15
15
  validateMemoryEvent,
16
16
  } from './memory-schema.mjs';
17
+ import {
18
+ effectiveMemoryScope,
19
+ isRegisterMemoryKey,
20
+ memoryRecordKey,
21
+ sameMemoryScope,
22
+ } from './memory-scope.mjs';
17
23
  import {
18
24
  assertVaultPathSafe, assertVaultPathsSafe, mkdirVaultPath, unlinkVaultFile,
19
25
  VAULT_LOCK_BUSY, withVaultPathLock, writeVaultFileAtomic,
@@ -376,6 +382,7 @@ function sameCompleteCausalLineage(left, right) {
376
382
  }
377
383
 
378
384
  function comparable(left, right) {
385
+ if (left?.project_id !== right?.project_id || !sameMemoryScope(left, right)) return false;
379
386
  if (sameCausalActivation(left, right)) return true;
380
387
  const leftSupersedes = left.supersedes_event_id || left.supersedes;
381
388
  const rightSupersedes = right.supersedes_event_id || right.supersedes;
@@ -387,7 +394,7 @@ function comparable(left, right) {
387
394
  function conflictGroupKey(event) {
388
395
  if (event.operation !== 'replace') return null;
389
396
  if (!Number.isInteger(event.base_revision) || typeof event.base_value_hash !== 'string') return null;
390
- return `${event.memory_key}\u0000${event.base_revision}\u0000${event.base_value_hash}`;
397
+ return `${memoryRecordKey(event)}\u0000${event.base_revision}\u0000${event.base_value_hash}`;
391
398
  }
392
399
 
393
400
  function candidateId(reason, memoryKey, eventIds) {
@@ -421,6 +428,7 @@ function blockedByCoreCandidate(event, coreValue) {
421
428
  reason: 'blocked_by_core',
422
429
  status: 'blocked_by_core',
423
430
  memory_key: event.memory_key,
431
+ ...(event.scope ? { scope: effectiveMemoryScope(event), record_key: memoryRecordKey(event) } : {}),
424
432
  event_ids: [event.event_id],
425
433
  proposed_value: event.value,
426
434
  core_value: coreValue,
@@ -442,6 +450,10 @@ function conflictCandidate(memoryKey, events, currentEvent = null) {
442
450
  candidate_id: candidateId('conflict', memoryKey, eventIds),
443
451
  reason: 'conflict',
444
452
  memory_key: memoryKey,
453
+ ...((ordered[0] || currentEvent)?.scope ? {
454
+ scope: effectiveMemoryScope(ordered[0] || currentEvent || {}),
455
+ record_key: memoryRecordKey(ordered[0] || currentEvent || { memory_key: memoryKey }),
456
+ } : {}),
445
457
  event_ids: eventIds,
446
458
  values: eventIds.map((id) => byId.get(id).value),
447
459
  base_revision: ordered[0]?.base_revision ?? currentEvent?.revision ?? 0,
@@ -459,6 +471,7 @@ function conflictReviewEvent(candidate, candidateCount = 1) {
459
471
  event_id: `mem-review-${candidate.candidate_id}`,
460
472
  project_id: source.project_id || '',
461
473
  memory_key: candidate.memory_key,
474
+ scope: candidate.scope,
462
475
  operation: 'assert',
463
476
  value: `[revisão pendente: ${memoryKey}; candidates: ${candidateCount}; events: ${eventCount}]`,
464
477
  authority: 'candidate',
@@ -508,7 +521,9 @@ function isCausallyOlder(event, current) {
508
521
  if (sameCausalActivation(event, current)) {
509
522
  return Number(event.turn_sequence) < Number(current.turn_sequence);
510
523
  }
511
- if (Number.isInteger(event.activation_epoch) && Number.isInteger(current.activation_epoch)
524
+ if (event.canonical_session_id && event.canonical_session_id === current.canonical_session_id
525
+ && sameMemoryScope(event, current)
526
+ && Number.isInteger(event.activation_epoch) && Number.isInteger(current.activation_epoch)
512
527
  && event.activation_epoch !== current.activation_epoch) {
513
528
  return event.activation_epoch < current.activation_epoch;
514
529
  }
@@ -518,6 +533,30 @@ function isCausallyOlder(event, current) {
518
533
  && eventEffective < currentEffective;
519
534
  }
520
535
 
536
+ const AUTHORITY_RANK = Object.freeze({ candidate: 0, reported: 1, verified: 2 });
537
+
538
+ function sameRegisterLineage(left, right) {
539
+ return Boolean(left?.canonical_session_id)
540
+ && left.canonical_session_id === right?.canonical_session_id
541
+ && left.project_id === right?.project_id
542
+ && sameMemoryScope(left, right);
543
+ }
544
+
545
+ /** Positive means `incoming` is a safe successor; null means human comparison is required. */
546
+ function registerPrecedence(incoming, current) {
547
+ if (!incoming?.scope || !current?.scope
548
+ || !isRegisterMemoryKey(incoming?.memory_key) || !sameRegisterLineage(incoming, current)) return null;
549
+ const epoch = Number(incoming.activation_epoch ?? -1) - Number(current.activation_epoch ?? -1);
550
+ if (epoch) return epoch;
551
+ const turn = Number(incoming.turn_sequence ?? -1) - Number(current.turn_sequence ?? -1);
552
+ if (turn) return turn;
553
+ const authority = (AUTHORITY_RANK[incoming.authority] ?? -1) - (AUTHORITY_RANK[current.authority] ?? -1);
554
+ if (authority) return authority;
555
+ const observed = String(incoming.observed_at || '').localeCompare(String(current.observed_at || ''));
556
+ if (observed) return observed;
557
+ return String(incoming.event_id || '').localeCompare(String(current.event_id || ''));
558
+ }
559
+
521
560
  /**
522
561
  * Pure deterministic reducer. It pre-detects incomparable scalar siblings so replay order
523
562
  * never turns one concurrent writer into an accidental winner.
@@ -537,7 +576,21 @@ export function reduceMemoryEvents(inputEvents = [], {
537
576
  }
538
577
  if (!existing) unique.set(event.event_id, event);
539
578
  }
540
- const events = [...unique.values()].sort(eventOrder);
579
+ const rescopeTargets = new Set(
580
+ [...unique.values()].flatMap((item) => [
581
+ item.rescopes_event_id,
582
+ ...(Array.isArray(item.rescopes_event_ids) ? item.rescopes_event_ids : []),
583
+ ]).filter(Boolean),
584
+ );
585
+ const projectIds = new Set([...unique.values()].map((item) => item.project_id).filter(Boolean));
586
+ if (projectIds.size > 1) {
587
+ const error = new TypeError('Memory reducer cannot compare events from different projects.');
588
+ error.code = 'MEMORY_PROJECT_MIXED';
589
+ throw error;
590
+ }
591
+ const events = [...unique.values()]
592
+ .filter((item) => !rescopeTargets.has(item.event_id))
593
+ .sort(eventOrder);
541
594
  const candidateDecisions = new Map();
542
595
  for (const item of events) {
543
596
  const decision = item.candidate_decision;
@@ -604,7 +657,8 @@ export function reduceMemoryEvents(inputEvents = [], {
604
657
  continue;
605
658
  }
606
659
 
607
- const current = records.get(item.memory_key);
660
+ const recordKey = memoryRecordKey(item);
661
+ const current = records.get(recordKey);
608
662
  const currentSource = current?.source;
609
663
  if (isCausallyOlder(item, currentSource)) {
610
664
  superseded.push({ event_id: item.event_id, by_event_id: currentSource.event_id });
@@ -613,10 +667,12 @@ export function reduceMemoryEvents(inputEvents = [], {
613
667
 
614
668
  if (item.operation === 'assert') {
615
669
  if (current && hashMemoryValue(current.value) !== hashMemoryValue(item.value)) {
616
- if (sameCausalActivation(item, currentSource)
617
- && Number(item.turn_sequence) > Number(currentSource.turn_sequence)) {
618
- records.set(item.memory_key, { value: item.value, revision: current.revision + 1, source: item });
619
- tombstones.delete(item.memory_key);
670
+ const precedence = registerPrecedence(item, currentSource);
671
+ if ((sameCausalActivation(item, currentSource)
672
+ && Number(item.turn_sequence) > Number(currentSource.turn_sequence))
673
+ || precedence > 0) {
674
+ records.set(recordKey, { value: item.value, revision: current.revision + 1, source: item });
675
+ tombstones.delete(recordKey);
620
676
  superseded.push({ event_id: currentSource.event_id, by_event_id: item.event_id });
621
677
  revision += 1;
622
678
  appliedEventIds.push(item.event_id);
@@ -628,8 +684,8 @@ export function reduceMemoryEvents(inputEvents = [], {
628
684
  continue;
629
685
  }
630
686
  if (!current) {
631
- records.set(item.memory_key, { value: item.value, revision: 1, source: item });
632
- tombstones.delete(item.memory_key);
687
+ records.set(recordKey, { value: item.value, revision: 1, source: item });
688
+ tombstones.delete(recordKey);
633
689
  revision += 1;
634
690
  }
635
691
  appliedEventIds.push(item.event_id);
@@ -642,8 +698,8 @@ export function reduceMemoryEvents(inputEvents = [], {
642
698
  const byHash = new Map(oldValues.map((value) => [hashMemoryValue(value), value]));
643
699
  additions.forEach((value) => byHash.set(hashMemoryValue(value), value));
644
700
  const value = [...byHash.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, entry]) => entry);
645
- records.set(item.memory_key, { value, revision: (current?.revision || 0) + 1, source: item });
646
- tombstones.delete(item.memory_key);
701
+ records.set(recordKey, { value, revision: (current?.revision || 0) + 1, source: item });
702
+ tombstones.delete(recordKey);
647
703
  revision += 1;
648
704
  appliedEventIds.push(item.event_id);
649
705
  continue;
@@ -664,13 +720,13 @@ export function reduceMemoryEvents(inputEvents = [], {
664
720
  if (item.value !== null && item.value !== undefined && Array.isArray(current.value)) {
665
721
  const removalHash = hashMemoryValue(item.value);
666
722
  const value = current.value.filter((entry) => hashMemoryValue(entry) !== removalHash);
667
- records.set(item.memory_key, { value, revision: current.revision + 1, source: item });
668
- tombstones.set(`${item.memory_key}:${removalHash}`, {
723
+ records.set(recordKey, { value, revision: current.revision + 1, source: item });
724
+ tombstones.set(`${recordKey}:${removalHash}`, {
669
725
  event_id: item.event_id, removed_event_id: current.source.event_id, value_hash: removalHash,
670
726
  });
671
727
  } else {
672
- records.delete(item.memory_key);
673
- tombstones.set(item.memory_key, {
728
+ records.delete(recordKey);
729
+ tombstones.set(recordKey, {
674
730
  event_id: item.event_id,
675
731
  removed_event_id: current.source.event_id,
676
732
  value_hash: hashMemoryValue(current.value),
@@ -691,8 +747,8 @@ export function reduceMemoryEvents(inputEvents = [], {
691
747
  candidates.push(conflictCandidate(item.memory_key, current ? [currentEventFromRecord(current), item] : [item]));
692
748
  continue;
693
749
  }
694
- records.set(item.memory_key, { value: item.value, revision: current.revision + 1, source: item });
695
- tombstones.delete(item.memory_key);
750
+ records.set(recordKey, { value: item.value, revision: current.revision + 1, source: item });
751
+ tombstones.delete(recordKey);
696
752
  const explicitlySupersededIds = new Set(
697
753
  Array.isArray(item.supersedes)
698
754
  ? item.supersedes
@@ -732,16 +788,16 @@ export function reduceMemoryEvents(inputEvents = [], {
732
788
  advanced = false;
733
789
  for (const pending of deferredAsserts) {
734
790
  if (resolvedCandidateIds.has(pending.candidate.candidate_id)) continue;
735
- const current = records.get(pending.event.memory_key);
791
+ const current = records.get(memoryRecordKey(pending.event));
736
792
  const currentSource = current?.source;
737
793
  if (!sameCompleteCausalLineage(pending.event, currentSource)) continue;
738
794
  if (pending.event.turn_sequence > currentSource.turn_sequence) {
739
- records.set(pending.event.memory_key, {
795
+ records.set(memoryRecordKey(pending.event), {
740
796
  value: pending.event.value,
741
797
  revision: current.revision + 1,
742
798
  source: pending.event,
743
799
  });
744
- tombstones.delete(pending.event.memory_key);
800
+ tombstones.delete(memoryRecordKey(pending.event));
745
801
  superseded.push({ event_id: currentSource.event_id, by_event_id: pending.event.event_id });
746
802
  revision += 1;
747
803
  appliedEventIds.push(pending.event.event_id);
@@ -762,7 +818,7 @@ export function reduceMemoryEvents(inputEvents = [], {
762
818
  .map((candidate) => {
763
819
  const pending = pendingByCandidateId.get(candidate.candidate_id);
764
820
  if (!pending) return candidate;
765
- const finalSource = currentEventFromRecord(records.get(candidate.memory_key));
821
+ const finalSource = currentEventFromRecord(records.get(candidate.record_key || candidate.memory_key));
766
822
  const previousSource = candidate.events?.find(
767
823
  (event) => event.event_id !== pending.event.event_id,
768
824
  );
@@ -791,10 +847,16 @@ export function reduceMemoryEvents(inputEvents = [], {
791
847
  superseded.sort((left, right) => left.event_id.localeCompare(right.event_id));
792
848
  const eventCursor = events.at(-1)?.event_id || 'none';
793
849
  const stateHash = hashMemoryValue({ state, tombstones: tombstoneObject });
850
+ const ambiguousRecordKeys = new Set(
851
+ unresolvedCandidates.map((candidate) => candidate.record_key || candidate.memory_key),
852
+ );
794
853
  const activeEvents = [
795
- ...Object.entries(recordObject).map(([memoryKey, record]) => ({
854
+ ...Object.entries(recordObject)
855
+ .filter(([recordKey]) => !ambiguousRecordKeys.has(recordKey))
856
+ .map(([recordKey, record]) => ({
796
857
  ...record.source,
797
- memory_key: memoryKey,
858
+ memory_key: record.source.memory_key,
859
+ projection_key: recordKey,
798
860
  operation: 'assert',
799
861
  value: record.value,
800
862
  })),
@@ -0,0 +1,25 @@
1
+ CREATE TABLE IF NOT EXISTS document_chunks (
2
+ chunk_id TEXT PRIMARY KEY,
3
+ project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
4
+ logical_path TEXT NOT NULL,
5
+ title TEXT NOT NULL DEFAULT '',
6
+ heading TEXT NOT NULL DEFAULT '',
7
+ entity_type TEXT NOT NULL DEFAULT 'document',
8
+ change_slug TEXT NOT NULL DEFAULT '',
9
+ session_id TEXT NOT NULL DEFAULT '',
10
+ work_session_id TEXT NOT NULL DEFAULT '',
11
+ authority TEXT NOT NULL DEFAULT 'candidate',
12
+ observed_at TEXT NOT NULL,
13
+ validity TEXT NOT NULL DEFAULT 'active',
14
+ content_hash TEXT NOT NULL,
15
+ ordinal INTEGER NOT NULL DEFAULT 0,
16
+ content TEXT NOT NULL,
17
+ FOREIGN KEY(project_id, logical_path) REFERENCES documents(project_id, logical_path) ON DELETE CASCADE,
18
+ UNIQUE(project_id, logical_path, ordinal)
19
+ );
20
+
21
+ CREATE INDEX IF NOT EXISTS idx_document_chunks_project_path
22
+ ON document_chunks(project_id, logical_path, ordinal);
23
+
24
+ CREATE INDEX IF NOT EXISTS idx_document_chunks_project_type
25
+ ON document_chunks(project_id, entity_type, validity, observed_at);
package/src/memory.mjs CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  } from './validate-memory.mjs';
21
21
  import { validateCore } from './validate-core.mjs';
22
22
  import { checkMemoryBundle } from '../hooks/vault-health.mjs';
23
+ import { scopeForMemoryKey } from '../hooks/memory-scope.mjs';
23
24
 
24
25
  const BRAIN = '.brain';
25
26
  const LEDGER = 'MEMORY_EVENTS.jsonl';
@@ -229,6 +230,92 @@ export function migrateMemory(vault, {
229
230
  }
230
231
  }
231
232
 
233
+ function rescopeEventId(sourceEventId, scope) {
234
+ return `mem-rescope-${hash(`${sourceEventId}\0${scope.type}\0${scope.id}`).slice(0, 24)}`;
235
+ }
236
+
237
+ /** Plan an append-only migration without choosing a winner for ambiguous keys. */
238
+ export function planScopedMemoryMigration(vault) {
239
+ const ledger = readMemoryLedger(vault);
240
+ if (ledger.status !== 'ok') throw new Error('Ledger corrompido; execute memory repair antes do rescope.');
241
+ const projection = deriveMemoryProjection(vault, ledger.events);
242
+ const ambiguous = new Set(projection.candidates.map((candidate) => candidate.record_key || candidate.memory_key));
243
+ const existingIds = new Set(ledger.events.map((event) => event.event_id));
244
+ const planned = [];
245
+
246
+ for (const [recordKey, record] of Object.entries(projection.records)) {
247
+ if (ambiguous.has(recordKey) || record.source?.scope) continue;
248
+ const source = record.source;
249
+ const sameLegacyKey = ledger.events.filter((event) => (
250
+ !event.scope && event.memory_key === source.memory_key && !event.rescopes_event_id
251
+ ));
252
+ const scope = scopeForMemoryKey(source.memory_key, {
253
+ ...source,
254
+ projectId: source.project_id,
255
+ workSessionId: source.work_session_id,
256
+ branch: source.value?.branch || source.value?.branch_name,
257
+ worktreeId: source.value?.worktree_id,
258
+ repositoryId: source.value?.repository_id,
259
+ });
260
+ const eventId = rescopeEventId(source.event_id, scope);
261
+ if (existingIds.has(eventId)) continue;
262
+ planned.push({
263
+ v: 1,
264
+ event_id: eventId,
265
+ project_id: source.project_id,
266
+ memory_key: source.memory_key,
267
+ scope,
268
+ operation: 'assert',
269
+ value: record.value,
270
+ authority: source.authority,
271
+ canonical_session_id: source.canonical_session_id || 'memory-rescope',
272
+ activation_id: source.activation_id || 'memory-rescope',
273
+ activation_epoch: Number.isInteger(source.activation_epoch) ? source.activation_epoch : 0,
274
+ turn_sequence: Number.isInteger(source.turn_sequence) ? source.turn_sequence : 0,
275
+ source_turn_id: source.source_turn_id || 'memory-rescope',
276
+ observed_at: source.observed_at || new Date(0).toISOString(),
277
+ evidence: [...new Set([...(source.evidence || []), `memory-rescope:${source.event_id}`])],
278
+ rescopes_event_id: source.event_id,
279
+ rescopes_event_ids: sameLegacyKey.map((event) => event.event_id).sort(),
280
+ });
281
+ }
282
+
283
+ return {
284
+ status: 'dry-run',
285
+ ledger_events: ledger.events.length,
286
+ planned: planned.length,
287
+ ambiguous: projection.candidates.length,
288
+ events: planned,
289
+ };
290
+ }
291
+
292
+ export function rescopeMemoryEvents(vault, { apply = false } = {}) {
293
+ projectId(vault);
294
+ const plan = planScopedMemoryMigration(vault);
295
+ if (!apply) {
296
+ return {
297
+ status: 'dry-run',
298
+ ledger_events: plan.ledger_events,
299
+ planned: plan.planned,
300
+ ambiguous: plan.ambiguous,
301
+ scopes: plan.events.map((event) => ({
302
+ event_id: event.event_id, memory_key: event.memory_key, scope: event.scope,
303
+ })),
304
+ };
305
+ }
306
+ if (!plan.events.length) {
307
+ return { status: 'unchanged', migrated: 0, ambiguous: plan.ambiguous };
308
+ }
309
+ for (const event of plan.events) enqueueMemoryEvent(vault, event);
310
+ const projection = projectMemoryOutbox(vault);
311
+ return {
312
+ status: projection.status === 'projected' ? 'migrated' : projection.status,
313
+ migrated: plan.events.length,
314
+ ambiguous: plan.ambiguous,
315
+ checkpoint: projection.checkpoint || null,
316
+ };
317
+ }
318
+
232
319
  function readCandidates(vault) {
233
320
  const path = brainPath(vault, CANDIDATES);
234
321
  const checked = checkedVaultFile(vault, path, 'MEMORY_CANDIDATES.jsonl');
@@ -266,6 +353,7 @@ function sanitizedCandidate(candidate, index) {
266
353
  reason: candidate.reason,
267
354
  status: candidate.status || 'active',
268
355
  memory_key: candidate.memory_key,
356
+ ...(candidate.scope ? { scope: candidate.scope } : {}),
269
357
  event_ids: [...eventIds].sort(lexicalCompare),
270
358
  };
271
359
  }
@@ -328,6 +416,7 @@ export function listMemoryCandidatesForCuration(vault) {
328
416
  reason: safe.reason,
329
417
  status: safe.status,
330
418
  memory_key: safe.memory_key,
419
+ ...(safe.scope ? { scope: safe.scope } : {}),
331
420
  events: safe.event_ids.map((eventId) => sanitizedCurationEvent(candidate, eventId, index)),
332
421
  };
333
422
  })
@@ -388,7 +477,7 @@ function promotedSupersedes(vault, candidate, selected) {
388
477
  const ledger = readMemoryLedger(vault);
389
478
  if (ledger.status !== 'ok') throw new Error('Ledger de memória inválido durante a promoção.');
390
479
  const projection = deriveMemoryProjection(vault, ledger.events);
391
- const current = projection.records?.[candidate.memory_key]?.source;
480
+ const current = projection.records?.[candidate.record_key || candidate.memory_key]?.source;
392
481
  if (!current?.event_id) return [...memberIds].sort();
393
482
  if (memberIds.has(current.event_id)) {
394
483
  projection.superseded
@@ -572,6 +661,9 @@ export function decideMemoryCandidate(vault, {
572
661
  event_id: `cli-${action}-${hash(`${candidateId}\0${selected?.event_id || ''}`).slice(0, 20)}`,
573
662
  project_id: projectId(vault),
574
663
  memory_key: action === 'promote' ? candidate.memory_key : `candidate.decision.${candidateId}`,
664
+ scope: action === 'promote'
665
+ ? (candidate.scope || selected?.scope || { type: 'project', id: projectId(vault) })
666
+ : { type: 'project', id: projectId(vault) },
575
667
  operation: action === 'promote' && selected ? 'replace' : 'assert',
576
668
  value: action === 'promote' ? promotedMemoryValue(selectedValue) : 'rejected',
577
669
  authority: 'verified',
@@ -2087,6 +2179,7 @@ export function runMemory(argv) {
2087
2179
  result = listMemoryCandidates(vault, { activeOnly: candidatesArgs.activeOnly });
2088
2180
  }
2089
2181
  else if (sub === 'migrate') result = migrateMemory(vault, { apply: argv.includes('--apply') });
2182
+ else if (sub === 'rescope') result = rescopeMemoryEvents(vault, { apply: argv.includes('--apply') });
2090
2183
  else if (sub === 'repair') result = repairMemory(vault);
2091
2184
  else if (sub === 'reconcile') {
2092
2185
  result = reconcileMemory(vault, {
@@ -2109,7 +2202,7 @@ export function runMemory(argv) {
2109
2202
  action: sub, candidateId: positional, ...(eventId ? { eventId } : {}),
2110
2203
  });
2111
2204
  }
2112
- else { process.stderr.write('wendkeep memory: use status | candidates [--active] | migrate [--apply] | repair | recover-attempt <session> [--apply] | reconcile <session> --by-session <session> --reason <text> [--apply] | promote <candidate> [--event <event-id>] | reject <candidate>.\n'); process.exitCode = 2; return; }
2205
+ else { process.stderr.write('wendkeep memory: use status | candidates [--active] | curate | migrate [--apply] | rescope [--apply] | repair | recover-attempt <session> [--apply] | reconcile <session> --by-session <session> --reason <text> [--apply] | promote <candidate> [--event <event-id>] | reject <candidate>.\n'); process.exitCode = 2; return; }
2113
2206
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
2114
2207
  if (sub === 'status' && argv.includes('--gate')) process.exitCode = result.status === 'blocked' ? 1 : 0;
2115
2208
  else if (sub === 'reconcile' && reconcileArgs.apply) process.exitCode = result.health?.status === 'blocked' ? 1 : 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 = 4;
10
13
  export const OBSERVER_EVENT_SCHEMA_VERSION = 1;
11
14
 
12
15
  const SCHEMA_DIR = fileURLToPath(new URL('../schema/observer/', import.meta.url));
@@ -117,12 +120,106 @@ export function migrateObserverDatabase(db) {
117
120
  throw error;
118
121
  }
119
122
  }
123
+ rebuildSqlEvidenceIndex(db, { missingOnly: true });
120
124
  return {
121
125
  version: Number(db.prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations').get().version || 0),
122
126
  applied: db.prepare('SELECT version, name, applied_at FROM schema_migrations ORDER BY version').all(),
123
127
  };
124
128
  }
125
129
 
130
+ export function observerFts5Support(db) {
131
+ try {
132
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS evidence_chunks_fts USING fts5(
133
+ chunk_id UNINDEXED,
134
+ project_id UNINDEXED,
135
+ logical_path,
136
+ title,
137
+ heading,
138
+ content,
139
+ tokenize = 'unicode61 remove_diacritics 2'
140
+ )`);
141
+ return { supported: true, engine: 'fts5' };
142
+ } catch (error) {
143
+ return { supported: false, engine: 'lexical-fallback', reason: text(error?.message || error) };
144
+ }
145
+ }
146
+
147
+ function synchronizeSqlFts(db) {
148
+ const support = observerFts5Support(db);
149
+ if (!support.supported) return support;
150
+ const chunks = Number(db.prepare('SELECT COUNT(*) AS count FROM document_chunks').get().count || 0);
151
+ const indexed = Number(db.prepare('SELECT COUNT(*) AS count FROM evidence_chunks_fts').get().count || 0);
152
+ if (chunks === indexed) return support;
153
+ db.exec(`DELETE FROM evidence_chunks_fts;
154
+ INSERT INTO evidence_chunks_fts(chunk_id, project_id, logical_path, title, heading, content)
155
+ SELECT chunk_id, project_id, logical_path, title, heading, content
156
+ FROM document_chunks;`);
157
+ return { ...support, rebuilt: true, chunks };
158
+ }
159
+
160
+ function writeSqlDocumentChunks(db, {
161
+ projectId, logicalPath, content, metadata = {}, entityType = 'document', capturedAt = '',
162
+ } = {}) {
163
+ const chunks = chunkMarkdownDocument({
164
+ projectId,
165
+ logicalPath,
166
+ content,
167
+ metadata: { ...metadata, observed_at: metadata.observed_at || capturedAt },
168
+ entityType,
169
+ });
170
+ db.prepare('DELETE FROM document_chunks WHERE project_id = ? AND logical_path = ?').run(projectId, logicalPath);
171
+ const fts = observerFts5Support(db);
172
+ if (fts.supported) {
173
+ db.prepare('DELETE FROM evidence_chunks_fts WHERE project_id = ? AND logical_path = ?').run(projectId, logicalPath);
174
+ }
175
+ const insert = db.prepare(`INSERT INTO document_chunks(
176
+ chunk_id, project_id, logical_path, title, heading, entity_type, change_slug,
177
+ session_id, work_session_id, authority, observed_at, validity, content_hash, ordinal, content
178
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
179
+ const insertFts = fts.supported
180
+ ? db.prepare('INSERT INTO evidence_chunks_fts(chunk_id, project_id, logical_path, title, heading, content) VALUES (?, ?, ?, ?, ?, ?)')
181
+ : null;
182
+ for (const chunk of chunks) {
183
+ insert.run(
184
+ chunk.chunk_id, chunk.project_id, chunk.logical_path, chunk.title, chunk.heading,
185
+ chunk.entity_type, chunk.change_slug, chunk.session_id, chunk.work_session_id,
186
+ chunk.authority, chunk.observed_at, chunk.validity, chunk.content_hash,
187
+ chunk.ordinal, chunk.content,
188
+ );
189
+ insertFts?.run(chunk.chunk_id, chunk.project_id, chunk.logical_path, chunk.title, chunk.heading, chunk.content);
190
+ }
191
+ return { chunks: chunks.length, fts };
192
+ }
193
+
194
+ export function rebuildSqlEvidenceIndex(db, { missingOnly = false } = {}) {
195
+ const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'document_chunks'").get();
196
+ if (!table) return { documents: 0, chunks: 0, fts: { supported: false, engine: 'unavailable' } };
197
+ const documents = db.prepare(`SELECT d.project_id, d.logical_path, d.content, d.metadata_json, d.entity_type, d.captured_at
198
+ FROM documents d
199
+ WHERE d.deleted_at IS NULL
200
+ AND (? = 0 OR NOT EXISTS (
201
+ SELECT 1 FROM document_chunks c
202
+ WHERE c.project_id = d.project_id AND c.logical_path = d.logical_path
203
+ ))
204
+ ORDER BY d.project_id, d.logical_path`).all(missingOnly ? 1 : 0);
205
+ let chunks = 0;
206
+ let fts = observerFts5Support(db);
207
+ for (const document of documents) {
208
+ const result = writeSqlDocumentChunks(db, {
209
+ projectId: document.project_id,
210
+ logicalPath: document.logical_path,
211
+ content: document.content,
212
+ metadata: parseJson(document.metadata_json),
213
+ entityType: document.entity_type,
214
+ capturedAt: document.captured_at,
215
+ });
216
+ chunks += result.chunks;
217
+ fts = result.fts;
218
+ }
219
+ fts = synchronizeSqlFts(db);
220
+ return { documents: documents.length, chunks, fts };
221
+ }
222
+
126
223
  export function ensureObserverDatabase(dataDir) {
127
224
  const db = openObserverDatabase(dataDir);
128
225
  try {
@@ -266,6 +363,14 @@ function applyDocument(db, event) {
266
363
  VALUES (?, ?, ?, ?, 'upsert', ?, ?, ?, ?, ?, ?)
267
364
  `).run(event.event_id, event.project_id, text(p.entity_type || p.entityType, 'memory'), logicalPath, revision, contentHash,
268
365
  text(p.source_session_id || p.sourceSessionId), text(p.source_turn_id || p.sourceTurnId), event.occurred_at, json(p));
366
+ writeSqlDocumentChunks(db, {
367
+ projectId: event.project_id,
368
+ logicalPath,
369
+ content,
370
+ metadata: p.metadata,
371
+ entityType: text(p.entity_type || p.entityType, 'memory'),
372
+ capturedAt: text(p.captured_at || event.occurred_at),
373
+ });
269
374
  return { stale: false };
270
375
  }
271
376
 
@@ -279,6 +384,10 @@ function applyDocumentDelete(db, event) {
279
384
  VALUES (?, ?, ?, ?, 'delete', ?, '', ?, ?, ?, ?)
280
385
  `).run(event.event_id, event.project_id, text(event.payload.entity_type || event.payload.entityType, 'memory'), logicalPath,
281
386
  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));
387
+ db.prepare('DELETE FROM document_chunks WHERE project_id = ? AND logical_path = ?').run(event.project_id, logicalPath);
388
+ if (observerFts5Support(db).supported) {
389
+ db.prepare('DELETE FROM evidence_chunks_fts WHERE project_id = ? AND logical_path = ?').run(event.project_id, logicalPath);
390
+ }
282
391
  return { stale: false };
283
392
  }
284
393
 
@@ -542,11 +651,38 @@ export function readSqlTree(db, projectId, prefix = '') {
542
651
  return { schema_version: OBSERVER_SQL_SCHEMA_VERSION, project_id: projectId, prefix, documents: rows };
543
652
  }
544
653
 
545
- export function searchSqlDocuments(db, projectId, query = '') {
654
+ export function searchSqlDocuments(db, projectId, query = '', { forceLexical = false } = {}) {
546
655
  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) }));
656
+ const terms = recallTerms(query);
657
+ if (!terms.length) return [];
658
+ const fts = forceLexical
659
+ ? { supported: false, engine: 'lexical-fallback' }
660
+ : observerFts5Support(db);
661
+ const columns = `c.chunk_id, c.project_id, c.logical_path, c.title, c.heading,
662
+ c.entity_type, c.change_slug, c.session_id, c.work_session_id, c.authority,
663
+ c.observed_at, c.validity, c.content_hash AS chunk_content_hash, c.ordinal, c.content,
664
+ d.content_hash, d.revision, d.captured_at, d.source_session_id`;
665
+ let rows;
666
+ if (fts.supported) {
667
+ const expression = [...new Set(terms)]
668
+ .map((term) => `"${term.replaceAll('"', '""')}"`)
669
+ .join(' OR ');
670
+ rows = db.prepare(`SELECT ${columns} FROM evidence_chunks_fts f
671
+ JOIN document_chunks c ON c.chunk_id = f.chunk_id
672
+ JOIN documents d ON d.project_id = c.project_id AND d.logical_path = c.logical_path
673
+ WHERE evidence_chunks_fts MATCH ? AND c.project_id = ? AND d.deleted_at IS NULL
674
+ ORDER BY bm25(evidence_chunks_fts, 0, 0, 1.5, 3.0, 2.5, 1.0), c.observed_at DESC
675
+ LIMIT 200`).all(expression, projectId);
676
+ } else {
677
+ // FTS5 may be unavailable in a valid Observer runtime. Rank the complete project corpus
678
+ // instead of truncating by recency before matching, which would make old exact evidence
679
+ // permanently unreachable.
680
+ rows = db.prepare(`SELECT ${columns} FROM document_chunks c
681
+ JOIN documents d ON d.project_id = c.project_id AND d.logical_path = c.logical_path
682
+ WHERE c.project_id = ? AND d.deleted_at IS NULL
683
+ ORDER BY c.observed_at DESC, c.logical_path, c.ordinal`).all(projectId);
684
+ }
685
+ return recallEvidence(rows, query, { topK: 5 });
550
686
  }
551
687
 
552
688
  export function readSqlSync(db, projectId) {
package/src/taxonomy.mjs CHANGED
@@ -65,6 +65,8 @@ export const HOOK_FILES = [
65
65
  'subagent-usage.mjs',
66
66
  'pricing.json',
67
67
  'brain-core.mjs',
68
+ 'evidence-recall.mjs',
69
+ 'memory-scope.mjs',
68
70
  'memory-schema.mjs',
69
71
  'memory-store.mjs',
70
72
  'memory-handoff.mjs',
@@ -83,6 +85,7 @@ export const HOOK_FILES = [
83
85
  'harness-doctor.mjs',
84
86
  'lessons-core.mjs',
85
87
  'brain-inject.mjs',
88
+ 'evidence-context.mjs',
86
89
  'brain-recall.mjs',
87
90
  'brain-reindex.mjs',
88
91
  'session-backfill.mjs',
@@ -114,6 +117,7 @@ export const RUNNABLE_HOOKS = [
114
117
  'subagent-stop',
115
118
  'task-log',
116
119
  'brain-inject',
120
+ 'evidence-context',
117
121
  'brain-recall',
118
122
  'brain-reindex',
119
123
  'vault-health',