memorix 1.6.3 → 1.7.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.
@@ -2148,6 +2148,316 @@ body {
2148
2148
  color: var(--text-primary);
2149
2149
  }
2150
2150
 
2151
+ /* Maintenance workbench */
2152
+ .maintenance-strip {
2153
+ display: flex;
2154
+ align-items: center;
2155
+ justify-content: space-between;
2156
+ gap: 16px;
2157
+ padding: 12px 0;
2158
+ margin-bottom: 14px;
2159
+ border-top: 1px solid var(--border-subtle);
2160
+ border-bottom: 1px solid var(--border-subtle);
2161
+ }
2162
+
2163
+ .maintenance-strip-heading {
2164
+ display: flex;
2165
+ flex-direction: column;
2166
+ gap: 2px;
2167
+ min-width: 0;
2168
+ font-size: 13px;
2169
+ font-weight: 600;
2170
+ color: var(--text-primary);
2171
+ }
2172
+
2173
+ .maintenance-strip-heading small {
2174
+ font-size: 11px;
2175
+ font-weight: 400;
2176
+ color: var(--text-muted);
2177
+ }
2178
+
2179
+ .maintenance-actions {
2180
+ display: flex;
2181
+ flex-wrap: wrap;
2182
+ justify-content: flex-end;
2183
+ gap: 8px;
2184
+ }
2185
+
2186
+ .maintenance-actions button,
2187
+ .maintenance-primary,
2188
+ .maintenance-secondary {
2189
+ display: inline-flex;
2190
+ align-items: center;
2191
+ justify-content: center;
2192
+ gap: 7px;
2193
+ min-height: 36px;
2194
+ padding: 8px 12px;
2195
+ border: 1px solid var(--border-medium);
2196
+ border-radius: var(--radius-sm);
2197
+ background: transparent;
2198
+ color: var(--text-secondary);
2199
+ font: 500 12px var(--font-sans);
2200
+ cursor: pointer;
2201
+ transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
2202
+ }
2203
+
2204
+ .maintenance-actions button:hover,
2205
+ .maintenance-secondary:hover {
2206
+ background: var(--bg-card-hover);
2207
+ border-color: var(--text-muted);
2208
+ color: var(--text-primary);
2209
+ }
2210
+
2211
+ .maintenance-dialog {
2212
+ width: min(680px, calc(100vw - 32px));
2213
+ max-height: min(760px, calc(100dvh - 32px));
2214
+ padding: 0;
2215
+ border: 1px solid var(--border-medium);
2216
+ border-radius: var(--radius-md);
2217
+ background: var(--bg-surface);
2218
+ color: var(--text-primary);
2219
+ box-shadow: var(--elevation-3);
2220
+ overflow: hidden;
2221
+ }
2222
+
2223
+ .maintenance-dialog::backdrop {
2224
+ background: rgba(5, 5, 12, 0.72);
2225
+ backdrop-filter: blur(3px);
2226
+ }
2227
+
2228
+ .maintenance-dialog-shell {
2229
+ display: grid;
2230
+ grid-template-rows: auto minmax(0, 1fr) auto;
2231
+ max-height: min(760px, calc(100dvh - 32px));
2232
+ }
2233
+
2234
+ .maintenance-dialog-header,
2235
+ .maintenance-dialog-footer {
2236
+ display: flex;
2237
+ align-items: center;
2238
+ justify-content: space-between;
2239
+ gap: 12px;
2240
+ padding: 16px 18px;
2241
+ border-bottom: 1px solid var(--border-subtle);
2242
+ }
2243
+
2244
+ .maintenance-dialog-footer {
2245
+ justify-content: flex-end;
2246
+ border-top: 1px solid var(--border-subtle);
2247
+ border-bottom: 0;
2248
+ }
2249
+
2250
+ .maintenance-dialog-kicker {
2251
+ margin-bottom: 3px;
2252
+ font: 500 10px var(--font-mono);
2253
+ color: var(--text-muted);
2254
+ text-transform: uppercase;
2255
+ }
2256
+
2257
+ .maintenance-dialog-header h2 {
2258
+ font-size: 17px;
2259
+ line-height: 1.3;
2260
+ }
2261
+
2262
+ .maintenance-dialog-close {
2263
+ width: 34px;
2264
+ height: 34px;
2265
+ flex: 0 0 34px;
2266
+ }
2267
+
2268
+ .maintenance-dialog-body {
2269
+ min-height: 180px;
2270
+ overflow: auto;
2271
+ padding: 18px;
2272
+ }
2273
+
2274
+ .maintenance-summary {
2275
+ display: grid;
2276
+ grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
2277
+ border-top: 1px solid var(--border-subtle);
2278
+ border-bottom: 1px solid var(--border-subtle);
2279
+ margin-bottom: 16px;
2280
+ }
2281
+
2282
+ .maintenance-metric {
2283
+ display: flex;
2284
+ flex-direction: column;
2285
+ gap: 5px;
2286
+ min-width: 0;
2287
+ padding: 12px;
2288
+ border-right: 1px solid var(--border-subtle);
2289
+ }
2290
+
2291
+ .maintenance-metric:last-child {
2292
+ border-right: 0;
2293
+ }
2294
+
2295
+ .maintenance-metric span {
2296
+ font-size: 10px;
2297
+ color: var(--text-muted);
2298
+ }
2299
+
2300
+ .maintenance-metric strong {
2301
+ font: 600 19px var(--font-mono);
2302
+ color: var(--text-primary);
2303
+ }
2304
+
2305
+ .maintenance-records,
2306
+ .maintenance-group {
2307
+ display: flex;
2308
+ flex-direction: column;
2309
+ }
2310
+
2311
+ .maintenance-group {
2312
+ margin-bottom: 12px;
2313
+ border-top: 1px solid var(--border-subtle);
2314
+ }
2315
+
2316
+ .maintenance-group-title {
2317
+ padding: 10px 8px 6px;
2318
+ font-size: 11px;
2319
+ color: var(--text-secondary);
2320
+ }
2321
+
2322
+ .maintenance-record {
2323
+ display: grid;
2324
+ grid-template-columns: 52px minmax(0, 1fr) minmax(80px, auto);
2325
+ align-items: center;
2326
+ gap: 10px;
2327
+ min-height: 42px;
2328
+ padding: 8px;
2329
+ border-bottom: 1px solid var(--border-subtle);
2330
+ font-size: 12px;
2331
+ }
2332
+
2333
+ .maintenance-record-id,
2334
+ .maintenance-record-meta {
2335
+ font-family: var(--font-mono);
2336
+ color: var(--text-muted);
2337
+ }
2338
+
2339
+ .maintenance-record-title,
2340
+ .maintenance-record-meta {
2341
+ min-width: 0;
2342
+ overflow: hidden;
2343
+ text-overflow: ellipsis;
2344
+ white-space: nowrap;
2345
+ }
2346
+
2347
+ .maintenance-record-meta {
2348
+ text-align: right;
2349
+ font-size: 10px;
2350
+ }
2351
+
2352
+ .maintenance-toggle {
2353
+ display: flex;
2354
+ align-items: center;
2355
+ gap: 9px;
2356
+ margin-bottom: 14px;
2357
+ font-size: 12px;
2358
+ color: var(--text-secondary);
2359
+ cursor: pointer;
2360
+ }
2361
+
2362
+ .maintenance-toggle input {
2363
+ width: 16px;
2364
+ height: 16px;
2365
+ accent-color: var(--accent-purple);
2366
+ }
2367
+
2368
+ .maintenance-empty {
2369
+ display: flex;
2370
+ flex-direction: column;
2371
+ align-items: center;
2372
+ justify-content: center;
2373
+ gap: 8px;
2374
+ min-height: 130px;
2375
+ color: var(--text-muted);
2376
+ text-align: center;
2377
+ }
2378
+
2379
+ .maintenance-primary {
2380
+ border-color: var(--accent-purple);
2381
+ background: var(--accent-purple);
2382
+ color: var(--bg-base);
2383
+ }
2384
+
2385
+ .maintenance-primary:hover:not(:disabled) {
2386
+ filter: brightness(1.08);
2387
+ }
2388
+
2389
+ .maintenance-primary:disabled,
2390
+ .export-btn:disabled {
2391
+ opacity: 0.45;
2392
+ cursor: not-allowed;
2393
+ }
2394
+
2395
+ .dashboard-toast {
2396
+ position: fixed;
2397
+ right: 20px;
2398
+ bottom: 20px;
2399
+ z-index: 1200;
2400
+ max-width: min(420px, calc(100vw - 40px));
2401
+ padding: 11px 14px;
2402
+ border: 1px solid var(--accent-green);
2403
+ border-radius: var(--radius-sm);
2404
+ background: var(--bg-card);
2405
+ color: var(--text-primary);
2406
+ box-shadow: var(--elevation-2);
2407
+ font-size: 12px;
2408
+ opacity: 0;
2409
+ transform: translateY(10px);
2410
+ pointer-events: none;
2411
+ transition: opacity var(--transition-fast), transform var(--transition-fast);
2412
+ }
2413
+
2414
+ .dashboard-toast[data-tone="error"] {
2415
+ border-color: var(--accent-red);
2416
+ }
2417
+
2418
+ .dashboard-toast.visible {
2419
+ opacity: 1;
2420
+ transform: translateY(0);
2421
+ }
2422
+
2423
+ .page-header-actions {
2424
+ display: flex;
2425
+ align-items: center;
2426
+ justify-content: space-between;
2427
+ gap: 16px;
2428
+ }
2429
+
2430
+ @media (max-width: 720px) {
2431
+ .maintenance-strip,
2432
+ .page-header-actions {
2433
+ align-items: stretch;
2434
+ flex-direction: column;
2435
+ }
2436
+
2437
+ .maintenance-actions {
2438
+ display: grid;
2439
+ grid-template-columns: repeat(3, minmax(0, 1fr));
2440
+ }
2441
+
2442
+ .maintenance-actions button {
2443
+ min-width: 0;
2444
+ padding-inline: 8px;
2445
+ }
2446
+
2447
+ .maintenance-record {
2448
+ grid-template-columns: 44px minmax(0, 1fr);
2449
+ }
2450
+
2451
+ .maintenance-record-meta {
2452
+ grid-column: 2;
2453
+ text-align: left;
2454
+ }
2455
+
2456
+ .maintenance-dialog-body {
2457
+ padding: 14px;
2458
+ }
2459
+ }
2460
+
2151
2461
  /* Low quality indicator */
2152
2462
  .obs-card.low-quality {
2153
2463
  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
+ }