memorix 1.2.9 → 1.3.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 (68) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +30 -4
  3. package/README.zh-CN.md +28 -2
  4. package/assets/star-history-dark.svg +30 -0
  5. package/assets/star-history-light.svg +30 -0
  6. package/assets/star-history.json +599 -0
  7. package/dist/cli/index.js +1945 -579
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/index.js +1292 -267
  10. package/dist/index.js.map +1 -1
  11. package/dist/maintenance-runner.js +97 -21
  12. package/dist/maintenance-runner.js.map +1 -1
  13. package/dist/memcode-runtime/CHANGELOG.md +24 -0
  14. package/dist/sdk.d.ts +1 -1
  15. package/dist/sdk.js +1294 -269
  16. package/dist/sdk.js.map +1 -1
  17. package/dist/types.d.ts +99 -1
  18. package/dist/types.js.map +1 -1
  19. package/dist/vector-backfill-runner.js +97 -21
  20. package/dist/vector-backfill-runner.js.map +1 -1
  21. package/docs/1.3-MEMORY-ARCHITECTURE.md +236 -0
  22. package/docs/AGENT_OPERATOR_PLAYBOOK.md +5 -3
  23. package/docs/API_REFERENCE.md +20 -1
  24. package/docs/ARCHITECTURE.md +34 -1
  25. package/docs/DEVELOPMENT.md +10 -3
  26. package/docs/INTEGRATIONS.md +1 -1
  27. package/docs/MODULES.md +27 -1
  28. package/docs/README.md +3 -3
  29. package/docs/SETUP.md +6 -4
  30. package/docs/dev-log/progress.txt +22 -0
  31. package/docs/hooks-architecture.md +1 -1
  32. package/llms-full.txt +5 -1
  33. package/llms.txt +2 -0
  34. package/package.json +7 -2
  35. package/plugins/antigravity/memorix/rules/memorix.md +2 -0
  36. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  37. package/plugins/claude/memorix/skills/memorix-memory/SKILL.md +3 -0
  38. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  39. package/plugins/codex/memorix/skills/memorix-memory/SKILL.md +3 -0
  40. package/plugins/copilot/memorix/plugin.json +1 -1
  41. package/plugins/copilot/memorix/skills/memorix-memory/SKILL.md +3 -0
  42. package/plugins/cursor/memorix/skills/memorix-memory/SKILL.md +3 -0
  43. package/plugins/gemini/memorix/GEMINI.md +2 -0
  44. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  45. package/plugins/omp/memorix/package.json +1 -1
  46. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  47. package/plugins/pi/memorix/package.json +1 -1
  48. package/plugins/pi/memorix/skills/memorix-memory/SKILL.md +3 -0
  49. package/server.json +38 -0
  50. package/src/cli/command-guide.ts +9 -1
  51. package/src/cli/commands/agent-integrations.ts +12 -6
  52. package/src/cli/commands/codegraph.ts +1 -0
  53. package/src/cli/commands/memory.ts +217 -1
  54. package/src/cli/commands/setup.ts +200 -23
  55. package/src/codegraph/auto-context.ts +11 -0
  56. package/src/codegraph/context-pack.ts +3 -0
  57. package/src/embedding/api-provider.ts +23 -9
  58. package/src/embedding/provider.ts +21 -10
  59. package/src/hooks/installers/index.ts +2 -0
  60. package/src/knowledge/context-assembly.ts +2 -1
  61. package/src/knowledge/workset.ts +77 -4
  62. package/src/memory/long-term-store.ts +242 -0
  63. package/src/memory/long-term-types.ts +136 -0
  64. package/src/memory/long-term.ts +626 -0
  65. package/src/memory/owner.ts +75 -0
  66. package/src/server.ts +112 -30
  67. package/src/store/sqlite-db.ts +74 -0
  68. package/src/types.ts +20 -0
@@ -162,7 +162,9 @@ const OPENCLAW_BUNDLE_AGENTS = new Set<AgentName>(['openclaw']);
162
162
  const ANTIGRAVITY_PLUGIN_AGENTS = new Set<AgentName>(['antigravity']);
163
163
  const HERMES_PLUGIN_AGENTS = new Set<AgentName>(['hermes']);
164
164
  const OMP_PACKAGE_AGENTS = new Set<AgentName>(['omp']);
