memorix 1.6.3 → 1.7.1

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.
@@ -937,6 +937,7 @@ body {
937
937
  .graph-inspector {
938
938
  width: 280px;
939
939
  flex-shrink: 0;
940
+ min-width: 0;
940
941
  background: var(--bg-card);
941
942
  border-left: 1px solid var(--border-subtle);
942
943
  overflow-y: auto;
@@ -973,6 +974,7 @@ body {
973
974
  color: var(--text-primary);
974
975
  margin-bottom: 4px;
975
976
  word-break: break-word;
977
+ overflow-wrap: anywhere;
976
978
  }
977
979
 
978
980
  .gi-type {
@@ -1049,6 +1051,7 @@ body {
1049
1051
  background: var(--bg-surface);
1050
1052
  border-left: 2px solid var(--accent-purple);
1051
1053
  word-break: break-word;
1054
+ overflow-wrap: anywhere;
1052
1055
  }
1053
1056
 
1054
1057
  .gi-rel-item {
@@ -1058,6 +1061,7 @@ body {
1058
1061
  display: flex;
1059
1062
  align-items: center;
1060
1063
  gap: 6px;
1064
+ min-width: 0;
1061
1065
  }
1062
1066
 
1063
1067
  .gi-rel-arrow {
@@ -1080,6 +1084,9 @@ body {
1080
1084
  font-weight: 500;
1081
1085
  color: var(--text-primary);
1082
1086
  cursor: pointer;
1087
+ min-width: 0;
1088
+ overflow-wrap: anywhere;
1089
+ word-break: break-word;
1083
1090
  transition: color var(--transition-fast);
1084
1091
  }
1085
1092
 
@@ -1142,6 +1149,10 @@ body {
1142
1149
  transition: opacity var(--transition-fast);
1143
1150
  z-index: 100;
1144
1151
  max-width: 260px;
1152
+ box-sizing: border-box;
1153
+ white-space: normal;
1154
+ overflow-wrap: anywhere;
1155
+ word-break: break-word;
1145
1156
  box-shadow: var(--elevation-2);
1146
1157
  }
1147
1158
 
@@ -1530,6 +1541,10 @@ body {
1530
1541
  transition: opacity var(--transition-fast);
1531
1542
  z-index: 100;
1532
1543
  max-width: 300px;
1544
+ box-sizing: border-box;
1545
+ white-space: normal;
1546
+ overflow-wrap: anywhere;
1547
+ word-break: break-word;
1533
1548
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
1534
1549
  }
1535
1550
 
@@ -2148,6 +2163,319 @@ body {
2148
2163
  color: var(--text-primary);
2149
2164
  }
2150
2165
 
2166
+ /* Maintenance workbench */
2167
+ .maintenance-strip {
2168
+ display: flex;
2169
+ align-items: center;
2170
+ justify-content: space-between;
2171
+ gap: 16px;
2172
+ padding: 12px 0;
2173
+ margin-bottom: 14px;
2174
+ border-top: 1px solid var(--border-subtle);
2175
+ border-bottom: 1px solid var(--border-subtle);
2176
+ }
2177
+
2178
+ .maintenance-strip-heading {
2179
+ display: flex;
2180
+ flex-direction: column;
2181
+ gap: 2px;
2182
+ min-width: 0;
2183
+ font-size: 13px;
2184
+ font-weight: 600;
2185
+ color: var(--text-primary);
2186
+ }
2187
+
2188
+ .maintenance-strip-heading small {
2189
+ font-size: 11px;
2190
+ font-weight: 400;
2191
+ color: var(--text-muted);
2192
+ }
2193
+
2194
+ .maintenance-actions {
2195
+ display: flex;
2196
+ flex-wrap: wrap;
2197
+ justify-content: flex-end;
2198
+ gap: 8px;
2199
+ }
2200
+
2201
+ .maintenance-actions button,
2202
+ .maintenance-primary,
2203
+ .maintenance-secondary {
2204
+ display: inline-flex;
2205
+ align-items: center;
2206
+ justify-content: center;
2207
+ gap: 7px;
2208
+ min-height: 36px;
2209
+ padding: 8px 12px;
2210
+ border: 1px solid var(--border-medium);
2211
+ border-radius: var(--radius-sm);
2212
+ background: transparent;
2213
+ color: var(--text-secondary);
2214
+ font: 500 12px var(--font-sans);
2215
+ cursor: pointer;
2216
+ transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
2217
+ }
2218
+
2219
+ .maintenance-actions button:hover,
2220
+ .maintenance-secondary:hover {
2221
+ background: var(--bg-card-hover);
2222
+ border-color: var(--text-muted);
2223
+ color: var(--text-primary);
2224
+ }
2225
+
2226
+ .maintenance-dialog {
2227
+ position: fixed;
2228
+ inset: 0;
2229
+ margin: auto;
2230
+ width: min(680px, calc(100vw - 32px));
2231
+ max-height: min(760px, calc(100dvh - 32px));
2232
+ padding: 0;
2233
+ border: 1px solid var(--border-medium);
2234
+ border-radius: var(--radius-md);
2235
+ background: var(--bg-surface);
2236
+ color: var(--text-primary);
2237
+ box-shadow: var(--elevation-3);
2238
+ overflow: hidden;
2239
+ }
2240
+
2241
+ .maintenance-dialog::backdrop {
2242
+ background: rgba(5, 5, 12, 0.72);
2243
+ backdrop-filter: blur(3px);
2244
+ }
2245
+
2246
+ .maintenance-dialog-shell {
2247
+ display: grid;
2248
+ grid-template-rows: auto minmax(0, 1fr) auto;
2249
+ max-height: min(760px, calc(100dvh - 32px));
2250
+ }
2251
+
2252
+ .maintenance-dialog-header,
2253
+ .maintenance-dialog-footer {
2254
+ display: flex;
2255
+ align-items: center;
2256
+ justify-content: space-between;
2257
+ gap: 12px;
2258
+ padding: 16px 18px;
2259
+ border-bottom: 1px solid var(--border-subtle);
2260
+ }
2261
+
2262
+ .maintenance-dialog-footer {
2263
+ justify-content: flex-end;
2264
+ border-top: 1px solid var(--border-subtle);
2265
+ border-bottom: 0;
2266
+ }
2267
+
2268
+ .maintenance-dialog-kicker {
2269
+ margin-bottom: 3px;
2270
+ font: 500 10px var(--font-mono);
2271
+ color: var(--text-muted);
2272
+ text-transform: uppercase;
2273
+ }
2274
+
2275
+ .maintenance-dialog-header h2 {
2276
+ font-size: 17px;
2277
+ line-height: 1.3;
2278
+ }
2279
+
2280
+ .maintenance-dialog-close {
2281
+ width: 34px;
2282
+ height: 34px;
2283
+ flex: 0 0 34px;
2284
+ }
2285
+
2286
+ .maintenance-dialog-body {
2287
+ min-height: 180px;
2288
+ overflow: auto;
2289
+ padding: 18px;
2290
+ }
2291
+
2292
+ .maintenance-summary {
2293
+ display: grid;
2294
+ grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
2295
+ border-top: 1px solid var(--border-subtle);
2296
+ border-bottom: 1px solid var(--border-subtle);
2297
+ margin-bottom: 16px;
2298
+ }
2299
+
2300
+ .maintenance-metric {
2301
+ display: flex;
2302
+ flex-direction: column;
2303
+ gap: 5px;
2304
+ min-width: 0;
2305
+ padding: 12px;
2306
+ border-right: 1px solid var(--border-subtle);
2307
+ }
2308
+
2309
+ .maintenance-metric:last-child {
2310
+ border-right: 0;
2311
+ }
2312
+
2313
+ .maintenance-metric span {
2314
+ font-size: 10px;
2315
+ color: var(--text-muted);
2316
+ }
2317
+
2318
+ .maintenance-metric strong {
2319
+ font: 600 19px var(--font-mono);
2320
+ color: var(--text-primary);
2321
+ }
2322
+
2323
+ .maintenance-records,
2324
+ .maintenance-group {
2325
+ display: flex;
2326
+ flex-direction: column;
2327
+ }
2328
+
2329
+ .maintenance-group {
2330
+ margin-bottom: 12px;
2331
+ border-top: 1px solid var(--border-subtle);
2332
+ }
2333
+
2334
+ .maintenance-group-title {
2335
+ padding: 10px 8px 6px;
2336
+ font-size: 11px;
2337
+ color: var(--text-secondary);
2338
+ }
2339
+
2340
+ .maintenance-record {
2341
+ display: grid;
2342
+ grid-template-columns: 52px minmax(0, 1fr) minmax(80px, auto);
2343
+ align-items: center;
2344
+ gap: 10px;
2345
+ min-height: 42px;
2346
+ padding: 8px;
2347
+ border-bottom: 1px solid var(--border-subtle);
2348
+ font-size: 12px;
2349
+ }
2350
+
2351
+ .maintenance-record-id,
2352
+ .maintenance-record-meta {
2353
+ font-family: var(--font-mono);
2354
+ color: var(--text-muted);
2355
+ }
2356
+
2357
+ .maintenance-record-title,
2358
+ .maintenance-record-meta {
2359
+ min-width: 0;
2360
+ overflow: hidden;
2361
+ text-overflow: ellipsis;
2362
+ white-space: nowrap;
2363
+ }
2364
+
2365
+ .maintenance-record-meta {
2366
+ text-align: right;
2367
+ font-size: 10px;
2368
+ }
2369
+
2370
+ .maintenance-toggle {
2371
+ display: flex;
2372
+ align-items: center;
2373
+ gap: 9px;
2374
+ margin-bottom: 14px;
2375
+ font-size: 12px;
2376
+ color: var(--text-secondary);
2377
+ cursor: pointer;
2378
+ }
2379
+
2380
+ .maintenance-toggle input {
2381
+ width: 16px;
2382
+ height: 16px;
2383
+ accent-color: var(--accent-purple);
2384
+ }
2385
+
2386
+ .maintenance-empty {
2387
+ display: flex;
2388
+ flex-direction: column;
2389
+ align-items: center;
2390
+ justify-content: center;
2391
+ gap: 8px;
2392
+ min-height: 130px;
2393
+ color: var(--text-muted);
2394
+ text-align: center;
2395
+ }
2396
+
2397
+ .maintenance-primary {
2398
+ border-color: var(--accent-purple);
2399
+ background: var(--accent-purple);
2400
+ color: var(--bg-base);
2401
+ }
2402
+
2403
+ .maintenance-primary:hover:not(:disabled) {
2404
+ filter: brightness(1.08);
2405
+ }
2406
+
2407
+ .maintenance-primary:disabled,
2408
+ .export-btn:disabled {
2409
+ opacity: 0.45;
2410
+ cursor: not-allowed;
2411
+ }
2412
+
2413
+ .dashboard-toast {
2414
+ position: fixed;
2415
+ right: 20px;
2416
+ bottom: 20px;
2417
+ z-index: 1200;
2418
+ max-width: min(420px, calc(100vw - 40px));
2419
+ padding: 11px 14px;
2420
+ border: 1px solid var(--accent-green);
2421
+ border-radius: var(--radius-sm);
2422
+ background: var(--bg-card);
2423
+ color: var(--text-primary);
2424
+ box-shadow: var(--elevation-2);
2425
+ font-size: 12px;
2426
+ opacity: 0;
2427
+ transform: translateY(10px);
2428
+ pointer-events: none;
2429
+ transition: opacity var(--transition-fast), transform var(--transition-fast);
2430
+ }
2431
+
2432
+ .dashboard-toast[data-tone="error"] {
2433
+ border-color: var(--accent-red);
2434
+ }
2435
+
2436
+ .dashboard-toast.visible {
2437
+ opacity: 1;
2438
+ transform: translateY(0);
2439
+ }
2440
+
2441
+ .page-header-actions {
2442
+ display: flex;
2443
+ align-items: center;
2444
+ justify-content: space-between;
2445
+ gap: 16px;
2446
+ }
2447
+
2448
+ @media (max-width: 720px) {
2449
+ .maintenance-strip,
2450
+ .page-header-actions {
2451
+ align-items: stretch;
2452
+ flex-direction: column;
2453
+ }
2454
+
2455
+ .maintenance-actions {
2456
+ display: grid;
2457
+ grid-template-columns: repeat(3, minmax(0, 1fr));
2458
+ }
2459
+
2460
+ .maintenance-actions button {
2461
+ min-width: 0;
2462
+ padding-inline: 8px;
2463
+ }
2464
+
2465
+ .maintenance-record {
2466
+ grid-template-columns: 44px minmax(0, 1fr);
2467
+ }
2468
+
2469
+ .maintenance-record-meta {
2470
+ grid-column: 2;
2471
+ text-align: left;
2472
+ }
2473
+
2474
+ .maintenance-dialog-body {
2475
+ padding: 14px;
2476
+ }
2477
+ }
2478
+
2151
2479
  /* Low quality indicator */
2152
2480
  .obs-card.low-quality {
2153
2481
  border-left: 3px solid var(--accent-amber);
@@ -0,0 +1,143 @@
1
+ import type { Observation } from '../types.js';
2
+ import type { ObservationStore } from '../store/obs-store.js';
3
+
4
+ const LOW_QUALITY_PATTERNS = [
5
+ /^Session activity/i,
6
+ /^Updated \S+\.\w+$/i,
7
+ /^Created \S+\.\w+$/i,
8
+ /^Deleted \S+\.\w+$/i,
9
+ /^Modified \S+\.\w+$/i,
10
+ /^Ran command:/i,
11
+ /^Read file:/i,
12
+ ];
13
+
14
+ const EXPLICIT_NOISE_TITLE_PATTERNS = [
15
+ /^\s*\[(?:test|demo|benchmark|sandbox|playground|测试|演示)\]/i,
16
+ /^\s*(?:Used\s+(?:mcp__\S+|apply_patch)|Ran:)\s*/i,
17
+ ];
18
+
19
+ const EXPLICIT_NOISE_MARKERS = new Set([
20
+ 'test-fixture',
21
+ 'demo-fixture',
22
+ 'benchmark-fixture',
23
+ 'sandbox-fixture',
24
+ 'cleanup-noise',
25
+ ]);
26
+
27
+ export interface CleanupNoiseHit {
28
+ observation: Observation;
29
+ reason: 'system-self' | 'demo/test/noise';
30
+ }
31
+
32
+ export interface CleanupDuplicateGroup {
33
+ canonical: Observation;
34
+ duplicates: Observation[];
35
+ }
36
+
37
+ export interface CleanupAnalysis {
38
+ totalActive: number;
39
+ highQuality: number;
40
+ lowQuality: Observation[];
41
+ duplicateGroups: CleanupDuplicateGroup[];
42
+ duplicates: Observation[];
43
+ noise: CleanupNoiseHit[];
44
+ toRemove: Observation[];
45
+ toArchive: Observation[];
46
+ }
47
+
48
+ export function isLowQualityObservation(title: string): boolean {
49
+ return LOW_QUALITY_PATTERNS.some((pattern) => pattern.test(title.trim()));
50
+ }
51
+
52
+ export function classifyNoiseObservation(
53
+ observation: Pick<Observation, 'title' | 'narrative' | 'entityName' | 'facts' | 'concepts'>,
54
+ ): { isNoise: boolean; reason?: CleanupNoiseHit['reason'] } {
55
+ const title = observation.title?.trim() ?? '';
56
+ const entityName = observation.entityName?.trim().toLowerCase() ?? '';
57
+ const concepts = (observation.concepts ?? []).map((concept) => concept.trim().toLowerCase());
58
+
59
+ if (
60
+ entityName === 'memorix.demo'
61
+ || entityName.startsWith('for_memmcp_test')
62
+ || concepts.some((concept) => EXPLICIT_NOISE_MARKERS.has(concept))
63
+ ) {
64
+ return { isNoise: true, reason: 'system-self' };
65
+ }
66
+ if (EXPLICIT_NOISE_TITLE_PATTERNS.some((pattern) => pattern.test(title))) {
67
+ return { isNoise: true, reason: 'demo/test/noise' };
68
+ }
69
+ return { isNoise: false };
70
+ }
71
+
72
+ export function analyzeCleanupObservations(
73
+ observations: readonly Observation[],
74
+ options: { includeNoise?: boolean } = {},
75
+ ): CleanupAnalysis {
76
+ const active = observations.filter((observation) => (observation.status ?? 'active') === 'active');
77
+ const lowQuality = active.filter((observation) => isLowQualityObservation(observation.title ?? ''));
78
+ const lowQualityIds = new Set(lowQuality.map((observation) => observation.id));
79
+ const highQuality = active.filter((observation) => !lowQualityIds.has(observation.id));
80
+
81
+ const groups = new Map<string, CleanupDuplicateGroup>();
82
+ for (const observation of highQuality) {
83
+ const key = `${observation.type}|${observation.title}|${observation.entityName}`;
84
+ const existing = groups.get(key);
85
+ if (existing) existing.duplicates.push(observation);
86
+ else groups.set(key, { canonical: observation, duplicates: [] });
87
+ }
88
+ const duplicateGroups = [...groups.values()].filter((group) => group.duplicates.length > 0);
89
+ const duplicates = duplicateGroups.flatMap((group) => group.duplicates);
90
+ const excludedIds = new Set([...lowQuality, ...duplicates].map((observation) => observation.id));
91
+
92
+ const noise: CleanupNoiseHit[] = [];
93
+ if (options.includeNoise) {
94
+ for (const observation of active) {
95
+ if (excludedIds.has(observation.id)) continue;
96
+ const classification = classifyNoiseObservation(observation);
97
+ if (classification.isNoise && classification.reason) {
98
+ noise.push({ observation, reason: classification.reason });
99
+ }
100
+ }
101
+ }
102
+
103
+ const toRemove = [...lowQuality, ...duplicates];
104
+ const toArchive = noise.map((hit) => hit.observation);
105
+ return {
106
+ totalActive: active.length,
107
+ highQuality: highQuality.length - duplicates.length - toArchive.length,
108
+ lowQuality,
109
+ duplicateGroups,
110
+ duplicates,
111
+ noise,
112
+ toRemove,
113
+ toArchive,
114
+ };
115
+ }
116
+
117
+ function requireObservationIds(observations: readonly Observation[], action: string): number[] {
118
+ const ids = observations.map((observation) => observation.id);
119
+ if (ids.some((id) => typeof id !== 'number')) {
120
+ throw new Error(`Cannot ${action}: an observation has no persisted ID.`);
121
+ }
122
+ return ids as number[];
123
+ }
124
+
125
+ export async function applyCleanupMutations(
126
+ store: ObservationStore,
127
+ toArchive: readonly Observation[],
128
+ toRemove: readonly Observation[],
129
+ ): Promise<{ archived: number; removed: number }> {
130
+ const archiveIds = requireObservationIds(toArchive, 'archive');
131
+ const removeIds = requireObservationIds(toRemove, 'delete');
132
+ const removals = new Set(removeIds);
133
+ if (archiveIds.some((id) => removals.has(id))) {
134
+ throw new Error('Cleanup cannot archive and delete the same observation.');
135
+ }
136
+
137
+ await store.atomic(async (tx) => {
138
+ await Promise.all(archiveIds.map((id) => tx.setStatus(id, 'archived', 'active')));
139
+ await Promise.all(removeIds.map((id) => tx.remove(id)));
140
+ });
141
+
142
+ return { archived: archiveIds.length, removed: removeIds.length };
143
+ }
@@ -0,0 +1,178 @@
1
+ import type { CompactDecision } from '../llm/memory-manager.js';
2
+ import { deduplicateMemory } from '../llm/memory-manager.js';
3
+ import type { ObservationStore } from '../store/obs-store.js';
4
+ import type { Observation, ObservationReader } from '../types.js';
5
+ import { canManageObservation } from './visibility.js';
6
+
7
+ export interface DeduplicationAction {
8
+ resolveId: number;
9
+ keepId: number;
10
+ entityName: string;
11
+ resolveTitle: string;
12
+ keepTitle: string;
13
+ reason: string;
14
+ decision: 'UPDATE' | 'DELETE' | 'NONE';
15
+ usedLLM: boolean;
16
+ }
17
+
18
+ export interface DeduplicationPlan {
19
+ scanned: number;
20
+ entities: number;
21
+ comparisons: number;
22
+ failedComparisons: number;
23
+ actions: DeduplicationAction[];
24
+ resolveIds: number[];
25
+ }
26
+
27
+ type DeduplicationDecision = (
28
+ newer: { title: string; narrative: string; facts: string[] },
29
+ existing: Array<{ id: number; title: string; narrative: string; facts: string }>,
30
+ ) => Promise<CompactDecision | null>;
31
+
32
+ function chronological(left: Observation, right: Observation): number {
33
+ const byTime = new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime();
34
+ return byTime || left.id - right.id;
35
+ }
36
+
37
+ function actionFromDecision(
38
+ older: Observation,
39
+ newer: Observation,
40
+ decision: CompactDecision,
41
+ ): DeduplicationAction | null {
42
+ let resolveId: number;
43
+ let keepId: number;
44
+
45
+ if (decision.action === 'NONE') {
46
+ resolveId = newer.id;
47
+ keepId = older.id;
48
+ } else if (decision.action === 'UPDATE') {
49
+ resolveId = decision.targetId ?? older.id;
50
+ keepId = resolveId === newer.id ? older.id : newer.id;
51
+ } else if (decision.action === 'DELETE') {
52
+ resolveId = decision.targetId ?? older.id;
53
+ keepId = resolveId === newer.id ? older.id : newer.id;
54
+ } else {
55
+ return null;
56
+ }
57
+
58
+ const records = new Map([[older.id, older], [newer.id, newer]]);
59
+ const resolved = records.get(resolveId);
60
+ const kept = records.get(keepId);
61
+ if (!resolved || !kept || resolved.id === kept.id) return null;
62
+
63
+ return {
64
+ resolveId,
65
+ keepId,
66
+ entityName: newer.entityName,
67
+ resolveTitle: resolved.title,
68
+ keepTitle: kept.title,
69
+ reason: decision.reason,
70
+ decision: decision.action,
71
+ usedLLM: decision.usedLLM,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Build one deterministic, reviewable deduplication plan. Callers own project,
77
+ * visibility, and optional search filtering before passing observations in.
78
+ */
79
+ export async function planMemoryDeduplication(
80
+ observations: readonly Observation[],
81
+ options: { limit?: number; decide?: DeduplicationDecision } = {},
82
+ ): Promise<DeduplicationPlan> {
83
+ const limit = Math.min(100, Math.max(2, Math.floor(options.limit ?? 20)));
84
+ const candidates = observations
85
+ .filter((observation) => (observation.status ?? 'active') === 'active')
86
+ .sort(chronological)
87
+ .slice(-limit);
88
+ const byEntity = new Map<string, Observation[]>();
89
+ for (const observation of candidates) {
90
+ const group = byEntity.get(observation.entityName) ?? [];
91
+ group.push(observation);
92
+ byEntity.set(observation.entityName, group);
93
+ }
94
+
95
+ const decide = options.decide ?? deduplicateMemory;
96
+ const actions = new Map<number, DeduplicationAction>();
97
+ let comparisons = 0;
98
+ let failedComparisons = 0;
99
+
100
+ for (const group of byEntity.values()) {
101
+ if (group.length < 2) continue;
102
+ group.sort(chronological);
103
+ for (let index = 0; index < group.length; index += 1) {
104
+ for (let compareIndex = index + 1; compareIndex < group.length; compareIndex += 1) {
105
+ const older = group[index];
106
+ const newer = group[compareIndex];
107
+ comparisons += 1;
108
+ let decision: CompactDecision | null;
109
+ try {
110
+ decision = await decide(
111
+ { title: newer.title, narrative: newer.narrative, facts: newer.facts },
112
+ [{ id: older.id, title: older.title, narrative: older.narrative, facts: older.facts.join('\n') }],
113
+ );
114
+ } catch {
115
+ failedComparisons += 1;
116
+ continue;
117
+ }
118
+ if (!decision) continue;
119
+ const action = actionFromDecision(older, newer, decision);
120
+ if (action && !actions.has(action.resolveId)) actions.set(action.resolveId, action);
121
+ }
122
+ }
123
+ }
124
+
125
+ const planned = [...actions.values()];
126
+ for (const [entityName, group] of byEntity) {
127
+ const resolvedIds = new Set(
128
+ planned.filter((action) => action.entityName === entityName).map((action) => action.resolveId),
129
+ );
130
+ if (group.length > 0 && group.every((observation) => resolvedIds.has(observation.id))) {
131
+ const survivor = group[group.length - 1];
132
+ const unsafeIndex = planned.findIndex((action) => action.resolveId === survivor.id);
133
+ if (unsafeIndex >= 0) planned.splice(unsafeIndex, 1);
134
+ }
135
+ }
136
+ return {
137
+ scanned: candidates.length,
138
+ entities: byEntity.size,
139
+ comparisons,
140
+ failedComparisons,
141
+ actions: planned,
142
+ resolveIds: planned.map((action) => action.resolveId),
143
+ };
144
+ }
145
+
146
+ export async function applyDeduplicationPlan(
147
+ store: ObservationStore,
148
+ projectId: string,
149
+ resolveIds: readonly number[],
150
+ reader: ObservationReader = { projectId },
151
+ ): Promise<{ resolved: number[]; skipped: number[] }> {
152
+ const uniqueIds = [...new Set(resolveIds)];
153
+ const manageable: number[] = [];
154
+ const skipped: number[] = [];
155
+
156
+ for (const id of uniqueIds) {
157
+ const observation = await store.getById(id);
158
+ if (
159
+ observation
160
+ && observation.projectId === projectId
161
+ && (observation.status ?? 'active') === 'active'
162
+ && canManageObservation(observation, reader)
163
+ ) {
164
+ manageable.push(id);
165
+ } else {
166
+ skipped.push(id);
167
+ }
168
+ }
169
+
170
+ const resolved = skipped.length > 0 || manageable.length === 0
171
+ ? []
172
+ : await store.atomic(async (tx) => {
173
+ const updates = await Promise.all(manageable.map((id) => tx.setStatus(id, 'resolved', 'active')));
174
+ return manageable.filter((_, index) => updates[index]);
175
+ });
176
+
177
+ return { resolved, skipped };
178
+ }