memorix 1.2.1 → 1.2.3

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 (90) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +14 -2
  3. package/README.zh-CN.md +14 -2
  4. package/dist/cli/index.js +15424 -13780
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/index.js +1337 -536
  7. package/dist/index.js.map +1 -1
  8. package/dist/maintenance-runner.d.ts +1 -1
  9. package/dist/maintenance-runner.js +8458 -8087
  10. package/dist/maintenance-runner.js.map +1 -1
  11. package/dist/memcode-runtime/CHANGELOG.md +23 -0
  12. package/dist/sdk.d.ts +7 -2
  13. package/dist/sdk.js +1365 -542
  14. package/dist/sdk.js.map +1 -1
  15. package/dist/types.d.ts +49 -1
  16. package/dist/types.js.map +1 -1
  17. package/docs/1.2.2-MEMORY-CONTROL-PLANE.md +434 -0
  18. package/docs/AGENT_OPERATOR_PLAYBOOK.md +4 -0
  19. package/docs/API_REFERENCE.md +24 -4
  20. package/docs/README.md +1 -1
  21. package/docs/dev-log/progress.txt +101 -11
  22. package/package.json +1 -1
  23. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  24. package/src/cli/command-guide.ts +192 -0
  25. package/src/cli/commands/audit.ts +9 -4
  26. package/src/cli/commands/cleanup.ts +5 -1
  27. package/src/cli/commands/codegraph.ts +15 -5
  28. package/src/cli/commands/context.ts +3 -2
  29. package/src/cli/commands/doctor.ts +4 -2
  30. package/src/cli/commands/explain.ts +9 -3
  31. package/src/cli/commands/handoff.ts +21 -7
  32. package/src/cli/commands/identity.ts +116 -0
  33. package/src/cli/commands/ingest-image.ts +5 -3
  34. package/src/cli/commands/lock.ts +11 -10
  35. package/src/cli/commands/memory.ts +58 -21
  36. package/src/cli/commands/message.ts +19 -14
  37. package/src/cli/commands/operator-shared.ts +98 -3
  38. package/src/cli/commands/poll.ts +16 -6
  39. package/src/cli/commands/reasoning.ts +17 -3
  40. package/src/cli/commands/retention.ts +9 -4
  41. package/src/cli/commands/serve-http.ts +8 -2
  42. package/src/cli/commands/session.ts +44 -10
  43. package/src/cli/commands/skills.ts +10 -5
  44. package/src/cli/commands/status.ts +4 -3
  45. package/src/cli/commands/task.ts +26 -17
  46. package/src/cli/commands/team.ts +14 -10
  47. package/src/cli/commands/transfer.ts +63 -10
  48. package/src/cli/identity.ts +89 -0
  49. package/src/cli/index.ts +96 -19
  50. package/src/cli/invocation.ts +115 -0
  51. package/src/cli/tui/chat-service.ts +41 -18
  52. package/src/cli/tui/data.ts +23 -44
  53. package/src/cli/tui/operator-context.ts +60 -0
  54. package/src/cli/tui/session-service.ts +3 -2
  55. package/src/cli/tui/views/MemoryView.tsx +10 -8
  56. package/src/codegraph/auto-context.ts +31 -2
  57. package/src/codegraph/context-pack.ts +1 -0
  58. package/src/codegraph/project-context.ts +2 -0
  59. package/src/compact/engine.ts +26 -10
  60. package/src/compact/index-format.ts +25 -2
  61. package/src/dashboard/server.ts +46 -9
  62. package/src/hooks/admission.ts +117 -0
  63. package/src/hooks/handler.ts +98 -91
  64. package/src/knowledge/context-assembly.ts +97 -0
  65. package/src/knowledge/workset.ts +179 -10
  66. package/src/memory/admission.ts +57 -0
  67. package/src/memory/consolidation.ts +13 -2
  68. package/src/memory/disclosure-policy.ts +6 -1
  69. package/src/memory/export-import.ts +11 -3
  70. package/src/memory/graph-context.ts +8 -2
  71. package/src/memory/observations.ts +162 -4
  72. package/src/memory/quality-audit.ts +2 -0
  73. package/src/memory/retention.ts +22 -2
  74. package/src/memory/session.ts +29 -11
  75. package/src/memory/visibility.ts +80 -0
  76. package/src/orchestrate/memorix-bridge.ts +38 -0
  77. package/src/runtime/control-plane-maintenance.ts +1 -0
  78. package/src/runtime/isolated-maintenance.ts +1 -0
  79. package/src/runtime/lifecycle.ts +18 -0
  80. package/src/runtime/maintenance-jobs.ts +1 -0
  81. package/src/runtime/maintenance-runner.ts +2 -0
  82. package/src/runtime/project-maintenance.ts +89 -0
  83. package/src/sdk.ts +35 -5
  84. package/src/server.ts +267 -83
  85. package/src/store/orama-store.ts +61 -6
  86. package/src/store/sqlite-db.ts +23 -1
  87. package/src/store/sqlite-store.ts +12 -2
  88. package/src/team/handoff.ts +7 -0
  89. package/src/types.ts +51 -0
  90. package/src/wiki/generator.ts +2 -0
