memorix 1.2.3 → 1.2.5

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 (52) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +3 -3
  3. package/README.zh-CN.md +3 -3
  4. package/dist/cli/index.js +5273 -4730
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/index.js +506 -99
  7. package/dist/index.js.map +1 -1
  8. package/dist/maintenance-runner.js +176 -53
  9. package/dist/maintenance-runner.js.map +1 -1
  10. package/dist/memcode-runtime/CHANGELOG.md +33 -0
  11. package/dist/sdk.d.ts +4 -2
  12. package/dist/sdk.js +507 -99
  13. package/dist/sdk.js.map +1 -1
  14. package/dist/types.d.ts +8 -1
  15. package/dist/types.js.map +1 -1
  16. package/docs/1.2.4-PERSISTENT-MEMORY-DELIVERY.md +86 -0
  17. package/docs/1.2.5-RETRIEVAL-PERFORMANCE.md +85 -0
  18. package/docs/AGENT_OPERATOR_PLAYBOOK.md +13 -1
  19. package/docs/API_REFERENCE.md +13 -3
  20. package/docs/PERFORMANCE.md +22 -1
  21. package/docs/dev-log/progress.txt +73 -7
  22. package/package.json +2 -1
  23. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  24. package/src/cli/capability-map.ts +1 -1
  25. package/src/cli/command-guide.ts +4 -1
  26. package/src/cli/commands/agent-integrations.ts +5 -1
  27. package/src/cli/commands/codegraph.ts +1 -1
  28. package/src/cli/commands/context.ts +9 -1
  29. package/src/cli/commands/memory.ts +12 -3
  30. package/src/cli/commands/operator-shared.ts +74 -2
  31. package/src/cli/commands/resume.ts +31 -0
  32. package/src/cli/index.ts +5 -1
  33. package/src/codegraph/auto-context.ts +54 -1
  34. package/src/codegraph/task-lens.ts +29 -0
  35. package/src/compact/token-budget.ts +16 -1
  36. package/src/config/resolved-config.ts +29 -4
  37. package/src/config/toml-loader.ts +9 -5
  38. package/src/hooks/handler.ts +127 -66
  39. package/src/hooks/installers/index.ts +5 -4
  40. package/src/hooks/official-skills.ts +6 -4
  41. package/src/hooks/rules/memorix-agent-rules.md +9 -7
  42. package/src/knowledge/context-assembly.ts +4 -1
  43. package/src/knowledge/workset.ts +89 -1
  44. package/src/memory/session.ts +144 -10
  45. package/src/runtime/project-maintenance.ts +1 -14
  46. package/src/sdk.ts +4 -0
  47. package/src/server.ts +144 -25
  48. package/src/store/bun-sqlite-compat.ts +118 -15
  49. package/src/store/orama-store.ts +39 -18
  50. package/src/store/sqlite-db.ts +3 -3
  51. package/src/timeout.ts +23 -0
  52. package/src/types.ts +8 -0
@@ -1,21 +1,120 @@
1
1
  /**
2
2
  * Bun SQLite Compatibility Layer
3
3
  *
4
- * Provides a better-sqlite3 compatible API using bun:sqlite when running under Bun.
5
- * Falls back to better-sqlite3 when running under Node.js.
4
+ * Provides a small better-sqlite3 compatibility layer for the available local
5
+ * SQLite implementation. Node 22 ships node:sqlite, which keeps Memorix
6
+ * usable when an optional better-sqlite3 native binary is unavailable.
6
7
  */
7
8
 
8
9
  import { createRequire } from 'node:module';
9
10
 
10
11
  let Database: any;
12
+ let driver: 'better-sqlite3' | 'node:sqlite' | 'bun:sqlite' | undefined;
11
13
  const requireFromHere = createRequire(import.meta.url);
12
14
 
