memorix 1.9.5 → 1.9.7

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 (64) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +6 -1
  3. package/README.zh-CN.md +6 -1
  4. package/dist/cli/index.js +1743 -348
  5. package/dist/index.d.ts +12 -7
  6. package/dist/index.js +1048 -266
  7. package/dist/maintenance-runner.js +339 -187
  8. package/dist/media-audio-runner.js +339 -187
  9. package/dist/media-video-runner.js +339 -187
  10. package/dist/memcode-runtime/CHANGELOG.md +53 -0
  11. package/dist/sdk.js +967 -241
  12. package/dist/vector-backfill-runner.js +339 -187
  13. package/docs/API_REFERENCE.md +36 -0
  14. package/docs/CONFIGURATION.md +43 -0
  15. package/docs/DEVELOPMENT.md +1 -1
  16. package/docs/KNOWN_ISSUES_AND_ROADMAP.md +49 -14
  17. package/docs/PERFORMANCE.md +46 -2
  18. package/docs/README.md +2 -1
  19. package/docs/SETUP.md +1 -1
  20. package/package.json +2 -1
  21. package/plugins/antigravity/memorix/plugin.json +1 -1
  22. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  23. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  24. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  25. package/plugins/copilot/memorix/plugin.json +1 -1
  26. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  27. package/plugins/hermes/memorix/plugin.yaml +1 -1
  28. package/plugins/omp/memorix/package.json +1 -1
  29. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  30. package/plugins/pi/memorix/package.json +1 -1
  31. package/server.json +2 -2
  32. package/src/cli/capability-map.ts +1 -0
  33. package/src/cli/command-guide.ts +13 -1
  34. package/src/cli/commands/context.ts +2 -0
  35. package/src/cli/commands/continuity.ts +155 -0
  36. package/src/cli/commands/init.ts +5 -2
  37. package/src/cli/commands/serve-http.ts +242 -42
  38. package/src/cli/index.ts +3 -1
  39. package/src/cli/tui/ConfigureView.tsx +2 -0
  40. package/src/codegraph/auto-context.ts +17 -0
  41. package/src/config/legacy-loader.ts +2 -2
  42. package/src/config/resolved-config.ts +8 -5
  43. package/src/config/yaml-loader.ts +20 -10
  44. package/src/embedding/api-provider.ts +4 -2
  45. package/src/embedding/fastembed-provider.ts +47 -14
  46. package/src/embedding/transformers-provider.ts +29 -7
  47. package/src/evaluation/memory-quality-gate.ts +90 -0
  48. package/src/hooks/official-skills.ts +2 -0
  49. package/src/knowledge/context-assembly.ts +1 -0
  50. package/src/knowledge/context-receipt.ts +15 -0
  51. package/src/knowledge/task-continuity.ts +413 -0
  52. package/src/knowledge/workset.ts +77 -0
  53. package/src/llm/provider.ts +2 -1
  54. package/src/orchestrate/coordinator.ts +60 -0
  55. package/src/orchestrate/effect-ledger.ts +160 -0
  56. package/src/orchestrate/pipeline-trace.ts +40 -6
  57. package/src/runtime/maintenance-jobs.ts +12 -6
  58. package/src/server/modern-mcp-bridge.ts +153 -27
  59. package/src/server/tool-profile.ts +3 -2
  60. package/src/server.ts +129 -5
  61. package/src/store/orama-store.ts +16 -1
  62. package/src/store/sqlite-db.ts +144 -4
  63. package/src/store/sqlite-fts.ts +38 -16
  64. package/src/team/team-store.ts +62 -27
