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
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Launch-root guards for HTTP/stdio/background.
3
+ *
4
+ * LaunchAgents and Cursor stdio often start with cwd=$HOME. Binding that path
5
+ * treats the home directory as a project and can create a decoy ~/memorix.db
6
+ * while the real store lives in ~/.memorix/data/memorix.db.
7
+ */
8
+
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ export const LAST_PROJECT_ROOT_FILENAME = 'last-project-root';
14
+
15
+ /**
16
+ * Resolve the home directory used by Memorix boundary guards.
17
+ *
18
+ * Node's os.homedir() is intentionally stable on Windows and does not always
19
+ * follow a process-level HOME/USERPROFILE override. The CLI and test harnesses
20
+ * use those environment variables as the configured home, so all guards need
21
+ * one shared resolution rule instead of mixing the two sources.
22
+ */
23
+ export function effectiveHomeDirectory(): string {
24
+ const configuredHome = process.platform === 'win32'
25
+ ? process.env.USERPROFILE?.trim() || process.env.HOME?.trim()
26
+ : process.env.HOME?.trim() || process.env.USERPROFILE?.trim();
27
+ return path.resolve(configuredHome || os.homedir());
28
+ }
29
+
30
+ export function isHomeDirectory(candidate: string, homeDir: string = effectiveHomeDirectory()): boolean {
31
+ try {
32
+ return path.resolve(candidate) === path.resolve(homeDir);
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ export function lastProjectRootPath(homeDir: string = effectiveHomeDirectory()): string {
39
+ return path.join(homeDir, '.memorix', LAST_PROJECT_ROOT_FILENAME);
40
+ }
41
+
42
+ export function readLastProjectRoot(homeDir: string = effectiveHomeDirectory()): string | null {
43
+ try {
44
+ const raw = readFileSync(lastProjectRootPath(homeDir), 'utf8').trim();
45
+ if (!raw) return null;
46
+ const resolved = path.resolve(raw);
47
+ if (isHomeDirectory(resolved, homeDir)) return null;
48
+ if (!existsSync(resolved)) return null;
49
+ return resolved;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ export function writeLastProjectRoot(projectRoot: string, homeDir?: string): void {
56
+ // Tests pass an explicit homeDir. Default writes are skipped under Vitest so
57
+ // unit tests do not mutate the developer's ~/.memorix/last-project-root.
58
+ if (process.env.VITEST && homeDir === undefined) return;
59
+ const resolvedHome = homeDir ?? effectiveHomeDirectory();
60
+ const resolved = path.resolve(projectRoot);
61
+ if (isHomeDirectory(resolved, resolvedHome)) return;
62
+ if (!existsSync(resolved)) return;
63
+ const memorixDir = path.join(resolvedHome, '.memorix');
64
+ mkdirSync(memorixDir, { recursive: true });
65
+ writeFileSync(lastProjectRootPath(resolvedHome), `${resolved}\n`, 'utf8');
66
+ }
67
+
68
+ export function homeProjectRootError(candidate: string): string {
69
+ return (
70
+ `Refusing to bind $HOME as a project root (${candidate}). ` +
71
+ 'Pass a git project via --cwd, MEMORIX_PROJECT_ROOT, or memorix_session_start({ projectRoot }). ' +
72
+ 'Memorix will not create ~/memorix.db.'
73
+ );
74
+ }
75
+
76
+ /**
77
+ * When the launcher cwd is $HOME, inherit the last git project or an explicit
78
+ * MEMORIX_PROJECT_ROOT. System directories stay fail-closed (no restore).
79
+ */
80
+ export function inheritProjectRootFromHome(options: {
81
+ homeDir: string;
82
+ envProjectRoot?: string;
83
+ readLastRoot?: () => string | null;
84
+ }): string | null {
85
+ const envRoot = options.envProjectRoot?.trim();
86
+ if (envRoot && !isHomeDirectory(envRoot, options.homeDir) && existsSync(envRoot)) {
87
+ return path.resolve(envRoot);
88
+ }
89
+ const lastRoot = (options.readLastRoot ?? (() => readLastProjectRoot(options.homeDir)))();
90
+ if (lastRoot && !isHomeDirectory(lastRoot, options.homeDir) && existsSync(lastRoot)) {
91
+ return lastRoot;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Shared SQLite must never be opened at $HOME — that creates a decoy ~/memorix.db.
98
+ */
99
+ export function resolveControlPlaneDataDir(options: {
100
+ requestedDataDir?: string;
101
+ homeDir?: string;
102
+ globalDataDir: string;
103
+ }): string {
104
+ const homeDir = options.homeDir ?? effectiveHomeDirectory();
105
+ if (options.requestedDataDir && !isHomeDirectory(options.requestedDataDir, homeDir)) {
106
+ return options.requestedDataDir;
107
+ }
108
+ return options.globalDataDir;
109
+ }
110
+
111
+ export function assertNotHomeDataDir(dataDir: string, homeDir: string = effectiveHomeDirectory()): void {
112
+ if (isHomeDirectory(dataDir, homeDir)) {
113
+ throw new Error(homeProjectRootError(dataDir));
114
+ }
115
+ }
@@ -1,5 +1,6 @@
1
1
  import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
 
@@ -52,6 +53,16 @@ function childError(prefix: string, detail: string): Error {
52
53
  return new Error(cleaned ? `${prefix}: ${cleaned}` : prefix);
53
54
  }
54
55
 
56
+ /**
57
+ * Isolated jobs already receive an absolute projectRoot on stdin. Using that
58
+ * directory as spawn cwd keeps the folder locked on Windows (EBUSY on rmdir)
59
+ * for the life of the child. Park the process in a disposable temp dir instead.
60
+ */
61
+ export function isolatedMaintenanceCwd(): string {
62
+ const tmp = os.tmpdir();
63
+ return existsSync(tmp) ? tmp : process.cwd();
64
+ }
65
+
55
66
  /**
56
67
  * Resolve the runner beside the compiled package entry. The CLI bundle lives
57
68
  * in dist/cli while the library entry lives directly in dist.
@@ -148,7 +159,7 @@ export async function runMaintenanceInChildProcess(
148
159
  let child: ChildProcessWithoutNullStreams;
149
160
  try {
150
161
  child = spawn(process.execPath, [runnerPath], {
151
- cwd: request.projectRoot,
162
+ cwd: isolatedMaintenanceCwd(),
152
163
  env,
153
164
  stdio: ['pipe', 'pipe', 'pipe'],
154
165
  windowsHide: true,
package/src/server.ts CHANGED
@@ -33,6 +33,7 @@ import { canManageObservation, canReadObservation, filterReadableObservations, r
33
33
  import { compactSearch, compactTimeline, compactDetail } from './compact/engine.js';
34
34
  import { buildGraphContextPacket, formatGraphContextPrompt } from './memory/graph-context.js';
35
35
  import { detectProject } from './project/detector.js';
36
+ import { homeProjectRootError, isHomeDirectory, writeLastProjectRoot } from './project/launch-root.js';
36
37
  import { registerAlias, initAliasRegistry, resolveAliases, autoMergeByBaseName } from './project/aliases.js';
37
38
  import { getProjectDataDir } from './store/persistence.js';
38
39
  import type { ObservationType, RuleSource, AgentTarget, MCPServerEntry, ObservationReader } from './types.js';
@@ -45,7 +46,8 @@ import {
45
46
  type ToolProfile,
46
47
  } from './server/tool-profile.js';
47
48
  import { initLLM, isLLMEnabled, getLLMConfig } from './llm/provider.js';
48
- import { compactOnWrite, deduplicateMemory } from './llm/memory-manager.js';
49
+ import { compactOnWrite } from './llm/memory-manager.js';
50
+ import { applyDeduplicationPlan, planMemoryDeduplication } from './memory/deduplication.js';
49
51
  import type { ExistingMemory } from './llm/memory-manager.js';
50
52
  import { runFormation, getMetricsSummary, getBeforeAfterMetrics } from './memory/formation/index.js';
51
53
  import type { FormationConfig, SearchHit, FormedMemory, FormationStage, FormationStageEvent } from './memory/formation/types.js';
@@ -228,6 +230,7 @@ export const BOOTSTRAP_SAFE_TOOL_NAMES = new Set([
228
230
  'memorix_codegraph_status',
229
231
  'memorix_graph_context',
230
232
  'memorix_context_pack',
233
+ 'memorix_session_start',
231
234
  ]);
232
235
 
233
236
  const AUTOPILOT_RETRIEVAL_BOUNDARY_TTL_MS = 2 * 60 * 1000;
@@ -265,7 +268,10 @@ export async function createMemorixServer(
265
268
  });
266
269
  const teamFeaturesEnabled = isToolInProfile('team_manage', toolProfile);
267
270
  const projectBinding = options.projectBinding ?? createProjectBindingController(cwd ?? process.cwd());
268
- const detectedProject = detectProject(cwd);
271
+ const detectedProjectRaw = detectProject(cwd);
272
+ const detectedProject = detectedProjectRaw && !isHomeDirectory(detectedProjectRaw.rootPath)
273
+ ? detectedProjectRaw
274
+ : null;
269
275
  let rawProject: import('./types.js').ProjectInfo;
270
276
  let projectResolved = true;
271
277
  let projectResolutionError: string | null = null;
@@ -316,6 +322,7 @@ export async function createMemorixServer(
316
322
  }
317
323
  if (projectResolved) {
318
324
  projectBinding.recordResolvedProject(project.id, project.rootPath);
325
+ writeLastProjectRoot(project.rootPath);
319
326
  }
320
327
 
321
328
  const registerMaintenanceTarget = async (): Promise<void> => {
@@ -362,6 +369,7 @@ export async function createMemorixServer(
362
369
 
363
370
  const lightweightUnresolvedSession = !projectResolved && deferProjectInitUntilBound;
364
371
  let projectRuntimeInitPromise: Promise<void> | null = null;
372
+ let runtimeHydratedForDir: string | null = null;
365
373
 
366
374
  const initializeProjectRuntime = async (logPrefix: 'startup' | 'switch'): Promise<void> => {
367
375
  await initObservationStore(projectDir);
@@ -430,14 +438,20 @@ export async function createMemorixServer(
430
438
  } catch { /* migration is optional */ }
431
439
 
432
440
  await initializeProjectRuntime('startup');
441
+ runtimeHydratedForDir = projectDir;
433
442
  } else {
434
443
  // Intentionally silent — serve-http.ts deferred logging handles session lifecycle visibility.
435
444
  // Noisy per-probe 'awaiting binding' log was removed to reduce terminal spam.
436
445
  }
437
446
 
438
447
  const ensureProjectRuntimeInitialized = async (): Promise<void> => {
439
- if (lightweightUnresolvedSession || !deferProjectRuntimeInit) return;
448
+ if (!projectResolved) return;
449
+ if (runtimeHydratedForDir === projectDir) {
450
+ if (projectRuntimeInitPromise) await projectRuntimeInitPromise;
451
+ return;
452
+ }
440
453
  if (!projectRuntimeInitPromise) {
454
+ const targetDir = projectDir;
441
455
  projectRuntimeInitPromise = (async () => {
442
456
  // Auto-merge obvious alias groups by scanning observed projectIds in data.
443
457
  try {
@@ -470,7 +484,8 @@ export async function createMemorixServer(
470
484
  }
471
485
  } catch { /* migration is optional */ }
472
486
 
473
- await initializeProjectRuntime('startup');
487
+ await initializeProjectRuntime(runtimeHydratedForDir ? 'switch' : 'startup');
488
+ runtimeHydratedForDir = targetDir;
474
489
  })().catch((err) => {
475
490
  projectRuntimeInitPromise = null;
476
491
  throw err;
@@ -2489,66 +2504,31 @@ export async function createMemorixServer(
2489
2504
  return { content: [{ type: 'text' as const, text: 'Not enough memories in scope to deduplicate.' }] };
2490
2505
  }
2491
2506
 
2492
- // Group by entity for focused dedup
2493
- const byEntity = new Map<string, typeof candidates>();
2494
- for (const obs of candidates) {
2495
- const list = byEntity.get(obs.entityName) ?? [];
2496
- list.push(obs);
2497
- byEntity.set(obs.entityName, list);
2498
- }
2499
-
2500
- const actions: string[] = [];
2501
- const toResolve: number[] = [];
2502
-
2503
- for (const [entity, group] of byEntity) {
2504
- if (group.length < 2) continue;
2505
-
2506
- // Compare each pair within entity group
2507
- for (let i = 0; i < group.length; i++) {
2508
- for (let j = i + 1; j < group.length; j++) {
2509
- const newer = group[j];
2510
- const older = group[i];
2511
- try {
2512
- const decision = await deduplicateMemory(
2513
- { title: newer.title, narrative: newer.narrative, facts: newer.facts },
2514
- [{ id: older.id, title: older.title, narrative: older.narrative, facts: older.facts.join('\n') }],
2515
- );
2516
- if (decision && decision.action === 'UPDATE' && decision.targetId) {
2517
- actions.push(`[UPDATED] #${older.id} "${older.title}" → superseded by #${newer.id} (${decision.reason})${decision.usedLLM ? ' [LLM]' : ' [heuristic]'}`);
2518
- toResolve.push(older.id);
2519
- } else if (decision && decision.action === 'NONE') {
2520
- actions.push(`[DELETE] #${newer.id} "${newer.title}" → redundant (${decision.reason})${decision.usedLLM ? ' [LLM]' : ' [heuristic]'}`);
2521
- toResolve.push(newer.id);
2522
- } else if (decision && decision.action === 'DELETE') {
2523
- actions.push(`[ERROR] #${decision.targetId ?? older.id} → outdated (${decision.reason})${decision.usedLLM ? ' [LLM]' : ' [heuristic]'}`);
2524
- toResolve.push(decision.targetId ?? older.id);
2525
- }
2526
- } catch (dedupErr) { actions.push(`[WARN] comparison failed: ${(dedupErr as Error)?.message ?? dedupErr}`); }
2527
- }
2528
- }
2529
- }
2507
+ const manageableCandidates = candidates.filter((observation) => canManageObservation(observation, reader));
2508
+ const plan = await planMemoryDeduplication(manageableCandidates);
2509
+ const actionLines = plan.actions.map((item) =>
2510
+ `Resolve #${item.resolveId} "${item.resolveTitle}"; keep #${item.keepId} "${item.keepTitle}" (${item.reason})${item.usedLLM ? ' [LLM]' : ' [heuristic]'}`,
2511
+ );
2530
2512
 
2531
- if (actions.length === 0) {
2532
- return { content: [{ type: 'text' as const, text: `[OK] Scanned ${candidates.length} memories across ${byEntity.size} entities — no duplicates found.` }] };
2513
+ if (actionLines.length === 0) {
2514
+ return { content: [{ type: 'text' as const, text: `[OK] Scanned ${plan.scanned} memories across ${plan.entities} entities — no duplicates found.` }] };
2533
2515
  }
2534
2516
 
2535
2517
  if (dryRun) {
2536
2518
  return {
2537
2519
  content: [{
2538
2520
  type: 'text' as const,
2539
- text: `[SEARCH] DRY RUN — ${actions.length} action(s) found:\n\n${actions.join('\n')}\n\nRun with dryRun=false to apply.`,
2521
+ text: `[SEARCH] DRY RUN — ${actionLines.length} action(s) found:\n\n${actionLines.join('\n')}\n\nRun with dryRun=false to apply.`,
2540
2522
  }],
2541
2523
  };
2542
2524
  }
2543
2525
 
2544
- // Apply resolutions
2545
- const unique = [...new Set(toResolve)];
2546
- await resolveObservations(unique, 'resolved');
2526
+ const result = await applyDeduplicationPlan(getObservationStore(), project.id, plan.resolveIds, reader);
2547
2527
 
2548
2528
  return {
2549
2529
  content: [{
2550
2530
  type: 'text' as const,
2551
- text: `[CLEANUP] Deduplicated: resolved ${unique.length} memory(ies)\n\n${actions.join('\n')}`,
2531
+ text: `[CLEANUP] Deduplicated: resolved ${result.resolved.length} memory(ies)\n\n${actionLines.join('\n')}`,
2552
2532
  }],
2553
2533
  };
2554
2534
  },
@@ -3846,6 +3826,15 @@ export async function createMemorixServer(
3846
3826
  // BEFORE checking whether the project is resolved. This is the primary
3847
3827
  // mechanism for HTTP/control-plane multi-project support.
3848
3828
  if (explicitRoot && typeof explicitRoot === 'string') {
3829
+ if (isHomeDirectory(explicitRoot)) {
3830
+ return {
3831
+ content: [{
3832
+ type: 'text' as const,
3833
+ text: homeProjectRootError(explicitRoot),
3834
+ }],
3835
+ isError: true as const,
3836
+ };
3837
+ }
3849
3838
  let bound = await switchProject(explicitRoot, 'explicit-project-root');
3850
3839
  // switchProject returns false for both "same project, no-op" and "no git repo".
3851
3840
  // Only scan subdirectories when explicitRoot is not itself a git repo; otherwise
@@ -5503,8 +5492,12 @@ export async function createMemorixServer(
5503
5492
  ): Promise<boolean> => {
5504
5493
  if (source === 'mcp-roots' && projectBinding.isExplicit()) return false;
5505
5494
  const { detectProjectWithDiagnostics } = await import('./project/detector.js');
5495
+ if (isHomeDirectory(newCwd)) {
5496
+ console.error(`[memorix] Refusing to bind $HOME as a project root: ${newCwd}`);
5497
+ return false;
5498
+ }
5506
5499
  const result = detectProjectWithDiagnostics(newCwd);
5507
- if (!result.project) {
5500
+ if (!result.project || isHomeDirectory(result.project.rootPath)) {
5508
5501
  if (result.failure) {
5509
5502
  console.error(`[memorix] Project detection failed for "${newCwd}": [${result.failure.reason}] ${result.failure.detail}`);
5510
5503
  }
@@ -5523,6 +5516,7 @@ export async function createMemorixServer(
5523
5516
  else if (source === 'mcp-roots') projectBinding.bindFromRoots(newDetected.rootPath);
5524
5517
  else projectBinding.bindStartup(newDetected.rootPath);
5525
5518
  projectBinding.recordResolvedProject(project.id, project.rootPath);
5519
+ writeLastProjectRoot(project.rootPath);
5526
5520
  return false; // same project, no-op
5527
5521
  }
5528
5522
 
@@ -5580,7 +5574,12 @@ export async function createMemorixServer(
5580
5574
  }
5581
5575
  } catch { /* best-effort - coordination features degrade gracefully */ }
5582
5576
 
5583
- await initializeProjectRuntime('switch');
5577
+ writeLastProjectRoot(project.rootPath);
5578
+ runtimeHydratedForDir = null;
5579
+ projectRuntimeInitPromise = null;
5580
+ void ensureProjectRuntimeInitialized().catch((err) => {
5581
+ console.error(`[memorix] Background project runtime init failed: ${err instanceof Error ? err.message : err}`);
5582
+ });
5584
5583
  try {
5585
5584
  await startProjectMaintenanceWorker();
5586
5585
  } catch {
@@ -59,7 +59,7 @@ export interface ObservationStore {
59
59
  /** Load one project's observations without scanning the shared flat store. */
60
60
  loadByProject(
61
61
  projectId: string,
62
- options?: { status?: string; limit?: number; offset?: number; afterId?: number },
62
+ options?: { status?: string; limit?: number; offset?: number; afterId?: number; newestFirst?: boolean },
63
63
  ): Promise<Observation[]>;
64
64
 
65
65
  /** Count one project's observations without materializing the rows. */
@@ -231,7 +231,7 @@ export class DegradedBackend implements ObservationStore {
231
231
  return [];
232
232
  }
233
233
 
234
- async loadByProject(_projectId: string, _options?: { status?: string; limit?: number; offset?: number; afterId?: number }): Promise<Observation[]> {
234
+ async loadByProject(_projectId: string, _options?: { status?: string; limit?: number; offset?: number; afterId?: number; newestFirst?: boolean }): Promise<Observation[]> {
235
235
  return [];
236
236
  }
237
237
 
@@ -16,6 +16,7 @@ import { createRequire } from 'node:module';
16
16
  import path from 'node:path';
17
17
  import fs from 'node:fs';
18
18
  import { createDatabase, loadSqlite } from './bun-sqlite-compat.js';
19
+ import { assertNotHomeDataDir } from '../project/launch-root.js';
19
20
 
20
21
  // Dynamic require for SQLite (better-sqlite3, node:sqlite, or bun:sqlite)
21
22
  let BetterSqlite3: any;
@@ -998,6 +999,7 @@ const _dbCache = new Map<string, any>();
998
999
  */
999
1000
  export function getDatabase(dataDir: string): any {
1000
1001
  const normalized = path.resolve(dataDir);
1002
+ assertNotHomeDataDir(normalized);
1001
1003
  const existing = _dbCache.get(normalized);
1002
1004
  if (existing) return existing;
1003
1005
 
@@ -122,6 +122,8 @@ export class SqliteBackend implements ObservationStore {
122
122
  private stmtSelectByProjectStatus: any = null;
123
123
  private stmtSelectByProjectAfterId: any = null;
124
124
  private stmtSelectByProjectStatusAfterId: any = null;
125
+ private stmtSelectByProjectNewest: any = null;
126
+ private stmtSelectByProjectStatusNewest: any = null;
125
127
  private stmtCountByProject: any = null;
126
128
  private stmtCountByProjectStatus: any = null;
127
129
  private stmtSelectByTopicKey: any = null;
@@ -175,6 +177,14 @@ export class SqliteBackend implements ObservationStore {
175
177
  SELECT * FROM observations WHERE projectId = ? AND status = ? AND id > ?
176
178
  ORDER BY id ASC LIMIT ?
177
179
  `);
180
+ this.stmtSelectByProjectNewest = this.db.prepare(`
181
+ SELECT * FROM observations WHERE projectId = ?
182
+ ORDER BY createdAt DESC, id DESC LIMIT ? OFFSET ?
183
+ `);
184
+ this.stmtSelectByProjectStatusNewest = this.db.prepare(`
185
+ SELECT * FROM observations WHERE projectId = ? AND status = ?
186
+ ORDER BY createdAt DESC, id DESC LIMIT ? OFFSET ?
187
+ `);
178
188
  this.stmtCountByProject = this.db.prepare(
179
189
  `SELECT COUNT(*) AS count FROM observations WHERE projectId = ?`,
180
190
  );
@@ -288,15 +298,20 @@ export class SqliteBackend implements ObservationStore {
288
298
 
289
299
  async loadByProject(
290
300
  projectId: string,
291
- options: { status?: string; limit?: number; offset?: number; afterId?: number } = {},
301
+ options: { status?: string; limit?: number; offset?: number; afterId?: number; newestFirst?: boolean } = {},
292
302
  ): Promise<Observation[]> {
293
303
  const limit = options.limit == null ? -1 : Math.max(1, Math.floor(options.limit));
294
304
  const offset = options.offset == null ? 0 : Math.max(0, Math.floor(options.offset));
295
305
  const afterId = options.afterId == null ? null : Math.max(0, Math.floor(options.afterId));
306
+ const newestFirst = options.newestFirst === true && afterId === null;
296
307
  const rows = afterId === null
297
- ? (options.status
298
- ? this.stmtSelectByProjectStatus.all(projectId, options.status, limit, offset)
299
- : this.stmtSelectByProject.all(projectId, limit, offset))
308
+ ? (newestFirst
309
+ ? (options.status
310
+ ? this.stmtSelectByProjectStatusNewest.all(projectId, options.status, limit, offset)
311
+ : this.stmtSelectByProjectNewest.all(projectId, limit, offset))
312
+ : (options.status
313
+ ? this.stmtSelectByProjectStatus.all(projectId, options.status, limit, offset)
314
+ : this.stmtSelectByProject.all(projectId, limit, offset)))
300
315
  : (options.status
301
316
  ? this.stmtSelectByProjectStatusAfterId.all(projectId, options.status, afterId, limit)
302
317
  : this.stmtSelectByProjectAfterId.all(projectId, afterId, limit));