165
- const PACKAGE_OWNED_INTEGRATION_AGENTS = new Set<AgentName>(['antigravity', 'openclaw', 'hermes', 'omp']);
165
+ // These hosts have a native package/plugin surface. Their default setup must not
166
+ // create project-local fallback files beside the user's own agent configuration.
167
+ const PACKAGE_OWNED_INTEGRATION_AGENTS = new Set<AgentName>(['codex', 'antigravity', 'openclaw', 'hermes', 'omp']);
166
168
  const SUPPORTED_SETUP_AGENTS: AgentName[] = [
167
169
  'claude',
168
170
  'codex',
@@ -484,6 +486,32 @@ function mergeTomlMcpConfig(existingContent: string | null, generatedContent: st
484
486
  return `${parts.join('\n\n')}\n`;
485
487
  }
486
488
 
489
+ type LegacyArtifactStatus = 'removed' | 'preserved' | 'missing';
490
+
491
+ export interface CodexLegacyMigrationResult {
492
+ mcp: LegacyArtifactStatus;
493
+ projectHooks: LegacyArtifactStatus;
494
+ pluginFolder: LegacyArtifactStatus;
495
+ }
496
+
497
+ function getTomlSection(content: string, section: string): string | null {
498
+ const lines = content.split(/\r?\n/);
499
+ const header = `[${section}]`;
500
+ const start = lines.findIndex((line) => line.trim() === header);
501
+ if (start < 0) return null;
502
+
503
+ let end = lines.length;
504
+ for (let index = start + 1; index < lines.length; index += 1) {
505
+ const trimmed = lines[index].trim();
506
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
507
+ end = index;
508
+ break;
509
+ }
510
+ }
511
+
512
+ return lines.slice(start + 1, end).join('\n');
513
+ }
514
+
487
515
  function isLegacyCodexMemorixNodeServer(server: MCPServerEntry): boolean {
488
516
  if (server.name !== 'memorix' || server.command.trim().toLowerCase() !== 'node') return false;
489
517
  const startsMemorixStdio = server.args.some((arg) => arg.trim().toLowerCase() === 'serve');
@@ -494,6 +522,22 @@ function isLegacyCodexMemorixNodeServer(server: MCPServerEntry): boolean {
494
522
  return startsMemorixStdio && sourceEntry;
495
523
  }
496
524
 
525
+ function isOwnedLegacyCodexMcp(content: string): boolean {
526
+ const section = getTomlSection(content, 'mcp_servers.memorix');
527
+ if (section === null || getTomlSection(content, 'mcp_servers.memorix.env') !== null) return false;
528
+
529
+ const allowedKeys = new Set(['command', 'args', 'type', 'startup_timeout_sec', 'tool_timeout_sec', 'enabled']);
530
+ for (const rawLine of section.split(/\r?\n/)) {
531
+ const line = rawLine.trim();
532
+ if (!line || line.startsWith('#')) continue;
533
+ const key = line.match(/^([A-Za-z0-9_]+)\s*=/)?.[1];
534
+ if (!key || !allowedKeys.has(key)) return false;
535
+ }
536
+
537
+ const server = new CodexMCPAdapter().parse(content).find((entry) => entry.name === 'memorix');
538
+ return Boolean(server && isLegacyCodexMemorixNodeServer(server));
539
+ }
540
+
497
541
  /**
498
542
  * Codex plugins now own Memorix stdio MCP registration. Remove only the old
499
543
  * source-path form emitted by pre-plugin setup, never a user's custom server.
@@ -508,9 +552,7 @@ export async function removeLegacyCodexMemorixMcpConfig(
508
552
  return { configPath, removed: false };
509
553
  }
510
554
 
511
- const adapter = new CodexMCPAdapter();
512
- const legacyServer = adapter.parse(existingContent).find(isLegacyCodexMemorixNodeServer);
513
- if (!legacyServer) return { configPath, removed: false };
555
+ if (!isOwnedLegacyCodexMcp(existingContent)) return { configPath, removed: false };
514
556
 
515
557
  let nextContent = removeTomlSection(existingContent, 'mcp_servers.memorix.env');
516
558
  nextContent = removeTomlSection(nextContent, 'mcp_servers.memorix');
@@ -518,6 +560,88 @@ export async function removeLegacyCodexMemorixMcpConfig(
518
560
  return { configPath, removed: true };
519
561
  }
520
562
 
563
+ function collectHookCommands(value: unknown, commands: string[]): void {
564
+ if (Array.isArray(value)) {
565
+ for (const item of value) collectHookCommands(item, commands);
566
+ return;
567
+ }
568
+ if (!value || typeof value !== 'object') return;
569
+
570
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
571
+ if ((key === 'command' || key === 'commandWindows') && typeof child === 'string') {
572
+ commands.push(child);
573
+ } else {
574
+ collectHookCommands(child, commands);
575
+ }
576
+ }
577
+ }
578
+
579
+ function isOwnedLegacyCodexHooks(content: string): boolean {
580
+ try {
581
+ const parsed = JSON.parse(content) as { hooks?: unknown };
582
+ if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
583
+ const commands: string[] = [];
584
+ collectHookCommands(parsed.hooks, commands);
585
+ return commands.length > 0
586
+ && commands.every((command) => /^memorix(?:\.cmd)?\s+hook(?:\s|$)/i.test(command.trim()));
587
+ } catch {
588
+ return false;
589
+ }
590
+ }
591
+
592
+ async function removeOwnedLegacyCodexPluginFolder(homeDir: string): Promise<LegacyArtifactStatus> {
593
+ const canonicalPath = path.join(homeDir, 'plugins', 'memorix');
594
+ const legacyPath = path.join(homeDir, '.codex', 'plugins', 'memorix');
595
+ if (!existsSync(legacyPath)) return 'missing';
596
+ if (!existsSync(canonicalPath)) return 'preserved';
597
+
598
+ try {
599
+ const manifest = JSON.parse(await readFile(path.join(legacyPath, '.codex-plugin', 'plugin.json'), 'utf-8')) as Record<string, unknown>;
600
+ const repository = String(manifest.repository ?? '');
601
+ if (manifest.name !== 'memorix' || !repository.includes('github.com/AVIDS2/memorix')) return 'preserved';
602
+ await rm(legacyPath, { recursive: true, force: true });
603
+ return 'removed';
604
+ } catch {
605
+ return 'preserved';
606
+ }
607
+ }
608
+
609
+ export async function migrateLegacyCodexIntegration(options: {
610
+ homeDir?: string;
611
+ projectRoot?: string;
612
+ } = {}): Promise<CodexLegacyMigrationResult> {
613
+ const homeDir = options.homeDir ?? homedir();
614
+ const projectRoot = options.projectRoot ?? process.cwd();
615
+ const configPath = path.join(homeDir, '.codex', 'config.toml');
616
+ let mcp: LegacyArtifactStatus = 'missing';
617
+
618
+ try {
619
+ const content = await readFile(configPath, 'utf-8');
620
+ if (getTomlSection(content, 'mcp_servers.memorix') !== null) {
621
+ mcp = (await removeLegacyCodexMemorixMcpConfig(configPath)).removed ? 'removed' : 'preserved';
622
+ }
623
+ } catch {
624
+ mcp = 'missing';
625
+ }
626
+
627
+ const hooksPath = path.join(projectRoot, '.codex', 'hooks.json');
628
+ let projectHooks: LegacyArtifactStatus = 'missing';
629
+ try {
630
+ const content = await readFile(hooksPath, 'utf-8');
631
+ if (isOwnedLegacyCodexHooks(content)) {
632
+ await rm(hooksPath, { force: true });
633
+ projectHooks = 'removed';
634
+ } else {
635
+ projectHooks = 'preserved';
636
+ }
637
+ } catch {
638
+ projectHooks = 'missing';
639
+ }
640
+
641
+ const pluginFolder = await removeOwnedLegacyCodexPluginFolder(homeDir);
642
+ return { mcp, projectHooks, pluginFolder };
643
+ }
644
+
521
645
  function mergeYamlMcpConfig(existingContent: string | null, generatedContent: string): string {
522
646
  let existing: Record<string, unknown> = {};
523
647
  try {
@@ -678,7 +802,9 @@ export async function installPluginPackage(options: PluginInstallOptions): Promi
678
802
  }
679
803
 
680
804
  if (options.agent === 'codex') {
681
- const pluginPath = path.join(home, '.codex', 'plugins', 'memorix');
805
+ // Codex discovers personal plugins from the user marketplace. Keep the
806
+ // package outside ~/.codex so setup does not look like project/config state.
807
+ const pluginPath = path.join(home, 'plugins', 'memorix');
682
808
  const marketplacePath = path.join(home, '.agents', 'plugins', 'marketplace.json');
683
809
  await copyDir(source, pluginPath);
684
810
  await stampCodexPluginVersion(pluginPath);
@@ -908,6 +1034,39 @@ function tryInstallClaudePlugin(marketplaceRoot: string): { ok: boolean; message
908
1034
  };
909
1035
  }
910
1036
 
1037
+ export type CodexPluginState = 'ready' | 'disabled' | 'missing' | 'unknown';
1038
+
1039
+ export function parseCodexPluginState(content: string): CodexPluginState {
1040
+ try {
1041
+ // Some Windows builds emit a one-line diagnostic before the requested JSON.
1042
+ const start = content.indexOf('{');
1043
+ const end = content.lastIndexOf('}');
1044
+ if (start < 0 || end < start) return 'unknown';
1045
+ const parsed = JSON.parse(content.slice(start, end + 1)) as { installed?: unknown };
1046
+ if (!Array.isArray(parsed.installed)) return 'unknown';
1047
+
1048
+ const plugin = parsed.installed.find((entry): entry is Record<string, unknown> => (
1049
+ Boolean(entry)
1050
+ && typeof entry === 'object'
1051
+ && (entry as Record<string, unknown>).pluginId === 'memorix@personal'
1052
+ ));
1053
+ if (!plugin || plugin.installed !== true) return 'missing';
1054
+ return plugin.enabled === true ? 'ready' : 'disabled';
1055
+ } catch {
1056
+ return 'unknown';
1057
+ }
1058
+ }
1059
+
1060
+ function getCodexPluginState(): CodexPluginState {
1061
+ const result = spawnSync('codex', ['plugin', 'list', '--json'], {
1062
+ encoding: 'utf-8',
1063
+ stdio: 'pipe',
1064
+ shell: process.platform === 'win32',
1065
+ });
1066
+ if (result.status !== 0) return 'unknown';
1067
+ return parseCodexPluginState(result.stdout || '');
1068
+ }
1069
+
911
1070
  export function tryInstallCodexPlugin(): { ok: boolean; message: string } {
912
1071
  const result = spawnSync('codex', ['plugin', 'add', 'memorix@personal', '--json'], {
913
1072
  encoding: 'utf-8',
@@ -915,13 +1074,28 @@ export function tryInstallCodexPlugin(): { ok: boolean; message: string } {
915
1074
  shell: process.platform === 'win32',
916
1075
  });
917
1076
 
918
- if (result.status === 0) {
919
- return { ok: true, message: 'codex: plugin installed from Personal marketplace' };
920
- }
921
-
922
1077
  const output = `${result.stderr || ''}\n${result.stdout || ''}`.toLowerCase();
923
- if (output.includes('already')) {
924
- return { ok: true, message: 'codex: plugin already installed from Personal marketplace' };
1078
+ const addFinished = result.status === 0 || output.includes('already');
1079
+ if (addFinished) {
1080
+ const pluginState = getCodexPluginState();
1081
+ if (pluginState === 'ready') {
1082
+ return {
1083
+ ok: true,
1084
+ message: result.status === 0
1085
+ ? 'codex: plugin installed and enabled from Personal marketplace'
1086
+ : 'codex: plugin already installed and enabled from Personal marketplace',
1087
+ };
1088
+ }
1089
+ if (pluginState === 'disabled') {
1090
+ return {
1091
+ ok: false,
1092
+ message: 'codex: Memorix plugin is installed but disabled. Enable it in Codex Plugins before any legacy MCP or project hooks are removed.',
1093
+ };
1094
+ }
1095
+ return {
1096
+ ok: false,
1097
+ message: 'codex: plugin registration finished, but Codex did not confirm an enabled Memorix plugin. Legacy MCP and project hooks were kept unchanged.',
1098
+ };
925
1099
  }
926
1100
 
927
1101
  const detail = (result.stderr || result.stdout || result.error?.message || '').trim();
@@ -1106,7 +1280,9 @@ export async function installAgentSetup(agent: AgentName, plan: SetupPlan, globa
1106
1280
  const hasAntigravityPlugin = plan.actions.includes('antigravity-plugin') && agent === 'antigravity';
1107
1281
  const hasHermesPlugin = plan.actions.includes('hermes-plugin') && agent === 'hermes';
1108
1282
  const hasOmpPackage = plan.actions.includes('omp-package') && agent === 'omp';
1109
- const wantsMcpConfig = plan.mcp !== 'none' && agent !== 'pi';
1283
+ // Codex's supported default is the user-level plugin. Never create a
1284
+ // project-local .codex/config.toml as an implicit fallback.
1285
+ const wantsMcpConfig = plan.mcp !== 'none' && agent !== 'pi' && !(agent === 'codex' && !global);
1110
1286
 
1111
1287
  if (PLUGIN_PACKAGE_AGENTS.has(agent) && plan.includePlugin && !global) {
1112
1288
  p.log.info(`${agent}: project setup leaves user-level plugins untouched. Run \`memorix setup --agent ${agent} --global\` to install its plugin, skills, and lifecycle hooks.`);
@@ -1115,7 +1291,7 @@ export async function installAgentSetup(agent: AgentName, plan: SetupPlan, globa
1115
1291
  if (hasPluginPackage) {
1116
1292
  const result = await installPluginPackage({
1117
1293
  agent: agent as PluginInstallOptions['agent'],
1118
- includeHooks: plan.actions.includes('hooks'),
1294
+ includeHooks: plan.includeHooks,
1119
1295
  });
1120
1296
  p.log.success(`${agent}: plugin package -> ${result.pluginPath}`);
1121
1297
  if (result.marketplacePath) {
@@ -1123,17 +1299,16 @@ export async function installAgentSetup(agent: AgentName, plan: SetupPlan, globa
1123
1299
  }
1124
1300
  if (agent === 'codex') {
1125
1301
  const install = tryInstallCodexPlugin();
1126
- if (install.ok) p.log.success(install.message);
1127
- else p.log.warn(install.message);
1128
1302
  if (install.ok) {
1129
- try {
1130
- const migration = await removeLegacyCodexMemorixMcpConfig();
1131
- if (migration.removed) {
1132
- p.log.info(`codex: removed legacy source-path MCP config from ${migration.configPath}`);
1133
- }
1134
- } catch {
1135
- p.log.warn('codex: plugin installed, but the legacy MCP config could not be checked');
1136
- }
1303
+ p.log.success(install.message);
1304
+ const migration = await migrateLegacyCodexIntegration({ projectRoot: process.cwd() });
1305
+ if (migration.mcp === 'removed') p.log.info('codex: removed the legacy source-path MCP block; the enabled plugin now owns MCP.');
1306
+ if (migration.mcp === 'preserved') p.log.warn('codex: kept a customized legacy Memorix MCP block; remove it manually only after confirming plugin MCP works.');
1307
+ if (migration.projectHooks === 'removed') p.log.info('codex: removed a legacy project-local Memorix hooks.json; plugin hooks now own lifecycle capture.');
1308
+ if (migration.projectHooks === 'preserved') p.log.warn('codex: kept a project .codex/hooks.json because it contains non-Memorix commands.');
1309
+ if (migration.pluginFolder === 'removed') p.log.info('codex: removed the migrated legacy plugin folder under ~/.codex/plugins.');
1310
+ } else {
1311
+ p.log.warn(install.message);
1137
1312
  }
1138
1313
  } else if (agent === 'claude' && result.marketplaceRoot) {
1139
1314
  const install = tryInstallClaudePlugin(result.marketplaceRoot);
@@ -1224,6 +1399,8 @@ export async function installAgentSetup(agent: AgentName, plan: SetupPlan, globa
1224
1399
  const result = await installMcpConfig({ agent: agent as McpConfigAgent, projectRoot: targetRoot, global, mcp });
1225
1400
  p.log.success(`${agent}: MCP config -> ${result.configPath}`);
1226
1401
  }
1402
+ } else if (agent === 'codex' && !global && plan.mcp !== 'none') {
1403
+ p.log.info('codex: project-local .codex config is intentionally untouched. Run `memorix setup --agent codex --global` to install the user-level plugin.');
1227
1404
  }
1228
1405
 
1229
1406
  if (hasPluginPackage) {
@@ -388,6 +388,7 @@ export async function buildAutoProjectContext(input: {
388
388
  stale: overview.freshness.stale,
389
389
  },
390
390
  runtimeCautions,
391
+ ...(input.reader ? { reader: input.reader } : {}),
391
392
  ...(input.deliveryTarget ? { deliveryTarget: input.deliveryTarget } : {}),
392
393
  });
393
394
 
@@ -601,6 +602,16 @@ export function formatAutoProjectContextSummary(context: AutoProjectContext): st
601
602
  : '- none yet',
602
603
  );
603
604
 
605
+ if (context.workset.durableMemory.length > 0) {
606
+ lines.push('', 'Durable memory');
607
+ for (const memory of context.workset.durableMemory.slice(0, 3)) {
608
+ lines.push(
609
+ '- ' + memory.kind + ' (' + memory.scope + ', ' + memory.state + '): '
610
+ + compactContinuationText(memory.summary, 28),
611
+ );
612
+ }
613
+ }
614
+
604
615
  const continuation = context.workset.continuation;
605
616
  if (continuation?.previousSession || continuation?.memories.length || continuation?.compactCheckpoint) {
606
617
  lines.push('', 'Resume from prior work');
@@ -10,6 +10,7 @@ import type {
10
10
  } from './types.js';
11
11
  import type { CodeGraphStore } from './store.js';
12
12
  import { isCodeGraphExcludedPath } from './exclude.js';
13
+ import type { ObservationReader } from '../types.js';
13
14
 
14
15
  export interface ContextPackMemory {
15
16
  id: number;
@@ -276,6 +277,7 @@ export async function attachTaskWorkset(input: {
276
277
  semanticCode?: ExternalCodeGraphOutline;
277
278
  providerQuality?: CodeGraphProviderQuality;
278
279
  runtimeCautions?: WorksetCaution[];
280
+ reader?: ObservationReader;
279
281
  }): Promise<ContextPack> {
280
282
  const semanticStartHere = input.semanticCode
281
283
  ? [...input.semanticCode.relatedFiles, ...input.semanticCode.entryPoints.map(entry => entry.path)]
@@ -316,6 +318,7 @@ export async function attachTaskWorkset(input: {
316
318
  stale: input.pack.warnings.filter(warning => warning.status === 'stale').length,
317
319
  },
318
320
  runtimeCautions: input.runtimeCautions,
321
+ ...(input.reader ? { reader: input.reader } : {}),
319
322
  deliveryTarget: 'context-pack',
320
323
  });
321
324
  return { ...input.pack, workset };
@@ -9,7 +9,7 @@ import { createHash } from 'node:crypto';
9
9
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
11
  import { homedir } from 'node:os';
12
- import type { EmbeddingProvider } from './provider.js';
12
+ import type { EmbeddingProvider, EmbeddingRequestOptions } from './provider.js';
13
13
 
14
14
  const CACHE_DIR = process.env.MEMORIX_DATA_DIR || join(homedir(), '.memorix', 'data');
15
15
  const CACHE_FILE = join(CACHE_DIR, '.embedding-api-cache.json');
@@ -306,6 +306,10 @@ export interface APIEmbeddingProviderCreateOptions {
306
306
  * the provider. This keeps startup/cache hydration off the remote API path.
307
307
  */
308
308
  allowNetworkProbe?: boolean;
309
+ /** Bound the one-off remote dimension probe used on a cold API start. */
310
+ requestTimeoutMs?: number;
311
+ /** Disable retries for a bounded, best-effort API initialization. */
312
+ retry?: boolean;
309
313
  }