package/src/server.ts CHANGED
@@ -253,7 +253,9 @@ export async function createMemorixServer(
253
253
  graphManager: KnowledgeGraphManager;
254
254
  projectId: string;
255
255
  deferredInit: () => Promise<void>;
256
- switchProject: (newCwd: string) => Promise<boolean>;
256
+ activateProjectRuntime: () => Promise<void>;
257
+ switchProject: (newCwd: string, source?: ProjectBindingSource) => Promise<boolean>;
258
+ getProjectDataDir: () => string;
257
259
  isExplicitlyBound: () => boolean;
258
260
  getRequestContext: () => import('./server/request-context.js').MemorixRequestContext;
259
261
  handleTransportClose: () => void;
@@ -497,6 +499,31 @@ export async function createMemorixServer(
497
499
  await projectRuntimeInitPromise;
498
500
  };
499
501
 
502
+ /**
503
+ * Restore process-wide stores/config before a pooled modern runtime handles
504
+ * a tool call. The durable database is shared, but project-root config and
505
+ * the in-memory observation facade are process-global legacy state.
506
+ */
507
+ const activateProjectRuntime = async (): Promise<void> => {
508
+ if (!projectResolved) return;
509
+
510
+ await initObservationStore(projectDir);
511
+ await initMiniSkillStore(projectDir);
512
+ await initSessionStore(projectDir);
513
+ await initObservations(projectDir, { skipCorpusLoad: true });
514
+
515
+ try {
516
+ const { initProjectRoot } = await import('./config/yaml-loader.js');
517
+ const { resetDotenv, loadDotenv } = await import('./config/dotenv-loader.js');
518
+ initProjectRoot(project.rootPath);
519
+ resetDotenv();
520
+ loadDotenv(project.rootPath);
521
+ } catch {
522
+ // Config activation is best-effort; durable project scoping remains explicit.
523
+ }
524
+ initLLM({ projectRoot: project.rootPath });
525
+ };
526
+
500
527
  let maintenanceWorker: { stop(): void } | null = null;
501
528
  const startProjectMaintenanceWorker = async (autoCleanup = false): Promise<void> => {
502
529
  maintenanceWorker?.stop();
@@ -838,8 +865,9 @@ export async function createMemorixServer(
838
865
  currentFormationStage = event.stage;
839
866
  if (event.stageDurationMs !== undefined) {
840
867
  completedFormationStages[event.stage] = event.stageDurationMs;
841
- }
842
- };
868
+ }
869
+ };
870
+
843
871
  try {
844
872
  const formationConfig: FormationConfig = {
845
873
  mode: 'active',
@@ -1593,6 +1621,7 @@ export async function createMemorixServer(
1593
1621
  'reliable code-bound memories, stale/suspect cautions, and verification hints. Use this at the start of a new coding turn or after switching tasks.',
1594
1622
  inputSchema: {
1595
1623
  task: z.string().optional().describe('Current coding task or question'),
1624
+ taskId: z.string().max(120).optional().describe('Optional task continuity ledger id to include in the brief'),
1596
1625
  refresh: z.enum(['auto', 'always', 'never']).optional().default('auto').describe(
1597
1626
  'Code Memory refresh policy. auto refreshes only when missing or stale.',
1598
1627
  ),
@@ -1606,7 +1635,7 @@ export async function createMemorixServer(
1606
1635
  limit: z.number().optional().describe('Reserved for future source limits; current prompt stays compact by default.'),
1607
1636
  },
1608
1637
  },
1609
- async ({ task, refresh, format, agent }) => {
1638
+ async ({ task, taskId, refresh, format, agent }) => {
1610
1639
  const unresolved = requireResolvedProject('build project context for the current project');
1611
1640
  if (unresolved) return unresolved;
1612
1641
 
@@ -1637,6 +1666,7 @@ export async function createMemorixServer(
1637
1666
  dataDir: projectDir,
1638
1667
  observations,
1639
1668
  task,
1669
+ taskId,
1640
1670
  agent,
1641
1671
  refresh: refresh ?? 'auto',
1642
1672
  reader: getObservationReader(),
@@ -4594,6 +4624,99 @@ export async function createMemorixServer(
4594
4624
  },
4595
4625
  );
4596
4626
 
4627
+ server.registerTool(
4628
+ 'memorix_continuity',
4629
+ {
4630
+ title: 'Task Continuity Ledger',
4631
+ description:
4632
+ 'Maintain a bounded, append-only continuity ledger for a coding task. ' +
4633
+ 'Use start for requirements, record for decisions/verification/risks, ' +
4634
+ 'show or list to inspect, and close for the final outcome. Pass the returned taskId ' +
4635
+ 'to memorix_project_context so the current brief includes this ledger.',
4636
+ inputSchema: {
4637
+ action: z.enum(['start', 'record', 'show', 'list', 'close']).describe('Continuity operation'),
4638
+ taskId: z.string().max(120).optional().describe('Continuity task id'),
4639
+ task: z.string().max(1_000).optional().describe('Task description for start'),
4640
+ requirements: z.array(z.string().max(2_000)).max(8).optional().describe('Requirements for start'),
4641
+ kind: z.enum(['requirement', 'decision', 'verification', 'risk']).optional().describe('Entry kind for record'),
4642
+ content: z.string().max(2_000).optional().describe('Requirement, decision, verification, risk, or outcome text'),
4643
+ verificationStatus: z.enum(['pending', 'passed', 'failed', 'skipped']).optional(),
4644
+ verificationId: z.string().max(120).optional().describe('Stable verification obligation id; reuse it to move pending to passed/failed'),
4645
+ idempotencyKey: z.string().max(200).optional().describe('Stable retry key; repeating the same operation returns the original event'),
4646
+ status: z.enum(['completed', 'blocked', 'abandoned']).optional().describe('Outcome status for close'),
4647
+ sourceRef: z.string().max(500).optional().describe('Evidence or source reference'),
4648
+ evidenceRefs: z.array(z.string().max(500)).max(8).optional(),
4649
+ actor: z.string().max(200).optional(),
4650
+ limit: z.number().int().positive().max(100).optional().default(20),
4651
+ },
4652
+ },
4653
+ async ({ action, taskId, task, requirements, kind, content, verificationStatus, verificationId, idempotencyKey, status, sourceRef, evidenceRefs, actor, limit }) => {
4654
+ const unresolved = requireResolvedProject('manage task continuity for the current project');
4655
+ if (unresolved) return unresolved;
4656
+ const { TaskContinuityStore } = await import('./knowledge/task-continuity.js');
4657
+ const continuity = new TaskContinuityStore();
4658
+ await continuity.init(projectDir);
4659
+ const effectiveActor = actor ?? currentAgentId;
4660
+ try {
4661
+ if (action === 'start') {
4662
+ if (!task?.trim()) return { content: [{ type: 'text' as const, text: 'task is required for continuity start.' }], isError: true };
4663
+ const result = continuity.start({
4664
+ projectId: project.id,
4665
+ task,
4666
+ ...(taskId ? { taskId } : {}),
4667
+ requirements,
4668
+ ...(effectiveActor ? { actor: effectiveActor } : {}),
4669
+ ...(idempotencyKey ? { idempotencyKey } : {}),
4670
+ });
4671
+ return { content: [{ type: 'text' as const, text: JSON.stringify({ projectId: project.id, ...result }, null, 2) }] };
4672
+ }
4673
+ if (action === 'list') {
4674
+ return { content: [{ type: 'text' as const, text: JSON.stringify({ projectId: project.id, ledgers: continuity.list(project.id, limit), }, null, 2) }] };
4675
+ }
4676
+ if (!taskId?.trim()) return { content: [{ type: 'text' as const, text: 'taskId is required for this continuity action.' }], isError: true };
4677
+ if (action === 'show') {
4678
+ const ledger = continuity.get(project.id, taskId);
4679
+ if (!ledger) return { content: [{ type: 'text' as const, text: 'Task continuity ledger not found.' }], isError: true };
4680
+ return { content: [{ type: 'text' as const, text: JSON.stringify({ projectId: project.id, ledger }, null, 2) }] };
4681
+ }
4682
+ if (action === 'record') {
4683
+ if (!kind || !content?.trim()) {
4684
+ return { content: [{ type: 'text' as const, text: 'record requires kind and content.' }], isError: true };
4685
+ }
4686
+ const result = continuity.record({
4687
+ projectId: project.id,
4688
+ taskId,
4689
+ kind,
4690
+ content,
4691
+ ...(verificationStatus ? { verificationStatus } : {}),
4692
+ ...(sourceRef ? { sourceRef } : {}),
4693
+ ...(evidenceRefs ? { evidenceRefs } : {}),
4694
+ ...(effectiveActor ? { actor: effectiveActor } : {}),
4695
+ ...(verificationId ? { verificationId } : {}),
4696
+ ...(idempotencyKey ? { idempotencyKey } : {}),
4697
+ });
4698
+ return { content: [{ type: 'text' as const, text: JSON.stringify({ projectId: project.id, ...result }, null, 2) }] };
4699
+ }
4700
+ if (!status || !content?.trim()) {
4701
+ return { content: [{ type: 'text' as const, text: 'close requires status and content.' }], isError: true };
4702
+ }
4703
+ const result = continuity.close({
4704
+ projectId: project.id,
4705
+ taskId,
4706
+ status,
4707
+ content,
4708
+ ...(sourceRef ? { sourceRef } : {}),
4709
+ ...(evidenceRefs ? { evidenceRefs } : {}),
4710
+ ...(effectiveActor ? { actor: effectiveActor } : {}),
4711
+ ...(idempotencyKey ? { idempotencyKey } : {}),
4712
+ });
4713
+ return { content: [{ type: 'text' as const, text: JSON.stringify({ projectId: project.id, ...result }, null, 2) }] };
4714
+ } catch (error) {
4715
+ return { content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }], isError: true };
4716
+ }
4717
+ },
4718
+ );
4719
+
4597
4720
  // Use shared TeamStore (from HTTP server) or create new one (stdio mode).
4598
4721
  // All team state is canonical in SQLite — no JSON persistence, no sync/flush.
4599
4722
  if (teamFeaturesEnabled) {
@@ -5725,7 +5848,8 @@ export async function createMemorixServer(
5725
5848
  };
5726
5849
 
5727
5850
  return {
5728
- server, graphManager, projectId: project.id, deferredInit, switchProject,
5851
+ server, graphManager, projectId: project.id, deferredInit, activateProjectRuntime, switchProject,
5852
+ getProjectDataDir: () => projectDir,
5729
5853
  isExplicitlyBound: () => projectBinding.isExplicit(),
5730
5854
  getRequestContext,
5731
5855
  handleTransportClose,
@@ -909,6 +909,11 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
909
909
  // Fast-tier queries skip embedding entirely (fulltext is sufficient)
910
910
  let queryVector: number[] | null = null;
911
911
  let embeddingProviderForSearch: EmbeddingProvider | null = null;
912
+ // Distinct from `embeddingEnabled` (static config): this tracks whether the
913
+ // current query can use embeddings (provider unavailable, incompatible
914
+ // dimensions, TLS, network, or timeout). `canUsePersistentLexical` below
915
+ // must fall back whenever this is true.
916
+ let embeddingUnavailable = false;
912
917
  if (quality !== 'fast' && embeddingEnabled && hasQuery && tier !== 'fast') {
913
918
  try {
914
919
  const provider = await getEmbeddingProvider();
@@ -916,6 +921,7 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
916
921
  embeddingProviderForSearch = provider;
917
922
  const activeVectorDimensions = getVectorDimensions();
918
923
  if (activeVectorDimensions !== null && provider.dimensions !== activeVectorDimensions) {
924
+ embeddingUnavailable = true;
919
925
  rememberSearchMode(
920
926
  modeKey,
921
927
  `fulltext (embedding dimension mismatch: provider ${provider.dimensions}d vs index ${activeVectorDimensions}d)`,
@@ -961,9 +967,12 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
961
967
  : { text: 0.6, vector: 0.4 },
962
968
  };
963
969
  }
970
+ } else {
971
+ embeddingUnavailable = true;
964
972
  }
965
973
  } catch (error) {
966
974
  // Fallback to fulltext if embedding fails or times out
975
+ embeddingUnavailable = true;
967
976
  rememberSearchMode(modeKey, 'fulltext (embedding unavailable)');
968
977
  console.error('[memorix] Embedding failed or timed out, falling back to fulltext search');
969
978
  }
@@ -972,8 +981,14 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
972
981
  mark('preSearch');
973
982
  let results: SearchResultLike | Awaited<ReturnType<typeof search>>;
974
983
  const persistentCorpusEligible = await shouldUsePersistentSearch(projectIds, options.projectId);
984
+ // `embeddingEnabled` alone misses the common case: embedding is configured
985
+ // (enabled) but the current provider is unavailable, has incompatible
986
+ // dimensions, or failed at runtime. Without this per-query state, those
987
+ // failures leave queryVector null AND canUsePersistentLexical false, so
988
+ // neither the semantic nor lexical persistent path runs — a silent,
989
+ // deterministic empty result despite the FTS5 index having real matches.
975
990
  const canUsePersistentLexical = persistentCorpusEligible && Boolean(hasQuery) && !queryVector &&
976
- (quality === 'fast' || tier === 'fast' || !embeddingEnabled);
991
+ (quality === 'fast' || tier === 'fast' || !embeddingEnabled || embeddingUnavailable);
977
992
  let persistent = persistentCorpusEligible && queryVector && embeddingProviderForSearch
978
993
  ? await searchPersistentSemantically(options, projectIds, requestLimit, queryVector, embeddingProviderForSearch)
979
994
  : canUsePersistentLexical
@@ -821,6 +821,45 @@ CREATE TABLE IF NOT EXISTS memory_feedback_events (
821
821
  );
822
822
  `;
823
823
 
824
+ const CREATE_TASK_CONTINUITY_EVENTS_TABLE = `
825
+ CREATE TABLE IF NOT EXISTS task_continuity_events (
826
+ id TEXT PRIMARY KEY,
827
+ project_id TEXT NOT NULL,
828
+ task_id TEXT NOT NULL,
829
+ task TEXT NOT NULL,
830
+ kind TEXT NOT NULL,
831
+ content TEXT NOT NULL,
832
+ item_status TEXT,
833
+ source_ref TEXT,
834
+ evidence_json TEXT NOT NULL DEFAULT '[]',
835
+ actor TEXT,
836
+ created_at TEXT NOT NULL,
837
+ event_key TEXT,
838
+ item_key TEXT,
839
+ supersedes_id TEXT
840
+ );
841
+ `;
842
+
843
+ const CREATE_AGENT_EFFECTS_TABLE = `
844
+ CREATE TABLE IF NOT EXISTS agent_effects (
845
+ id TEXT PRIMARY KEY,
846
+ pipeline_id TEXT NOT NULL,
847
+ task_id TEXT NOT NULL,
848
+ attempt INTEGER NOT NULL,
849
+ effect_key TEXT NOT NULL,
850
+ tool TEXT NOT NULL,
851
+ risk_tier TEXT NOT NULL,
852
+ replay_policy TEXT NOT NULL,
853
+ status TEXT NOT NULL,
854
+ call_id TEXT,
855
+ idempotency_key TEXT NOT NULL,
856
+ input_fingerprint TEXT,
857
+ detail TEXT,
858
+ started_at INTEGER NOT NULL,
859
+ finished_at INTEGER
860
+ );
861
+ `;
862
+
824
863
  const CREATE_MCP_BINDINGS_TABLE = `
825
864
  CREATE TABLE IF NOT EXISTS mcp_bindings (
826
865
  handle_id TEXT PRIMARY KEY,
@@ -899,7 +938,18 @@ CREATE INDEX IF NOT EXISTS idx_evidence_cards_candidate ON evidence_cards(projec
899
938
  CREATE INDEX IF NOT EXISTS idx_evidence_card_events_card ON evidence_card_events(card_id, created_at);
900
939
  CREATE INDEX IF NOT EXISTS idx_memory_feedback_states_project ON memory_feedback_states(project_id, candidate_kind, updated_at DESC);
901
940
  CREATE INDEX IF NOT EXISTS idx_memory_feedback_events_candidate ON memory_feedback_events(project_id, candidate_kind, candidate_id, observed_at DESC);
941
+ CREATE INDEX IF NOT EXISTS idx_task_continuity_project_task ON task_continuity_events(project_id, task_id, created_at ASC);
942
+ CREATE INDEX IF NOT EXISTS idx_task_continuity_project_recent ON task_continuity_events(project_id, created_at DESC);
943
+ CREATE INDEX IF NOT EXISTS idx_task_continuity_item ON task_continuity_events(project_id, task_id, kind, item_key, created_at DESC);
944
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_task_continuity_event_key
945
+ ON task_continuity_events(project_id, task_id, event_key)
946
+ WHERE event_key IS NOT NULL;
947
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_effect_identity
948
+ ON agent_effects(pipeline_id, task_id, attempt, effect_key);
949
+ CREATE INDEX IF NOT EXISTS idx_agent_effect_pipeline ON agent_effects(pipeline_id, started_at ASC);
902
950
  CREATE INDEX IF NOT EXISTS idx_mcp_bindings_project ON mcp_bindings(project_id, last_used_at DESC);
951
+ CREATE INDEX IF NOT EXISTS idx_mcp_bindings_root ON mcp_bindings(project_root, last_used_at DESC);
952
+ CREATE INDEX IF NOT EXISTS idx_mcp_bindings_expiry ON mcp_bindings(expires_at);
903
953
  CREATE UNIQUE INDEX IF NOT EXISTS idx_maintenance_jobs_active_dedupe
904
954
  ON maintenance_jobs(project_id, kind, dedupe_key)
905
955
  WHERE status IN ('pending', 'running', 'retry');
@@ -1078,6 +1128,8 @@ const SCHEMA_MIGRATIONS: SchemaMigration[] = [
1078
1128
  db.exec('CREATE INDEX IF NOT EXISTS idx_memory_feedback_states_project ON memory_feedback_states(project_id, candidate_kind, updated_at DESC)');
1079
1129
  db.exec('CREATE INDEX IF NOT EXISTS idx_memory_feedback_events_candidate ON memory_feedback_events(project_id, candidate_kind, candidate_id, observed_at DESC)');
1080
1130
  db.exec('CREATE INDEX IF NOT EXISTS idx_mcp_bindings_project ON mcp_bindings(project_id, last_used_at DESC)');
1131
+ db.exec('CREATE INDEX IF NOT EXISTS idx_mcp_bindings_root ON mcp_bindings(project_root, last_used_at DESC)');
1132
+ db.exec('CREATE INDEX IF NOT EXISTS idx_mcp_bindings_expiry ON mcp_bindings(expires_at)');
1081
1133
  },
1082
1134
  },
1083
1135
  {
@@ -1089,6 +1141,18 @@ const SCHEMA_MIGRATIONS: SchemaMigration[] = [
1089
1141
  addColumnIfMissing(db, 'code_edges', 'source', 'source TEXT');
1090
1142
  },
1091
1143
  },
1144
+ {
1145
+ id: '1.9-continuity-v2',
1146
+ apply: (db) => {
1147
+ addColumnIfMissing(db, 'task_continuity_events', 'event_key', 'event_key TEXT');
1148
+ addColumnIfMissing(db, 'task_continuity_events', 'item_key', 'item_key TEXT');
1149
+ addColumnIfMissing(db, 'task_continuity_events', 'supersedes_id', 'supersedes_id TEXT');
1150
+ db.exec('CREATE INDEX IF NOT EXISTS idx_task_continuity_item ON task_continuity_events(project_id, task_id, kind, item_key, created_at DESC)');
1151
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_task_continuity_event_key
1152
+ ON task_continuity_events(project_id, task_id, event_key)
1153
+ WHERE event_key IS NOT NULL`);
1154
+ },
1155
+ },
1092
1156
  ];
1093
1157
 
1094
1158
  function applySchemaMigrations(db: any): void {
@@ -1110,6 +1174,28 @@ function applySchemaMigrations(db: any): void {
1110
1174
  // ── Singleton cache ─────────────────────────────────────────────────
1111
1175
 
1112
1176
  const _dbCache = new Map<string, any>();
1177
+ const _dbLeases = new Map<string, number>();
1178
+
1179
+ export interface DatabaseLease {
1180
+ dataDir: string;
1181
+ db: any;
1182
+ release: () => void;
1183
+ }
1184
+
1185
+ export interface DatabaseStats {
1186
+ cached: number;
1187
+ leased: number;
1188
+ leasedDataDirs: number;
1189
+ }
1190
+
1191
+ function normalizeDataDir(dataDir: string): string {
1192
+ return path.resolve(dataDir);
1193
+ }
1194
+
1195
+ function touchDatabase(normalized: string, db: any): void {
1196
+ _dbCache.delete(normalized);
1197
+ _dbCache.set(normalized, db);
1198
+ }
1113
1199
 
1114
1200
  /**
1115
1201
  * Get or create a shared SQLite database handle for the given data directory.
@@ -1120,10 +1206,13 @@ const _dbCache = new Map<string, any>();
1120
1206
  * Callers must NOT close the returned handle directly — use closeDatabase().
1121
1207
  */
1122
1208
  export function getDatabase(dataDir: string): any {
1123
- const normalized = path.resolve(dataDir);
1209
+ const normalized = normalizeDataDir(dataDir);
1124
1210
  assertNotHomeDataDir(normalized);
1125
1211
  const existing = _dbCache.get(normalized);
1126
- if (existing) return existing;
1212
+ if (existing) {
1213
+ touchDatabase(normalized, existing);
1214
+ return existing;
1215
+ }
1127
1216
 
1128
1217
  loadBetterSqlite3();
1129
1218
  fs.mkdirSync(dataDir, { recursive: true });
@@ -1161,6 +1250,8 @@ export function getDatabase(dataDir: string): any {
1161
1250
  db.exec(CREATE_MAINTENANCE_JOBS_TABLE);
1162
1251
  db.exec(CREATE_MAINTENANCE_TARGETS_TABLE);
1163
1252
  db.exec(CREATE_COMPACTION_CHECKPOINTS_TABLE);
1253
+ db.exec(CREATE_TASK_CONTINUITY_EVENTS_TABLE);
1254
+ db.exec(CREATE_AGENT_EFFECTS_TABLE);
1164
1255
 
1165
1256
  // Phase 3a migration: add sourceSnapshot + updatedAt to mini_skills
1166
1257
  // Idempotent — ALTER TABLE ADD COLUMN throws if column already exists
@@ -1194,7 +1285,7 @@ export function getDatabase(dataDir: string): any {
1194
1285
  db.prepare(`INSERT OR IGNORE INTO meta (key, value) VALUES ('next_id', '1')`).run();
1195
1286
  db.prepare(`INSERT OR IGNORE INTO meta (key, value) VALUES ('mini_skills_generation', '0')`).run();
1196
1287
 
1197
- _dbCache.set(normalized, db);
1288
+ touchDatabase(normalized, db);
1198
1289
  return db;
1199
1290
  } catch (error) {
1200
1291
  // A schema/migration/FTS failure must not leave an uncached connection
@@ -1204,12 +1295,60 @@ export function getDatabase(dataDir: string): any {
1204
1295
  }
1205
1296
  }
1206
1297
 
1298
+ /**
1299
+ * Hold a database handle across an HTTP/session lifetime. Idle eviction only
1300
+ * closes handles with no active leases, so cache pressure cannot invalidate a
1301
+ * store that is still serving a request.
1302
+ */
1303
+ export function acquireDatabase(dataDir: string): DatabaseLease {
1304
+ const normalized = normalizeDataDir(dataDir);
1305
+ const db = getDatabase(normalized);
1306
+ _dbLeases.set(normalized, (_dbLeases.get(normalized) ?? 0) + 1);
1307
+ let released = false;
1308
+ return {
1309
+ dataDir: normalized,
1310
+ db,
1311
+ release: () => {
1312
+ if (released) return;
1313
+ released = true;
1314
+ const count = (_dbLeases.get(normalized) ?? 0) - 1;
1315
+ if (count > 0) _dbLeases.set(normalized, count);
1316
+ else _dbLeases.delete(normalized);
1317
+ },
1318
+ };
1319
+ }
1320
+
1321
+ /** Return bounded resource information for health and diagnostics. */
1322
+ export function getDatabaseStats(): DatabaseStats {
1323
+ let leased = 0;
1324
+ for (const count of _dbLeases.values()) leased += count;
1325
+ return { cached: _dbCache.size, leased, leasedDataDirs: _dbLeases.size };
1326
+ }
1327
+
1328
+ /**
1329
+ * Evict least-recently-used unleased handles until the cache is within its
1330
+ * target. Stores that need a handle across awaits must hold a lease first.
1331
+ */
1332
+ export function evictIdleDatabases(maxCached: number): number {
1333
+ const target = Math.max(0, Math.floor(maxCached));
1334
+ let evicted = 0;
1335
+ for (const [normalized, db] of [..._dbCache]) {
1336
+ if (_dbCache.size <= target) break;
1337
+ if ((_dbLeases.get(normalized) ?? 0) > 0) continue;
1338
+ try { db.close(); } catch { /* best-effort */ }
1339
+ _dbCache.delete(normalized);
1340
+ evicted++;
1341
+ }
1342
+ return evicted;
1343
+ }
1344
+
1207
1345
  /**
1208
1346
  * Close and remove a cached database handle for the given data directory.
1209
1347
  * Safe to call even if no handle exists.
1210
1348
  */
1211
1349
  export function closeDatabase(dataDir: string): void {
1212
- const normalized = path.resolve(dataDir);
1350
+ const normalized = normalizeDataDir(dataDir);
1351
+ if ((_dbLeases.get(normalized) ?? 0) > 0) return;
1213
1352
  const db = _dbCache.get(normalized);
1214
1353
  if (db) {
1215
1354
  try { db.close(); } catch { /* best-effort */ }
@@ -1225,6 +1364,7 @@ export function closeAllDatabases(): void {
1225
1364
  try { db.close(); } catch { /* best-effort */ }
1226
1365
  _dbCache.delete(key);
1227
1366
  }
1367
+ _dbLeases.clear();
1228
1368
  }
1229
1369
 
1230
1370
  /**
@@ -1,4 +1,5 @@
1
1
  import type { Observation } from '../types.js';
2
+ import { isTransientSqliteError } from './sqlite-reliability.js';
2
3
 
3
4
  /** Rows returned by the derived FTS5 index before normal Observation parsing. */
4
5
  export interface SqliteLexicalHit {
@@ -56,31 +57,52 @@ export function buildFtsQuery(query: string): string | null {
56
57
  return unique.map((token) => `"${normalizeFtsToken(token)}"`).join(' OR ');
57
58
  }
58
59
 
60
+ /**
61
+ * initializeObservationLexicalIndex runs exactly once per db handle (called
62
+ * from getDatabase's synchronous setup), so a transient cross-process BUSY
63
+ * here would otherwise be cached as a permanent "unavailable" for that
64
+ * handle's entire lifetime, with no other call site ever re-checking it.
65
+ */
66
+ const FTS_INIT_MAX_ATTEMPTS = 3;
67
+
59
68
  /**
60
69
  * Create and reconcile the external-content FTS5 index for one SQLite handle.
61
- * FTS5 is an optional SQLite capability, so an unavailable extension is a
62
- * normal fallback rather than a reason to make the durable store unusable.
70
+ * FTS5 is an optional SQLite capability, so a genuinely unavailable extension
71
+ * is a normal fallback rather than a reason to make the durable store
72
+ * unusable. Cross-process lock contention (SQLITE_BUSY) is not that case —
73
+ * it is retried a few times, mirroring the retry already applied to the
74
+ * write paths in sqlite-store.ts / session-store.ts / mini-skill-store.ts,
75
+ * since the caller here is synchronous and cannot await retrySqliteBusy.
63
76
  */
64
77
  export function initializeObservationLexicalIndex(db: any): boolean {
65
78
  const known = ftsAvailability.get(db);
66
79
  if (known !== undefined) return known;
67
80
 
68
- try {
69
- db.exec(CREATE_OBSERVATION_FTS);
70
- const observationCount = Number(db.prepare('SELECT COUNT(*) AS count FROM observations').get()?.count ?? 0);
71
- const indexedCount = Number(db.prepare(`SELECT COUNT(*) AS count FROM ${FTS_TABLE}`).get()?.count ?? 0);
72
- if (observationCount !== indexedCount) {
73
- db.exec(`INSERT INTO ${FTS_TABLE}(${FTS_TABLE}) VALUES ('rebuild')`);
81
+ let lastError: unknown;
82
+ for (let attempt = 1; attempt <= FTS_INIT_MAX_ATTEMPTS; attempt++) {
83
+ try {
84
+ db.exec(CREATE_OBSERVATION_FTS);
85
+ const observationCount = Number(db.prepare('SELECT COUNT(*) AS count FROM observations').get()?.count ?? 0);
86
+ const indexedCount = Number(db.prepare(`SELECT COUNT(*) AS count FROM ${FTS_TABLE}`).get()?.count ?? 0);
87
+ if (observationCount !== indexedCount) {
88
+ db.exec(`INSERT INTO ${FTS_TABLE}(${FTS_TABLE}) VALUES ('rebuild')`);
89
+ }
90
+ ftsAvailability.set(db, true);
91
+ return true;
92
+ } catch (error) {
93
+ lastError = error;
94
+ if (!isTransientSqliteError(error) || attempt === FTS_INIT_MAX_ATTEMPTS) break;
95
+ // better-sqlite3 is synchronous, so there is no await-able delay here;
96
+ // re-entering db.exec/prepare re-enters SQLite's own busy_timeout wait
97
+ // (already configured in sqlite-db.ts), which is the retry itself.
74
98
  }
75
- ftsAvailability.set(db, true);
76
- return true;
77
- } catch (error) {
78
- ftsAvailability.set(db, false);
79
- console.warn(
80
- `[memorix] SQLite FTS5 unavailable; using the existing search fallback: ${error instanceof Error ? error.message : String(error)}`,
81
- );
82
- return false;
83
99
  }
100
+
101
+ ftsAvailability.set(db, false);
102
+ console.warn(
103
+ `[memorix] SQLite FTS5 unavailable; using the existing search fallback: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
104
+ );
105
+ return false;
84
106
  }
85
107
 
86
108
  export function isObservationLexicalIndexEnabled(db: any): boolean {