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.
- package/CHANGELOG.md +23 -0
- package/README.md +14 -2
- package/README.zh-CN.md +14 -2
- package/dist/cli/index.js +15424 -13780
- package/dist/cli/index.js.map +1 -1
- package/dist/index.js +1337 -536
- package/dist/index.js.map +1 -1
- package/dist/maintenance-runner.d.ts +1 -1
- package/dist/maintenance-runner.js +8458 -8087
- package/dist/maintenance-runner.js.map +1 -1
- package/dist/memcode-runtime/CHANGELOG.md +23 -0
- package/dist/sdk.d.ts +7 -2
- package/dist/sdk.js +1365 -542
- package/dist/sdk.js.map +1 -1
- package/dist/types.d.ts +49 -1
- package/dist/types.js.map +1 -1
- package/docs/1.2.2-MEMORY-CONTROL-PLANE.md +434 -0
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +4 -0
- package/docs/API_REFERENCE.md +24 -4
- package/docs/README.md +1 -1
- package/docs/dev-log/progress.txt +101 -11
- package/package.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/src/cli/command-guide.ts +192 -0
- package/src/cli/commands/audit.ts +9 -4
- package/src/cli/commands/cleanup.ts +5 -1
- package/src/cli/commands/codegraph.ts +15 -5
- package/src/cli/commands/context.ts +3 -2
- package/src/cli/commands/doctor.ts +4 -2
- package/src/cli/commands/explain.ts +9 -3
- package/src/cli/commands/handoff.ts +21 -7
- package/src/cli/commands/identity.ts +116 -0
- package/src/cli/commands/ingest-image.ts +5 -3
- package/src/cli/commands/lock.ts +11 -10
- package/src/cli/commands/memory.ts +58 -21
- package/src/cli/commands/message.ts +19 -14
- package/src/cli/commands/operator-shared.ts +98 -3
- package/src/cli/commands/poll.ts +16 -6
- package/src/cli/commands/reasoning.ts +17 -3
- package/src/cli/commands/retention.ts +9 -4
- package/src/cli/commands/serve-http.ts +8 -2
- package/src/cli/commands/session.ts +44 -10
- package/src/cli/commands/skills.ts +10 -5
- package/src/cli/commands/status.ts +4 -3
- package/src/cli/commands/task.ts +26 -17
- package/src/cli/commands/team.ts +14 -10
- package/src/cli/commands/transfer.ts +63 -10
- package/src/cli/identity.ts +89 -0
- package/src/cli/index.ts +96 -19
- package/src/cli/invocation.ts +115 -0
- package/src/cli/tui/chat-service.ts +41 -18
- package/src/cli/tui/data.ts +23 -44
- package/src/cli/tui/operator-context.ts +60 -0
- package/src/cli/tui/session-service.ts +3 -2
- package/src/cli/tui/views/MemoryView.tsx +10 -8
- package/src/codegraph/auto-context.ts +31 -2
- package/src/codegraph/context-pack.ts +1 -0
- package/src/codegraph/project-context.ts +2 -0
- package/src/compact/engine.ts +26 -10
- package/src/compact/index-format.ts +25 -2
- package/src/dashboard/server.ts +46 -9
- package/src/hooks/admission.ts +117 -0
- package/src/hooks/handler.ts +98 -91
- package/src/knowledge/context-assembly.ts +97 -0
- package/src/knowledge/workset.ts +179 -10
- package/src/memory/admission.ts +57 -0
- package/src/memory/consolidation.ts +13 -2
- package/src/memory/disclosure-policy.ts +6 -1
- package/src/memory/export-import.ts +11 -3
- package/src/memory/graph-context.ts +8 -2
- package/src/memory/observations.ts +162 -4
- package/src/memory/quality-audit.ts +2 -0
- package/src/memory/retention.ts +22 -2
- package/src/memory/session.ts +29 -11
- package/src/memory/visibility.ts +80 -0
- package/src/orchestrate/memorix-bridge.ts +38 -0
- package/src/runtime/control-plane-maintenance.ts +1 -0
- package/src/runtime/isolated-maintenance.ts +1 -0
- package/src/runtime/lifecycle.ts +18 -0
- package/src/runtime/maintenance-jobs.ts +1 -0
- package/src/runtime/maintenance-runner.ts +2 -0
- package/src/runtime/project-maintenance.ts +89 -0
- package/src/sdk.ts +35 -5
- package/src/server.ts +267 -83
- package/src/store/orama-store.ts +61 -6
- package/src/store/sqlite-db.ts +23 -1
- package/src/store/sqlite-store.ts +12 -2
- package/src/team/handoff.ts +7 -0
- package/src/types.ts +51 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/memory/retention.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/memory/session.ts
CHANGED
|
@@ -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 &&
|
|
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 =
|
|
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
|
|
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
|
|
393
|
-
//
|
|
394
|
-
//
|
|
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
|
+
}
|