npm package: @sno-ai/promptix
Description: Three-tier prompt loading with Redis caching and LRU cache support
Latest version: 1.0.0

# Promptix (@sno/promptix)

Three-tier prompt loading system with Redis caching, user-specific prompts, and multilingual support.

## Architecture

```
┌─────────────────────────────────────────────────────────────────────┐
│                          Promptix                                   │
├─────────────────────────────────────────────────────────────────────┤
│  L1: LRU Cache (0.1ms)  →  L2: Redis (1-2ms)  →  L3: File (5-10ms)  │
└─────────────────────────────────────────────────────────────────────┘
```

**Features:**
- Dependency injection (no hardcoded config)
- Optional Redis (works file-only if unavailable)
- Pub/Sub cache invalidation
- User-specific prompts via `userId`
- Multilingual support (en, zh, es)
- Graceful fallback chain

---

## Installation

```bash
# As npm package (private)
npm install @sno/promptix

# Or as git submodule
git submodule add https://github.com/sno-ai/promptix.git package/promptix
```

**Peer Dependencies:**
```json
{
  "ioredis": "^5.0.0"  // Optional - only needed for Redis features
}
```

---

## How to Use

### Basic Usage

```typescript
import { createPromptLoader } from "@sno/promptix";

// Create loader with file-only mode (no Redis)
const loader = createPromptLoader({
  promptDir: "./prompts",
});

// Load English prompt
const prompt = await loader.loadPrompt("memory", "extract", 1);
```

### With Redis

```typescript
const loader = createPromptLoader({
  promptDir: "./prompts",
  redisUrl: "redis://localhost:6379",
});

// Initialize Redis connection
await loader.initRedis();

// Start cache invalidation listener (optional)
await loader.startInvalidationListener();

// Load prompt
const prompt = await loader.loadPrompt("memory", "extract", 1);

// Cleanup on shutdown
await loader.close();
```

### With User-Specific Prompt

```typescript
// Load user-specific prompt
const prompt = await loader.loadPrompt("memory", "extract", 1, {
  userId: "user_12345",
});
// Falls back to generic prompt if user-specific not found
```

### With Language

```typescript
// Load Chinese prompt
const prompt = await loader.loadPrompt("memory", "extract", 1, {
  language: "zh",
});
// Falls back to English if Chinese not found

// Load Spanish prompt for specific user
const prompt = await loader.loadPrompt("memory", "extract", 1, {
  userId: "user_12345",
  language: "es",
});
```

### Full Options

```typescript
const prompt = await loader.loadPrompt("memory", "extract", 2, {
  fallbackVersion: 1,      // Try v1 if v2 not found
  context: "coding",       // App context (default: "default")
  userId: "user_123",      // User-specific prompt
  language: "zh",          // Language (default: "en")
});
```

---

## Directory Structure

**Filename Convention:** `{promptName}_v{version}.md` (version suffix required)

```
{promptDir}/
├── {context}/                              # Context (e.g., "default", "coding")
│   └── {category}/                         # Category (e.g., "memory", "graph")
│       │
│       │── {promptName}_v{version}.md      # English (root - no subdirectory)
│       │
│       ├── zh/                             # Chinese (optional)
│       │   └── {promptName}_v{version}.md
│       │
│       ├── es/                             # Spanish (optional)
│       │   └── {promptName}_v{version}.md
│       │
│       └── {userId}/                       # User-specific (optional)
│           │── {promptName}_v{version}.md  # User English
│           ├── zh/
│           │   └── {promptName}_v{version}.md  # User Chinese
│           └── es/
│               └── {promptName}_v{version}.md  # User Spanish
```

### Example Directory

```
prompts/
├── default/
│   ├── memory/
│   │   ├── extract_v1.md                   # English v1 (generic)
│   │   ├── extract_v2.md                   # English v2 (generic)
│   │   ├── zh/
│   │   │   └── extract_v1.md               # Chinese v1
│   │   ├── es/
│   │   │   └── extract_v1.md               # Spanish v1
│   │   └── user_12345/
│   │       ├── extract_v1.md               # User-specific English v1
│   │       └── zh/
│   │           └── extract_v1.md           # User-specific Chinese v1
│   │
│   └── graph/
│       └── build_v1.md
│
└── coding/                                 # Context for coding assistants
    └── memory/
        └── extract_v1.md                   # Coding-specific prompt
```

---

## Input Parameters

### `createPromptLoader(config)`

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `promptDir` | `string` | Yes | - | Base directory for prompt files |
| `redisUrl` | `string` | No | - | Redis connection URL |
| `cacheSize` | `number` | No | `100` | LRU cache max entries |
| `cacheTtlSeconds` | `number` | No | `21600` | Local cache TTL (6 hours) |
| `invalidationChannel` | `string` | No | `"prompt:invalidate"` | Redis pub/sub channel |
| `redisConnectTimeoutMs` | `number` | No | `5000` | Redis connect timeout |
| `redisCommandTimeoutMs` | `number` | No | `5000` | Redis command timeout |
| `redisMaxRetries` | `number` | No | `3` | Max retries per request |
| `logger` | `PromptLoaderLogger` | No | `console` | Custom logger |