15
+ function loadNodeSqlite(): any {
16
+ const nodeSqlite = requireFromHere('node:sqlite');
17
+ return nodeSqlite.DatabaseSync;
18
+ }
19
+
20
+ function isNativeBindingFailure(error: unknown): boolean {
21
+ const message = error instanceof Error ? error.message : String(error);
22
+ return /could not locate the bindings file|better_sqlite3\.node|module did not self-register/i.test(message);
23
+ }
24
+
25
+ function addCompatibility(db: any): any {
26
+ // Bun and node:sqlite do not expose better-sqlite3's pragma helper.
27
+ if (!db.pragma) {
28
+ db.pragma = function (pragma: string, options?: { simple?: boolean }) {
29
+ const statement = db.prepare(`PRAGMA ${pragma}`);
30
+ if (options?.simple) {
31
+ const result = statement.get();
32
+ return result ? Object.values(result)[0] : undefined;
33
+ }
34
+ return statement.all();
35
+ };
36
+ }
37
+
38
+ // Stores use better-sqlite3's synchronous transaction factory. Keep nested
39
+ // calls correct with savepoints instead of silently committing inner work.
40
+ if (!db.transaction) {
41
+ let depth = 0;
42
+ let sequence = 0;
43
+ db.transaction = function <T>(fn: (...args: any[]) => T) {
44
+ return (...args: any[]): T => {
45
+ const outermost = depth === 0;
46
+ const savepoint = `memorix_tx_${++sequence}`;
47
+ if (outermost) {
48
+ db.exec('BEGIN IMMEDIATE');
49
+ } else {
50
+ db.exec(`SAVEPOINT ${savepoint}`);
51
+ }
52
+ depth += 1;
53
+ try {
54
+ const result = fn(...args);
55
+ if (result && typeof (result as any).then === 'function') {
56
+ throw new Error('[memorix] SQLite transactions must be synchronous');
57
+ }
58
+ depth -= 1;
59
+ if (outermost) {
60
+ db.exec('COMMIT');
61
+ } else {
62
+ db.exec(`RELEASE SAVEPOINT ${savepoint}`);
63
+ }
64
+ return result;
65
+ } catch (error) {
66
+ depth -= 1;
67
+ try {
68
+ if (outermost) {
69
+ db.exec('ROLLBACK');
70
+ } else {
71
+ db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`);
72
+ db.exec(`RELEASE SAVEPOINT ${savepoint}`);
73
+ }
74
+ } catch {
75
+ // Preserve the original application failure when rollback itself fails.
76
+ }
77
+ throw error;
78
+ }
79
+ };
80
+ };
81
+ }
82
+
83
+ return db;
84
+ }
85
+
86
+ function instantiateDatabase(Sqlite: any, filePath: string, options?: any): any {
87
+ // DatabaseSync validates that an explicitly supplied second argument is an
88
+ // object, unlike better-sqlite3 which accepts undefined.
89
+ return driver === 'node:sqlite' && options === undefined
90
+ ? new Sqlite(filePath)
91
+ : new Sqlite(filePath, options);
92
+ }
93
+
13
94
  export function loadSqlite(): any {
14
95
  if (Database) return Database;
15
96
 
97
+ // Kept as an operational escape hatch and a regression-test seam. Node 22
98
+ // is the supported minimum runtime, so this does not expand the platform
99
+ // contract beyond package.json's existing engine requirement.
100
+ if (process.env.MEMORIX_SQLITE_DRIVER === 'node') {
101
+ Database = loadNodeSqlite();
102
+ driver = 'node:sqlite';
103
+ return Database;
104
+ }
105
+
16
106
  // Try better-sqlite3 first (Node.js)
17
107
  try {
18
108
  Database = requireFromHere('better-sqlite3');
109
+ driver = 'better-sqlite3';
110
+ return Database;
111
+ } catch {
112
+ // Fall through to node:sqlite, then Bun's runtime implementation.
113
+ }
114
+
115
+ try {
116
+ Database = loadNodeSqlite();
117
+ driver = 'node:sqlite';
19
118
  return Database;
20
119
  } catch {
21
120
  // Fall through to bun:sqlite
@@ -26,9 +125,10 @@ export function loadSqlite(): any {
26
125
  // bun:sqlite is a Bun built-in
27
126
  const bunSqlite = requireFromHere('bun:sqlite');
28
127
  Database = bunSqlite.Database;
128
+ driver = 'bun:sqlite';
29
129
  return Database;
30
130
  } catch {
31
- throw new Error('[memorix] Neither better-sqlite3 nor bun:sqlite is available');
131
+ throw new Error('[memorix] SQLite is unavailable (better-sqlite3, node:sqlite, and bun:sqlite failed)');
32
132
  }
33
133
  }
34
134
 
@@ -38,18 +138,21 @@ export function loadSqlite(): any {
38
138
  */
39
139
  export function createDatabase(path: string, options?: any): any {
40
140
  const Sqlite = loadSqlite();
41
- const db = new Sqlite(path, options);
42
-
43
- // Bun's sqlite doesn't have .pragma() method, add compatibility
44
- if (!db.pragma) {
45
- db.pragma = function (pragma: string, options?: any) {
46
- if (options && options.simple) {
47
- const result = db.prepare(`PRAGMA ${pragma}`).get();
48
- return result ? Object.values(result)[0] : undefined;
49
- }
50
- return db.prepare(`PRAGMA ${pragma}`).all();
51
- };
141
+ try {
142
+ return addCompatibility(instantiateDatabase(Sqlite, path, options));
143
+ } catch (error) {
144
+ // require('better-sqlite3') can succeed even when npm had no prebuild and
145
+ // no local compiler. Fall back to Node's built-in SQLite for that one
146
+ // recoverable case; real open/corruption errors still surface unchanged.
147
+ if (driver !== 'better-sqlite3' || !isNativeBindingFailure(error)) throw error;
148
+ Database = loadNodeSqlite();
149
+ driver = 'node:sqlite';
150
+ return addCompatibility(instantiateDatabase(Database, path, options));
52
151
  }
152
+ }
53
153
 
54
- return db;
154
+ /** Test-only reset for the driver cache used by the forced node:sqlite path. */
155
+ export function resetSqliteDriverForTests(): void {
156
+ Database = undefined;
157
+ driver = undefined;
55
158
  }
@@ -17,6 +17,7 @@ import { getEmbeddingProvider, type EmbeddingProvider } from '../embedding/provi
17
17
  import { calculateProjectAffinity, extractProjectKeywords, type AffinityContext, type MemoryContent } from './project-affinity.js';
18
18
  import { detectQueryIntent, applyIntentBoost } from '../search/intent-detector.js';
19
19
  import { maybeExpandSearchQuery } from '../search/query-expansion.js';
20
+ import { withTimeout } from '../timeout.js';
20
21
 
21
22
  let db: AnyOrama | null = null;
22
23
  let dbInitPromise: Promise<AnyOrama> | null = null;
@@ -501,8 +502,12 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
501
502
  const t0 = perf ? performance.now() : 0;
502
503
  const mark = (label: string) => { if (perf) { const now = performance.now(); process.stderr.write(` [search-perf] ${label}: ${(now - t0).toFixed(0)}ms\n`); } };
503
504
  const modeKey = options.projectId ?? SEARCH_MODE_DEFAULT_KEY;
504
- lastSearchModeByProject.set(modeKey, embeddingEnabled ? 'hybrid' : 'fulltext');
505
+ const quality = options.quality ?? 'balanced';
505
506
  const database = await getDb();
507
+ lastSearchModeByProject.set(
508
+ modeKey,
509
+ quality === 'fast' ? 'fulltext (fast profile)' : embeddingEnabled ? 'hybrid' : 'fulltext',
510
+ );
506
511
 
507
512
  // Resolve project aliases — safety net for observations not yet migrated to canonical ID.
508
513
  // After migration, this is typically a single-element array matching options.projectId.
@@ -531,17 +536,28 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
531
536
  // Determine search mode: hybrid (with vector) or fulltext (default)
532
537
  const hasQuery = options.query && options.query.trim().length > 0;
533
538
  const originalQuery = options.query;
534
- const tier = hasQuery ? classifyQueryTier(originalQuery!) : 'fast' as QueryTier;
539
+ // The fast profile is a bounded local probe, not a lower-quality spelling
540
+ // corrector. Skip query classification and the expensive recall enrichments
541
+ // it would otherwise inherit from the balanced path.
542
+ const tier = quality === 'fast' ? 'fast' as QueryTier : hasQuery ? classifyQueryTier(originalQuery!) : 'fast' as QueryTier;
535
543
  mark(`tier=${tier}`);
536
544
 
537
- // Query expansion: only for heavy-tier (CJK) queries
538
- const expandedEmbeddingQuery = tier === 'heavy' ? await maybeExpandSearchQuery(options.query!) : options.query;
545
+ // LLM quality work is explicit. It never runs on the default search path.
546
+ if (quality === 'thorough' && hasQuery) {
547
+ const { initLLM, isLLMEnabled } = await import('../llm/provider.js');
548
+ if (!isLLMEnabled()) initLLM();
549
+ }
550
+
551
+ // Query expansion: only for thorough heavy-tier (CJK) searches.
552
+ const expandedEmbeddingQuery = quality === 'thorough' && tier === 'heavy'
553
+ ? await maybeExpandSearchQuery(options.query!)
554
+ : options.query;
539
555
  mark('queryExpansion');
540
556
 
541
557
  // ── Intent-Aware Recall ──────────────────────────────────────
542
558
  // Detect query intent (why/when/how/what/problem) and adjust
543
559
  // field weights and type boosting accordingly.
544
- const intentResult = hasQuery ? detectQueryIntent(originalQuery!) : null;
560
+ const intentResult = quality === 'fast' || !hasQuery ? null : detectQueryIntent(originalQuery!);
545
561
 
546
562
  // Orama's vector/hybrid search can leak cross-project hits even when `where`
547
563
  // is present, so always keep enough headroom for a deterministic post-filter.
@@ -565,20 +581,24 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
565
581
  let searchParams: Record<string, unknown> = {
566
582
  term: originalQuery,
567
583
  limit: requestLimit,
568
- includeVectors: true,
584
+ // Fast search never uses vectors, so returning them only adds work and
585
+ // payload pressure to an explicitly latency-sensitive path.
586
+ includeVectors: quality !== 'fast',
569
587
  ...(Object.keys(filters).length > 0 ? { where: filters } : {}),
570
588
  // Search specific fields (not tokens, accessCount, etc.)
571
589
  properties: ['title', 'entityName', 'narrative', 'facts', 'concepts', 'filesModified'],
572
590
  // Field boosting: intent-aware or default
573
591
  boost: fieldBoost,
574
592
  // Fuzzy tolerance: allow 1-char typos for short queries, 2 for longer
575
- ...(hasQuery ? { tolerance: originalQuery!.length > 6 ? 2 : 1 } : {}),
593
+ // Fuzzy matching is useful for ordinary recall, but grows sharply with
594
+ // corpus size. The fast profile deliberately uses exact lexical matching.
595
+ ...(hasQuery && quality !== 'fast' ? { tolerance: originalQuery!.length > 6 ? 2 : 1 } : {}),
576
596
  };
577
597
 
578
598
  // If embedding provider is available and query tier warrants it, use hybrid search
579
599
  // Fast-tier queries skip embedding entirely (fulltext is sufficient)
580
600
  let queryVector: number[] | null = null;
581
- if (embeddingEnabled && hasQuery && tier !== 'fast') {
601
+ if (quality !== 'fast' && embeddingEnabled && hasQuery && tier !== 'fast') {
582
602
  try {
583
603
  const provider = await getEmbeddingProvider();
584
604
  if (provider) {
@@ -594,11 +614,11 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
594
614
  } else {
595
615
  // Embedding timeout: 15 seconds
596
616
  const EMBEDDING_TIMEOUT_MS = 15000;
597
- const embedPromise = provider.embed(expandedEmbeddingQuery!);
598
- const timeoutPromise = new Promise<never>((_, reject) =>
599
- setTimeout(() => reject(new Error(`Embedding timeout after ${EMBEDDING_TIMEOUT_MS}ms`)), EMBEDDING_TIMEOUT_MS)
617
+ queryVector = await withTimeout(
618
+ provider.embed(expandedEmbeddingQuery!),
619
+ EMBEDDING_TIMEOUT_MS,
620
+ 'Embedding',
600
621
  );
601
- queryVector = await Promise.race([embedPromise, timeoutPromise]);
602
622
  mark('embedding');
603
623
  // Detect CJK-heavy queries: BM25 can't tokenize Chinese/Japanese/Korean well
604
624
  const cjkRatio = (originalQuery!.match(/[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/g) || []).length / originalQuery!.length;
@@ -946,9 +966,10 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
946
966
 
947
967
  // ── LLM Reranking (heavy-tier only) ────────────────────────────
948
968
  // Only triggered for heavy-tier queries with ambiguous top results.
949
- // Fast and standard tiers skip entirely.
969
+ // Non-thorough profiles, fast, and standard tiers skip entirely.
950
970
  // Ambiguity check: top-2 scores within 30% → results are uncertain.
951
- const shouldRerank = tier === 'heavy'
971
+ const shouldRerank = quality === 'thorough'
972
+ && tier === 'heavy'
952
973
  && hasQuery
953
974
  && intermediate.length > 2
954
975
  && (() => {
@@ -979,11 +1000,11 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
979
1000
  // LLM rerank timeout: configurable via MEMORIX_RERANK_TIMEOUT_MS, default 5s
980
1001
  const _parsedRerank = parseInt(process.env.MEMORIX_RERANK_TIMEOUT_MS || '', 10);
981
1002
  const RERANK_TIMEOUT_MS = Number.isFinite(_parsedRerank) && _parsedRerank > 0 ? _parsedRerank : 5000;
982
- const rerankPromise = rerankResults(originalQuery!, candidates);
983
- const timeoutPromise = new Promise<never>((_, reject) =>
984
- setTimeout(() => reject(new Error(`LLM rerank timeout after ${RERANK_TIMEOUT_MS}ms`)), RERANK_TIMEOUT_MS)
1003
+ const { reranked, usedLLM } = await withTimeout(
1004
+ rerankResults(originalQuery!, candidates),
1005
+ RERANK_TIMEOUT_MS,
1006
+ 'LLM rerank',
985
1007
  );
986
- const { reranked, usedLLM } = await Promise.race([rerankPromise, timeoutPromise]);
987
1008
  mark(`rerank(usedLLM=${usedLLM})`);
988
1009
 
989
1010
  if (usedLLM) {
@@ -17,7 +17,7 @@ import path from 'node:path';
17
17
  import fs from 'node:fs';
18
18
  import { createDatabase, loadSqlite } from './bun-sqlite-compat.js';
19
19
 
20
- // Dynamic require for SQLite (better-sqlite3 or bun:sqlite)
20
+ // Dynamic require for SQLite (better-sqlite3, node:sqlite, or bun:sqlite)
21
21
  let BetterSqlite3: any;
22
22
 
23
23
  export function loadBetterSqlite3(): any {
@@ -26,7 +26,7 @@ export function loadBetterSqlite3(): any {
26
26
  BetterSqlite3 = loadSqlite();
27
27
  return BetterSqlite3;
28
28
  } catch {
29
- throw new Error('[memorix] SQLite is not available (neither better-sqlite3 nor bun:sqlite)');
29
+ throw new Error('[memorix] SQLite is not available (better-sqlite3, node:sqlite, and bun:sqlite all failed)');
30
30
  }
31
31
  }
32
32
 
@@ -688,7 +688,7 @@ function applySchemaMigrations(db: any): void {
688
688
  const _dbCache = new Map<string, any>();
689
689
 
690
690
  /**
691
- * Get or create a shared better-sqlite3 database handle for the given data directory.
691
+ * Get or create a shared SQLite database handle for the given data directory.
692
692
  *
693
693
  * The handle is cached per normalized dataDir path. All stores (observations,
694
694
  * mini-skills, sessions) share the same connection and the same DB file.
package/src/timeout.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Run an operation with a bounded wait without leaving its watchdog alive.
3
+ *
4
+ * The underlying operation is not force-cancelled because many existing
5
+ * callers do not expose an AbortSignal. Its settlement is still observed so a
6
+ * late rejection is handled, while the caller receives the timeout promptly.
7
+ */
8
+ export function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
9
+ return new Promise<T>((resolve, reject) => {
10
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
11
+
12
+ promise.then(
13
+ (value) => {
14
+ clearTimeout(timer);
15
+ resolve(value);
16
+ },
17
+ (error) => {
18
+ clearTimeout(timer);
19
+ reject(error);
20
+ },
21
+ );
22
+ });
23
+ }
package/src/types.ts CHANGED
@@ -239,6 +239,9 @@ export interface TimelineContext {
239
239
  after: IndexEntry[];
240
240
  }
241
241
 
242
+ /** Retrieval work allowed for a search request. */
243
+ export type RetrievalQuality = 'fast' | 'balanced' | 'thorough';
244
+
242
245
  /** Search options for the compact engine */
243
246
  export interface SearchOptions {
244
247
  query: string;
@@ -257,6 +260,11 @@ export interface SearchOptions {
257
260
  trackAccess?: boolean;
258
261
  /** Internal reader context for agent-facing visibility filtering. */
259
262
  reader?: ObservationReader;
263
+ /**
264
+ * `fast` stays fully local, `balanced` (default) permits embeddings, and
265
+ * `thorough` explicitly permits optional LLM query rewrite and reranking.
266
+ */
267
+ quality?: RetrievalQuality;
260
268
  }
261
269
 
262
270
  /** Topic key family heuristics for suggesting stable topic keys */