@@ -8,7 +8,16 @@
8
8
  * and in the Orama search index (for full-text + vector search).
9
9
  */
10
10
 
11
- import type { Observation, ObservationType, ObservationStatus, MemorixDocument, ProgressInfo } from '../types.js';
11
+ import type {
12
+ Observation,
13
+ ObservationAdmissionState,
14
+ ObservationReader,
15
+ ObservationVisibility,
16
+ ObservationType,
17
+ ObservationStatus,
18
+ MemorixDocument,
19
+ ProgressInfo,
20
+ } from '../types.js';
12
21
  import { TOPIC_KEY_FAMILIES } from '../types.js';
13
22
  import {
14
23
  insertObservation,
@@ -25,13 +34,15 @@ import {
25
34
  makeOramaObservationId,
26
35
  getLastSearchMode,
27
36
  searchObservations,
37
+ updateObservationMetadata,
28
38
  } from '../store/orama-store.js';
29
39
  import { getObservationStore, initObservationStore } from '../store/obs-store.js';
30
40
  import { countTextTokens } from '../compact/token-budget.js';
31
41
  import { extractEntities, enrichConcepts } from './entity-extractor.js';
32
42
  import { getEmbeddingProvider, isEmbeddingExplicitlyDisabled } from '../embedding/provider.js';
33
43
  import { sanitizeCredentials } from './secret-filter.js';
34
- import { enqueueClaimDerivation } from '../runtime/lifecycle.js';
44
+ import { enqueueClaimDerivation, enqueueObservationQualification } from '../runtime/lifecycle.js';
45
+ import { canManageObservation, resolveObservationVisibility } from './visibility.js';
35
46
 
36
47
  /** In-memory observation list (loaded from persistence on init) */
37
48
  let observations: Observation[] = [];
@@ -137,6 +148,20 @@ function queueClaimDerivation(observation: Observation): void {
137
148
  }
138
149
  }
139
150
 