### `loadPrompt(category, promptName, version, options?)`

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `category` | `string` | Yes | - | Prompt category (e.g., `"memory"`, `"graph"`) |
| `promptName` | `string` | Yes | - | Prompt name without version (e.g., `"extract"`) |
| `version` | `number` | Yes | - | Primary version to load (1-9999) |
| `options.fallbackVersion` | `number` | No | - | Version to try if primary not found |
| `options.context` | `string` | No | `"default"` | Application context |
| `options.userId` | `string` | No | - | User ID for user-specific prompts |
| `options.language` | `"en" \| "zh" \| "es"` | No | `"en"` | Language code |

### Validation Rules

| Field | Pattern | Max Length | Notes |
|-------|---------|------------|-------|
| `category` | `^[a-z][a-z0-9_]{0,63}$` | 64 | Lowercase, alphanumeric, underscores |
| `promptName` | `^[a-z][a-z0-9_]{0,63}$` | 64 | Lowercase, alphanumeric, underscores |
| `context` | `^[a-z][a-z0-9_-]{0,63}$` | 64 | Also allows hyphens |
| `userId` | `^[a-zA-Z0-9_-]{1,64}$` | 64 | Alphanumeric, underscores, hyphens |
| `version` | Integer 1-9999 | - | - |

---

## Fallback Chain

Prompts are searched in priority order until found:

### With `userId` + `language`

```
1. {context}/{category}/{userId}/{lang}/{promptName}_v{version}.md
2. {context}/{category}/{userId}/{promptName}_v{version}.md          # English fallback
3. {context}/{category}/{lang}/{promptName}_v{version}.md            # Generic fallback
4. {context}/{category}/{promptName}_v{version}.md                   # English generic
5. default/{category}/{userId}/{lang}/{promptName}_v{version}.md     # Default context
6. default/{category}/{userId}/{promptName}_v{version}.md
7. default/{category}/{lang}/{promptName}_v{version}.md
8. default/{category}/{promptName}_v{version}.md
```

### With `language` only (no `userId`)

```
1. {context}/{category}/{lang}/{promptName}_v{version}.md
2. {context}/{category}/{promptName}_v{version}.md                   # English fallback
3. default/{category}/{lang}/{promptName}_v{version}.md              # Default context
4. default/{category}/{promptName}_v{version}.md
```

### With `userId` only (English)

```
1. {context}/{category}/{userId}/{promptName}_v{version}.md
2. {context}/{category}/{promptName}_v{version}.md                   # Generic fallback
3. default/{category}/{userId}/{promptName}_v{version}.md            # Default context
4. default/{category}/{promptName}_v{version}.md
```

### Basic (no `userId`, English)

```
1. {context}/{category}/{promptName}_v{version}.md
2. default/{category}/{promptName}_v{version}.md                     # Default context
```

---

## Output

### `loadPrompt()` Return Value

Returns: `Promise<string>` - The prompt template content.

### Cache Statistics

```typescript
const stats = loader.getStats();
```

Returns `CacheStats`:
```typescript
{
  localCache: {
    size: 45,        // Current cached items
    maxSize: 100,    // Max capacity
    hits: 1250,      // Cache hits
    misses: 48,      // Cache misses
    hitRate: 96.3    // Hit rate percentage
  },
  redisAvailable: true,   // Redis connection status
  pubsubActive: true      // Invalidation listener status
}
```

### Errors

| Error Class | When Thrown |
|-------------|-------------|
| `PromptNotFoundError` | Prompt file not found in any fallback path |
| `InvalidPromptError` | Template content invalid (empty, too short, too large) |
| `SecurityError` | Path traversal or dangerous characters detected |

---

## Redis Key Format

```
prompt:{context}:{category}:{userId}:{lang}:{promptName}:v{version}
```

Where:
- `userId` = `"_"` when no user specified
- `lang` = `"en"` for English (always explicit in Redis)

### Examples

```
prompt:default:memory:_:en:extract:v1           # Basic English
prompt:default:memory:_:zh:extract:v1           # Chinese
prompt:default:memory:user_123:en:extract:v1    # User-specific English
prompt:coding:memory:user_123:zh:extract:v2     # User-specific Chinese in coding context
```

---

## Cache Invalidation

### Programmatic

```typescript
// Clear all
loader.invalidate("*");

// Clear category
loader.invalidate("memory:*");

// Clear specific
loader.invalidate("default:memory:extract:v1");
```

### Via Redis Pub/Sub

```bash
# Publish invalidation message
redis-cli PUBLISH prompt:invalidate "memory:*"
```

---

## Best Practices

1. **Version Management**: Start with v1, increment for breaking changes
2. **Fallback Versions**: Always provide `fallbackVersion` for critical prompts
3. **User-Specific Prompts**: Use `userId` for per-user fine-tuning, not user data storage
4. **Languages**: Only create localized files when translation is complete
5. **Context**: Use for app modes (coding, chat), not user segmentation

---

## Migration from `subdirectory`

The parameter `subdirectory` has been renamed to `category` for clarity:

```typescript
// Before
loader.loadPrompt("memory_agent", "prompt", 1);

// After
loader.loadPrompt("memory", "extract", 1);
```

Directory structure remains the same.