memorix 1.6.2 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/cli/index.js +2042 -1430
  3. package/dist/cli/memcode.js +0 -0
  4. package/dist/dashboard/static/app.js +293 -33
  5. package/dist/dashboard/static/index.html +17 -0
  6. package/dist/dashboard/static/style.css +310 -0
  7. package/dist/index.js +1669 -970
  8. package/dist/maintenance-runner.js +1061 -1017
  9. package/dist/media-audio-runner.js +1052 -1009
  10. package/dist/media-video-runner.js +1052 -1009
  11. package/dist/memcode-runtime/CHANGELOG.md +34 -0
  12. package/dist/sdk.js +2293 -1684
  13. package/dist/vector-backfill-runner.js +1050 -1007
  14. package/docs/ARCHITECTURE.md +3 -0
  15. package/docs/DASHBOARD.md +54 -0
  16. package/docs/README.md +2 -0
  17. package/package.json +1 -1
  18. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  19. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  20. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  21. package/plugins/copilot/memorix/plugin.json +1 -1
  22. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  23. package/plugins/omp/memorix/package.json +1 -1
  24. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  25. package/plugins/pi/memorix/package.json +1 -1
  26. package/server.json +2 -2
  27. package/src/cli/commands/background.ts +17 -3
  28. package/src/cli/commands/cleanup.ts +13 -114
  29. package/src/cli/commands/memory.ts +12 -43
  30. package/src/cli/commands/operator-shared.ts +12 -8
  31. package/src/cli/commands/serve-http.ts +25 -7
  32. package/src/cli/commands/serve-shared.ts +49 -6
  33. package/src/cli/commands/serve.ts +4 -0
  34. package/src/dashboard/maintenance.ts +303 -0
  35. package/src/dashboard/server.ts +100 -2
  36. package/src/dashboard/static/index.html +17 -0
  37. package/src/dashboard/static/style.css +310 -0
  38. package/src/memory/cleanup.ts +143 -0
  39. package/src/memory/deduplication.ts +178 -0
  40. package/src/memory/session.ts +13 -3
  41. package/src/project/launch-root.ts +115 -0
  42. package/src/runtime/isolated-maintenance.ts +12 -1
  43. package/src/server.ts +50 -51
  44. package/src/store/obs-store.ts +2 -2
  45. package/src/store/sqlite-db.ts +2 -0
  46. package/src/store/sqlite-store.ts +19 -4
@@ -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
+ }
@@ -140,10 +140,20 @@ async function loadAliasActiveSessions(projectIds: Set<string>): Promise<Session
140
140
  return groups.flat();
141
141
  }
142
142
 
143
- async function loadAliasActiveObservations(projectIds: Set<string>): Promise<Observation[]> {
143
+ const SESSION_RESUME_OBSERVATION_LIMIT = 64;
144
+ const SESSION_CONTEXT_OBSERVATION_LIMIT = 128;
145
+
146
+ async function loadAliasActiveObservations(
147
+ projectIds: Set<string>,
148
+ limit: number = SESSION_RESUME_OBSERVATION_LIMIT,
149
+ ): Promise<Observation[]> {
144
150
  const store = getObservationStore();
145
151
  const groups = await Promise.all(
146
- [...projectIds].map((projectId) => store.loadByProject(projectId, { status: 'active' })),
152
+ [...projectIds].map((projectId) => store.loadByProject(projectId, {
153
+ status: 'active',
154
+ limit,
155
+ newestFirst: true,
156
+ })),
147
157
  );
148
158
  return groups.flat();
149
159
  }
@@ -522,7 +532,7 @@ export async function getSessionContext(
522
532
  const aliasSet = await resolveProjectIds(projectId);
523
533
  const [sessions, allObs] = await Promise.all([
524
534
  loadAliasSessions(aliasSet),
525
- loadAliasActiveObservations(aliasSet),
535
+ loadAliasActiveObservations(aliasSet, SESSION_CONTEXT_OBSERVATION_LIMIT),
526
536
  ]);
527
537
  const readableObs = readableAliasObservations(allObs, aliasSet, reader);
528
538
  /** Check if a session summary contains noise/system-self content */