310
314
 
311
315
  function resolveEnvEmbeddingApiKey(): string | undefined {
@@ -370,7 +374,10 @@ export class APIEmbeddingProvider implements EmbeddingProvider {
370
374
  }
371
375
  } else {
372
376
  if (!allowNetworkProbe) return null;
373
- probeDimensions = await APIEmbeddingProvider.probeAPI(config);
377
+ probeDimensions = await APIEmbeddingProvider.probeAPI(config, {
378
+ timeoutMs: options.requestTimeoutMs,
379
+ retry: options.retry,
380
+ });
374
381
  console.error(`[memorix] API embedding: ${config.model} @ ${config.baseUrl} (${probeDimensions}d)`);
375
382
  // Persist for next cold start
376
383
  saveCachedDims(config, probeDimensions).catch(() => {});
@@ -430,7 +437,7 @@ export class APIEmbeddingProvider implements EmbeddingProvider {
430
437
  return { apiKey, baseUrl, model, requestedDimensions };
431
438
  }
432
439
 
433
- private static async probeAPI(config: APIEmbeddingConfig): Promise<number> {
440
+ private static async probeAPI(config: APIEmbeddingConfig, options?: EmbeddingRequestOptions): Promise<number> {
434
441
  const body: Record<string, unknown> = {
435
442
  model: config.model,
436
443
  input: 'dimension probe',
@@ -443,6 +450,7 @@ export class APIEmbeddingProvider implements EmbeddingProvider {
443
450
  `${config.baseUrl}/embeddings`,
444
451
  config.apiKey,
445
452
  body,
453
+ options,
446
454
  );
447
455
 
448
456
  if (response.data.length === 0 || !response.data[0].embedding) {
@@ -452,7 +460,7 @@ export class APIEmbeddingProvider implements EmbeddingProvider {
452
460
  return response.data[0].embedding.length;
453
461
  }
454
462
 
455
- async embed(text: string): Promise<number[]> {
463
+ async embed(text: string, options?: EmbeddingRequestOptions): Promise<number[]> {
456
464
  const normalized = normalizeText(text);
457
465
  const hash = textHash(normalized, this.cacheKeyNamespace);
458
466
 
@@ -477,6 +485,7 @@ export class APIEmbeddingProvider implements EmbeddingProvider {
477
485
  `${this.config.baseUrl}/embeddings`,
478
486
  this.config.apiKey,
479
487
  body,
488
+ options,
480
489
  );
481
490
  const embedding = response.data[0].embedding;
482
491
  if (embedding.length !== this.dimensions) {
@@ -521,7 +530,7 @@ export class APIEmbeddingProvider implements EmbeddingProvider {
521
530
  return embedding;
522
531
  }
523
532
 
524
- async embedBatch(texts: string[]): Promise<number[][]> {
533
+ async embedBatch(texts: string[], options?: EmbeddingRequestOptions): Promise<number[][]> {
525
534
  await ensureDiskCacheLoaded();
526
535
  const normalizedTexts = texts.map(normalizeText);
527
536
  const results: number[][] = new Array(texts.length);
@@ -562,6 +571,7 @@ export class APIEmbeddingProvider implements EmbeddingProvider {
562
571
  `${this.config.baseUrl}/embeddings`,
563
572
  this.config.apiKey,
564
573
  body,
574
+ options,
565
575
  );
566
576
 
567
577
  this.trackUsage(response);
@@ -638,10 +648,14 @@ async function fetchWithRetry(
638
648
  url: string,
639
649
  apiKey: string,
640
650
  body: Record<string, unknown>,
651
+ options: EmbeddingRequestOptions = {},
641
652
  attempt = 0,
642
653
  ): Promise<EmbeddingAPIResponse> {
643
654
  const controller = new AbortController();
644
- const timeout = setTimeout(() => controller.abort(), 10_000);
655
+ const timeoutMs = typeof options.timeoutMs === 'number' && Number.isFinite(options.timeoutMs)
656
+ ? Math.max(100, Math.min(Math.floor(options.timeoutMs), 60_000))
657
+ : 10_000;
658
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
645
659
  let response: Response;
646
660
  try {
647
661
  response = await fetch(url, {
@@ -656,7 +670,7 @@ async function fetchWithRetry(
656
670
  } catch (err: unknown) {
657
671
  clearTimeout(timeout);
658
672
  if (err instanceof Error && err.name === 'AbortError') {
659
- throw new Error(`Embedding API timeout after 10s: ${url}`);
673
+ throw new Error(`Embedding API timeout after ${timeoutMs}ms: ${url}`);
660
674
  }
661
675
  throw err;
662
676
  }
@@ -666,13 +680,13 @@ async function fetchWithRetry(
666
680
  return response.json() as Promise<EmbeddingAPIResponse>;
667
681
  }
668
682
 
669
- if ((response.status === 429 || response.status >= 500) && attempt < MAX_RETRIES) {
683
+ if (options.retry !== false && (response.status === 429 || response.status >= 500) && attempt < MAX_RETRIES) {
670
684
  const delay = BASE_DELAY_MS * Math.pow(2, attempt);
671
685
  const retryAfter = response.headers.get('retry-after');
672
686
  const waitMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : delay;
673
687
  console.error(`[memorix] Embedding API ${response.status}, retry ${attempt + 1}/${MAX_RETRIES} in ${waitMs}ms`);
674
688
  await new Promise(resolve => setTimeout(resolve, waitMs));
675
- return fetchWithRetry(url, apiKey, body, attempt + 1);
689
+ return fetchWithRetry(url, apiKey, body, options, attempt + 1);
676
690
  }
677
691
 
678
692
  const errorText = await response.text().catch(() => 'unknown error');
@@ -27,15 +27,22 @@
27
27
  * Architecture inspired by Mem0's multi-provider embedding design.
28
28
  */
29
29
 
30
+ export interface EmbeddingRequestOptions {
31
+ /** Bound one remote embedding request without changing the provider default. */
32
+ timeoutMs?: number;
33
+ /** Disable provider retries for latency-sensitive best-effort work. */
34
+ retry?: boolean;
35
+ }
36
+
30
37
  export interface EmbeddingProvider {
31
38
  /** Provider name for logging/cache keys */
32
39
  readonly name: string;
33
40
  /** Vector dimensions (e.g., 384 for bge-small) */
34
41
  readonly dimensions: number;
35
42
  /** Generate embedding for a single text */
36
- embed(text: string): Promise<number[]>;
43
+ embed(text: string, options?: EmbeddingRequestOptions): Promise<number[]>;
37
44
  /** Generate embeddings for multiple texts (batch) */
38
- embedBatch(texts: string[]): Promise<number[][]>;
45
+ embedBatch(texts: string[], options?: EmbeddingRequestOptions): Promise<number[][]>;
39
46
  /** Return already-persisted embeddings without generating new vectors. */
40
47
  getCachedEmbeddings?(texts: string[]): Promise<(number[] | null)[]>;
41
48
  }
@@ -43,6 +50,10 @@ export interface EmbeddingProvider {
43
50
  export interface GetEmbeddingProviderOptions {
44
51
  /** Avoid remote API dimension probes while preparing a startup index. */
45
52
  allowNetworkProbe?: boolean;
53
+ /** Bound a remote API dimension probe for a latency-sensitive caller. */
54
+ requestTimeoutMs?: number;
55
+ /** Disable retries while resolving a best-effort provider. */
56
+ retry?: boolean;
46
57
  }
47
58
 
48
59
  /** Singleton provider instance (null = not available) */
@@ -205,9 +216,9 @@ function wrapProvider(candidate: EmbeddingProvider): EmbeddingProvider {
205
216
  return {
206
217
  name: candidate.name,
207
218
  dimensions: candidate.dimensions,
208
- async embed(text: string): Promise<number[]> {
219
+ async embed(text: string, options?: EmbeddingRequestOptions): Promise<number[]> {
209
220
  try {
210
- return await candidate.embed(text);
221
+ return await candidate.embed(text, options);
211
222
  } catch (error) {
212
223
  if (kind === 'api' && getEmbeddingMode() === 'auto' && isTemporaryEmbeddingFailure(error)) {
213
224
  warnOnce('[memorix] API embedding temporarily unavailable — switching to local fallback provider');
@@ -215,7 +226,7 @@ function wrapProvider(candidate: EmbeddingProvider): EmbeddingProvider {
215
226
  if (fallback) {
216
227
  provider = wrapProvider(fallback);
217
228
  warnOnce(`[memorix] Embedding fallback activated: ${provider.name} (${provider.dimensions}d)`);
218
- return provider.embed(text);
229
+ return provider.embed(text, options);
219
230
  }
220
231
  }
221
232
 
@@ -225,9 +236,9 @@ function wrapProvider(candidate: EmbeddingProvider): EmbeddingProvider {
225
236
  throw error;
226
237
  }
227
238
  },
228
- async embedBatch(texts: string[]): Promise<number[][]> {
239
+ async embedBatch(texts: string[], options?: EmbeddingRequestOptions): Promise<number[][]> {
229
240
  try {
230
- return await candidate.embedBatch(texts);
241
+ return await candidate.embedBatch(texts, options);
231
242
  } catch (error) {
232
243
  if (kind === 'api' && getEmbeddingMode() === 'auto' && isTemporaryEmbeddingFailure(error)) {
233
244
  warnOnce('[memorix] API embedding temporarily unavailable — switching to local fallback provider');
@@ -235,7 +246,7 @@ function wrapProvider(candidate: EmbeddingProvider): EmbeddingProvider {
235
246
  if (fallback) {
236
247
  provider = wrapProvider(fallback);
237
248
  warnOnce(`[memorix] Embedding fallback activated: ${provider.name} (${provider.dimensions}d)`);
238
- return provider.embedBatch(texts);
249
+ return provider.embedBatch(texts, options);
239
250
  }
240
251
  }
241
252
 
@@ -331,7 +342,7 @@ export async function getEmbeddingProvider(
331
342
 
332
343
  // API mode: remote embedding via OpenAI-compatible endpoint
333
344
  if (mode === 'api') {
334
- const initialized = await createAPIProvider();
345
+ const initialized = await createAPIProvider(options);
335
346
  if (!initialized) return null;
336
347
  provider = wrapProvider(initialized);
337
348
  warnOnce(`[memorix] Embedding provider: ${provider.name} (${provider.dimensions}d)`);
@@ -340,7 +351,7 @@ export async function getEmbeddingProvider(
340
351
 
341
352
  // Auto mode: try configured API first, then local fallbacks
342
353
  if (hasAPIEmbeddingConfig()) {
343
- const initialized = await createAPIProvider();
354
+ const initialized = await createAPIProvider(options);
344
355
  if (initialized) {
345
356
  provider = wrapProvider(initialized);
346
357
  warnOnce(`[memorix] Embedding provider: ${provider.name} (${provider.dimensions}d)`);
@@ -1247,6 +1247,8 @@ function getAgentRulesContent(agent?: AgentName, scope: 'project' | 'global' = '
1247
1247
  '- Include \x60filesModified\x60 when relevant',
1248
1248
  '- Use \x60topicKey\x60 for topics that evolve over time (prevents duplicates)',
1249
1249
  '- For "why" decisions, use \x60memorix_store_reasoning\x60',
1250
+ '- For a stable fact, reusable procedure, or completed episode that merits deliberate long-term review, include \x60longTerm\x60 in \x60memorix_store\x60 with the appropriate kind and normally \x60scope: "project"\x60. It creates a candidate only: do not use it for routine updates, do not make project-derived evidence portable user memory, and do not assume it enters context until an operator qualifies and approves it through \x60memorix memory long-term\x60.',
1251
+ '- A \x60user\x60 + \x60portable\x60 durable memory delivered in a task brief is intentionally available across projects. When it matches the task, use it as reusable background even if its origin differs; do not treat it as a current-project fact. Expand it only when needed with \x60memorix_detail\x60 using its \x60durable:<id>\x60 reference and a specific purpose.',
1250
1252
  '',
1251
1253
  "**Don't store:** greetings, simple file reads, trivial commands (ls, pwd, git status).",
1252
1254
  '',
@@ -18,6 +18,7 @@ export type ContextCandidateKind =
18
18
  | 'semantic-code'
19
19
  | 'start-here'
20
20
  | 'memory'
21
+ | 'durable-memory'
21
22
  | 'claim'
22
23
  | 'knowledge-page'
23
24
  | 'workflow'
@@ -43,7 +44,7 @@ export interface ContextReceiptOmission {
43
44
  }
44
45
 
45
46
  export interface ContextReceipt {
46
- version: '1.2.4';
47
+ version: '1.3';
47
48
  target: ContextDeliveryTarget;
48
49
  elapsedMs: number;
49
50
  budget: {