151
+ function queueObservationQualification(observation: Observation): void {
152
+ const dataDir = projectDir;
153
+ if (!dataDir || observation.admissionState !== 'candidate') return;
154
+ try {
155
+ enqueueObservationQualification({
156
+ dataDir,
157
+ projectId: observation.projectId,
158
+ source: 'automatic-capture:' + observation.id,
159
+ });
160
+ } catch {
161
+ // Candidate evidence remains available for a later qualification scan.
162
+ }
163
+ }
164
+
140
165
  function isVectorCompatibleWithCurrentIndex(embedding: number[] | null): boolean {
141
166
  if (!embedding) return false;
142
167
  const vectorDimensions = getVectorDimensions();
@@ -253,7 +278,13 @@ export async function storeObservation(input: {
253
278
  relatedEntities?: string[];
254
279
  sourceDetail?: 'explicit' | 'hook' | 'git-ingest';
255
280
  valueCategory?: 'core' | 'contextual' | 'ephemeral';
281
+ admissionState?: ObservationAdmissionState;
282
+ admissionReason?: string;
283
+ visibility?: ObservationVisibility;
284
+ sharedWithAgentIds?: string[];
256
285
  createdByAgentId?: string;
286
+ /** Optional caller-bound write scope for topic-key upserts. */
287
+ visibilityReader?: ObservationReader;
257
288
  }): Promise<{ observation: Observation; upserted: boolean }> {
258
289
  const now = new Date().toISOString();
259
290
 
@@ -273,6 +304,9 @@ export async function storeObservation(input: {
273
304
  o => o.topicKey === input.topicKey && o.projectId === input.projectId,
274
305
  );
275
306
  if (existing) {
307
+ if (input.visibilityReader && !canManageObservation(existing, input.visibilityReader)) {
308
+ throw new Error('Cannot update a memory outside this session\'s write scope.');
309
+ }
276
310
  return { observation: await upsertObservation(existing, input, now), upserted: true };
277
311
  }
278
312
  }
@@ -320,6 +354,9 @@ export async function storeObservation(input: {
320
354
  if (input.topicKey) {
321
355
  const diskExisting = await tx.findByTopicKey(input.projectId, input.topicKey);
322
356
  if (diskExisting) {
357
+ if (input.visibilityReader && !canManageObservation(diskExisting, input.visibilityReader)) {
358
+ throw new Error('Cannot update a memory outside this session\'s write scope.');
359
+ }
323
360
  // Switch to upsert path — update the existing observation after this
324
361
  // short transaction has released its lock.
325
362
  upsertedInsideLock = true;
@@ -355,6 +392,10 @@ export async function storeObservation(input: {
355
392
  relatedEntities: input.relatedEntities,
356
393
  sourceDetail: input.sourceDetail,
357
394
  valueCategory: input.valueCategory,
395
+ admissionState: input.admissionState,
396
+ admissionReason: input.admissionReason ? sanitizeCredentials(input.admissionReason) : undefined,
397
+ visibility: input.visibility ?? 'project',
398
+ sharedWithAgentIds: input.sharedWithAgentIds,
358
399
  createdByAgentId: input.createdByAgentId,
359
400
  // Predict the generation that atomic() will commit after this callback.
360
401
  // bumpGeneration() runs after fn(tx) returns, incrementing by 1.
@@ -409,6 +450,10 @@ export async function storeObservation(input: {
409
450
  relatedEntities: input.relatedEntities,
410
451
  sourceDetail: input.sourceDetail,
411
452
  valueCategory: input.valueCategory,
453
+ admissionState: input.admissionState,
454
+ admissionReason: input.admissionReason ? sanitizeCredentials(input.admissionReason) : undefined,
455
+ visibility: input.visibility ?? 'project',
456
+ sharedWithAgentIds: input.sharedWithAgentIds,
412
457
  createdByAgentId: input.createdByAgentId,
413
458
  writeGeneration: 0,
414
459
  };
@@ -435,6 +480,11 @@ export async function storeObservation(input: {
435
480
  source: input.source ?? 'agent',
436
481
  sourceDetail: input.sourceDetail ?? '',
437
482
  valueCategory: input.valueCategory ?? '',
483
+ admissionState: input.admissionState ?? '',
484
+ admissionReason: input.admissionReason ? sanitizeCredentials(input.admissionReason) : '',
485
+ visibility: input.visibility ?? 'project',
486
+ createdByAgentId: input.createdByAgentId ?? '',
487
+ sharedWithAgentIds: JSON.stringify(input.sharedWithAgentIds ?? []),
438
488
  };
439
489
 
440
490
  await insertObservation(doc);
@@ -448,7 +498,10 @@ export async function storeObservation(input: {
448
498
  }
449
499
 
450
500
  await bindObservationCodeRefsBestEffort(observation);
451
- queueClaimDerivation(observation);
501
+ queueObservationQualification(observation);
502
+ if (resolveObservationVisibility(observation) === 'project') {
503
+ queueClaimDerivation(observation);
504
+ }
452
505
 
453
506
  // Generate embedding async (fire-and-forget) — never blocks MCP response
454
507
  // Track in vectorMissingIds until embedding is successfully written.
@@ -518,6 +571,10 @@ async function upsertObservation(
518
571
  progress?: ProgressInfo;
519
572
  sourceDetail?: 'explicit' | 'hook' | 'git-ingest';
520
573
  valueCategory?: 'core' | 'contextual' | 'ephemeral';
574
+ admissionState?: ObservationAdmissionState;
575
+ admissionReason?: string;
576
+ visibility?: ObservationVisibility;
577
+ sharedWithAgentIds?: string[];
521
578
  },
522
579
  now: string,
523
580
  ): Promise<Observation> {
@@ -556,6 +613,10 @@ async function upsertObservation(
556
613
  if (input.progress) existing.progress = input.progress;
557
614
  if (input.sourceDetail !== undefined) existing.sourceDetail = input.sourceDetail;
558
615
  if (input.valueCategory !== undefined) existing.valueCategory = input.valueCategory;
616
+ if (input.admissionState !== undefined) existing.admissionState = input.admissionState;
617
+ if (input.admissionReason !== undefined) existing.admissionReason = sanitizeCredentials(input.admissionReason);
618
+ if (input.visibility !== undefined) existing.visibility = input.visibility;
619
+ if (input.sharedWithAgentIds !== undefined) existing.sharedWithAgentIds = input.sharedWithAgentIds;
559
620
 
560
621
  // Re-index in Orama WITHOUT embedding first (non-blocking)
561
622
  const doc: MemorixDocument = {
@@ -577,6 +638,11 @@ async function upsertObservation(
577
638
  source: existing.source ?? 'agent',
578
639
  sourceDetail: existing.sourceDetail ?? '',
579
640
  valueCategory: existing.valueCategory ?? '',
641
+ admissionState: existing.admissionState ?? '',
642
+ admissionReason: existing.admissionReason ?? '',
643
+ visibility: existing.visibility ?? 'project',
644
+ createdByAgentId: existing.createdByAgentId ?? '',
645
+ sharedWithAgentIds: JSON.stringify(existing.sharedWithAgentIds ?? []),
580
646
  };
581
647
 
582
648
  // Remove old doc and insert updated one (with retry for concurrent upsert race)
@@ -603,7 +669,10 @@ async function upsertObservation(
603
669
  }
604
670
 
605
671
  await bindObservationCodeRefsBestEffort(existing);
606
- queueClaimDerivation(existing);
672
+ queueObservationQualification(existing);
673
+ if (resolveObservationVisibility(existing) === 'project') {
674
+ queueClaimDerivation(existing);
675
+ }
607
676
 
608
677
  // Generate embedding async (fire-and-forget) — never blocks MCP response
609
678
  const searchableText = [input.title, input.narrative, ...(input.facts ?? [])].join(' ');
@@ -651,6 +720,79 @@ export function getObservation(id: number, projectId?: string): Observation | un
651
720
  return observations.find((o) => o.id === id && (projectId ? o.projectId === projectId : true));
652
721
  }
653
722
 
723
+ /**
724
+ * Promote or demote an automatic observation without changing its content.
725
+ * The expected-state guard keeps concurrent maintenance workers from
726
+ * resurrecting an observation that another writer has already changed.
727
+ */
728
+ export async function updateObservationAdmission(input: {
729
+ observationId: number;
730
+ projectId?: string;
731
+ expectedState?: ObservationAdmissionState;
732
+ admissionState: ObservationAdmissionState;
733
+ admissionReason: string;
734
+ visibility?: ObservationVisibility;
735
+ }): Promise<Observation | undefined> {
736
+ await ensureFreshObservations();
737
+ const cached = observations.find((observation) =>
738
+ observation.id === input.observationId &&
739
+ (!input.projectId || observation.projectId === input.projectId),
740
+ );
741
+ if (!cached) return undefined;
742
+
743
+ const now = new Date().toISOString();
744
+ const admissionReason = sanitizeCredentials(input.admissionReason).slice(0, 240);
745
+ const apply = (current: Observation): Observation | undefined => {
746
+ if (input.projectId && current.projectId !== input.projectId) return undefined;
747
+ if (input.expectedState && current.admissionState !== input.expectedState) return undefined;
748
+ return {
749
+ ...current,
750
+ admissionState: input.admissionState,
751
+ admissionReason,
752
+ ...(input.visibility !== undefined ? { visibility: input.visibility } : {}),
753
+ updatedAt: now,
754
+ };
755
+ };
756
+
757
+ let updated: Observation | undefined;
758
+ if (projectDir) {
759
+ const store = getObservationStore();
760
+ await store.atomic(async (tx) => {
761
+ const current = await tx.getById(input.observationId);
762
+ if (!current) return;
763
+ const next = apply(current);
764
+ if (!next) return;
765
+ await tx.update(next);
766
+ updated = next;
767
+ });
768
+ } else {
769
+ updated = apply(cached);
770
+ }
771
+
772
+ if (!updated) return undefined;
773
+ observations = observations.map((observation) =>
774
+ observation.id === updated!.id && observation.projectId === updated!.projectId
775
+ ? updated!
776
+ : observation,
777
+ );
778
+
779
+ try {
780
+ await updateObservationMetadata(updated.projectId, updated.id, {
781
+ admissionState: updated.admissionState,
782
+ admissionReason: updated.admissionReason,
783
+ ...(updated.visibility !== undefined ? { visibility: updated.visibility } : {}),
784
+ });
785
+ } catch {
786
+ // SQLite is canonical. The next index hydration repairs a missed update.
787
+ }
788
+
789
+ if (updated.admissionState === 'qualified' && resolveObservationVisibility(updated) === 'project') {
790
+ queueClaimDerivation(updated);
791
+ }
792
+
793
+ return updated;
794
+ }
795
+
654
796
  /**
655
797
  * Resolve observations — mark them as resolved (completed/no longer active).
656
798
  * This prevents resolved memories from appearing in default search results.
@@ -701,6 +843,11 @@ export async function resolveObservations(
701
843
  source: obs.source ?? 'agent',
702
844
  sourceDetail: obs.sourceDetail ?? '',
703
845
  valueCategory: obs.valueCategory ?? '',
846
+ admissionState: obs.admissionState ?? '',
847
+ admissionReason: obs.admissionReason ?? '',
848
+ visibility: obs.visibility ?? 'project',
849
+ createdByAgentId: obs.createdByAgentId ?? '',
850
+ sharedWithAgentIds: JSON.stringify(obs.sharedWithAgentIds ?? []),
704
851
  };
705
852
  await insertObservation(doc);
706
853
  // Async embedding update (fire-and-forget)
@@ -904,6 +1051,11 @@ export async function reindexObservations(): Promise<number> {
904
1051
  source: obs.source ?? 'agent',
905
1052
  sourceDetail: obs.sourceDetail ?? '',
906
1053
  valueCategory: obs.valueCategory ?? '',
1054
+ admissionState: obs.admissionState ?? '',
1055
+ admissionReason: obs.admissionReason ?? '',
1056
+ visibility: obs.visibility ?? 'project',
1057
+ createdByAgentId: obs.createdByAgentId ?? '',
1058
+ sharedWithAgentIds: JSON.stringify(obs.sharedWithAgentIds ?? []),
907
1059
  ...(compatibleEmbedding ? { embedding: compatibleEmbedding } : {}),
908
1060
  };
909
1061
  await insertObservation(doc);
@@ -1028,6 +1180,7 @@ export async function probeSearchIndex(projectId: string): Promise<string> {
1028
1180
  await searchObservations({
1029
1181
  query: 'semantic memory retrieval status',
1030
1182
  projectId,
1183
+ reader: { projectId },
1031
1184
  limit: 1,
1032
1185
  status: 'all',
1033
1186
  trackAccess: false,
@@ -1114,6 +1267,11 @@ export async function backfillVectorEmbeddings(options: {
1114
1267
  source: obs.source ?? 'agent',
1115
1268
  sourceDetail: obs.sourceDetail ?? '',
1116
1269
  valueCategory: obs.valueCategory ?? '',
1270
+ admissionState: obs.admissionState ?? '',
1271
+ admissionReason: obs.admissionReason ?? '',
1272
+ visibility: obs.visibility ?? 'project',
1273
+ createdByAgentId: obs.createdByAgentId ?? '',
1274
+ sharedWithAgentIds: JSON.stringify(obs.sharedWithAgentIds ?? []),
1117
1275
  embedding,
1118
1276
  };
1119
1277
  await insertObservation(doc);
@@ -137,6 +137,8 @@ export function auditMemoryQuality(
137
137
  source: obs.source ?? 'agent',
138
138
  sourceDetail: obs.sourceDetail ?? '',
139
139
  valueCategory: obs.valueCategory ?? '',
140
+ admissionState: obs.admissionState ?? '',
141
+ admissionReason: obs.admissionReason ?? '',
140
142
  }, options.referenceTime) !== 'active')
141
143
  .map((obs) => makeEntry(obs, 'Outside active retention zone'));
142
144
 
@@ -15,8 +15,9 @@
15
15
  * retention period but are no longer permanently immune — they decay normally.
16
16
  */
17
17
 
18
- import type { MemorixDocument, Observation } from '../types.js';
18
+ import type { MemorixDocument, Observation, ObservationReader } from '../types.js';
19
19
  import { getObservationStore } from '../store/obs-store.js';
20
+ import { canManageObservation } from './visibility.js';
20
21
 
21
22
  // ── Importance → Retention Period mapping ────────────────────────────
22
23
 
@@ -113,6 +114,10 @@ export function isImmune(doc: MemorixDocument): boolean {
113
114
  // Probe observations are operational heartbeats -- never immune, regardless of valueCategory or access.
114
115
  if (doc.type === 'probe') return false;
115
116
 
117
+ // A candidate has not earned durable status. Never let an automatic core
118
+ // classification turn unqualified capture into permanently retained memory.
119
+ if (doc.admissionState === 'candidate' || doc.admissionState === 'ephemeral') return false;
120
+
116
121
  // formation-classified core memories are immune regardless of type
117
122
  if (doc.valueCategory === 'core') return true;
118
123
 
@@ -135,6 +140,11 @@ export function getImmunityReason(doc: MemorixDocument): string | null {
135
140
  // Probe observations are never immune
136
141
  if (doc.type === 'probe') return null;
137
142
 
143
+ // Automatic traces and candidates have not earned durable status. Keep the
144
+ // explanation aligned with isImmune() even when an earlier classifier gave
145
+ // a candidate the core value category.
146
+ if (doc.admissionState === 'candidate' || doc.admissionState === 'ephemeral') return null;
147
+
138
148
  if (doc.valueCategory === 'core') return 'core valueCategory (formation-classified)';
139
149
  const importance = getImportanceLevel(doc);
140
150
  if (importance === 'critical') return 'critical importance';
@@ -369,6 +379,8 @@ export interface ArchiveExpiredBatchOptions {
369
379
  limit?: number;
370
380
  referenceTime?: Date;
371
381
  accessMap?: Map<number, { accessCount: number; lastAccessedAt: string }>;
382
+ /** Omit only for trusted background maintenance. */
383
+ reader?: ObservationReader;
372
384
  }
373
385
 
374
386
  export interface ArchiveExpiredBatchResult {
@@ -401,6 +413,8 @@ function toRetentionDocument(
401
413
  source: obs.source ?? 'agent',
402
414
  sourceDetail: obs.sourceDetail ?? '',
403
415
  valueCategory: obs.valueCategory ?? '',
416
+ admissionState: obs.admissionState ?? '',
417
+ admissionReason: obs.admissionReason ?? '',
404
418
  };
405
419
  }
406
420
 
@@ -423,6 +437,7 @@ export async function archiveExpiredBatch(
423
437
  const hasMore = page.length > limit;
424
438
  const scanned = hasMore ? page.slice(0, limit) : page;
425
439
  const candidateIds = scanned
440
+ .filter((observation) => !options.reader || canManageObservation(observation, options.reader))
426
441
  .filter((observation) => getRetentionZone(
427
442
  toRetentionDocument(observation, options.accessMap),
428
443
  options.referenceTime,
@@ -461,6 +476,7 @@ export async function archiveExpired(
461
476
  referenceTime?: Date,
462
477
  accessMap?: Map<number, { accessCount: number; lastAccessedAt: string }>,
463
478
  projectId?: string,
479
+ reader?: ObservationReader,
464
480
  ): Promise<{ archived: number; remaining: number }> {
465
481
  const store = getObservationStore();
466
482
  if (projectId) {
@@ -472,12 +488,16 @@ export async function archiveExpired(
472
488
  afterId,
473
489
  referenceTime,
474
490
  accessMap,
491
+ reader,
475
492
  });
476
493
  archived += batch.archived;
477
494
  afterId = batch.nextCursor;
478
495
  } while (afterId !== undefined);
479
496
 
480
- const remaining = await store.countByProject(projectId, { status: 'active' });
497
+ const remainingObservations = await store.loadByProject(projectId, { status: 'active' });
498
+ const remaining = reader
499
+ ? remainingObservations.filter((observation) => canManageObservation(observation, reader)).length
500
+ : remainingObservations.length;
481
501
  return { archived, remaining };
482
502
  }
483
503
 
@@ -11,13 +11,15 @@
11
11
  * - Cross-agent session awareness (all agents share session data)
12
12
  */
13
13
 
14
- import type { Observation, Session } from '../types.js';
14
+ import type { Observation, ObservationReader, Session } from '../types.js';
15
+ import { isEligibleForAutomaticDelivery } from './admission.js';
15
16
  import { classifyLayer } from './disclosure-policy.js';
16
17
  import { resolveAliases } from '../project/aliases.js';
17
18
  import { getObservationStore } from '../store/obs-store.js';
18
19
  import { getSessionStore } from '../store/session-store.js';
19
20
  import { KnowledgeGraphManager } from './graph.js';
20
21
  import { redactCredentials, sanitizeCredentials } from './secret-filter.js';
22
+ import { canReadObservation } from './visibility.js';
21
23
 
22
24
  const PRIORITY_TYPES = new Set(['gotcha', 'decision', 'problem-solution', 'trade-off', 'discovery']);
23
25
  const TYPE_EMOJI: Record<string, string> = {
@@ -241,7 +243,7 @@ export function scoreObservationForSessionContext(obs: Observation, projectToken
241
243
  export async function startSession(
242
244
  projectDir: string,
243
245
  projectId: string,
244
- opts?: { sessionId?: string; agent?: string },
246
+ opts?: { sessionId?: string; agent?: string; reader?: ObservationReader },
245
247
  ): Promise<{ session: Session; previousContext: string }> {
246
248
  const sessionId = opts?.sessionId || generateSessionId();
247
249
  const now = new Date().toISOString();
@@ -255,7 +257,7 @@ export async function startSession(
255
257
  };
256
258
 
257
259
  // Load previous context before creating new session
258
- const previousContext = await getSessionContext(projectDir, projectId);
260
+ const previousContext = await getSessionContext(projectDir, projectId, 3, opts?.reader);
259
261
 
260
262
  // Atomic rollover: complete all active sessions for this project's aliases
261
263
  // and insert the new session in a single SQLite transaction.
@@ -305,17 +307,31 @@ export async function endSession(
305
307
  * Key Memories — durable explicit working context (L2)
306
308
  * Session History— orientation log
307
309
  * L3 Evidence — pointers to git-memory and hook traces (on-demand)
310
+ *
311
+ * When a reader is supplied, automatic observation delivery follows that
312
+ * identity's visibility boundary. Omit it only for trusted maintenance paths.
308
313
  */
309
314
  export async function getSessionContext(
310
315
  projectDir: string,
311
316
  projectId: string,
312
317
  limit: number = 3,
318
+ reader?: ObservationReader,
313
319
  ): Promise<string> {
314
320
  const aliasSet = await resolveProjectIds(projectId);
315
321
  const [sessions, allObs] = await Promise.all([
316
322
  loadAliasSessions(aliasSet),
317
323
  loadAliasActiveObservations(aliasSet),
318
324
  ]);
325
+ const readableObs = reader
326
+ ? allObs.filter((observation) => {
327
+ // Aliases represent one project across moved/renamed worktrees. Preserve
328
+ // that project equivalence while still enforcing the caller's identity.
329
+ const observationReader = reader.projectId && aliasSet.has(observation.projectId)
330
+ ? { ...reader, projectId: observation.projectId }
331
+ : reader;
332
+ return canReadObservation(observation, observationReader);
333
+ })
334
+ : allObs;
319
335
  /** Check if a session summary contains noise/system-self content */
320
336
  const isNoisySummary = (summary: string | undefined): boolean => {
321
337
  if (!summary) return false;
@@ -328,7 +344,7 @@ export async function getSessionContext(
328
344
  .sort((a, b) => new Date(b.endedAt || b.startedAt).getTime() - new Date(a.endedAt || a.startedAt).getTime())
329
345
  .slice(0, limit);
330
346
 
331
- if (projectSessions.length === 0 && allObs.length === 0) {
347
+ if (projectSessions.length === 0 && readableObs.length === 0) {
332
348
  return '';
333
349
  }
334
350
 
@@ -336,7 +352,7 @@ export async function getSessionContext(
336
352
  const projectTokens = tokenizeProjectId(projectId);
337
353
 
338
354
  // ── Partition project observations by disclosure layer ─────────────
339
- const projectObs = allObs
355
+ const projectObs = readableObs
340
356
  .filter((obs) => !isNoiseObservation(obs) && !isSystemSelfObservation(obs));
341
357
 
342
358
  // L2: durable working context (explicit/undefined/core), priority types only
@@ -369,13 +385,15 @@ export async function getSessionContext(
369
385
 
370
386
  // L1: recent hook activity signals (titles only, most recent first)
371
387
  const l1HookObs = projectObs
372
- .filter((obs) => classifyLayer(obs) === 'L1')
388
+ .filter((obs) => isEligibleForAutomaticDelivery(obs) && classifyLayer(obs) === 'L1')
373
389
  .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
374
390
  .slice(0, 3);
375
391
 
376
392
  // L3: git-ingest evidence count (pointer only, not injected)
377
393
  const l3GitCount = projectObs.filter((obs) => classifyLayer(obs) === 'L3').length;
378
- const totalHookCount = projectObs.filter((obs) => classifyLayer(obs) === 'L1').length;
394
+ const totalHookCount = projectObs
395
+ .filter((obs) => isEligibleForAutomaticDelivery(obs) && classifyLayer(obs) === 'L1')
396
+ .length;
379
397
 
380
398
  // Active entities: unique entity names from top-scored L2 memories.
381
399
  // Surfaced in L1 Routing as next-hop search guidance — not working context.
@@ -389,11 +407,11 @@ export async function getSessionContext(
389
407
  // Active entities enrich the section when it is shown but do not open it alone.
390
408
  const hasL1Content = l1HookObs.length > 0 || l3GitCount > 0;
391
409
  if (hasL1Content) {
392
- // Graph neighbor routing hint: 1-hop neighbors of activeEntities from the
393
- // knowledge graph. Routing only no query expansion, no rerank, no 2-hop
394
- // traversal. Silently skipped if graph is absent, empty, or throws.
410
+ // Graph relations do not yet carry observation visibility metadata, so an
411
+ // agent-facing reader must not use them as an indirect disclosure channel.
412
+ // Trusted maintenance paths retain the existing routing hint.
395
413
  let graphNeighbors: string[] = [];
396
- if (activeEntities.length > 0) {
414
+ if (!reader && activeEntities.length > 0) {
397
415
  try {
398
416
  const graphMgr = new KnowledgeGraphManager(projectDir);
399
417
  await graphMgr.init();
@@ -0,0 +1,80 @@
1
+ import type { ObservationReader, ObservationVisibility } from '../types.js';
2
+
3
+ /** Minimal common shape shared by persisted observations and Orama documents. */
4
+ export interface VisibilityRecord {
5
+ projectId: string;
6
+ visibility?: ObservationVisibility | string;
7
+ createdByAgentId?: string;
8
+ sharedWithAgentIds?: string[] | string;
9
+ }
10
+
11
+ /**
12
+ * Pre-control-plane records were intentionally project-shared. Preserve that
13
+ * behavior so an upgrade does not make a user's historical memory disappear.
14
+ */
15
+ export function resolveObservationVisibility(record: Pick<VisibilityRecord, 'visibility'>): ObservationVisibility {
16
+ switch (record.visibility) {
17
+ case 'personal':
18
+ case 'team':
19
+ case 'project':
20
+ return record.visibility;
21
+ default:
22
+ return 'project';
23
+ }
24
+ }
25
+
26
+ function sharedAgentIds(record: VisibilityRecord): string[] {
27
+ if (Array.isArray(record.sharedWithAgentIds)) return record.sharedWithAgentIds;
28
+ if (typeof record.sharedWithAgentIds !== 'string' || !record.sharedWithAgentIds) return [];
29
+ try {
30
+ const parsed = JSON.parse(record.sharedWithAgentIds);
31
+ return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
32
+ } catch {
33
+ return [];
34
+ }
35
+ }
36
+
37
+ /**
38
+ * The policy is intentionally fail-closed for personal and team scopes. A
39
+ * missing actor never turns a private record into a project-wide result.
40
+ * `undefined` is reserved for trusted internal maintenance, not MCP delivery.
41
+ */
42
+ export function canReadObservation(record: VisibilityRecord, reader?: ObservationReader): boolean {
43
+ if (!reader) return true;
44
+
45
+ const sameProject = reader.projectId === record.projectId;
46
+ const visibility = resolveObservationVisibility(record);
47
+ // An explicit global search may inspect project-visible facts across projects,
48
+ // but it never gains a team or personal scope without a bound project.
49
+ if (visibility === 'project') return !reader.projectId || sameProject;
50
+ if (!sameProject || !reader.agentId) return false;
51
+
52
+ if (visibility === 'team') return reader.isTeamMember === true;
53
+ return record.createdByAgentId === reader.agentId || sharedAgentIds(record).includes(reader.agentId);
54
+ }
55
+
56
+ /**
57
+ * Reading a targeted handoff does not grant the recipient permission to alter
58
+ * it. Project evidence is jointly maintainable; personal records stay owned by
59
+ * their creator; team records require an active team member.
60
+ */
61
+ export function canManageObservation(record: VisibilityRecord, reader?: ObservationReader): boolean {
62
+ if (!reader) return true;
63
+ if (!reader.projectId || reader.projectId !== record.projectId) return false;
64
+
65
+ switch (resolveObservationVisibility(record)) {
66
+ case 'project':
67
+ return true;
68
+ case 'team':
69
+ return reader.isTeamMember === true;
70
+ case 'personal':
71
+ return Boolean(reader.agentId && record.createdByAgentId === reader.agentId);
72
+ }
73
+ }
74
+
75
+ export function filterReadableObservations<T extends VisibilityRecord>(
76
+ observations: readonly T[],
77
+ reader?: ObservationReader,
78
+ ): T[] {
79
+ return reader ? observations.filter((observation) => canReadObservation(observation, reader)) : [...observations];
80
+ }