mirror of
https://github.com/samvallad33/vestige.git
synced 2026-06-28 21:49:38 +02:00
fix: dedup on ingest, Intel Mac CI, npm versions, remove dead TS package
- Route ingest tool through smart_ingest (Prediction Error Gating) to prevent duplicate memories when content is similar to existing entries - Fix Intel Mac release build: use macos-13 runner for x86_64-apple-darwin (macos-latest is now ARM64, causing silent cross-compile failures) - Sync npm package version to 1.1.2 (was 1.0.0 in package.json, 1.1.0 in postinstall.js BINARY_VERSION) - Add vestige-restore to npm makeExecutable list - Remove abandoned packages/core/ TypeScript package (pre-Rust implementation referencing FSRS-5, chromadb, ollama — 32K lines of dead code) - Sync workspace Cargo.toml version to 1.1.2 Closes #5 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
709c06c2fa
commit
a680fa7d2f
49 changed files with 76 additions and 32094 deletions
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -30,7 +30,7 @@ jobs:
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
archive: zip
|
archive: zip
|
||||||
- target: x86_64-apple-darwin
|
- target: x86_64-apple-darwin
|
||||||
os: macos-latest
|
os: macos-13
|
||||||
archive: tar.gz
|
archive: tar.gz
|
||||||
- target: aarch64-apple-darwin
|
- target: aarch64-apple-darwin
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
|
|
|
||||||
53
Cargo.lock
generated
53
Cargo.lock
generated
|
|
@ -342,9 +342,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.54"
|
version = "4.5.56"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
|
checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
|
|
@ -352,9 +352,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.5.54"
|
version = "4.5.56"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
|
checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
|
|
@ -364,9 +364,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.5.49"
|
version = "4.5.55"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
|
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
|
|
@ -1279,9 +1279,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iana-time-zone"
|
name = "iana-time-zone"
|
||||||
version = "0.1.64"
|
version = "0.1.65"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
|
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_system_properties",
|
"android_system_properties",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
|
|
@ -1430,7 +1430,7 @@ dependencies = [
|
||||||
"rgb",
|
"rgb",
|
||||||
"tiff",
|
"tiff",
|
||||||
"zune-core 0.5.1",
|
"zune-core 0.5.1",
|
||||||
"zune-jpeg 0.5.11",
|
"zune-jpeg 0.5.12",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1929,9 +1929,12 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notify-types"
|
name = "notify-types"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d"
|
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
|
|
@ -2075,9 +2078,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-src"
|
name = "openssl-src"
|
||||||
version = "300.5.4+3.5.4"
|
version = "300.5.5+3.5.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72"
|
checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
@ -3436,9 +3439,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.19.0"
|
version = "1.20.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
|
checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
|
@ -3477,7 +3480,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vestige-core"
|
name = "vestige-core"
|
||||||
version = "1.1.0"
|
version = "1.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"directories",
|
"directories",
|
||||||
|
|
@ -3511,7 +3514,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vestige-mcp"
|
name = "vestige-mcp"
|
||||||
version = "1.1.1"
|
version = "1.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
@ -3998,18 +4001,18 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.33"
|
version = "0.8.36"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd"
|
checksum = "dafd85c832c1b68bbb4ec0c72c7f6f4fc5179627d2bc7c26b30e4c0cc11e76cc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.33"
|
version = "0.8.36"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1"
|
checksum = "7cb7e4e8436d9db52fbd6625dbf2f45243ab84994a72882ec8227b99e72b439a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
@ -4078,9 +4081,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.16"
|
version = "1.0.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
|
checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-core"
|
name = "zune-core"
|
||||||
|
|
@ -4114,9 +4117,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-jpeg"
|
name = "zune-jpeg"
|
||||||
version = "0.5.11"
|
version = "0.5.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2959ca473aae96a14ecedf501d20b3608d2825ba280d5adb57d651721885b0c2"
|
checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zune-core 0.5.1",
|
"zune-core 0.5.1",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ members = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.0.0"
|
version = "1.1.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
repository = "https://github.com/samvallad33/vestige"
|
repository = "https://github.com/samvallad33/vestige"
|
||||||
|
|
|
||||||
|
|
@ -76,14 +76,50 @@ pub async fn execute(
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut storage = storage.lock().await;
|
let mut storage = storage.lock().await;
|
||||||
let node = storage.ingest(input).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
// Route through smart_ingest when embeddings are available to prevent duplicates.
|
||||||
"success": true,
|
// Falls back to raw ingest only when embeddings aren't ready.
|
||||||
"nodeId": node.id,
|
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||||
"message": format!("Knowledge ingested successfully. Node ID: {}", node.id),
|
{
|
||||||
"hasEmbedding": node.has_embedding.unwrap_or(false),
|
let fallback_input = input.clone();
|
||||||
}))
|
match storage.smart_ingest(input) {
|
||||||
|
Ok(result) => {
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"nodeId": result.node.id,
|
||||||
|
"decision": result.decision,
|
||||||
|
"message": format!("Knowledge ingested successfully. Node ID: {} ({})", result.node.id, result.decision),
|
||||||
|
"hasEmbedding": result.node.has_embedding.unwrap_or(false),
|
||||||
|
"similarity": result.similarity,
|
||||||
|
"reason": result.reason,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// smart_ingest failed — fall through to raw ingest with cloned input
|
||||||
|
let node = storage.ingest(fallback_input).map_err(|e| e.to_string())?;
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"nodeId": node.id,
|
||||||
|
"decision": "create",
|
||||||
|
"message": format!("Knowledge ingested successfully. Node ID: {}", node.id),
|
||||||
|
"hasEmbedding": node.has_embedding.unwrap_or(false),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for builds without embedding features
|
||||||
|
#[cfg(not(all(feature = "embeddings", feature = "vector-search")))]
|
||||||
|
{
|
||||||
|
let node = storage.ingest(input).map_err(|e| e.to_string())?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"nodeId": node.id,
|
||||||
|
"decision": "create",
|
||||||
|
"message": format!("Knowledge ingested successfully. Node ID: {}", node.id),
|
||||||
|
"hasEmbedding": node.has_embedding.unwrap_or(false),
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
35
packages/core/.gitignore
vendored
35
packages/core/.gitignore
vendored
|
|
@ -1,35 +0,0 @@
|
||||||
# Dependencies
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Build output
|
|
||||||
dist/
|
|
||||||
|
|
||||||
# Database (user data)
|
|
||||||
*.db
|
|
||||||
*.db-wal
|
|
||||||
*.db-shm
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
|
|
||||||
# Test coverage
|
|
||||||
coverage/
|
|
||||||
|
|
||||||
# Temporary files
|
|
||||||
*.tmp
|
|
||||||
*.temp
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
# Vestige
|
|
||||||
|
|
||||||
[](https://www.npmjs.com/package/vestige-mcp)
|
|
||||||
[](https://modelcontextprotocol.io)
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
|
||||||
|
|
||||||
**Git Blame for AI Thoughts** - Memory that decays, strengthens, and discovers connections like the human mind.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Why Vestige?
|
|
||||||
|
|
||||||
| Feature | Vestige | Mem0 | Zep | Letta |
|
|
||||||
|---------|--------|------|-----|-------|
|
|
||||||
| FSRS-5 spaced repetition | Yes | No | No | No |
|
|
||||||
| Dual-strength memory | Yes | No | No | No |
|
|
||||||
| Sentiment-weighted retention | Yes | No | Yes | No |
|
|
||||||
| Local-first (no cloud) | Yes | No | No | No |
|
|
||||||
| Git context capture | Yes | No | No | No |
|
|
||||||
| Semantic connections | Yes | Limited | Yes | Yes |
|
|
||||||
| Free & open source | Yes | Freemium | Freemium | Yes |
|
|
||||||
|
|
||||||
## Quickstart
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install
|
|
||||||
npx vestige-mcp init
|
|
||||||
|
|
||||||
# Add to Claude Desktop config
|
|
||||||
# ~/.config/claude/claude_desktop_config.json (Mac/Linux)
|
|
||||||
# %APPDATA%\Claude\claude_desktop_config.json (Windows)
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"vestige": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["vestige-mcp"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Restart Claude Desktop - done!
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Concepts
|
|
||||||
|
|
||||||
### Cognitive Science Foundation
|
|
||||||
|
|
||||||
Vestige implements proven memory science:
|
|
||||||
|
|
||||||
- **FSRS-5**: State-of-the-art spaced repetition algorithm (powers Anki's 100M+ users)
|
|
||||||
- **Dual-Strength Memory**: Separate storage and retrieval strength (Bjork & Bjork, 1992)
|
|
||||||
- **Ebbinghaus Decay**: Memories fade naturally without reinforcement using `R = e^(-t/S)`
|
|
||||||
- **Sentiment Weighting**: Emotional memories decay slower via AFINN-165 lexicon analysis
|
|
||||||
|
|
||||||
### Developer Features
|
|
||||||
|
|
||||||
- **Git-Blame for Thoughts**: Every memory captures git branch, commit hash, and changed files
|
|
||||||
- **REM Cycle**: Background connection discovery between unrelated memories
|
|
||||||
- **Shadow Self**: Queue unsolved problems for future inspiration when new knowledge arrives
|
|
||||||
|
|
||||||
## MCP Tools
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `ingest` | Store knowledge with metadata (source, people, tags, git context) |
|
|
||||||
| `recall` | Search memories by query with relevance ranking |
|
|
||||||
| `get_knowledge` | Retrieve specific memory by ID |
|
|
||||||
| `get_related` | Find connected nodes via graph traversal |
|
|
||||||
| `mark_reviewed` | Reinforce a memory (triggers spaced repetition) |
|
|
||||||
| `remember_person` | Add/update person in your network |
|
|
||||||
| `get_person` | Retrieve person details and relationship health |
|
|
||||||
| `daily_brief` | Get summary of memory state and review queue |
|
|
||||||
| `health_check` | Check database health with recommendations |
|
|
||||||
| `backup` | Create timestamped database backup |
|
|
||||||
|
|
||||||
## MCP Resources
|
|
||||||
|
|
||||||
| Resource | URI | Description |
|
|
||||||
|----------|-----|-------------|
|
|
||||||
| Recent memories | `memory://knowledge/recent` | Last 20 stored memories |
|
|
||||||
| Decaying memories | `memory://knowledge/decaying` | Memories below 50% retention |
|
|
||||||
| People network | `memory://people/network` | Your relationship graph |
|
|
||||||
| System context | `memory://context` | Active window, git branch, clipboard |
|
|
||||||
|
|
||||||
## CLI Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Memory
|
|
||||||
vestige stats # Quick overview
|
|
||||||
vestige recall "query" # Search memories
|
|
||||||
vestige review # Show due for review
|
|
||||||
|
|
||||||
# Ingestion
|
|
||||||
vestige eat <url|path> # Ingest documentation
|
|
||||||
|
|
||||||
# REM Cycle
|
|
||||||
vestige dream # Discover connections
|
|
||||||
vestige dream --dry-run # Preview only
|
|
||||||
|
|
||||||
# Shadow Self
|
|
||||||
vestige problem "desc" # Log unsolved problem
|
|
||||||
vestige problems # List open problems
|
|
||||||
vestige solve <id> "fix" # Mark solved
|
|
||||||
|
|
||||||
# Context
|
|
||||||
vestige context # Show current context
|
|
||||||
vestige watch # Start context daemon
|
|
||||||
|
|
||||||
# Maintenance
|
|
||||||
vestige backup # Create backup
|
|
||||||
vestige optimize # Vacuum and reindex
|
|
||||||
vestige decay # Apply memory decay
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Create `~/.vestige/config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"fsrs": {
|
|
||||||
"desiredRetention": 0.9,
|
|
||||||
"maxStability": 365
|
|
||||||
},
|
|
||||||
"rem": {
|
|
||||||
"enabled": true,
|
|
||||||
"maxAnalyze": 50,
|
|
||||||
"minStrength": 0.3
|
|
||||||
},
|
|
||||||
"decay": {
|
|
||||||
"sentimentBoost": 2.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Locations
|
|
||||||
|
|
||||||
| File | Path |
|
|
||||||
|------|------|
|
|
||||||
| Main database | `~/.vestige/vestige.db` |
|
|
||||||
| Shadow Self | `~/.vestige/shadow.db` |
|
|
||||||
| Backups | `~/.vestige/backups/` |
|
|
||||||
| Context | `~/.vestige/context.json` |
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### Memory Decay
|
|
||||||
|
|
||||||
```
|
|
||||||
Retention = e^(-days/stability)
|
|
||||||
|
|
||||||
New memory: S=1.0 -> 37% after 1 day
|
|
||||||
Reviewed once: S=2.5 -> 67% after 1 day
|
|
||||||
Reviewed 3x: S=15.6 -> 94% after 1 day
|
|
||||||
Emotional: S x 1.85 boost
|
|
||||||
```
|
|
||||||
|
|
||||||
### REM Cycle Connections
|
|
||||||
|
|
||||||
The REM cycle discovers hidden relationships:
|
|
||||||
|
|
||||||
| Connection Type | Trigger | Strength |
|
|
||||||
|----------------|---------|----------|
|
|
||||||
| `entity_shared` | Same people mentioned | 0.5 + (count * 0.2) |
|
|
||||||
| `concept_overlap` | 2+ shared concepts | 0.4 + (count * 0.15) |
|
|
||||||
| `keyword_similarity` | Jaccard > 15% | similarity * 2 |
|
|
||||||
| `temporal_proximity` | Same day + overlap | 0.3 |
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- [API Reference](./docs/api.md) - Full TypeScript API documentation
|
|
||||||
- [Configuration](./docs/configuration.md) - All config options
|
|
||||||
- [Architecture](./docs/architecture.md) - System design and data flow
|
|
||||||
- [Cognitive Science](./docs/cognitive-science.md) - The research behind Vestige
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT - see [LICENSE](./LICENSE)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Vestige**: The only AI memory system built on 130 years of cognitive science research.
|
|
||||||
6126
packages/core/package-lock.json
generated
6126
packages/core/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,74 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@vestige/core",
|
|
||||||
"version": "0.3.0",
|
|
||||||
"description": "Cognitive memory for AI - FSRS-5, dual-strength, sleep consolidation",
|
|
||||||
"type": "module",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"types": "dist/index.d.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"./fsrs": {
|
|
||||||
"types": "./dist/core/fsrs.d.ts",
|
|
||||||
"import": "./dist/core/fsrs.js"
|
|
||||||
},
|
|
||||||
"./database": {
|
|
||||||
"types": "./dist/core/database.d.ts",
|
|
||||||
"import": "./dist/core/database.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"vestige": "./dist/cli.js"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsup",
|
|
||||||
"dev": "tsup --watch",
|
|
||||||
"start": "node dist/index.js",
|
|
||||||
"inspect": "npx @anthropic-ai/mcp-inspector node dist/index.js",
|
|
||||||
"test": "rstest",
|
|
||||||
"lint": "eslint src --ext .ts",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"mcp",
|
|
||||||
"memory",
|
|
||||||
"cognitive-science",
|
|
||||||
"fsrs",
|
|
||||||
"spaced-repetition",
|
|
||||||
"knowledge-management",
|
|
||||||
"second-brain",
|
|
||||||
"ai",
|
|
||||||
"claude"
|
|
||||||
],
|
|
||||||
"author": "samvallad33",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
||||||
"better-sqlite3": "^11.0.0",
|
|
||||||
"chokidar": "^3.6.0",
|
|
||||||
"chromadb": "^1.9.0",
|
|
||||||
"date-fns": "^3.6.0",
|
|
||||||
"glob": "^10.4.0",
|
|
||||||
"gray-matter": "^4.0.3",
|
|
||||||
"marked": "^12.0.0",
|
|
||||||
"nanoid": "^5.0.7",
|
|
||||||
"natural": "^6.12.0",
|
|
||||||
"node-cron": "^3.0.3",
|
|
||||||
"ollama": "^0.5.0",
|
|
||||||
"p-limit": "^6.0.0",
|
|
||||||
"zod": "^3.23.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@rstest/core": "^0.8.0",
|
|
||||||
"@types/better-sqlite3": "^7.6.10",
|
|
||||||
"@types/node": "^20.14.0",
|
|
||||||
"@types/node-cron": "^3.0.11",
|
|
||||||
"tsup": "^8.1.0",
|
|
||||||
"typescript": "^5.4.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
3920
packages/core/pnpm-lock.yaml
generated
3920
packages/core/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +0,0 @@
|
||||||
import { defineConfig } from '@rstest/core';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testMatch: ['**/*.test.ts'],
|
|
||||||
setupFiles: ['./src/__tests__/setup.ts'],
|
|
||||||
coverage: {
|
|
||||||
include: ['src/**/*.ts'],
|
|
||||||
exclude: ['src/__tests__/**', 'src/**/*.d.ts'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,476 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach } from '@rstest/core';
|
|
||||||
import Database from 'better-sqlite3';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import {
|
|
||||||
createTestDatabase,
|
|
||||||
createTestNode,
|
|
||||||
createTestPerson,
|
|
||||||
createTestEdge,
|
|
||||||
cleanupTestDatabase,
|
|
||||||
generateTestId,
|
|
||||||
} from './setup.js';
|
|
||||||
|
|
||||||
describe('VestigeDatabase', () => {
|
|
||||||
let db: Database.Database;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
db = createTestDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanupTestDatabase(db);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Schema Setup', () => {
|
|
||||||
it('should create all required tables', () => {
|
|
||||||
const tables = db.prepare(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
|
||||||
).all() as { name: string }[];
|
|
||||||
|
|
||||||
const tableNames = tables.map(t => t.name);
|
|
||||||
|
|
||||||
expect(tableNames).toContain('knowledge_nodes');
|
|
||||||
expect(tableNames).toContain('knowledge_fts');
|
|
||||||
expect(tableNames).toContain('people');
|
|
||||||
expect(tableNames).toContain('interactions');
|
|
||||||
expect(tableNames).toContain('graph_edges');
|
|
||||||
expect(tableNames).toContain('sources');
|
|
||||||
expect(tableNames).toContain('embeddings');
|
|
||||||
expect(tableNames).toContain('vestige_metadata');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create required indexes', () => {
|
|
||||||
const indexes = db.prepare(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'"
|
|
||||||
).all() as { name: string }[];
|
|
||||||
|
|
||||||
const indexNames = indexes.map(i => i.name);
|
|
||||||
|
|
||||||
expect(indexNames).toContain('idx_nodes_created_at');
|
|
||||||
expect(indexNames).toContain('idx_nodes_last_accessed');
|
|
||||||
expect(indexNames).toContain('idx_nodes_retention');
|
|
||||||
expect(indexNames).toContain('idx_people_name');
|
|
||||||
expect(indexNames).toContain('idx_edges_from');
|
|
||||||
expect(indexNames).toContain('idx_edges_to');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('insertNode', () => {
|
|
||||||
it('should create a new knowledge node', () => {
|
|
||||||
const id = nanoid();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const nodeData = createTestNode({
|
|
||||||
content: 'Test knowledge content',
|
|
||||||
tags: ['test', 'knowledge'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
|
||||||
INSERT INTO knowledge_nodes (
|
|
||||||
id, content, summary,
|
|
||||||
created_at, updated_at, last_accessed_at, access_count,
|
|
||||||
retention_strength, stability_factor, sentiment_intensity,
|
|
||||||
source_type, source_platform,
|
|
||||||
confidence, people, concepts, events, tags
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
|
|
||||||
stmt.run(
|
|
||||||
id,
|
|
||||||
nodeData.content,
|
|
||||||
null,
|
|
||||||
now,
|
|
||||||
now,
|
|
||||||
now,
|
|
||||||
0,
|
|
||||||
1.0,
|
|
||||||
1.0,
|
|
||||||
0,
|
|
||||||
nodeData.sourceType,
|
|
||||||
nodeData.sourcePlatform,
|
|
||||||
0.8,
|
|
||||||
JSON.stringify(nodeData.people),
|
|
||||||
JSON.stringify(nodeData.concepts),
|
|
||||||
JSON.stringify(nodeData.events),
|
|
||||||
JSON.stringify(nodeData.tags)
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = db.prepare('SELECT * FROM knowledge_nodes WHERE id = ?').get(id) as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result['content']).toBe('Test knowledge content');
|
|
||||||
expect(JSON.parse(result['tags'] as string)).toContain('test');
|
|
||||||
expect(JSON.parse(result['tags'] as string)).toContain('knowledge');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should store retention and stability factors', () => {
|
|
||||||
const id = nanoid();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const nodeData = createTestNode();
|
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
|
||||||
INSERT INTO knowledge_nodes (
|
|
||||||
id, content,
|
|
||||||
created_at, updated_at, last_accessed_at,
|
|
||||||
retention_strength, stability_factor, sentiment_intensity,
|
|
||||||
storage_strength, retrieval_strength,
|
|
||||||
source_type, source_platform,
|
|
||||||
confidence, people, concepts, events, tags
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
|
|
||||||
stmt.run(
|
|
||||||
id,
|
|
||||||
nodeData.content,
|
|
||||||
now,
|
|
||||||
now,
|
|
||||||
now,
|
|
||||||
0.85,
|
|
||||||
2.5,
|
|
||||||
0.7,
|
|
||||||
1.5,
|
|
||||||
0.9,
|
|
||||||
nodeData.sourceType,
|
|
||||||
nodeData.sourcePlatform,
|
|
||||||
0.8,
|
|
||||||
'[]',
|
|
||||||
'[]',
|
|
||||||
'[]',
|
|
||||||
'[]'
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = db.prepare('SELECT * FROM knowledge_nodes WHERE id = ?').get(id) as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(result['retention_strength']).toBe(0.85);
|
|
||||||
expect(result['stability_factor']).toBe(2.5);
|
|
||||||
expect(result['sentiment_intensity']).toBe(0.7);
|
|
||||||
expect(result['storage_strength']).toBe(1.5);
|
|
||||||
expect(result['retrieval_strength']).toBe(0.9);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('searchNodes', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Insert test nodes for searching
|
|
||||||
const nodes = [
|
|
||||||
{ id: generateTestId(), content: 'TypeScript is a typed superset of JavaScript' },
|
|
||||||
{ id: generateTestId(), content: 'React is a JavaScript library for building user interfaces' },
|
|
||||||
{ id: generateTestId(), content: 'Python is a versatile programming language' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
|
||||||
INSERT INTO knowledge_nodes (
|
|
||||||
id, content, created_at, updated_at, last_accessed_at,
|
|
||||||
source_type, source_platform, confidence, people, concepts, events, tags
|
|
||||||
) VALUES (?, ?, datetime('now'), datetime('now'), datetime('now'), 'manual', 'manual', 0.8, '[]', '[]', '[]', '[]')
|
|
||||||
`);
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
stmt.run(node.id, node.content);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find nodes by keyword using FTS', () => {
|
|
||||||
const results = db.prepare(`
|
|
||||||
SELECT kn.* FROM knowledge_nodes kn
|
|
||||||
JOIN knowledge_fts fts ON kn.id = fts.id
|
|
||||||
WHERE knowledge_fts MATCH ?
|
|
||||||
ORDER BY rank
|
|
||||||
`).all('JavaScript') as Record<string, unknown>[];
|
|
||||||
|
|
||||||
expect(results.length).toBe(2);
|
|
||||||
expect(results.some(r => (r['content'] as string).includes('TypeScript'))).toBe(true);
|
|
||||||
expect(results.some(r => (r['content'] as string).includes('React'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not find unrelated content', () => {
|
|
||||||
const results = db.prepare(`
|
|
||||||
SELECT kn.* FROM knowledge_nodes kn
|
|
||||||
JOIN knowledge_fts fts ON kn.id = fts.id
|
|
||||||
WHERE knowledge_fts MATCH ?
|
|
||||||
`).all('Rust') as Record<string, unknown>[];
|
|
||||||
|
|
||||||
expect(results.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find partial matches', () => {
|
|
||||||
const results = db.prepare(`
|
|
||||||
SELECT kn.* FROM knowledge_nodes kn
|
|
||||||
JOIN knowledge_fts fts ON kn.id = fts.id
|
|
||||||
WHERE knowledge_fts MATCH ?
|
|
||||||
`).all('programming') as Record<string, unknown>[];
|
|
||||||
|
|
||||||
expect(results.length).toBe(1);
|
|
||||||
expect((results[0]['content'] as string)).toContain('Python');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('People Operations', () => {
|
|
||||||
it('should insert a person', () => {
|
|
||||||
const id = nanoid();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const personData = createTestPerson({
|
|
||||||
name: 'John Doe',
|
|
||||||
relationshipType: 'friend',
|
|
||||||
organization: 'Acme Inc',
|
|
||||||
});
|
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
|
||||||
INSERT INTO people (
|
|
||||||
id, name, aliases, relationship_type, organization,
|
|
||||||
contact_frequency, shared_topics, shared_projects, relationship_health,
|
|
||||||
social_links, created_at, updated_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
|
|
||||||
stmt.run(
|
|
||||||
id,
|
|
||||||
personData.name,
|
|
||||||
JSON.stringify(personData.aliases),
|
|
||||||
personData.relationshipType,
|
|
||||||
personData.organization,
|
|
||||||
personData.contactFrequency,
|
|
||||||
JSON.stringify(personData.sharedTopics),
|
|
||||||
JSON.stringify(personData.sharedProjects),
|
|
||||||
personData.relationshipHealth,
|
|
||||||
JSON.stringify(personData.socialLinks),
|
|
||||||
now,
|
|
||||||
now
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = db.prepare('SELECT * FROM people WHERE id = ?').get(id) as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result['name']).toBe('John Doe');
|
|
||||||
expect(result['relationship_type']).toBe('friend');
|
|
||||||
expect(result['organization']).toBe('Acme Inc');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find person by name', () => {
|
|
||||||
const id = nanoid();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO people (id, name, aliases, social_links, shared_topics, shared_projects, created_at, updated_at)
|
|
||||||
VALUES (?, ?, '[]', '{}', '[]', '[]', ?, ?)
|
|
||||||
`).run(id, 'Jane Smith', now, now);
|
|
||||||
|
|
||||||
const result = db.prepare('SELECT * FROM people WHERE name = ?').get('Jane Smith') as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result['id']).toBe(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find person by alias', () => {
|
|
||||||
const id = nanoid();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO people (id, name, aliases, social_links, shared_topics, shared_projects, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, '{}', '[]', '[]', ?, ?)
|
|
||||||
`).run(id, 'Robert Johnson', JSON.stringify(['Bob', 'Bobby']), now, now);
|
|
||||||
|
|
||||||
const result = db.prepare(`
|
|
||||||
SELECT * FROM people WHERE name = ? OR aliases LIKE ?
|
|
||||||
`).get('Bob', '%"Bob"%') as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result['name']).toBe('Robert Johnson');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Graph Edges', () => {
|
|
||||||
let nodeId1: string;
|
|
||||||
let nodeId2: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
nodeId1 = nanoid();
|
|
||||||
nodeId2 = nanoid();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
// Create two nodes
|
|
||||||
const stmt = db.prepare(`
|
|
||||||
INSERT INTO knowledge_nodes (
|
|
||||||
id, content, created_at, updated_at, last_accessed_at,
|
|
||||||
source_type, source_platform, confidence, people, concepts, events, tags
|
|
||||||
) VALUES (?, ?, ?, ?, ?, 'manual', 'manual', 0.8, '[]', '[]', '[]', '[]')
|
|
||||||
`);
|
|
||||||
|
|
||||||
stmt.run(nodeId1, 'Node 1 content', now, now, now);
|
|
||||||
stmt.run(nodeId2, 'Node 2 content', now, now, now);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create an edge between nodes', () => {
|
|
||||||
const edgeId = nanoid();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const edgeData = createTestEdge(nodeId1, nodeId2, {
|
|
||||||
edgeType: 'relates_to',
|
|
||||||
weight: 0.8,
|
|
||||||
});
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO graph_edges (id, from_id, to_id, edge_type, weight, metadata, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`).run(edgeId, edgeData.fromId, edgeData.toId, edgeData.edgeType, edgeData.weight, '{}', now);
|
|
||||||
|
|
||||||
const result = db.prepare('SELECT * FROM graph_edges WHERE id = ?').get(edgeId) as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result['from_id']).toBe(nodeId1);
|
|
||||||
expect(result['to_id']).toBe(nodeId2);
|
|
||||||
expect(result['edge_type']).toBe('relates_to');
|
|
||||||
expect(result['weight']).toBe(0.8);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find related nodes', () => {
|
|
||||||
const edgeId = nanoid();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO graph_edges (id, from_id, to_id, edge_type, weight, metadata, created_at)
|
|
||||||
VALUES (?, ?, ?, 'relates_to', 0.5, '{}', ?)
|
|
||||||
`).run(edgeId, nodeId1, nodeId2, now);
|
|
||||||
|
|
||||||
const results = db.prepare(`
|
|
||||||
SELECT DISTINCT
|
|
||||||
CASE WHEN from_id = ? THEN to_id ELSE from_id END as related_id
|
|
||||||
FROM graph_edges
|
|
||||||
WHERE from_id = ? OR to_id = ?
|
|
||||||
`).all(nodeId1, nodeId1, nodeId1) as { related_id: string }[];
|
|
||||||
|
|
||||||
expect(results.length).toBe(1);
|
|
||||||
expect(results[0].related_id).toBe(nodeId2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should enforce unique constraint on from_id, to_id, edge_type', () => {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO graph_edges (id, from_id, to_id, edge_type, weight, metadata, created_at)
|
|
||||||
VALUES (?, ?, ?, 'relates_to', 0.5, '{}', ?)
|
|
||||||
`).run(nanoid(), nodeId1, nodeId2, now);
|
|
||||||
|
|
||||||
// Attempting to insert duplicate should fail
|
|
||||||
expect(() => {
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO graph_edges (id, from_id, to_id, edge_type, weight, metadata, created_at)
|
|
||||||
VALUES (?, ?, ?, 'relates_to', 0.7, '{}', ?)
|
|
||||||
`).run(nanoid(), nodeId1, nodeId2, now);
|
|
||||||
}).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Decay Simulation', () => {
|
|
||||||
it('should be able to update retention strength', () => {
|
|
||||||
const id = nanoid();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
// Insert a node with initial retention
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO knowledge_nodes (
|
|
||||||
id, content, created_at, updated_at, last_accessed_at,
|
|
||||||
retention_strength, stability_factor,
|
|
||||||
source_type, source_platform, confidence, people, concepts, events, tags
|
|
||||||
) VALUES (?, 'Test content', ?, ?, ?, 1.0, 1.0, 'manual', 'manual', 0.8, '[]', '[]', '[]', '[]')
|
|
||||||
`).run(id, now, now, now);
|
|
||||||
|
|
||||||
// Simulate decay
|
|
||||||
const newRetention = 0.75;
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE knowledge_nodes SET retention_strength = ? WHERE id = ?
|
|
||||||
`).run(newRetention, id);
|
|
||||||
|
|
||||||
const result = db.prepare('SELECT retention_strength FROM knowledge_nodes WHERE id = ?').get(id) as { retention_strength: number };
|
|
||||||
|
|
||||||
expect(result.retention_strength).toBe(0.75);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should track review count', () => {
|
|
||||||
const id = nanoid();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO knowledge_nodes (
|
|
||||||
id, content, created_at, updated_at, last_accessed_at,
|
|
||||||
review_count, source_type, source_platform, confidence, people, concepts, events, tags
|
|
||||||
) VALUES (?, 'Test content', ?, ?, ?, 0, 'manual', 'manual', 0.8, '[]', '[]', '[]', '[]')
|
|
||||||
`).run(id, now, now, now);
|
|
||||||
|
|
||||||
// Simulate review
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE knowledge_nodes
|
|
||||||
SET review_count = review_count + 1,
|
|
||||||
retention_strength = 1.0,
|
|
||||||
last_accessed_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`).run(new Date().toISOString(), id);
|
|
||||||
|
|
||||||
const result = db.prepare('SELECT review_count, retention_strength FROM knowledge_nodes WHERE id = ?').get(id) as { review_count: number; retention_strength: number };
|
|
||||||
|
|
||||||
expect(result.review_count).toBe(1);
|
|
||||||
expect(result.retention_strength).toBe(1.0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Statistics', () => {
|
|
||||||
it('should count nodes correctly', () => {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
// Insert 3 nodes
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO knowledge_nodes (
|
|
||||||
id, content, created_at, updated_at, last_accessed_at,
|
|
||||||
source_type, source_platform, confidence, people, concepts, events, tags
|
|
||||||
) VALUES (?, ?, ?, ?, ?, 'manual', 'manual', 0.8, '[]', '[]', '[]', '[]')
|
|
||||||
`).run(nanoid(), `Node ${i}`, now, now, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = db.prepare('SELECT COUNT(*) as count FROM knowledge_nodes').get() as { count: number };
|
|
||||||
expect(result.count).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should count people correctly', () => {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
// Insert 2 people
|
|
||||||
for (let i = 0; i < 2; i++) {
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO people (id, name, aliases, social_links, shared_topics, shared_projects, created_at, updated_at)
|
|
||||||
VALUES (?, ?, '[]', '{}', '[]', '[]', ?, ?)
|
|
||||||
`).run(nanoid(), `Person ${i}`, now, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = db.prepare('SELECT COUNT(*) as count FROM people').get() as { count: number };
|
|
||||||
expect(result.count).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should count edges correctly', () => {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
// Create nodes first
|
|
||||||
const nodeIds = [nanoid(), nanoid(), nanoid()];
|
|
||||||
for (const id of nodeIds) {
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO knowledge_nodes (
|
|
||||||
id, content, created_at, updated_at, last_accessed_at,
|
|
||||||
source_type, source_platform, confidence, people, concepts, events, tags
|
|
||||||
) VALUES (?, 'Content', ?, ?, ?, 'manual', 'manual', 0.8, '[]', '[]', '[]', '[]')
|
|
||||||
`).run(id, now, now, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert 2 edges
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO graph_edges (id, from_id, to_id, edge_type, weight, metadata, created_at)
|
|
||||||
VALUES (?, ?, ?, 'relates_to', 0.5, '{}', ?)
|
|
||||||
`).run(nanoid(), nodeIds[0], nodeIds[1], now);
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO graph_edges (id, from_id, to_id, edge_type, weight, metadata, created_at)
|
|
||||||
VALUES (?, ?, ?, 'supports', 0.7, '{}', ?)
|
|
||||||
`).run(nanoid(), nodeIds[1], nodeIds[2], now);
|
|
||||||
|
|
||||||
const result = db.prepare('SELECT COUNT(*) as count FROM graph_edges').get() as { count: number };
|
|
||||||
expect(result.count).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,560 +0,0 @@
|
||||||
import { describe, it, expect } from '@rstest/core';
|
|
||||||
import {
|
|
||||||
FSRSScheduler,
|
|
||||||
Grade,
|
|
||||||
FSRS_CONSTANTS,
|
|
||||||
initialDifficulty,
|
|
||||||
initialStability,
|
|
||||||
retrievability,
|
|
||||||
nextDifficulty,
|
|
||||||
nextRecallStability,
|
|
||||||
nextForgetStability,
|
|
||||||
nextInterval,
|
|
||||||
applySentimentBoost,
|
|
||||||
serializeFSRSState,
|
|
||||||
deserializeFSRSState,
|
|
||||||
optimalReviewTime,
|
|
||||||
isReviewDue,
|
|
||||||
type FSRSState,
|
|
||||||
type ReviewGrade,
|
|
||||||
} from '../core/fsrs.js';
|
|
||||||
|
|
||||||
describe('FSRS-5 Algorithm', () => {
|
|
||||||
describe('initialDifficulty', () => {
|
|
||||||
it('should return higher difficulty for Again grade', () => {
|
|
||||||
const dAgain = initialDifficulty(Grade.Again);
|
|
||||||
const dEasy = initialDifficulty(Grade.Easy);
|
|
||||||
expect(dAgain).toBeGreaterThan(dEasy);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clamp difficulty between 1 and 10', () => {
|
|
||||||
const grades: ReviewGrade[] = [Grade.Again, Grade.Hard, Grade.Good, Grade.Easy];
|
|
||||||
for (const grade of grades) {
|
|
||||||
const d = initialDifficulty(grade);
|
|
||||||
expect(d).toBeGreaterThanOrEqual(FSRS_CONSTANTS.MIN_DIFFICULTY);
|
|
||||||
expect(d).toBeLessThanOrEqual(FSRS_CONSTANTS.MAX_DIFFICULTY);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return difficulty in order: Again > Hard > Good > Easy', () => {
|
|
||||||
const dAgain = initialDifficulty(Grade.Again);
|
|
||||||
const dHard = initialDifficulty(Grade.Hard);
|
|
||||||
const dGood = initialDifficulty(Grade.Good);
|
|
||||||
const dEasy = initialDifficulty(Grade.Easy);
|
|
||||||
|
|
||||||
expect(dAgain).toBeGreaterThan(dHard);
|
|
||||||
expect(dHard).toBeGreaterThan(dGood);
|
|
||||||
expect(dGood).toBeGreaterThan(dEasy);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('initialStability', () => {
|
|
||||||
it('should return positive stability for all grades', () => {
|
|
||||||
const grades: ReviewGrade[] = [Grade.Again, Grade.Hard, Grade.Good, Grade.Easy];
|
|
||||||
for (const grade of grades) {
|
|
||||||
const s = initialStability(grade);
|
|
||||||
expect(s).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return higher stability for easier grades', () => {
|
|
||||||
const sAgain = initialStability(Grade.Again);
|
|
||||||
const sEasy = initialStability(Grade.Easy);
|
|
||||||
expect(sEasy).toBeGreaterThan(sAgain);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should ensure minimum stability', () => {
|
|
||||||
const grades: ReviewGrade[] = [Grade.Again, Grade.Hard, Grade.Good, Grade.Easy];
|
|
||||||
for (const grade of grades) {
|
|
||||||
const s = initialStability(grade);
|
|
||||||
expect(s).toBeGreaterThanOrEqual(FSRS_CONSTANTS.MIN_STABILITY);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('retrievability', () => {
|
|
||||||
it('should return 1.0 when elapsed days is 0', () => {
|
|
||||||
const r = retrievability(10, 0);
|
|
||||||
expect(r).toBeCloseTo(1.0, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should decay over time', () => {
|
|
||||||
const stability = 10;
|
|
||||||
const r0 = retrievability(stability, 0);
|
|
||||||
const r5 = retrievability(stability, 5);
|
|
||||||
const r30 = retrievability(stability, 30);
|
|
||||||
|
|
||||||
expect(r0).toBeGreaterThan(r5);
|
|
||||||
expect(r5).toBeGreaterThan(r30);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should decay slower with higher stability', () => {
|
|
||||||
const elapsedDays = 10;
|
|
||||||
const rLowStability = retrievability(5, elapsedDays);
|
|
||||||
const rHighStability = retrievability(50, elapsedDays);
|
|
||||||
|
|
||||||
expect(rHighStability).toBeGreaterThan(rLowStability);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 0 when stability is 0 or negative', () => {
|
|
||||||
expect(retrievability(0, 5)).toBe(0);
|
|
||||||
expect(retrievability(-1, 5)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return value between 0 and 1', () => {
|
|
||||||
const testCases = [
|
|
||||||
{ stability: 1, days: 100 },
|
|
||||||
{ stability: 100, days: 1 },
|
|
||||||
{ stability: 10, days: 10 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { stability, days } of testCases) {
|
|
||||||
const r = retrievability(stability, days);
|
|
||||||
expect(r).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(r).toBeLessThanOrEqual(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('nextDifficulty', () => {
|
|
||||||
it('should increase difficulty for Again grade', () => {
|
|
||||||
const currentD = 5;
|
|
||||||
const newD = nextDifficulty(currentD, Grade.Again);
|
|
||||||
expect(newD).toBeGreaterThan(currentD);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should decrease difficulty for Easy grade', () => {
|
|
||||||
const currentD = 5;
|
|
||||||
const newD = nextDifficulty(currentD, Grade.Easy);
|
|
||||||
expect(newD).toBeLessThan(currentD);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should keep difficulty within bounds', () => {
|
|
||||||
// Test at extremes
|
|
||||||
const lowD = nextDifficulty(FSRS_CONSTANTS.MIN_DIFFICULTY, Grade.Easy);
|
|
||||||
const highD = nextDifficulty(FSRS_CONSTANTS.MAX_DIFFICULTY, Grade.Again);
|
|
||||||
|
|
||||||
expect(lowD).toBeGreaterThanOrEqual(FSRS_CONSTANTS.MIN_DIFFICULTY);
|
|
||||||
expect(highD).toBeLessThanOrEqual(FSRS_CONSTANTS.MAX_DIFFICULTY);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('nextRecallStability', () => {
|
|
||||||
it('should increase stability after successful recall', () => {
|
|
||||||
const currentS = 10;
|
|
||||||
const difficulty = 5;
|
|
||||||
const r = 0.9;
|
|
||||||
|
|
||||||
const newS = nextRecallStability(currentS, difficulty, r, Grade.Good);
|
|
||||||
expect(newS).toBeGreaterThan(currentS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should give bigger boost for Easy grade', () => {
|
|
||||||
const currentS = 10;
|
|
||||||
const difficulty = 5;
|
|
||||||
const r = 0.9;
|
|
||||||
|
|
||||||
const sGood = nextRecallStability(currentS, difficulty, r, Grade.Good);
|
|
||||||
const sEasy = nextRecallStability(currentS, difficulty, r, Grade.Easy);
|
|
||||||
|
|
||||||
expect(sEasy).toBeGreaterThan(sGood);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply hard penalty for Hard grade', () => {
|
|
||||||
const currentS = 10;
|
|
||||||
const difficulty = 5;
|
|
||||||
const r = 0.9;
|
|
||||||
|
|
||||||
const sGood = nextRecallStability(currentS, difficulty, r, Grade.Good);
|
|
||||||
const sHard = nextRecallStability(currentS, difficulty, r, Grade.Hard);
|
|
||||||
|
|
||||||
expect(sHard).toBeLessThan(sGood);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use forget stability for Again grade', () => {
|
|
||||||
const currentS = 10;
|
|
||||||
const difficulty = 5;
|
|
||||||
const r = 0.9;
|
|
||||||
|
|
||||||
const sAgain = nextRecallStability(currentS, difficulty, r, Grade.Again);
|
|
||||||
|
|
||||||
// Should call nextForgetStability internally, resulting in lower stability
|
|
||||||
expect(sAgain).toBeLessThan(currentS);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('nextForgetStability', () => {
|
|
||||||
it('should return lower stability than current', () => {
|
|
||||||
const currentS = 10;
|
|
||||||
const difficulty = 5;
|
|
||||||
const r = 0.3;
|
|
||||||
|
|
||||||
const newS = nextForgetStability(difficulty, currentS, r);
|
|
||||||
expect(newS).toBeLessThan(currentS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return positive stability', () => {
|
|
||||||
const newS = nextForgetStability(5, 10, 0.5);
|
|
||||||
expect(newS).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should keep stability within bounds', () => {
|
|
||||||
const newS = nextForgetStability(10, 100, 0.1);
|
|
||||||
expect(newS).toBeGreaterThanOrEqual(FSRS_CONSTANTS.MIN_STABILITY);
|
|
||||||
expect(newS).toBeLessThanOrEqual(FSRS_CONSTANTS.MAX_STABILITY);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('nextInterval', () => {
|
|
||||||
it('should return 0 for 0 or negative stability', () => {
|
|
||||||
expect(nextInterval(0, 0.9)).toBe(0);
|
|
||||||
expect(nextInterval(-1, 0.9)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return longer intervals for higher stability', () => {
|
|
||||||
const iLow = nextInterval(5, 0.9);
|
|
||||||
const iHigh = nextInterval(50, 0.9);
|
|
||||||
|
|
||||||
expect(iHigh).toBeGreaterThan(iLow);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return shorter intervals for higher desired retention', () => {
|
|
||||||
const stability = 10;
|
|
||||||
const i90 = nextInterval(stability, 0.9);
|
|
||||||
const i95 = nextInterval(stability, 0.95);
|
|
||||||
|
|
||||||
expect(i90).toBeGreaterThan(i95);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 0 for 100% retention', () => {
|
|
||||||
expect(nextInterval(10, 1.0)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return max interval for 0% retention', () => {
|
|
||||||
expect(nextInterval(10, 0)).toBe(FSRS_CONSTANTS.MAX_STABILITY);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('applySentimentBoost', () => {
|
|
||||||
it('should not boost stability for neutral sentiment (0)', () => {
|
|
||||||
const stability = 10;
|
|
||||||
const boosted = applySentimentBoost(stability, 0, 2.0);
|
|
||||||
expect(boosted).toBe(stability);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply max boost for max sentiment (1)', () => {
|
|
||||||
const stability = 10;
|
|
||||||
const maxBoost = 2.0;
|
|
||||||
const boosted = applySentimentBoost(stability, 1, maxBoost);
|
|
||||||
expect(boosted).toBe(stability * maxBoost);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply proportional boost for intermediate sentiment', () => {
|
|
||||||
const stability = 10;
|
|
||||||
const maxBoost = 2.0;
|
|
||||||
const sentiment = 0.5;
|
|
||||||
const boosted = applySentimentBoost(stability, sentiment, maxBoost);
|
|
||||||
|
|
||||||
// Expected: stability * (1 + (maxBoost - 1) * sentiment) = 10 * 1.5 = 15
|
|
||||||
expect(boosted).toBe(15);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clamp sentiment and maxBoost values', () => {
|
|
||||||
const stability = 10;
|
|
||||||
|
|
||||||
// Sentiment should be clamped to 0-1
|
|
||||||
const boosted1 = applySentimentBoost(stability, -0.5, 2.0);
|
|
||||||
expect(boosted1).toBe(stability); // Clamped to 0
|
|
||||||
|
|
||||||
// maxBoost should be clamped to 1-3
|
|
||||||
const boosted2 = applySentimentBoost(stability, 1, 5.0);
|
|
||||||
expect(boosted2).toBe(stability * 3); // Clamped to 3
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('FSRSScheduler', () => {
|
|
||||||
describe('constructor', () => {
|
|
||||||
it('should create scheduler with default config', () => {
|
|
||||||
const scheduler = new FSRSScheduler();
|
|
||||||
const config = scheduler.getConfig();
|
|
||||||
|
|
||||||
expect(config.desiredRetention).toBe(0.9);
|
|
||||||
expect(config.maximumInterval).toBe(36500);
|
|
||||||
expect(config.enableSentimentBoost).toBe(true);
|
|
||||||
expect(config.maxSentimentBoost).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept custom config', () => {
|
|
||||||
const scheduler = new FSRSScheduler({
|
|
||||||
desiredRetention: 0.85,
|
|
||||||
maximumInterval: 365,
|
|
||||||
enableSentimentBoost: false,
|
|
||||||
maxSentimentBoost: 1.5,
|
|
||||||
});
|
|
||||||
const config = scheduler.getConfig();
|
|
||||||
|
|
||||||
expect(config.desiredRetention).toBe(0.85);
|
|
||||||
expect(config.maximumInterval).toBe(365);
|
|
||||||
expect(config.enableSentimentBoost).toBe(false);
|
|
||||||
expect(config.maxSentimentBoost).toBe(1.5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('newCard', () => {
|
|
||||||
it('should create new card with initial state', () => {
|
|
||||||
const scheduler = new FSRSScheduler();
|
|
||||||
const state = scheduler.newCard();
|
|
||||||
|
|
||||||
expect(state.state).toBe('New');
|
|
||||||
expect(state.reps).toBe(0);
|
|
||||||
expect(state.lapses).toBe(0);
|
|
||||||
expect(state.difficulty).toBeGreaterThanOrEqual(FSRS_CONSTANTS.MIN_DIFFICULTY);
|
|
||||||
expect(state.difficulty).toBeLessThanOrEqual(FSRS_CONSTANTS.MAX_DIFFICULTY);
|
|
||||||
expect(state.stability).toBeGreaterThan(0);
|
|
||||||
expect(state.scheduledDays).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('review', () => {
|
|
||||||
it('should handle new item review', () => {
|
|
||||||
const scheduler = new FSRSScheduler();
|
|
||||||
const state = scheduler.newCard();
|
|
||||||
|
|
||||||
const result = scheduler.review(state, Grade.Good, 0);
|
|
||||||
|
|
||||||
expect(result.state.stability).toBeGreaterThan(0);
|
|
||||||
expect(result.state.reps).toBe(1);
|
|
||||||
expect(result.state.state).not.toBe('New');
|
|
||||||
expect(result.interval).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(result.isLapse).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle Again grade as lapse for reviewed cards', () => {
|
|
||||||
const scheduler = new FSRSScheduler();
|
|
||||||
let state = scheduler.newCard();
|
|
||||||
|
|
||||||
// First review to move out of New state
|
|
||||||
const result1 = scheduler.review(state, Grade.Good, 0);
|
|
||||||
state = result1.state;
|
|
||||||
|
|
||||||
// Second review with Again (lapse)
|
|
||||||
const result2 = scheduler.review(state, Grade.Again, 1);
|
|
||||||
|
|
||||||
expect(result2.isLapse).toBe(true);
|
|
||||||
expect(result2.state.lapses).toBe(1);
|
|
||||||
expect(result2.state.state).toBe('Relearning');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply sentiment boost when enabled', () => {
|
|
||||||
const scheduler = new FSRSScheduler({ enableSentimentBoost: true, maxSentimentBoost: 2 });
|
|
||||||
const state = scheduler.newCard();
|
|
||||||
|
|
||||||
const resultNoBoost = scheduler.review(state, Grade.Good, 0, 0);
|
|
||||||
const resultWithBoost = scheduler.review(state, Grade.Good, 0, 1);
|
|
||||||
|
|
||||||
expect(resultWithBoost.state.stability).toBeGreaterThan(resultNoBoost.state.stability);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not apply sentiment boost when disabled', () => {
|
|
||||||
const scheduler = new FSRSScheduler({ enableSentimentBoost: false });
|
|
||||||
const state = scheduler.newCard();
|
|
||||||
|
|
||||||
const resultNoBoost = scheduler.review(state, Grade.Good, 0, 0);
|
|
||||||
const resultWithBoost = scheduler.review(state, Grade.Good, 0, 1);
|
|
||||||
|
|
||||||
// Stability should be the same since boost is disabled
|
|
||||||
expect(resultWithBoost.state.stability).toBe(resultNoBoost.state.stability);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should respect maximum interval', () => {
|
|
||||||
const maxInterval = 30;
|
|
||||||
const scheduler = new FSRSScheduler({ maximumInterval: maxInterval });
|
|
||||||
const state = scheduler.newCard();
|
|
||||||
|
|
||||||
// Review multiple times to build up stability
|
|
||||||
let currentState = state;
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const result = scheduler.review(currentState, Grade.Easy, 0);
|
|
||||||
expect(result.interval).toBeLessThanOrEqual(maxInterval);
|
|
||||||
currentState = result.state;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getRetrievability', () => {
|
|
||||||
it('should return 1.0 for just-reviewed card', () => {
|
|
||||||
const scheduler = new FSRSScheduler();
|
|
||||||
const state = scheduler.newCard();
|
|
||||||
state.lastReview = new Date();
|
|
||||||
|
|
||||||
const r = scheduler.getRetrievability(state, 0);
|
|
||||||
expect(r).toBeCloseTo(1.0, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return lower value after time passes', () => {
|
|
||||||
const scheduler = new FSRSScheduler();
|
|
||||||
const state = scheduler.newCard();
|
|
||||||
|
|
||||||
const r0 = scheduler.getRetrievability(state, 0);
|
|
||||||
const r10 = scheduler.getRetrievability(state, 10);
|
|
||||||
|
|
||||||
expect(r0).toBeGreaterThan(r10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('previewReviews', () => {
|
|
||||||
it('should return results for all grades', () => {
|
|
||||||
const scheduler = new FSRSScheduler();
|
|
||||||
const state = scheduler.newCard();
|
|
||||||
|
|
||||||
const preview = scheduler.previewReviews(state, 0);
|
|
||||||
|
|
||||||
expect(preview.again).toBeDefined();
|
|
||||||
expect(preview.hard).toBeDefined();
|
|
||||||
expect(preview.good).toBeDefined();
|
|
||||||
expect(preview.easy).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show increasing intervals from again to easy', () => {
|
|
||||||
const scheduler = new FSRSScheduler();
|
|
||||||
let state = scheduler.newCard();
|
|
||||||
|
|
||||||
// First review to establish some stability
|
|
||||||
const result = scheduler.review(state, Grade.Good, 0);
|
|
||||||
state = result.state;
|
|
||||||
|
|
||||||
const preview = scheduler.previewReviews(state, 1);
|
|
||||||
|
|
||||||
// Generally, easy should have longest interval, again shortest
|
|
||||||
expect(preview.easy.interval).toBeGreaterThanOrEqual(preview.good.interval);
|
|
||||||
expect(preview.good.interval).toBeGreaterThanOrEqual(preview.hard.interval);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('FSRS Utility Functions', () => {
|
|
||||||
describe('serializeFSRSState / deserializeFSRSState', () => {
|
|
||||||
it('should serialize and deserialize state correctly', () => {
|
|
||||||
const scheduler = new FSRSScheduler();
|
|
||||||
const state = scheduler.newCard();
|
|
||||||
|
|
||||||
const serialized = serializeFSRSState(state);
|
|
||||||
const deserialized = deserializeFSRSState(serialized);
|
|
||||||
|
|
||||||
expect(deserialized.difficulty).toBe(state.difficulty);
|
|
||||||
expect(deserialized.stability).toBe(state.stability);
|
|
||||||
expect(deserialized.state).toBe(state.state);
|
|
||||||
expect(deserialized.reps).toBe(state.reps);
|
|
||||||
expect(deserialized.lapses).toBe(state.lapses);
|
|
||||||
expect(deserialized.scheduledDays).toBe(state.scheduledDays);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve lastReview date', () => {
|
|
||||||
const state: FSRSState = {
|
|
||||||
difficulty: 5,
|
|
||||||
stability: 10,
|
|
||||||
state: 'Review',
|
|
||||||
reps: 5,
|
|
||||||
lapses: 1,
|
|
||||||
lastReview: new Date('2024-01-15T12:00:00Z'),
|
|
||||||
scheduledDays: 7,
|
|
||||||
};
|
|
||||||
|
|
||||||
const serialized = serializeFSRSState(state);
|
|
||||||
const deserialized = deserializeFSRSState(serialized);
|
|
||||||
|
|
||||||
expect(deserialized.lastReview.toISOString()).toBe(state.lastReview.toISOString());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('optimalReviewTime', () => {
|
|
||||||
it('should return interval based on stability', () => {
|
|
||||||
const state: FSRSState = {
|
|
||||||
difficulty: 5,
|
|
||||||
stability: 10,
|
|
||||||
state: 'Review',
|
|
||||||
reps: 3,
|
|
||||||
lapses: 0,
|
|
||||||
lastReview: new Date(),
|
|
||||||
scheduledDays: 7,
|
|
||||||
};
|
|
||||||
|
|
||||||
const interval = optimalReviewTime(state, 0.9);
|
|
||||||
expect(interval).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return shorter interval for higher retention target', () => {
|
|
||||||
const state: FSRSState = {
|
|
||||||
difficulty: 5,
|
|
||||||
stability: 10,
|
|
||||||
state: 'Review',
|
|
||||||
reps: 3,
|
|
||||||
lapses: 0,
|
|
||||||
lastReview: new Date(),
|
|
||||||
scheduledDays: 7,
|
|
||||||
};
|
|
||||||
|
|
||||||
const i90 = optimalReviewTime(state, 0.9);
|
|
||||||
const i95 = optimalReviewTime(state, 0.95);
|
|
||||||
|
|
||||||
expect(i90).toBeGreaterThan(i95);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isReviewDue', () => {
|
|
||||||
it('should return false for just-created card', () => {
|
|
||||||
const state: FSRSState = {
|
|
||||||
difficulty: 5,
|
|
||||||
stability: 10,
|
|
||||||
state: 'Review',
|
|
||||||
reps: 3,
|
|
||||||
lapses: 0,
|
|
||||||
lastReview: new Date(),
|
|
||||||
scheduledDays: 7,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(isReviewDue(state)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true when scheduled days have passed', () => {
|
|
||||||
const pastDate = new Date();
|
|
||||||
pastDate.setDate(pastDate.getDate() - 10);
|
|
||||||
|
|
||||||
const state: FSRSState = {
|
|
||||||
difficulty: 5,
|
|
||||||
stability: 10,
|
|
||||||
state: 'Review',
|
|
||||||
reps: 3,
|
|
||||||
lapses: 0,
|
|
||||||
lastReview: pastDate,
|
|
||||||
scheduledDays: 7,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(isReviewDue(state)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use retention threshold when provided', () => {
|
|
||||||
const pastDate = new Date();
|
|
||||||
pastDate.setDate(pastDate.getDate() - 5);
|
|
||||||
|
|
||||||
const state: FSRSState = {
|
|
||||||
difficulty: 5,
|
|
||||||
stability: 10,
|
|
||||||
state: 'Review',
|
|
||||||
reps: 3,
|
|
||||||
lapses: 0,
|
|
||||||
lastReview: pastDate,
|
|
||||||
scheduledDays: 30, // Not due by scheduledDays
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check with high retention threshold (should be due)
|
|
||||||
const isDueHighThreshold = isReviewDue(state, 0.95);
|
|
||||||
// Check with low retention threshold (might not be due)
|
|
||||||
const isDueLowThreshold = isReviewDue(state, 0.5);
|
|
||||||
|
|
||||||
// With higher threshold, more likely to be due
|
|
||||||
expect(isDueHighThreshold || !isDueLowThreshold).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,300 +0,0 @@
|
||||||
import Database from 'better-sqlite3';
|
|
||||||
import type { KnowledgeNodeInput, PersonNode, GraphEdge } from '../core/types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an in-memory database for testing
|
|
||||||
*/
|
|
||||||
export function createTestDatabase(): Database.Database {
|
|
||||||
const db = new Database(':memory:');
|
|
||||||
db.pragma('journal_mode = WAL');
|
|
||||||
db.pragma('foreign_keys = ON');
|
|
||||||
|
|
||||||
// Initialize tables (from database.ts initializeSchema)
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS knowledge_nodes (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
summary TEXT,
|
|
||||||
|
|
||||||
-- Temporal metadata
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
last_accessed_at TEXT NOT NULL,
|
|
||||||
access_count INTEGER DEFAULT 0,
|
|
||||||
|
|
||||||
-- Decay modeling (SM-2 inspired spaced repetition)
|
|
||||||
retention_strength REAL DEFAULT 1.0,
|
|
||||||
stability_factor REAL DEFAULT 1.0,
|
|
||||||
sentiment_intensity REAL DEFAULT 0,
|
|
||||||
next_review_date TEXT,
|
|
||||||
review_count INTEGER DEFAULT 0,
|
|
||||||
|
|
||||||
-- Dual-Strength Memory Model (Bjork & Bjork, 1992)
|
|
||||||
storage_strength REAL DEFAULT 1.0,
|
|
||||||
retrieval_strength REAL DEFAULT 1.0,
|
|
||||||
|
|
||||||
-- Provenance
|
|
||||||
source_type TEXT NOT NULL,
|
|
||||||
source_platform TEXT NOT NULL,
|
|
||||||
source_id TEXT,
|
|
||||||
source_url TEXT,
|
|
||||||
source_chain TEXT DEFAULT '[]',
|
|
||||||
git_context TEXT,
|
|
||||||
|
|
||||||
-- Confidence
|
|
||||||
confidence REAL DEFAULT 0.8,
|
|
||||||
is_contradicted INTEGER DEFAULT 0,
|
|
||||||
contradiction_ids TEXT DEFAULT '[]',
|
|
||||||
|
|
||||||
-- Extracted entities (JSON arrays)
|
|
||||||
people TEXT DEFAULT '[]',
|
|
||||||
concepts TEXT DEFAULT '[]',
|
|
||||||
events TEXT DEFAULT '[]',
|
|
||||||
tags TEXT DEFAULT '[]'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_nodes_created_at ON knowledge_nodes(created_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_nodes_last_accessed ON knowledge_nodes(last_accessed_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_nodes_retention ON knowledge_nodes(retention_strength);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_nodes_source_type ON knowledge_nodes(source_type);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_nodes_source_platform ON knowledge_nodes(source_platform);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Full-text search for content
|
|
||||||
db.exec(`
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_fts USING fts5(
|
|
||||||
id,
|
|
||||||
content,
|
|
||||||
summary,
|
|
||||||
tags,
|
|
||||||
content='knowledge_nodes',
|
|
||||||
content_rowid='rowid'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Triggers to keep FTS in sync
|
|
||||||
CREATE TRIGGER IF NOT EXISTS knowledge_ai AFTER INSERT ON knowledge_nodes BEGIN
|
|
||||||
INSERT INTO knowledge_fts(rowid, id, content, summary, tags)
|
|
||||||
VALUES (NEW.rowid, NEW.id, NEW.content, NEW.summary, NEW.tags);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS knowledge_ad AFTER DELETE ON knowledge_nodes BEGIN
|
|
||||||
INSERT INTO knowledge_fts(knowledge_fts, rowid, id, content, summary, tags)
|
|
||||||
VALUES ('delete', OLD.rowid, OLD.id, OLD.content, OLD.summary, OLD.tags);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS knowledge_au AFTER UPDATE ON knowledge_nodes BEGIN
|
|
||||||
INSERT INTO knowledge_fts(knowledge_fts, rowid, id, content, summary, tags)
|
|
||||||
VALUES ('delete', OLD.rowid, OLD.id, OLD.content, OLD.summary, OLD.tags);
|
|
||||||
INSERT INTO knowledge_fts(rowid, id, content, summary, tags)
|
|
||||||
VALUES (NEW.rowid, NEW.id, NEW.content, NEW.summary, NEW.tags);
|
|
||||||
END;
|
|
||||||
`);
|
|
||||||
|
|
||||||
// People table
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS people (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
aliases TEXT DEFAULT '[]',
|
|
||||||
|
|
||||||
-- Relationship context
|
|
||||||
how_we_met TEXT,
|
|
||||||
relationship_type TEXT,
|
|
||||||
organization TEXT,
|
|
||||||
role TEXT,
|
|
||||||
location TEXT,
|
|
||||||
|
|
||||||
-- Contact info
|
|
||||||
email TEXT,
|
|
||||||
phone TEXT,
|
|
||||||
social_links TEXT DEFAULT '{}',
|
|
||||||
|
|
||||||
-- Communication patterns
|
|
||||||
last_contact_at TEXT,
|
|
||||||
contact_frequency REAL DEFAULT 0,
|
|
||||||
preferred_channel TEXT,
|
|
||||||
|
|
||||||
-- Shared context
|
|
||||||
shared_topics TEXT DEFAULT '[]',
|
|
||||||
shared_projects TEXT DEFAULT '[]',
|
|
||||||
|
|
||||||
-- Meta
|
|
||||||
notes TEXT,
|
|
||||||
relationship_health REAL DEFAULT 0.5,
|
|
||||||
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_people_name ON people(name);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_people_last_contact ON people(last_contact_at);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Interactions table
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS interactions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
person_id TEXT NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
date TEXT NOT NULL,
|
|
||||||
summary TEXT NOT NULL,
|
|
||||||
topics TEXT DEFAULT '[]',
|
|
||||||
sentiment REAL,
|
|
||||||
action_items TEXT DEFAULT '[]',
|
|
||||||
source_node_id TEXT,
|
|
||||||
|
|
||||||
FOREIGN KEY (person_id) REFERENCES people(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (source_node_id) REFERENCES knowledge_nodes(id) ON DELETE SET NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_interactions_person ON interactions(person_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_interactions_date ON interactions(date);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Graph edges table
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS graph_edges (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
from_id TEXT NOT NULL,
|
|
||||||
to_id TEXT NOT NULL,
|
|
||||||
edge_type TEXT NOT NULL,
|
|
||||||
weight REAL DEFAULT 0.5,
|
|
||||||
metadata TEXT DEFAULT '{}',
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE(from_id, to_id, edge_type)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_edges_from ON graph_edges(from_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_edges_to ON graph_edges(to_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_edges_type ON graph_edges(edge_type);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Sources table
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS sources (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
platform TEXT NOT NULL,
|
|
||||||
original_id TEXT,
|
|
||||||
url TEXT,
|
|
||||||
file_path TEXT,
|
|
||||||
title TEXT,
|
|
||||||
author TEXT,
|
|
||||||
publication_date TEXT,
|
|
||||||
|
|
||||||
ingested_at TEXT NOT NULL,
|
|
||||||
last_synced_at TEXT NOT NULL,
|
|
||||||
content_hash TEXT,
|
|
||||||
|
|
||||||
node_count INTEGER DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sources_platform ON sources(platform);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sources_file_path ON sources(file_path);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Embeddings reference table
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS embeddings (
|
|
||||||
node_id TEXT PRIMARY KEY,
|
|
||||||
chroma_id TEXT NOT NULL,
|
|
||||||
model TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
|
|
||||||
FOREIGN KEY (node_id) REFERENCES knowledge_nodes(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Metadata table
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS vestige_metadata (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create test fixtures for knowledge nodes
|
|
||||||
*/
|
|
||||||
export function createTestNode(overrides: Partial<Omit<KnowledgeNodeInput, 'id'>> = {}): Omit<KnowledgeNodeInput, 'id'> {
|
|
||||||
return {
|
|
||||||
content: 'Test content for knowledge node',
|
|
||||||
sourceType: 'manual',
|
|
||||||
sourcePlatform: 'manual',
|
|
||||||
tags: [],
|
|
||||||
people: [],
|
|
||||||
concepts: [],
|
|
||||||
events: [],
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create test fixtures for people
|
|
||||||
*/
|
|
||||||
export function createTestPerson(overrides: Partial<Omit<PersonNode, 'id' | 'createdAt' | 'updatedAt'>> = {}): Omit<PersonNode, 'id' | 'createdAt' | 'updatedAt'> {
|
|
||||||
return {
|
|
||||||
name: 'Test Person',
|
|
||||||
relationshipType: 'colleague',
|
|
||||||
aliases: [],
|
|
||||||
socialLinks: {},
|
|
||||||
contactFrequency: 0,
|
|
||||||
sharedTopics: [],
|
|
||||||
sharedProjects: [],
|
|
||||||
relationshipHealth: 0.5,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create test fixtures for graph edges
|
|
||||||
*/
|
|
||||||
export function createTestEdge(fromId: string, toId: string, overrides: Partial<Omit<GraphEdge, 'id' | 'createdAt'>> = {}): Omit<GraphEdge, 'id' | 'createdAt'> {
|
|
||||||
return {
|
|
||||||
fromId,
|
|
||||||
toId,
|
|
||||||
edgeType: 'relates_to',
|
|
||||||
weight: 0.5,
|
|
||||||
metadata: {},
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up test database
|
|
||||||
*/
|
|
||||||
export function cleanupTestDatabase(db: Database.Database): void {
|
|
||||||
try {
|
|
||||||
db.close();
|
|
||||||
} catch {
|
|
||||||
// Ignore close errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for a specified amount of time (useful for async tests)
|
|
||||||
*/
|
|
||||||
export function wait(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a unique test ID
|
|
||||||
*/
|
|
||||||
export function generateTestId(): string {
|
|
||||||
return `test-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a mock timestamp for consistent testing
|
|
||||||
*/
|
|
||||||
export function mockTimestamp(daysAgo: number = 0): Date {
|
|
||||||
const date = new Date();
|
|
||||||
date.setDate(date.getDate() - daysAgo);
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,489 +0,0 @@
|
||||||
/**
|
|
||||||
* Configuration Management for Vestige MCP
|
|
||||||
*
|
|
||||||
* Provides centralized configuration with:
|
|
||||||
* - Zod schema validation
|
|
||||||
* - File-based configuration (~/.vestige/config.json)
|
|
||||||
* - Environment variable overrides
|
|
||||||
* - Type-safe accessors for all config sections
|
|
||||||
*
|
|
||||||
* Configuration priority (highest to lowest):
|
|
||||||
* 1. Environment variables
|
|
||||||
* 2. Config file
|
|
||||||
* 3. Default values
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
import path from 'path';
|
|
||||||
import os from 'os';
|
|
||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONFIGURATION SCHEMA
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Database configuration schema
|
|
||||||
*/
|
|
||||||
const DatabaseConfigSchema = z.object({
|
|
||||||
/** Path to the SQLite database file */
|
|
||||||
path: z.string().default(path.join(os.homedir(), '.vestige', 'vestige.db')),
|
|
||||||
/** Directory for database backups */
|
|
||||||
backupDir: z.string().default(path.join(os.homedir(), '.vestige', 'backups')),
|
|
||||||
/** SQLite busy timeout in milliseconds */
|
|
||||||
busyTimeout: z.number().default(5000),
|
|
||||||
/** SQLite cache size in pages (negative = KB) */
|
|
||||||
cacheSize: z.number().default(64000),
|
|
||||||
/** Maximum number of backup files to retain */
|
|
||||||
maxBackups: z.number().default(5),
|
|
||||||
}).default({});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FSRS (Free Spaced Repetition Scheduler) algorithm configuration
|
|
||||||
* Named with 'Config' prefix to avoid collision with FSRSConfigSchema in fsrs.ts
|
|
||||||
*/
|
|
||||||
const ConfigFSRSSchema = z.object({
|
|
||||||
/** Target retention rate (0.7 to 0.99) */
|
|
||||||
desiredRetention: z.number().min(0.7).max(0.99).default(0.9),
|
|
||||||
/** Custom FSRS-5 weights (19 values). If not provided, uses defaults. */
|
|
||||||
weights: z.array(z.number()).length(19).optional(),
|
|
||||||
/** Enable personalized scheduling based on review history */
|
|
||||||
enablePersonalization: z.boolean().default(false),
|
|
||||||
}).default({});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dual-strength memory model configuration
|
|
||||||
* Based on the distinction between storage strength and retrieval strength
|
|
||||||
*/
|
|
||||||
const MemoryConfigSchema = z.object({
|
|
||||||
/** Storage strength boost on passive access (read) */
|
|
||||||
storageBoostOnAccess: z.number().default(0.05),
|
|
||||||
/** Storage strength boost on active review */
|
|
||||||
storageBoostOnReview: z.number().default(0.1),
|
|
||||||
/** Half-life for retrieval strength decay in days */
|
|
||||||
retrievalDecayHalfLife: z.number().default(7),
|
|
||||||
/** Minimum retention strength before memory is considered weak */
|
|
||||||
minRetentionStrength: z.number().default(0.1),
|
|
||||||
}).default({});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sentiment analysis configuration for emotional memory weighting
|
|
||||||
*/
|
|
||||||
const SentimentConfigSchema = z.object({
|
|
||||||
/** Stability multiplier for highly emotional memories */
|
|
||||||
stabilityBoost: z.number().default(2.0),
|
|
||||||
/** Minimum boost applied to any memory */
|
|
||||||
minBoost: z.number().default(1.0),
|
|
||||||
}).default({});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* REM (Rapid Eye Movement) cycle configuration
|
|
||||||
* Handles memory consolidation and connection discovery
|
|
||||||
*/
|
|
||||||
const REMConfigSchema = z.object({
|
|
||||||
/** Enable REM cycle processing */
|
|
||||||
enabled: z.boolean().default(true),
|
|
||||||
/** Maximum number of memories to analyze per cycle */
|
|
||||||
maxAnalyze: z.number().default(50),
|
|
||||||
/** Minimum connection strength to create an edge */
|
|
||||||
minConnectionStrength: z.number().default(0.3),
|
|
||||||
/** Half-life for temporal proximity weighting in days */
|
|
||||||
temporalHalfLifeDays: z.number().default(7),
|
|
||||||
/** Decay factor for spreading activation (0-1) */
|
|
||||||
spreadingActivationDecay: z.number().default(0.8),
|
|
||||||
}).default({});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Memory consolidation configuration
|
|
||||||
* Controls the background process that strengthens important memories
|
|
||||||
*/
|
|
||||||
const ConsolidationConfigSchema = z.object({
|
|
||||||
/** Enable automatic consolidation */
|
|
||||||
enabled: z.boolean().default(true),
|
|
||||||
/** Hour of day to run consolidation (0-23) */
|
|
||||||
scheduleHour: z.number().min(0).max(23).default(3),
|
|
||||||
/** Window in hours for short-term memory processing */
|
|
||||||
shortTermWindowHours: z.number().default(24),
|
|
||||||
/** Minimum importance score for consolidation */
|
|
||||||
importanceThreshold: z.number().default(0.5),
|
|
||||||
/** Threshold below which memories may be pruned */
|
|
||||||
pruneThreshold: z.number().default(0.2),
|
|
||||||
}).default({});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Embeddings service configuration
|
|
||||||
*/
|
|
||||||
const EmbeddingsConfigSchema = z.object({
|
|
||||||
/** Embedding provider to use */
|
|
||||||
provider: z.enum(['ollama', 'fallback']).default('ollama'),
|
|
||||||
/** Ollama API host URL */
|
|
||||||
ollamaHost: z.string().default('http://localhost:11434'),
|
|
||||||
/** Embedding model name */
|
|
||||||
model: z.string().default('nomic-embed-text'),
|
|
||||||
/** Maximum text length to embed (characters) */
|
|
||||||
maxTextLength: z.number().default(8000),
|
|
||||||
}).default({});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vector store configuration for semantic search
|
|
||||||
*/
|
|
||||||
const VectorStoreConfigSchema = z.object({
|
|
||||||
/** Vector store provider */
|
|
||||||
provider: z.enum(['chromadb', 'sqlite']).default('chromadb'),
|
|
||||||
/** ChromaDB host URL */
|
|
||||||
chromaHost: z.string().default('http://localhost:8000'),
|
|
||||||
/** Name of the embeddings collection */
|
|
||||||
collectionName: z.string().default('vestige_embeddings'),
|
|
||||||
}).default({});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache configuration
|
|
||||||
*/
|
|
||||||
const CacheConfigSchema = z.object({
|
|
||||||
/** Enable caching */
|
|
||||||
enabled: z.boolean().default(true),
|
|
||||||
/** Maximum number of items in cache */
|
|
||||||
maxSize: z.number().default(10000),
|
|
||||||
/** Default time-to-live in milliseconds */
|
|
||||||
defaultTTLMs: z.number().default(5 * 60 * 1000),
|
|
||||||
}).default({});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logging configuration
|
|
||||||
*/
|
|
||||||
const LoggingConfigSchema = z.object({
|
|
||||||
/** Minimum log level */
|
|
||||||
level: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
|
||||||
/** Use structured JSON logging */
|
|
||||||
structured: z.boolean().default(true),
|
|
||||||
}).default({});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Input/output limits configuration
|
|
||||||
*/
|
|
||||||
const LimitsConfigSchema = z.object({
|
|
||||||
/** Maximum content length in characters */
|
|
||||||
maxContentLength: z.number().default(1_000_000),
|
|
||||||
/** Maximum name/title length in characters */
|
|
||||||
maxNameLength: z.number().default(500),
|
|
||||||
/** Maximum query length in characters */
|
|
||||||
maxQueryLength: z.number().default(10_000),
|
|
||||||
/** Maximum number of tags per item */
|
|
||||||
maxTagsCount: z.number().default(100),
|
|
||||||
/** Maximum items per batch operation */
|
|
||||||
maxBatchSize: z.number().default(1000),
|
|
||||||
/** Default pagination limit */
|
|
||||||
paginationDefault: z.number().default(50),
|
|
||||||
/** Maximum pagination limit */
|
|
||||||
paginationMax: z.number().default(500),
|
|
||||||
}).default({});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main configuration schema combining all sections
|
|
||||||
*/
|
|
||||||
const ConfigSchema = z.object({
|
|
||||||
database: DatabaseConfigSchema,
|
|
||||||
fsrs: ConfigFSRSSchema,
|
|
||||||
memory: MemoryConfigSchema,
|
|
||||||
sentiment: SentimentConfigSchema,
|
|
||||||
rem: REMConfigSchema,
|
|
||||||
consolidation: ConsolidationConfigSchema,
|
|
||||||
embeddings: EmbeddingsConfigSchema,
|
|
||||||
vectorStore: VectorStoreConfigSchema,
|
|
||||||
cache: CacheConfigSchema,
|
|
||||||
logging: LoggingConfigSchema,
|
|
||||||
limits: LimitsConfigSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inferred TypeScript type from the Zod schema
|
|
||||||
*/
|
|
||||||
export type VestigeConfig = z.infer<typeof ConfigSchema>;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONFIGURATION LOADING
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Singleton configuration instance
|
|
||||||
*/
|
|
||||||
let config: VestigeConfig | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Partial configuration type for environment overrides
|
|
||||||
*/
|
|
||||||
interface PartialVestigeConfig {
|
|
||||||
database?: {
|
|
||||||
path?: string;
|
|
||||||
backupDir?: string;
|
|
||||||
};
|
|
||||||
logging?: {
|
|
||||||
level?: string;
|
|
||||||
};
|
|
||||||
embeddings?: {
|
|
||||||
ollamaHost?: string;
|
|
||||||
model?: string;
|
|
||||||
};
|
|
||||||
vectorStore?: {
|
|
||||||
chromaHost?: string;
|
|
||||||
};
|
|
||||||
fsrs?: {
|
|
||||||
desiredRetention?: number;
|
|
||||||
};
|
|
||||||
rem?: {
|
|
||||||
enabled?: boolean;
|
|
||||||
};
|
|
||||||
consolidation?: {
|
|
||||||
enabled?: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load environment variable overrides
|
|
||||||
* Environment variables take precedence over file configuration
|
|
||||||
*/
|
|
||||||
function loadEnvConfig(): PartialVestigeConfig {
|
|
||||||
const env: PartialVestigeConfig = {};
|
|
||||||
|
|
||||||
// Database configuration
|
|
||||||
const dbPath = process.env['VESTIGE_DB_PATH'];
|
|
||||||
const backupDir = process.env['VESTIGE_BACKUP_DIR'];
|
|
||||||
if (dbPath || backupDir) {
|
|
||||||
env.database = {};
|
|
||||||
if (dbPath) env.database.path = dbPath;
|
|
||||||
if (backupDir) env.database.backupDir = backupDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logging configuration
|
|
||||||
const logLevel = process.env['VESTIGE_LOG_LEVEL'];
|
|
||||||
if (logLevel) {
|
|
||||||
env.logging = { level: logLevel };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Embeddings configuration
|
|
||||||
const ollamaHost = process.env['OLLAMA_HOST'];
|
|
||||||
const embeddingModel = process.env['VESTIGE_EMBEDDING_MODEL'];
|
|
||||||
if (ollamaHost || embeddingModel) {
|
|
||||||
env.embeddings = {};
|
|
||||||
if (ollamaHost) env.embeddings.ollamaHost = ollamaHost;
|
|
||||||
if (embeddingModel) env.embeddings.model = embeddingModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vector store configuration
|
|
||||||
const chromaHost = process.env['CHROMA_HOST'];
|
|
||||||
if (chromaHost) {
|
|
||||||
env.vectorStore = { chromaHost };
|
|
||||||
}
|
|
||||||
|
|
||||||
// FSRS configuration
|
|
||||||
const desiredRetention = process.env['VESTIGE_DESIRED_RETENTION'];
|
|
||||||
if (desiredRetention) {
|
|
||||||
const retention = parseFloat(desiredRetention);
|
|
||||||
if (!isNaN(retention)) {
|
|
||||||
env.fsrs = { desiredRetention: retention };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// REM configuration
|
|
||||||
const remEnabled = process.env['VESTIGE_REM_ENABLED'];
|
|
||||||
if (remEnabled) {
|
|
||||||
const enabled = remEnabled.toLowerCase() === 'true';
|
|
||||||
env.rem = { enabled };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consolidation configuration
|
|
||||||
const consolidationEnabled = process.env['VESTIGE_CONSOLIDATION_ENABLED'];
|
|
||||||
if (consolidationEnabled) {
|
|
||||||
const enabled = consolidationEnabled.toLowerCase() === 'true';
|
|
||||||
env.consolidation = { enabled };
|
|
||||||
}
|
|
||||||
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deep merge two objects, with source taking precedence
|
|
||||||
*/
|
|
||||||
function deepMerge<T extends Record<string, unknown>>(target: T, source: Partial<T>): T {
|
|
||||||
const result = { ...target };
|
|
||||||
|
|
||||||
for (const key of Object.keys(source) as (keyof T)[]) {
|
|
||||||
const sourceValue = source[key];
|
|
||||||
const targetValue = result[key];
|
|
||||||
|
|
||||||
if (
|
|
||||||
sourceValue !== undefined &&
|
|
||||||
typeof sourceValue === 'object' &&
|
|
||||||
sourceValue !== null &&
|
|
||||||
!Array.isArray(sourceValue) &&
|
|
||||||
typeof targetValue === 'object' &&
|
|
||||||
targetValue !== null &&
|
|
||||||
!Array.isArray(targetValue)
|
|
||||||
) {
|
|
||||||
result[key] = deepMerge(
|
|
||||||
targetValue as Record<string, unknown>,
|
|
||||||
sourceValue as Record<string, unknown>
|
|
||||||
) as T[keyof T];
|
|
||||||
} else if (sourceValue !== undefined) {
|
|
||||||
result[key] = sourceValue as T[keyof T];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load configuration from file and environment variables
|
|
||||||
*
|
|
||||||
* @param customPath - Optional custom path to config file
|
|
||||||
* @returns Validated configuration object
|
|
||||||
*/
|
|
||||||
export function loadConfig(customPath?: string): VestigeConfig {
|
|
||||||
if (config) return config;
|
|
||||||
|
|
||||||
const configPath = customPath || path.join(os.homedir(), '.vestige', 'config.json');
|
|
||||||
let fileConfig: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
// Load from file if it exists
|
|
||||||
if (fs.existsSync(configPath)) {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(configPath, 'utf-8');
|
|
||||||
fileConfig = JSON.parse(content) as Record<string, unknown>;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to load config from ${configPath}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load environment variable overrides
|
|
||||||
const envConfig = loadEnvConfig();
|
|
||||||
|
|
||||||
// Merge configs: file config first, then env overrides
|
|
||||||
const mergedConfig = deepMerge(fileConfig, envConfig as Record<string, unknown>);
|
|
||||||
|
|
||||||
// Validate and parse with Zod (applies defaults)
|
|
||||||
config = ConfigSchema.parse(mergedConfig);
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current configuration, loading it if necessary
|
|
||||||
*
|
|
||||||
* @returns The current configuration object
|
|
||||||
*/
|
|
||||||
export function getConfig(): VestigeConfig {
|
|
||||||
if (!config) {
|
|
||||||
return loadConfig();
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the configuration singleton (useful for testing)
|
|
||||||
*/
|
|
||||||
export function resetConfig(): void {
|
|
||||||
config = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONFIGURATION ACCESSORS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get database configuration
|
|
||||||
*/
|
|
||||||
export const getDatabaseConfig = () => getConfig().database;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get FSRS algorithm configuration
|
|
||||||
*/
|
|
||||||
export const getFSRSConfig = () => getConfig().fsrs;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get memory model configuration
|
|
||||||
*/
|
|
||||||
export const getMemoryConfig = () => getConfig().memory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get sentiment analysis configuration
|
|
||||||
*/
|
|
||||||
export const getSentimentConfig = () => getConfig().sentiment;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get REM cycle configuration
|
|
||||||
*/
|
|
||||||
export const getREMConfig = () => getConfig().rem;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get consolidation configuration
|
|
||||||
*/
|
|
||||||
export const getConsolidationConfig = () => getConfig().consolidation;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get embeddings service configuration
|
|
||||||
*/
|
|
||||||
export const getEmbeddingsConfig = () => getConfig().embeddings;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get vector store configuration
|
|
||||||
*/
|
|
||||||
export const getVectorStoreConfig = () => getConfig().vectorStore;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cache configuration
|
|
||||||
*/
|
|
||||||
export const getCacheConfig = () => getConfig().cache;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get logging configuration
|
|
||||||
*/
|
|
||||||
export const getLoggingConfig = () => getConfig().logging;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get limits configuration
|
|
||||||
*/
|
|
||||||
export const getLimitsConfig = () => getConfig().limits;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONFIGURATION VALIDATION
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate an unknown config object against the schema
|
|
||||||
*
|
|
||||||
* @param configObj - Unknown object to validate
|
|
||||||
* @returns Validated configuration object
|
|
||||||
* @throws ZodError if validation fails
|
|
||||||
*/
|
|
||||||
export function validateConfig(configObj: unknown): VestigeConfig {
|
|
||||||
return ConfigSchema.parse(configObj);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the Zod schema for configuration validation
|
|
||||||
*
|
|
||||||
* @returns The Zod configuration schema
|
|
||||||
*/
|
|
||||||
export function getConfigSchema() {
|
|
||||||
return ConfigSchema;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// EXPORTS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// Export individual schemas for external use
|
|
||||||
export {
|
|
||||||
ConfigSchema,
|
|
||||||
DatabaseConfigSchema,
|
|
||||||
ConfigFSRSSchema,
|
|
||||||
MemoryConfigSchema,
|
|
||||||
SentimentConfigSchema,
|
|
||||||
REMConfigSchema,
|
|
||||||
ConsolidationConfigSchema,
|
|
||||||
EmbeddingsConfigSchema,
|
|
||||||
VectorStoreConfigSchema,
|
|
||||||
CacheConfigSchema,
|
|
||||||
LoggingConfigSchema,
|
|
||||||
LimitsConfigSchema,
|
|
||||||
};
|
|
||||||
|
|
@ -1,409 +0,0 @@
|
||||||
/**
|
|
||||||
* Sleep Consolidation Simulation
|
|
||||||
*
|
|
||||||
* "The brain that consolidates while you sleep."
|
|
||||||
*
|
|
||||||
* This module simulates how the human brain consolidates memories during sleep.
|
|
||||||
* Based on cognitive science research on memory consolidation, it implements:
|
|
||||||
*
|
|
||||||
* KEY FEATURES:
|
|
||||||
* 1. Short-term Memory Processing - Identifies recent memories for consolidation
|
|
||||||
* 2. Importance-based Promotion - Promotes significant memories to long-term storage
|
|
||||||
* 3. REM Cycle Integration - Discovers new connections via semantic analysis
|
|
||||||
* 4. Synaptic Homeostasis - Prunes weak connections to prevent memory overload
|
|
||||||
* 5. Decay Application - Applies natural memory decay based on forgetting curve
|
|
||||||
*
|
|
||||||
* COGNITIVE SCIENCE BASIS:
|
|
||||||
* - Active Systems Consolidation: Hippocampus replays memories during sleep
|
|
||||||
* - Synaptic Homeostasis Hypothesis: Weak connections are pruned during sleep
|
|
||||||
* - Emotional Memory Enhancement: Emotional memories are preferentially consolidated
|
|
||||||
* - Spreading Activation: Related memories are co-activated and strengthened
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { VestigeDatabase } from './database.js';
|
|
||||||
import { runREMCycle } from './rem-cycle.js';
|
|
||||||
import type { KnowledgeNode } from './types.js';
|
|
||||||
import { logger } from '../utils/logger.js';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface ConsolidationResult {
|
|
||||||
/** Number of short-term memories processed */
|
|
||||||
shortTermProcessed: number;
|
|
||||||
/** Number of memories promoted to long-term storage */
|
|
||||||
promotedToLongTerm: number;
|
|
||||||
/** Number of new connections discovered via REM cycle */
|
|
||||||
connectionsDiscovered: number;
|
|
||||||
/** Number of weak edges pruned (synaptic homeostasis) */
|
|
||||||
edgesPruned: number;
|
|
||||||
/** Number of memories that had decay applied */
|
|
||||||
decayApplied: number;
|
|
||||||
/** Duration of consolidation cycle in milliseconds */
|
|
||||||
duration: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConsolidationOptions {
|
|
||||||
/** Hours to look back for short-term memories. Default: 24 */
|
|
||||||
shortTermWindowHours?: number;
|
|
||||||
/** Minimum importance score to promote to long-term. Default: 0.5 */
|
|
||||||
importanceThreshold?: number;
|
|
||||||
/** Edge weight below which connections are pruned. Default: 0.2 */
|
|
||||||
pruneThreshold?: number;
|
|
||||||
/** Maximum number of memories to analyze in REM cycle. Default: 100 */
|
|
||||||
maxAnalyze?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONSTANTS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/** Default short-term memory window (24 hours) */
|
|
||||||
const DEFAULT_SHORT_TERM_WINDOW_HOURS = 24;
|
|
||||||
|
|
||||||
/** Default importance threshold for long-term promotion */
|
|
||||||
const DEFAULT_IMPORTANCE_THRESHOLD = 0.5;
|
|
||||||
|
|
||||||
/** Default edge weight threshold for pruning */
|
|
||||||
const DEFAULT_PRUNE_THRESHOLD = 0.2;
|
|
||||||
|
|
||||||
/** Default max memories to analyze */
|
|
||||||
const DEFAULT_MAX_ANALYZE = 100;
|
|
||||||
|
|
||||||
/** Weight factors for importance calculation */
|
|
||||||
const EMOTION_WEIGHT = 0.4;
|
|
||||||
const ACCESS_WEIGHT = 0.3;
|
|
||||||
const CONNECTION_WEIGHT = 0.3;
|
|
||||||
|
|
||||||
/** Maximum values for normalization */
|
|
||||||
const MAX_ACCESSES_FOR_IMPORTANCE = 5;
|
|
||||||
const MAX_CONNECTIONS_FOR_IMPORTANCE = 5;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// HELPER FUNCTIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get memories created within the short-term window
|
|
||||||
* These are candidates for consolidation processing
|
|
||||||
*/
|
|
||||||
async function getShortTermMemories(
|
|
||||||
db: VestigeDatabase,
|
|
||||||
windowHours: number
|
|
||||||
): Promise<KnowledgeNode[]> {
|
|
||||||
const windowStart = new Date(Date.now() - windowHours * 60 * 60 * 1000);
|
|
||||||
const recentNodes = db.getRecentNodes({ limit: 500 }).items;
|
|
||||||
return recentNodes.filter(node => node.createdAt >= windowStart);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate importance score for a memory
|
|
||||||
*
|
|
||||||
* Importance = f(emotion, access_count, connection_count)
|
|
||||||
*
|
|
||||||
* The formula weights three factors:
|
|
||||||
* - Emotional intensity (40%): Emotionally charged memories are more important
|
|
||||||
* - Access count (30%): Frequently accessed memories are more important
|
|
||||||
* - Connection count (30%): Well-connected memories are more important
|
|
||||||
*
|
|
||||||
* @returns Importance score from 0 to 1
|
|
||||||
*/
|
|
||||||
function calculateImportance(db: VestigeDatabase, memory: KnowledgeNode): number {
|
|
||||||
// Get connection count for this memory
|
|
||||||
const connections = db.getRelatedNodes(memory.id, 1).length;
|
|
||||||
|
|
||||||
// Get emotional intensity (0 to 1)
|
|
||||||
const emotion = memory.sentimentIntensity || 0;
|
|
||||||
|
|
||||||
// Get access count
|
|
||||||
const accesses = memory.accessCount;
|
|
||||||
|
|
||||||
// Weighted importance formula
|
|
||||||
// Each component is normalized to 0-1 range
|
|
||||||
const emotionScore = emotion * EMOTION_WEIGHT;
|
|
||||||
const accessScore =
|
|
||||||
(Math.min(MAX_ACCESSES_FOR_IMPORTANCE, accesses) / MAX_ACCESSES_FOR_IMPORTANCE) *
|
|
||||||
ACCESS_WEIGHT;
|
|
||||||
const connectionScore =
|
|
||||||
(Math.min(MAX_CONNECTIONS_FOR_IMPORTANCE, connections) / MAX_CONNECTIONS_FOR_IMPORTANCE) *
|
|
||||||
CONNECTION_WEIGHT;
|
|
||||||
|
|
||||||
const importanceScore = emotionScore + accessScore + connectionScore;
|
|
||||||
|
|
||||||
return importanceScore;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Promote a memory to long-term storage
|
|
||||||
*
|
|
||||||
* This boosts the storage strength proportional to importance.
|
|
||||||
* Based on the Dual-Strength Memory Model (Bjork & Bjork, 1992),
|
|
||||||
* storage strength represents how well the memory is encoded.
|
|
||||||
*
|
|
||||||
* Boost factor ranges from 1x (importance=0) to 3x (importance=1)
|
|
||||||
*/
|
|
||||||
async function promoteToLongTerm(
|
|
||||||
db: VestigeDatabase,
|
|
||||||
nodeId: string,
|
|
||||||
importance: number
|
|
||||||
): Promise<void> {
|
|
||||||
// Calculate boost factor: 1x to 3x based on importance
|
|
||||||
const boost = 1 + importance * 2;
|
|
||||||
|
|
||||||
// Access the internal database connection
|
|
||||||
// Note: This uses internal access pattern for direct SQL operations
|
|
||||||
const internalDb = (db as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } }).db;
|
|
||||||
|
|
||||||
internalDb
|
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
UPDATE knowledge_nodes
|
|
||||||
SET storage_strength = storage_strength * ?,
|
|
||||||
stability_factor = stability_factor * ?
|
|
||||||
WHERE id = ?
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.run(boost, boost, nodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prune weak connections discovered by REM cycle
|
|
||||||
*
|
|
||||||
* This implements synaptic homeostasis - the brain's process of
|
|
||||||
* removing weak synaptic connections during sleep to:
|
|
||||||
* 1. Prevent memory overload
|
|
||||||
* 2. Improve signal-to-noise ratio
|
|
||||||
* 3. Conserve metabolic resources
|
|
||||||
*
|
|
||||||
* Only auto-discovered connections (from REM cycle) are pruned.
|
|
||||||
* User-created connections are preserved regardless of weight.
|
|
||||||
*/
|
|
||||||
async function pruneWeakConnections(
|
|
||||||
db: VestigeDatabase,
|
|
||||||
threshold: number
|
|
||||||
): Promise<number> {
|
|
||||||
// Access the internal database connection
|
|
||||||
const internalDb = (db as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => { changes: number } } } }).db;
|
|
||||||
|
|
||||||
// Remove edges below threshold that were auto-discovered by REM cycle
|
|
||||||
const result = internalDb
|
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
DELETE FROM graph_edges
|
|
||||||
WHERE weight < ?
|
|
||||||
AND json_extract(metadata, '$.discoveredBy') = 'rem_cycle'
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.run(threshold);
|
|
||||||
|
|
||||||
return result.changes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// MAIN CONSOLIDATION FUNCTION
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run Sleep Consolidation Simulation
|
|
||||||
*
|
|
||||||
* Based on cognitive science research on memory consolidation:
|
|
||||||
*
|
|
||||||
* PHASE 1: Identify short-term memories
|
|
||||||
* - Collect memories created within the specified window
|
|
||||||
* - These represent the "inbox" of memories to process
|
|
||||||
*
|
|
||||||
* PHASE 2: Calculate importance and promote
|
|
||||||
* - Score each memory based on emotion, access, connections
|
|
||||||
* - Memories above threshold are "promoted" (strengthened)
|
|
||||||
* - This simulates hippocampal replay during sleep
|
|
||||||
*
|
|
||||||
* PHASE 3: Run REM cycle for connection discovery
|
|
||||||
* - Analyze memories for semantic similarity
|
|
||||||
* - Discover new connections between related memories
|
|
||||||
* - Apply spreading activation for transitive connections
|
|
||||||
*
|
|
||||||
* PHASE 4: Prune weak connections (synaptic homeostasis)
|
|
||||||
* - Remove auto-discovered edges below weight threshold
|
|
||||||
* - Preserves signal-to-noise ratio in memory network
|
|
||||||
*
|
|
||||||
* PHASE 5: Apply decay to all memories
|
|
||||||
* - Apply Ebbinghaus forgetting curve
|
|
||||||
* - Emotional memories decay slower
|
|
||||||
* - Well-encoded memories (high storage strength) decay slower
|
|
||||||
*
|
|
||||||
* @param db - VestigeDatabase instance
|
|
||||||
* @param options - Consolidation configuration options
|
|
||||||
* @returns Results of the consolidation cycle
|
|
||||||
*/
|
|
||||||
export async function runConsolidation(
|
|
||||||
db: VestigeDatabase,
|
|
||||||
options: ConsolidationOptions = {}
|
|
||||||
): Promise<ConsolidationResult> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
const {
|
|
||||||
shortTermWindowHours = DEFAULT_SHORT_TERM_WINDOW_HOURS,
|
|
||||||
importanceThreshold = DEFAULT_IMPORTANCE_THRESHOLD,
|
|
||||||
pruneThreshold = DEFAULT_PRUNE_THRESHOLD,
|
|
||||||
maxAnalyze = DEFAULT_MAX_ANALYZE,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const result: ConsolidationResult = {
|
|
||||||
shortTermProcessed: 0,
|
|
||||||
promotedToLongTerm: 0,
|
|
||||||
connectionsDiscovered: 0,
|
|
||||||
edgesPruned: 0,
|
|
||||||
decayApplied: 0,
|
|
||||||
duration: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.info('Starting consolidation cycle', {
|
|
||||||
shortTermWindowHours,
|
|
||||||
importanceThreshold,
|
|
||||||
pruneThreshold,
|
|
||||||
maxAnalyze,
|
|
||||||
});
|
|
||||||
|
|
||||||
// PHASE 1: Identify short-term memories
|
|
||||||
// These are memories created within the window that need processing
|
|
||||||
const shortTermMemories = await getShortTermMemories(db, shortTermWindowHours);
|
|
||||||
result.shortTermProcessed = shortTermMemories.length;
|
|
||||||
|
|
||||||
logger.debug('Phase 1: Identified short-term memories', {
|
|
||||||
count: shortTermMemories.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
// PHASE 2: Calculate importance and promote to long-term
|
|
||||||
// This simulates the hippocampal replay that occurs during sleep
|
|
||||||
for (const memory of shortTermMemories) {
|
|
||||||
const importance = calculateImportance(db, memory);
|
|
||||||
if (importance >= importanceThreshold) {
|
|
||||||
await promoteToLongTerm(db, memory.id, importance);
|
|
||||||
result.promotedToLongTerm++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('Phase 2: Promoted memories to long-term storage', {
|
|
||||||
promoted: result.promotedToLongTerm,
|
|
||||||
threshold: importanceThreshold,
|
|
||||||
});
|
|
||||||
|
|
||||||
// PHASE 3: Run REM cycle for connection discovery
|
|
||||||
// This discovers semantic connections between memories
|
|
||||||
const remResult = await runREMCycle(db, { maxAnalyze });
|
|
||||||
result.connectionsDiscovered = remResult.connectionsCreated;
|
|
||||||
|
|
||||||
logger.debug('Phase 3: REM cycle complete', {
|
|
||||||
connectionsDiscovered: remResult.connectionsDiscovered,
|
|
||||||
connectionsCreated: remResult.connectionsCreated,
|
|
||||||
spreadingActivationEdges: remResult.spreadingActivationEdges,
|
|
||||||
});
|
|
||||||
|
|
||||||
// PHASE 4: Prune weak connections (synaptic homeostasis)
|
|
||||||
// Remove auto-discovered connections that are below the threshold
|
|
||||||
result.edgesPruned = await pruneWeakConnections(db, pruneThreshold);
|
|
||||||
|
|
||||||
logger.debug('Phase 4: Pruned weak connections', {
|
|
||||||
edgesPruned: result.edgesPruned,
|
|
||||||
threshold: pruneThreshold,
|
|
||||||
});
|
|
||||||
|
|
||||||
// PHASE 5: Apply decay to all memories
|
|
||||||
// Uses Ebbinghaus forgetting curve with emotional weighting
|
|
||||||
result.decayApplied = db.applyDecay();
|
|
||||||
|
|
||||||
logger.debug('Phase 5: Applied memory decay', {
|
|
||||||
memoriesAffected: result.decayApplied,
|
|
||||||
});
|
|
||||||
|
|
||||||
result.duration = Date.now() - startTime;
|
|
||||||
logger.info('Consolidation cycle complete', { ...result });
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SCHEDULING HELPER
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get recommended next consolidation time
|
|
||||||
*
|
|
||||||
* Returns the next occurrence of 3 AM local time.
|
|
||||||
* This is based on research showing that:
|
|
||||||
* - Deep sleep (when consolidation occurs) typically happens 3-4 AM
|
|
||||||
* - System resources are usually free at this time
|
|
||||||
* - Users are unlikely to be actively using the system
|
|
||||||
*
|
|
||||||
* @returns Date object representing the next recommended consolidation time
|
|
||||||
*/
|
|
||||||
export function getNextConsolidationTime(): Date {
|
|
||||||
const now = new Date();
|
|
||||||
const next = new Date(now);
|
|
||||||
|
|
||||||
// Schedule for 3 AM next day
|
|
||||||
next.setDate(next.getDate() + 1);
|
|
||||||
next.setHours(3, 0, 0, 0);
|
|
||||||
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preview consolidation results without making changes
|
|
||||||
*
|
|
||||||
* Useful for understanding what would happen during consolidation
|
|
||||||
* without actually modifying the database.
|
|
||||||
*
|
|
||||||
* Note: This still runs the analysis phases but skips the
|
|
||||||
* actual modification phases.
|
|
||||||
*/
|
|
||||||
export async function previewConsolidation(
|
|
||||||
db: VestigeDatabase,
|
|
||||||
options: ConsolidationOptions = {}
|
|
||||||
): Promise<{
|
|
||||||
shortTermCount: number;
|
|
||||||
wouldPromote: number;
|
|
||||||
potentialConnections: number;
|
|
||||||
weakEdgeCount: number;
|
|
||||||
}> {
|
|
||||||
const {
|
|
||||||
shortTermWindowHours = DEFAULT_SHORT_TERM_WINDOW_HOURS,
|
|
||||||
importanceThreshold = DEFAULT_IMPORTANCE_THRESHOLD,
|
|
||||||
pruneThreshold = DEFAULT_PRUNE_THRESHOLD,
|
|
||||||
maxAnalyze = DEFAULT_MAX_ANALYZE,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// Get short-term memories
|
|
||||||
const shortTermMemories = await getShortTermMemories(db, shortTermWindowHours);
|
|
||||||
|
|
||||||
// Count how many would be promoted
|
|
||||||
let wouldPromote = 0;
|
|
||||||
for (const memory of shortTermMemories) {
|
|
||||||
const importance = calculateImportance(db, memory);
|
|
||||||
if (importance >= importanceThreshold) {
|
|
||||||
wouldPromote++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preview REM cycle (dry run)
|
|
||||||
const remPreview = await runREMCycle(db, { maxAnalyze, dryRun: true });
|
|
||||||
|
|
||||||
// Count weak edges that would be pruned
|
|
||||||
const internalDb = (db as unknown as { db: { prepare: (sql: string) => { get: (...args: unknown[]) => { count: number } } } }).db;
|
|
||||||
const weakEdgeResult = internalDb
|
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT COUNT(*) as count FROM graph_edges
|
|
||||||
WHERE weight < ?
|
|
||||||
AND json_extract(metadata, '$.discoveredBy') = 'rem_cycle'
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.get(pruneThreshold) as { count: number };
|
|
||||||
|
|
||||||
return {
|
|
||||||
shortTermCount: shortTermMemories.length,
|
|
||||||
wouldPromote,
|
|
||||||
potentialConnections: remPreview.connectionsDiscovered,
|
|
||||||
weakEdgeCount: weakEdgeResult.count,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,270 +0,0 @@
|
||||||
/**
|
|
||||||
* Ghost in the Shell - Context Watcher
|
|
||||||
*
|
|
||||||
* Watches the active window and clipboard to provide contextual awareness.
|
|
||||||
* Vestige sees what you see.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Active window title detection (macOS via AppleScript)
|
|
||||||
* - Clipboard monitoring
|
|
||||||
* - Context file for MCP injection
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { execSync } from 'child_process';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import os from 'os';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface SystemContext {
|
|
||||||
timestamp: string;
|
|
||||||
activeWindow: {
|
|
||||||
app: string;
|
|
||||||
title: string;
|
|
||||||
} | null;
|
|
||||||
clipboard: string | null;
|
|
||||||
workingDirectory: string;
|
|
||||||
gitBranch: string | null;
|
|
||||||
recentFiles: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONTEXT FILE LOCATION
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const CONTEXT_FILE = path.join(os.homedir(), '.vestige', 'context.json');
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// PLATFORM-SPECIFIC IMPLEMENTATIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get active window info on macOS using AppleScript
|
|
||||||
*/
|
|
||||||
function getActiveWindowMac(): { app: string; title: string } | null {
|
|
||||||
try {
|
|
||||||
// Get frontmost app name
|
|
||||||
const appScript = `
|
|
||||||
tell application "System Events"
|
|
||||||
set frontApp to first application process whose frontmost is true
|
|
||||||
return name of frontApp
|
|
||||||
end tell
|
|
||||||
`;
|
|
||||||
const app = execSync(`osascript -e '${appScript}'`, {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
}).trim();
|
|
||||||
|
|
||||||
// Get window title
|
|
||||||
const titleScript = `
|
|
||||||
tell application "System Events"
|
|
||||||
tell (first application process whose frontmost is true)
|
|
||||||
if (count of windows) > 0 then
|
|
||||||
return name of front window
|
|
||||||
else
|
|
||||||
return ""
|
|
||||||
end if
|
|
||||||
end tell
|
|
||||||
end tell
|
|
||||||
`;
|
|
||||||
const title = execSync(`osascript -e '${titleScript}'`, {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
}).trim();
|
|
||||||
|
|
||||||
return { app, title };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get clipboard content on macOS
|
|
||||||
*/
|
|
||||||
function getClipboardMac(): string | null {
|
|
||||||
try {
|
|
||||||
const content = execSync('pbpaste', {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
maxBuffer: 1024 * 100, // 100KB max
|
|
||||||
});
|
|
||||||
// Truncate long clipboard content
|
|
||||||
if (content.length > 2000) {
|
|
||||||
return content.slice(0, 2000) + '\n... [truncated]';
|
|
||||||
}
|
|
||||||
return content || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current git branch
|
|
||||||
*/
|
|
||||||
function getGitBranch(): string | null {
|
|
||||||
try {
|
|
||||||
return execSync('git rev-parse --abbrev-ref HEAD', {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
}).trim();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get recently modified files in current directory
|
|
||||||
*/
|
|
||||||
function getRecentFiles(): string[] {
|
|
||||||
try {
|
|
||||||
// Get files modified in last hour, sorted by time
|
|
||||||
const result = execSync(
|
|
||||||
'find . -type f -mmin -60 -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/dist/*" 2>/dev/null | head -10',
|
|
||||||
{
|
|
||||||
encoding: 'utf-8',
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return result
|
|
||||||
.split('\n')
|
|
||||||
.map(f => f.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 10);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONTEXT CAPTURE
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Capture current system context
|
|
||||||
*/
|
|
||||||
export function captureContext(): SystemContext {
|
|
||||||
const platform = process.platform;
|
|
||||||
|
|
||||||
let activeWindow: { app: string; title: string } | null = null;
|
|
||||||
let clipboard: string | null = null;
|
|
||||||
|
|
||||||
if (platform === 'darwin') {
|
|
||||||
activeWindow = getActiveWindowMac();
|
|
||||||
clipboard = getClipboardMac();
|
|
||||||
}
|
|
||||||
// TODO: Add Windows and Linux support
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
activeWindow,
|
|
||||||
clipboard,
|
|
||||||
workingDirectory: process.cwd(),
|
|
||||||
gitBranch: getGitBranch(),
|
|
||||||
recentFiles: getRecentFiles(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format context for injection into Claude prompts
|
|
||||||
*/
|
|
||||||
export function formatContextForInjection(context: SystemContext): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
if (context.activeWindow) {
|
|
||||||
parts.push(`Active: ${context.activeWindow.app} - ${context.activeWindow.title}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.gitBranch) {
|
|
||||||
parts.push(`Git: ${context.gitBranch}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.recentFiles.length > 0) {
|
|
||||||
parts.push(`Recent: ${context.recentFiles.slice(0, 3).join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.clipboard && context.clipboard.length < 500) {
|
|
||||||
parts.push(`Clipboard: "${context.clipboard.slice(0, 200)}${context.clipboard.length > 200 ? '...' : ''}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join(' | ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save context to file for external consumption
|
|
||||||
*/
|
|
||||||
export function saveContext(context: SystemContext): void {
|
|
||||||
try {
|
|
||||||
const dir = path.dirname(CONTEXT_FILE);
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
fs.writeFileSync(CONTEXT_FILE, JSON.stringify(context, null, 2));
|
|
||||||
} catch {
|
|
||||||
// Ignore file write errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read saved context from file
|
|
||||||
*/
|
|
||||||
export function readSavedContext(): SystemContext | null {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(CONTEXT_FILE)) {
|
|
||||||
const content = fs.readFileSync(CONTEXT_FILE, 'utf-8');
|
|
||||||
return JSON.parse(content) as SystemContext;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore read errors
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// WATCHER DAEMON
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
let watcherInterval: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the context watcher daemon
|
|
||||||
* Updates context every N seconds
|
|
||||||
*/
|
|
||||||
export function startContextWatcher(intervalMs: number = 5000): void {
|
|
||||||
if (watcherInterval) {
|
|
||||||
console.log('Context watcher already running');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Starting context watcher (interval: ${intervalMs}ms)`);
|
|
||||||
|
|
||||||
// Capture immediately
|
|
||||||
const context = captureContext();
|
|
||||||
saveContext(context);
|
|
||||||
|
|
||||||
// Then update periodically
|
|
||||||
watcherInterval = setInterval(() => {
|
|
||||||
const ctx = captureContext();
|
|
||||||
saveContext(ctx);
|
|
||||||
}, intervalMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the context watcher daemon
|
|
||||||
*/
|
|
||||||
export function stopContextWatcher(): void {
|
|
||||||
if (watcherInterval) {
|
|
||||||
clearInterval(watcherInterval);
|
|
||||||
watcherInterval = null;
|
|
||||||
console.log('Context watcher stopped');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if watcher is running
|
|
||||||
*/
|
|
||||||
export function isWatcherRunning(): boolean {
|
|
||||||
return watcherInterval !== null;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,788 +0,0 @@
|
||||||
/**
|
|
||||||
* Embeddings Service - Semantic Understanding for Vestige
|
|
||||||
*
|
|
||||||
* Provides vector embeddings for knowledge nodes using Ollama.
|
|
||||||
* Embeddings enable semantic similarity search and connection discovery.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Ollama integration with nomic-embed-text model (768-dim, fast, high quality)
|
|
||||||
* - Graceful fallback to TF-IDF when Ollama unavailable
|
|
||||||
* - Availability caching to reduce connection overhead
|
|
||||||
* - Batch embedding support for efficiency
|
|
||||||
* - Utility functions for similarity search
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ollama } from 'ollama';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONFIGURATION
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ollama API endpoint. Defaults to local installation.
|
|
||||||
*/
|
|
||||||
const OLLAMA_HOST = process.env['OLLAMA_HOST'] || 'http://localhost:11434';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Embedding model to use. nomic-embed-text provides:
|
|
||||||
* - 768 dimensions
|
|
||||||
* - Fast inference
|
|
||||||
* - High quality embeddings for semantic search
|
|
||||||
* - 8192 token context window
|
|
||||||
*/
|
|
||||||
const EMBEDDING_MODEL = process.env['VESTIGE_EMBEDDING_MODEL'] || 'nomic-embed-text';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum characters to embed. nomic-embed-text supports ~8192 tokens,
|
|
||||||
* but we truncate to 8000 chars for safety margin.
|
|
||||||
*/
|
|
||||||
const MAX_TEXT_LENGTH = 8000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache duration for availability check (5 minutes in ms)
|
|
||||||
*/
|
|
||||||
const AVAILABILITY_CACHE_TTL = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default request timeout in milliseconds
|
|
||||||
*/
|
|
||||||
const DEFAULT_TIMEOUT = 30000;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// INTERFACES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service interface for generating and comparing text embeddings.
|
|
||||||
* Provides semantic similarity capabilities for knowledge retrieval.
|
|
||||||
*/
|
|
||||||
export interface EmbeddingService {
|
|
||||||
/**
|
|
||||||
* Generate an embedding vector for the given text.
|
|
||||||
* @param text - The text to embed
|
|
||||||
* @returns A promise resolving to a numeric vector
|
|
||||||
*/
|
|
||||||
generateEmbedding(text: string): Promise<number[]>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate embeddings for multiple texts in a single batch.
|
|
||||||
* More efficient than calling generateEmbedding multiple times.
|
|
||||||
* @param texts - Array of texts to embed
|
|
||||||
* @returns A promise resolving to an array of embedding vectors
|
|
||||||
*/
|
|
||||||
batchEmbeddings(texts: string[]): Promise<number[][]>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate similarity between two embedding vectors.
|
|
||||||
* @param embA - First embedding vector
|
|
||||||
* @param embB - Second embedding vector
|
|
||||||
* @returns Similarity score between 0 and 1
|
|
||||||
*/
|
|
||||||
getSimilarity(embA: number[], embB: number[]): number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the embedding service is available and ready.
|
|
||||||
* @returns A promise resolving to true if the service is available
|
|
||||||
*/
|
|
||||||
isAvailable(): Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration options for embedding services.
|
|
||||||
*/
|
|
||||||
export interface EmbeddingServiceConfig {
|
|
||||||
/** Ollama host URL (default: http://localhost:11434) */
|
|
||||||
host?: string;
|
|
||||||
/** Embedding model to use (default: nomic-embed-text) */
|
|
||||||
model?: string;
|
|
||||||
/** Request timeout in milliseconds (default: 30000) */
|
|
||||||
timeout?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result from embedding generation with metadata.
|
|
||||||
*/
|
|
||||||
export interface EmbeddingResult {
|
|
||||||
embedding: number[];
|
|
||||||
model: string;
|
|
||||||
dimension: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// COSINE SIMILARITY
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate cosine similarity between two vectors.
|
|
||||||
* Returns a value between -1 and 1, where:
|
|
||||||
* - 1 means identical direction
|
|
||||||
* - 0 means orthogonal (unrelated)
|
|
||||||
* - -1 means opposite direction
|
|
||||||
*
|
|
||||||
* @param a - First vector
|
|
||||||
* @param b - Second vector
|
|
||||||
* @returns Cosine similarity score
|
|
||||||
* @throws Error if vectors have different lengths or are empty
|
|
||||||
*/
|
|
||||||
export function cosineSimilarity(a: number[], b: number[]): number {
|
|
||||||
if (a.length === 0 || b.length === 0) {
|
|
||||||
throw new Error('Cannot compute cosine similarity of empty vectors');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (a.length !== b.length) {
|
|
||||||
throw new Error(
|
|
||||||
`Vector dimension mismatch: ${a.length} vs ${b.length}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let dotProduct = 0;
|
|
||||||
let normA = 0;
|
|
||||||
let normB = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < a.length; i++) {
|
|
||||||
const aVal = a[i]!;
|
|
||||||
const bVal = b[i]!;
|
|
||||||
dotProduct += aVal * bVal;
|
|
||||||
normA += aVal * aVal;
|
|
||||||
normB += bVal * bVal;
|
|
||||||
}
|
|
||||||
|
|
||||||
const magnitude = Math.sqrt(normA) * Math.sqrt(normB);
|
|
||||||
|
|
||||||
if (magnitude === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return dotProduct / magnitude;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize cosine similarity from [-1, 1] to [0, 1] range.
|
|
||||||
* Useful when you need a percentage-like similarity score.
|
|
||||||
*
|
|
||||||
* @param similarity - Cosine similarity value
|
|
||||||
* @returns Normalized similarity between 0 and 1
|
|
||||||
*/
|
|
||||||
export function normalizedSimilarity(similarity: number): number {
|
|
||||||
return (similarity + 1) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate Euclidean distance between two vectors.
|
|
||||||
*
|
|
||||||
* @param a - First vector
|
|
||||||
* @param b - Second vector
|
|
||||||
* @returns Euclidean distance (lower = more similar)
|
|
||||||
*/
|
|
||||||
export function euclideanDistance(a: number[], b: number[]): number {
|
|
||||||
if (a.length !== b.length) {
|
|
||||||
throw new Error(`Vector dimension mismatch: ${a.length} vs ${b.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let sum = 0;
|
|
||||||
for (let i = 0; i < a.length; i++) {
|
|
||||||
const diff = a[i]! - b[i]!;
|
|
||||||
sum += diff * diff;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.sqrt(sum);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// OLLAMA EMBEDDING SERVICE
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Production embedding service using Ollama with nomic-embed-text model.
|
|
||||||
* Provides high-quality semantic embeddings for knowledge retrieval.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Automatic text truncation for long inputs
|
|
||||||
* - Availability caching to reduce connection overhead
|
|
||||||
* - Graceful error handling with informative messages
|
|
||||||
* - Batch embedding support for efficiency
|
|
||||||
*/
|
|
||||||
export class OllamaEmbeddingService implements EmbeddingService {
|
|
||||||
private client: Ollama;
|
|
||||||
private availabilityCache: { available: boolean; timestamp: number } | null = null;
|
|
||||||
private readonly model: string;
|
|
||||||
private readonly timeout: number;
|
|
||||||
|
|
||||||
constructor(config: EmbeddingServiceConfig = {}) {
|
|
||||||
const {
|
|
||||||
host = OLLAMA_HOST,
|
|
||||||
model = EMBEDDING_MODEL,
|
|
||||||
timeout = DEFAULT_TIMEOUT,
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
this.client = new Ollama({ host });
|
|
||||||
this.model = model;
|
|
||||||
this.timeout = timeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Ollama is running and the embedding model is available.
|
|
||||||
* Results are cached for 5 minutes to reduce overhead.
|
|
||||||
*/
|
|
||||||
async isAvailable(): Promise<boolean> {
|
|
||||||
// Check cache first
|
|
||||||
if (
|
|
||||||
this.availabilityCache &&
|
|
||||||
Date.now() - this.availabilityCache.timestamp < AVAILABILITY_CACHE_TTL
|
|
||||||
) {
|
|
||||||
return this.availabilityCache.available;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to list models to verify connection with timeout
|
|
||||||
const response = await Promise.race([
|
|
||||||
this.client.list(),
|
|
||||||
new Promise<never>((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('Timeout')), this.timeout)
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const modelNames = response.models.map((m) => m.name);
|
|
||||||
|
|
||||||
// Check if our model is available (handle both "model" and "model:latest" formats)
|
|
||||||
const modelBase = this.model.split(':')[0];
|
|
||||||
const available = modelNames.some(
|
|
||||||
(name) => name === this.model ||
|
|
||||||
name.startsWith(`${this.model}:`) ||
|
|
||||||
name.split(':')[0] === modelBase
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!available) {
|
|
||||||
console.warn(
|
|
||||||
`Ollama is running but model '${this.model}' not found. ` +
|
|
||||||
`Available models: ${modelNames.join(', ') || 'none'}. ` +
|
|
||||||
`Run 'ollama pull ${this.model}' to install.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.availabilityCache = { available, timestamp: Date.now() };
|
|
||||||
return available;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
console.warn(`Ollama not available: ${message}`);
|
|
||||||
this.availabilityCache = { available: false, timestamp: Date.now() };
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Truncate text to fit within the model's context window.
|
|
||||||
*/
|
|
||||||
private truncateText(text: string): string {
|
|
||||||
if (text.length <= MAX_TEXT_LENGTH) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
console.warn(
|
|
||||||
`Text truncated from ${text.length} to ${MAX_TEXT_LENGTH} characters`
|
|
||||||
);
|
|
||||||
return text.slice(0, MAX_TEXT_LENGTH);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate an embedding for the given text.
|
|
||||||
*/
|
|
||||||
async generateEmbedding(text: string): Promise<number[]> {
|
|
||||||
if (!text || text.trim().length === 0) {
|
|
||||||
throw new Error('Cannot generate embedding for empty text');
|
|
||||||
}
|
|
||||||
|
|
||||||
const truncatedText = this.truncateText(text.trim());
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await Promise.race([
|
|
||||||
this.client.embed({
|
|
||||||
model: this.model,
|
|
||||||
input: truncatedText,
|
|
||||||
}),
|
|
||||||
new Promise<never>((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('Embedding timeout')), this.timeout)
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Response contains array of embeddings, we want the first one
|
|
||||||
if (!response.embeddings || response.embeddings.length === 0) {
|
|
||||||
throw new Error('No embeddings returned from Ollama');
|
|
||||||
}
|
|
||||||
|
|
||||||
const embedding = response.embeddings[0];
|
|
||||||
if (!embedding) {
|
|
||||||
throw new Error('No embedding returned from Ollama');
|
|
||||||
}
|
|
||||||
|
|
||||||
return embedding;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(`Failed to generate embedding: ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate embeddings for multiple texts in a batch.
|
|
||||||
* More efficient than individual calls for bulk operations.
|
|
||||||
*/
|
|
||||||
async batchEmbeddings(texts: string[]): Promise<number[][]> {
|
|
||||||
if (texts.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter and truncate texts
|
|
||||||
const validTexts = texts
|
|
||||||
.filter((t) => t && t.trim().length > 0)
|
|
||||||
.map((t) => this.truncateText(t.trim()));
|
|
||||||
|
|
||||||
if (validTexts.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await Promise.race([
|
|
||||||
this.client.embed({
|
|
||||||
model: this.model,
|
|
||||||
input: validTexts,
|
|
||||||
}),
|
|
||||||
new Promise<never>((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('Batch embedding timeout')), this.timeout * 2)
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!response.embeddings || response.embeddings.length !== validTexts.length) {
|
|
||||||
throw new Error(
|
|
||||||
`Expected ${validTexts.length} embeddings, got ${response.embeddings?.length ?? 0}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.embeddings;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(`Failed to generate batch embeddings: ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate similarity between two embedding vectors using cosine similarity.
|
|
||||||
*/
|
|
||||||
getSimilarity(embA: number[], embB: number[]): number {
|
|
||||||
return cosineSimilarity(embA, embB);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the model being used.
|
|
||||||
*/
|
|
||||||
getModel(): string {
|
|
||||||
return this.model;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the availability cache, forcing a fresh check on next call.
|
|
||||||
*/
|
|
||||||
clearCache(): void {
|
|
||||||
this.availabilityCache = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// FALLBACK EMBEDDING SERVICE
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default vocabulary size for fallback TF-IDF style embeddings.
|
|
||||||
*/
|
|
||||||
const DEFAULT_VOCAB_SIZE = 512;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fallback embedding service using TF-IDF style word frequency vectors.
|
|
||||||
* Used when Ollama is not available. Provides basic keyword-based
|
|
||||||
* similarity that works offline with no dependencies.
|
|
||||||
*
|
|
||||||
* Limitations compared to Ollama:
|
|
||||||
* - No semantic understanding (only keyword matching)
|
|
||||||
* - Fixed vocabulary may miss domain-specific terms
|
|
||||||
* - Lower quality similarity scores
|
|
||||||
*/
|
|
||||||
export class FallbackEmbeddingService implements EmbeddingService {
|
|
||||||
private readonly dimensions: number;
|
|
||||||
private readonly vocabulary: Map<string, number>;
|
|
||||||
private documentFrequency: Map<string, number>;
|
|
||||||
private documentCount: number;
|
|
||||||
|
|
||||||
constructor(vocabSize: number = DEFAULT_VOCAB_SIZE) {
|
|
||||||
this.dimensions = vocabSize;
|
|
||||||
this.vocabulary = new Map();
|
|
||||||
this.documentFrequency = new Map();
|
|
||||||
this.documentCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fallback service is always available (runs locally with no dependencies).
|
|
||||||
*/
|
|
||||||
async isAvailable(): Promise<boolean> {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tokenize text into normalized words.
|
|
||||||
*/
|
|
||||||
private tokenize(text: string): string[] {
|
|
||||||
return text
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^\w\s]/g, ' ')
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter((word) => word.length > 2 && word.length < 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or assign a vocabulary index for a word.
|
|
||||||
* Uses hash-based assignment for consistent but bounded vocabulary.
|
|
||||||
*/
|
|
||||||
private getWordIndex(word: string): number {
|
|
||||||
if (this.vocabulary.has(word)) {
|
|
||||||
return this.vocabulary.get(word)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple hash function for consistent word-to-index mapping
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < word.length; i++) {
|
|
||||||
const char = word.charCodeAt(i);
|
|
||||||
hash = ((hash << 5) - hash + char) | 0;
|
|
||||||
}
|
|
||||||
const index = Math.abs(hash) % this.dimensions;
|
|
||||||
this.vocabulary.set(word, index);
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a TF-IDF style embedding vector.
|
|
||||||
* Uses term frequency weighted by inverse document frequency approximation.
|
|
||||||
*/
|
|
||||||
async generateEmbedding(text: string): Promise<number[]> {
|
|
||||||
if (!text || text.trim().length === 0) {
|
|
||||||
throw new Error('Cannot generate embedding for empty text');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = this.tokenize(text);
|
|
||||||
if (tokens.length === 0) {
|
|
||||||
// Return zero vector for text with no valid tokens
|
|
||||||
return new Array(this.dimensions).fill(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate term frequency
|
|
||||||
const termFreq = new Map<string, number>();
|
|
||||||
for (const token of tokens) {
|
|
||||||
termFreq.set(token, (termFreq.get(token) || 0) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update document frequency for IDF
|
|
||||||
this.documentCount++;
|
|
||||||
const seenWords = new Set<string>();
|
|
||||||
for (const token of tokens) {
|
|
||||||
if (!seenWords.has(token)) {
|
|
||||||
this.documentFrequency.set(
|
|
||||||
token,
|
|
||||||
(this.documentFrequency.get(token) || 0) + 1
|
|
||||||
);
|
|
||||||
seenWords.add(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build embedding vector
|
|
||||||
const embedding = new Array(this.dimensions).fill(0);
|
|
||||||
const maxFreq = Math.max(...termFreq.values());
|
|
||||||
|
|
||||||
for (const [word, freq] of termFreq) {
|
|
||||||
const index = this.getWordIndex(word);
|
|
||||||
|
|
||||||
// TF: normalized term frequency (prevents bias towards long documents)
|
|
||||||
const tf = freq / maxFreq;
|
|
||||||
|
|
||||||
// IDF: inverse document frequency (common words get lower weight)
|
|
||||||
const df = this.documentFrequency.get(word) || 1;
|
|
||||||
const idf = Math.log((this.documentCount + 1) / (df + 1)) + 1;
|
|
||||||
|
|
||||||
// TF-IDF score (may have collisions, add to handle)
|
|
||||||
embedding[index] += tf * idf;
|
|
||||||
}
|
|
||||||
|
|
||||||
// L2 normalize the vector
|
|
||||||
const norm = Math.sqrt(embedding.reduce((sum, val) => sum + val * val, 0));
|
|
||||||
if (norm > 0) {
|
|
||||||
for (let i = 0; i < embedding.length; i++) {
|
|
||||||
embedding[i] /= norm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return embedding;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate embeddings for multiple texts.
|
|
||||||
*/
|
|
||||||
async batchEmbeddings(texts: string[]): Promise<number[][]> {
|
|
||||||
const embeddings: number[][] = [];
|
|
||||||
for (const text of texts) {
|
|
||||||
if (text && text.trim().length > 0) {
|
|
||||||
embeddings.push(await this.generateEmbedding(text));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return embeddings;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate similarity between two embedding vectors.
|
|
||||||
*/
|
|
||||||
getSimilarity(embA: number[], embB: number[]): number {
|
|
||||||
return cosineSimilarity(embA, embB);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the document frequency statistics.
|
|
||||||
* Useful when starting fresh with a new corpus.
|
|
||||||
*/
|
|
||||||
reset(): void {
|
|
||||||
this.vocabulary.clear();
|
|
||||||
this.documentFrequency.clear();
|
|
||||||
this.documentCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the dimensionality of embeddings.
|
|
||||||
*/
|
|
||||||
getDimensions(): number {
|
|
||||||
return this.dimensions;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// EMBEDDING CACHE
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple in-memory cache for embeddings.
|
|
||||||
* Reduces redundant API calls during REM cycles.
|
|
||||||
*/
|
|
||||||
export class EmbeddingCache {
|
|
||||||
private cache: Map<string, { embedding: number[]; timestamp: number }> = new Map();
|
|
||||||
private maxSize: number;
|
|
||||||
private ttlMs: number;
|
|
||||||
|
|
||||||
constructor(maxSize: number = 1000, ttlMinutes: number = 60) {
|
|
||||||
this.maxSize = maxSize;
|
|
||||||
this.ttlMs = ttlMinutes * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a cached embedding by node ID.
|
|
||||||
*/
|
|
||||||
get(nodeId: string): number[] | null {
|
|
||||||
const entry = this.cache.get(nodeId);
|
|
||||||
if (!entry) return null;
|
|
||||||
|
|
||||||
// Check if expired
|
|
||||||
if (Date.now() - entry.timestamp > this.ttlMs) {
|
|
||||||
this.cache.delete(nodeId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry.embedding;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache an embedding for a node ID.
|
|
||||||
*/
|
|
||||||
set(nodeId: string, embedding: number[]): void {
|
|
||||||
// Evict oldest if at capacity
|
|
||||||
if (this.cache.size >= this.maxSize) {
|
|
||||||
const oldestKey = this.cache.keys().next().value;
|
|
||||||
if (oldestKey) {
|
|
||||||
this.cache.delete(oldestKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cache.set(nodeId, {
|
|
||||||
embedding,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a node ID has a cached embedding.
|
|
||||||
*/
|
|
||||||
has(nodeId: string): boolean {
|
|
||||||
return this.get(nodeId) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all cached embeddings.
|
|
||||||
*/
|
|
||||||
clear(): void {
|
|
||||||
this.cache.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the number of cached embeddings.
|
|
||||||
*/
|
|
||||||
size(): number {
|
|
||||||
return this.cache.size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// FACTORY FUNCTIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
let defaultService: EmbeddingService | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the default embedding service (singleton).
|
|
||||||
* Uses cached instance for efficiency.
|
|
||||||
*/
|
|
||||||
export function getEmbeddingService(config?: EmbeddingServiceConfig): EmbeddingService {
|
|
||||||
if (!defaultService) {
|
|
||||||
defaultService = new OllamaEmbeddingService(config);
|
|
||||||
}
|
|
||||||
return defaultService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an embedding service with automatic fallback.
|
|
||||||
*
|
|
||||||
* Attempts to use Ollama with nomic-embed-text for high-quality semantic
|
|
||||||
* embeddings. Falls back to TF-IDF based keyword similarity if Ollama
|
|
||||||
* is not available.
|
|
||||||
*
|
|
||||||
* @param config - Optional configuration for the Ollama service
|
|
||||||
* @returns A promise resolving to an EmbeddingService instance
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const embeddings = await createEmbeddingService();
|
|
||||||
*
|
|
||||||
* const vec1 = await embeddings.generateEmbedding("TypeScript is great");
|
|
||||||
* const vec2 = await embeddings.generateEmbedding("JavaScript is popular");
|
|
||||||
*
|
|
||||||
* const similarity = embeddings.getSimilarity(vec1, vec2);
|
|
||||||
* console.log(`Similarity: ${similarity}`);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export async function createEmbeddingService(
|
|
||||||
config?: EmbeddingServiceConfig
|
|
||||||
): Promise<EmbeddingService> {
|
|
||||||
const ollama = new OllamaEmbeddingService(config);
|
|
||||||
|
|
||||||
if (await ollama.isAvailable()) {
|
|
||||||
console.log(`Using Ollama embedding service with model: ${config?.model || EMBEDDING_MODEL}`);
|
|
||||||
return ollama;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(
|
|
||||||
'Ollama not available, using fallback keyword similarity. ' +
|
|
||||||
'For better results, install Ollama and run: ollama pull nomic-embed-text'
|
|
||||||
);
|
|
||||||
return new FallbackEmbeddingService();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// UTILITY FUNCTIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the top K most similar items to a query embedding.
|
|
||||||
*
|
|
||||||
* @param queryEmbedding - The embedding to search for
|
|
||||||
* @param candidates - Array of items with embeddings
|
|
||||||
* @param k - Number of results to return
|
|
||||||
* @returns Top K items sorted by similarity (highest first)
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const results = findTopK(queryVec, documents, 10);
|
|
||||||
* results.forEach(({ item, similarity }) => {
|
|
||||||
* console.log(`${item.title}: ${similarity.toFixed(3)}`);
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function findTopK<T extends { embedding: number[] }>(
|
|
||||||
queryEmbedding: number[],
|
|
||||||
candidates: T[],
|
|
||||||
k: number
|
|
||||||
): Array<T & { similarity: number }> {
|
|
||||||
const scored = candidates.map((item) => ({
|
|
||||||
...item,
|
|
||||||
similarity: cosineSimilarity(queryEmbedding, item.embedding),
|
|
||||||
}));
|
|
||||||
|
|
||||||
scored.sort((a, b) => b.similarity - a.similarity);
|
|
||||||
|
|
||||||
return scored.slice(0, k);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter items by minimum similarity threshold.
|
|
||||||
*
|
|
||||||
* @param queryEmbedding - The embedding to search for
|
|
||||||
* @param candidates - Array of items with embeddings
|
|
||||||
* @param minSimilarity - Minimum similarity score (-1 to 1)
|
|
||||||
* @returns Items with similarity >= minSimilarity, sorted by similarity
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const relevant = filterBySimilarity(queryVec, documents, 0.7);
|
|
||||||
* console.log(`Found ${relevant.length} relevant documents`);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function filterBySimilarity<T extends { embedding: number[] }>(
|
|
||||||
queryEmbedding: number[],
|
|
||||||
candidates: T[],
|
|
||||||
minSimilarity: number
|
|
||||||
): Array<T & { similarity: number }> {
|
|
||||||
const scored = candidates
|
|
||||||
.map((item) => ({
|
|
||||||
...item,
|
|
||||||
similarity: cosineSimilarity(queryEmbedding, item.embedding),
|
|
||||||
}))
|
|
||||||
.filter((item) => item.similarity >= minSimilarity);
|
|
||||||
|
|
||||||
scored.sort((a, b) => b.similarity - a.similarity);
|
|
||||||
|
|
||||||
return scored;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute average embedding from multiple vectors.
|
|
||||||
* Useful for combining multiple documents into a single representation.
|
|
||||||
*
|
|
||||||
* @param embeddings - Array of embedding vectors
|
|
||||||
* @returns Average embedding vector
|
|
||||||
*/
|
|
||||||
export function averageEmbedding(embeddings: number[][]): number[] {
|
|
||||||
if (embeddings.length === 0) {
|
|
||||||
throw new Error('Cannot compute average of empty embedding array');
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstEmbedding = embeddings[0];
|
|
||||||
if (!firstEmbedding) {
|
|
||||||
throw new Error('Cannot compute average of empty embedding array');
|
|
||||||
}
|
|
||||||
|
|
||||||
const dimensions = firstEmbedding.length;
|
|
||||||
const result = new Array<number>(dimensions).fill(0);
|
|
||||||
|
|
||||||
for (const embedding of embeddings) {
|
|
||||||
if (embedding.length !== dimensions) {
|
|
||||||
throw new Error('All embeddings must have the same dimensions');
|
|
||||||
}
|
|
||||||
for (let i = 0; i < dimensions; i++) {
|
|
||||||
result[i]! += embedding[i]!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < dimensions; i++) {
|
|
||||||
result[i]! /= embeddings.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
@ -1,462 +0,0 @@
|
||||||
/**
|
|
||||||
* Vestige Error Types
|
|
||||||
*
|
|
||||||
* A comprehensive hierarchy of errors for proper error handling and reporting.
|
|
||||||
* Includes type guards, utilities, and a Result type for functional error handling.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Error Sanitization
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize error messages to prevent information leakage
|
|
||||||
*/
|
|
||||||
export function sanitizeErrorMessage(message: string): string {
|
|
||||||
let sanitized = message;
|
|
||||||
// Remove file paths
|
|
||||||
sanitized = sanitized.replace(/\/[^\s]+/g, '[PATH]');
|
|
||||||
// Remove SQL keywords
|
|
||||||
sanitized = sanitized.replace(/SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER/gi, '[SQL]');
|
|
||||||
// Redact credentials
|
|
||||||
sanitized = sanitized.replace(
|
|
||||||
/\b(password|secret|key|token|auth)\s*[=:]\s*\S+/gi,
|
|
||||||
'[REDACTED]'
|
|
||||||
);
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Base Error Class
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base error class for all Vestige errors
|
|
||||||
*/
|
|
||||||
export class VestigeError extends Error {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public readonly code: string,
|
|
||||||
public readonly statusCode: number = 500,
|
|
||||||
public readonly details?: Record<string, unknown>
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'VestigeError';
|
|
||||||
Error.captureStackTrace(this, this.constructor);
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON(): {
|
|
||||||
name: string;
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
statusCode: number;
|
|
||||||
details?: Record<string, unknown>;
|
|
||||||
} {
|
|
||||||
const result: {
|
|
||||||
name: string;
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
statusCode: number;
|
|
||||||
details?: Record<string, unknown>;
|
|
||||||
} = {
|
|
||||||
name: this.name,
|
|
||||||
code: this.code,
|
|
||||||
message: this.message,
|
|
||||||
statusCode: this.statusCode,
|
|
||||||
};
|
|
||||||
if (this.details !== undefined) {
|
|
||||||
result.details = this.details;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Specific Error Types
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validation errors (400)
|
|
||||||
*/
|
|
||||||
export class ValidationError extends VestigeError {
|
|
||||||
constructor(message: string, details?: Record<string, unknown>) {
|
|
||||||
super(message, 'VALIDATION_ERROR', 400, details);
|
|
||||||
this.name = 'ValidationError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resource not found (404)
|
|
||||||
*/
|
|
||||||
export class NotFoundError extends VestigeError {
|
|
||||||
constructor(resource: string, id?: string) {
|
|
||||||
super(
|
|
||||||
id ? `${resource} not found: ${id}` : `${resource} not found`,
|
|
||||||
'NOT_FOUND',
|
|
||||||
404,
|
|
||||||
{ resource, id }
|
|
||||||
);
|
|
||||||
this.name = 'NotFoundError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Conflict errors (409)
|
|
||||||
*/
|
|
||||||
export class ConflictError extends VestigeError {
|
|
||||||
constructor(message: string, details?: Record<string, unknown>) {
|
|
||||||
super(message, 'CONFLICT', 409, details);
|
|
||||||
this.name = 'ConflictError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Database operation errors (500)
|
|
||||||
*/
|
|
||||||
export class DatabaseError extends VestigeError {
|
|
||||||
constructor(message: string, cause?: unknown) {
|
|
||||||
super(sanitizeErrorMessage(message), 'DATABASE_ERROR', 500, {
|
|
||||||
cause: String(cause),
|
|
||||||
});
|
|
||||||
this.name = 'DatabaseError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Security-related errors (403)
|
|
||||||
*/
|
|
||||||
export class SecurityError extends VestigeError {
|
|
||||||
constructor(message: string, details?: Record<string, unknown>) {
|
|
||||||
super(message, 'SECURITY_ERROR', 403, details);
|
|
||||||
this.name = 'SecurityError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration errors (500)
|
|
||||||
*/
|
|
||||||
export class ConfigurationError extends VestigeError {
|
|
||||||
constructor(message: string, details?: Record<string, unknown>) {
|
|
||||||
super(message, 'CONFIGURATION_ERROR', 500, details);
|
|
||||||
this.name = 'ConfigurationError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Timeout errors (408)
|
|
||||||
*/
|
|
||||||
export class TimeoutError extends VestigeError {
|
|
||||||
constructor(operation: string, timeoutMs: number) {
|
|
||||||
super(`Operation timed out: ${operation}`, 'TIMEOUT', 408, {
|
|
||||||
operation,
|
|
||||||
timeoutMs,
|
|
||||||
});
|
|
||||||
this.name = 'TimeoutError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Embedding service errors
|
|
||||||
*/
|
|
||||||
export class EmbeddingError extends VestigeError {
|
|
||||||
constructor(message: string, cause?: unknown) {
|
|
||||||
super(message, 'EMBEDDING_ERROR', 500, { cause: String(cause) });
|
|
||||||
this.name = 'EmbeddingError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Concurrency/locking errors (409)
|
|
||||||
*/
|
|
||||||
export class ConcurrencyError extends VestigeError {
|
|
||||||
constructor(message: string = 'Operation failed due to concurrent access') {
|
|
||||||
super(message, 'CONCURRENCY_ERROR', 409);
|
|
||||||
this.name = 'ConcurrencyError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limit errors (429)
|
|
||||||
*/
|
|
||||||
export class RateLimitError extends VestigeError {
|
|
||||||
constructor(message: string, retryAfterMs?: number) {
|
|
||||||
super(message, 'RATE_LIMIT', 429, { retryAfterMs });
|
|
||||||
this.name = 'RateLimitError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentication errors (401)
|
|
||||||
*/
|
|
||||||
export class AuthenticationError extends VestigeError {
|
|
||||||
constructor(message: string = 'Authentication required') {
|
|
||||||
super(message, 'AUTHENTICATION_ERROR', 401);
|
|
||||||
this.name = 'AuthenticationError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Error Handling Utilities
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type guard for VestigeError
|
|
||||||
*/
|
|
||||||
export function isVestigeError(error: unknown): error is VestigeError {
|
|
||||||
return error instanceof VestigeError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert unknown error to VestigeError
|
|
||||||
*/
|
|
||||||
export function toVestigeError(error: unknown): VestigeError {
|
|
||||||
if (isVestigeError(error)) {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return new VestigeError(
|
|
||||||
sanitizeErrorMessage(error.message),
|
|
||||||
'UNKNOWN_ERROR',
|
|
||||||
500,
|
|
||||||
{ originalName: error.name }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof error === 'string') {
|
|
||||||
return new VestigeError(sanitizeErrorMessage(error), 'UNKNOWN_ERROR', 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new VestigeError('An unknown error occurred', 'UNKNOWN_ERROR', 500, {
|
|
||||||
errorType: typeof error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap function to catch and transform errors
|
|
||||||
*/
|
|
||||||
export function wrapError<T extends (...args: unknown[]) => Promise<unknown>>(
|
|
||||||
fn: T,
|
|
||||||
errorTransform?: (error: unknown) => VestigeError
|
|
||||||
): T {
|
|
||||||
const wrapped = async (...args: Parameters<T>): Promise<ReturnType<T>> => {
|
|
||||||
try {
|
|
||||||
return (await fn(...args)) as ReturnType<T>;
|
|
||||||
} catch (error) {
|
|
||||||
if (errorTransform) {
|
|
||||||
throw errorTransform(error);
|
|
||||||
}
|
|
||||||
throw toVestigeError(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return wrapped as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a function with error transformation
|
|
||||||
*/
|
|
||||||
export async function withErrorHandling<T>(
|
|
||||||
fn: () => Promise<T>,
|
|
||||||
errorTransform?: (error: unknown) => VestigeError
|
|
||||||
): Promise<T> {
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} catch (error) {
|
|
||||||
if (errorTransform) {
|
|
||||||
throw errorTransform(error);
|
|
||||||
}
|
|
||||||
throw toVestigeError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retry a function with exponential backoff
|
|
||||||
*/
|
|
||||||
export async function withRetry<T>(
|
|
||||||
fn: () => Promise<T>,
|
|
||||||
options: {
|
|
||||||
maxRetries?: number;
|
|
||||||
baseDelayMs?: number;
|
|
||||||
maxDelayMs?: number;
|
|
||||||
shouldRetry?: (error: unknown) => boolean;
|
|
||||||
} = {}
|
|
||||||
): Promise<T> {
|
|
||||||
const {
|
|
||||||
maxRetries = 3,
|
|
||||||
baseDelayMs = 100,
|
|
||||||
maxDelayMs = 5000,
|
|
||||||
shouldRetry = () => true,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
let lastError: unknown;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
|
|
||||||
if (attempt === maxRetries || !shouldRetry(error)) {
|
|
||||||
throw toVestigeError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw toVestigeError(lastError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Result Type (Optional Pattern)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result type for functional error handling
|
|
||||||
*/
|
|
||||||
export type Result<T, E = VestigeError> =
|
|
||||||
| { success: true; data: T }
|
|
||||||
| { success: false; error: E };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a success result
|
|
||||||
*/
|
|
||||||
export function ok<T>(data: T): Result<T, never> {
|
|
||||||
return { success: true, data };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an error result
|
|
||||||
*/
|
|
||||||
export function err<E = VestigeError>(error: E): Result<never, E> {
|
|
||||||
return { success: false, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if result is success
|
|
||||||
*/
|
|
||||||
export function isOk<T, E>(result: Result<T, E>): result is { success: true; data: T } {
|
|
||||||
return result.success;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if result is error
|
|
||||||
*/
|
|
||||||
export function isErr<T, E>(result: Result<T, E>): result is { success: false; error: E } {
|
|
||||||
return !result.success;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unwrap a result, throwing if it's an error
|
|
||||||
*/
|
|
||||||
export function unwrap<T, E>(result: Result<T, E>): T {
|
|
||||||
if (result.success) {
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
throw (result as { success: false; error: E }).error;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unwrap a result with a default value
|
|
||||||
*/
|
|
||||||
export function unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T {
|
|
||||||
if (result.success) {
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map over a successful result
|
|
||||||
*/
|
|
||||||
export function mapResult<T, U, E>(
|
|
||||||
result: Result<T, E>,
|
|
||||||
fn: (data: T) => U
|
|
||||||
): Result<U, E> {
|
|
||||||
if (result.success) {
|
|
||||||
return ok(fn(result.data));
|
|
||||||
}
|
|
||||||
return result as { success: false; error: E };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map over an error result
|
|
||||||
*/
|
|
||||||
export function mapError<T, E, F>(
|
|
||||||
result: Result<T, E>,
|
|
||||||
fn: (error: E) => F
|
|
||||||
): Result<T, F> {
|
|
||||||
if (!result.success) {
|
|
||||||
return err(fn((result as { success: false; error: E }).error));
|
|
||||||
}
|
|
||||||
return result as { success: true; data: T };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a function and return a Result
|
|
||||||
*/
|
|
||||||
export async function tryCatch<T>(
|
|
||||||
fn: () => Promise<T>
|
|
||||||
): Promise<Result<T, VestigeError>> {
|
|
||||||
try {
|
|
||||||
const data = await fn();
|
|
||||||
return ok(data);
|
|
||||||
} catch (error) {
|
|
||||||
return err(toVestigeError(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a synchronous function and return a Result
|
|
||||||
*/
|
|
||||||
export function tryCatchSync<T>(fn: () => T): Result<T, VestigeError> {
|
|
||||||
try {
|
|
||||||
const data = fn();
|
|
||||||
return ok(data);
|
|
||||||
} catch (error) {
|
|
||||||
return err(toVestigeError(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Error Assertion Helpers
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assert a condition, throwing ValidationError if false
|
|
||||||
*/
|
|
||||||
export function assertValid(
|
|
||||||
condition: boolean,
|
|
||||||
message: string,
|
|
||||||
details?: Record<string, unknown>
|
|
||||||
): asserts condition {
|
|
||||||
if (!condition) {
|
|
||||||
throw new ValidationError(message, details);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assert a value is not null or undefined
|
|
||||||
*/
|
|
||||||
export function assertDefined<T>(
|
|
||||||
value: T | null | undefined,
|
|
||||||
resource: string,
|
|
||||||
id?: string
|
|
||||||
): asserts value is T {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
throw new NotFoundError(resource, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assert a value exists, returning it if so
|
|
||||||
*/
|
|
||||||
export function requireDefined<T>(
|
|
||||||
value: T | null | undefined,
|
|
||||||
resource: string,
|
|
||||||
id?: string
|
|
||||||
): T {
|
|
||||||
assertDefined(value, resource, id);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
@ -1,815 +0,0 @@
|
||||||
/**
|
|
||||||
* FSRS-5 (Free Spaced Repetition Scheduler) Algorithm Implementation
|
|
||||||
*
|
|
||||||
* Based on the FSRS-5 algorithm by Jarrett Ye
|
|
||||||
* Paper: https://github.com/open-spaced-repetition/fsrs4anki/wiki/The-Algorithm
|
|
||||||
*
|
|
||||||
* This is a production-ready implementation with full TypeScript types,
|
|
||||||
* sentiment integration for emotional memory boosting, and comprehensive
|
|
||||||
* error handling.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// FSRS-5 CONSTANTS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FSRS-5 default weights (w0 to w18)
|
|
||||||
*
|
|
||||||
* These weights are optimized from millions of Anki review records.
|
|
||||||
* They control:
|
|
||||||
* - w0-w3: Initial stability for each grade (Again, Hard, Good, Easy)
|
|
||||||
* - w4-w5: Initial difficulty calculation
|
|
||||||
* - w6-w7: Short-term stability calculation
|
|
||||||
* - w8-w10: Stability increase factors after successful recall
|
|
||||||
* - w11-w14: Difficulty update parameters
|
|
||||||
* - w15-w16: Forgetting curve (stability after lapse)
|
|
||||||
* - w17-w18: Short-term scheduling parameters
|
|
||||||
*/
|
|
||||||
export const FSRS_WEIGHTS: readonly [
|
|
||||||
number, number, number, number, number,
|
|
||||||
number, number, number, number, number,
|
|
||||||
number, number, number, number, number,
|
|
||||||
number, number, number, number
|
|
||||||
] = [
|
|
||||||
0.40255, 1.18385, 3.173, 15.69105, 7.1949,
|
|
||||||
0.5345, 1.4604, 0.0046, 1.54575, 0.1192,
|
|
||||||
1.01925, 1.9395, 0.11, 0.29605, 2.2698,
|
|
||||||
0.2315, 2.9898, 0.51655, 0.6621
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FSRS algorithm constants
|
|
||||||
*/
|
|
||||||
export const FSRS_CONSTANTS = {
|
|
||||||
/** Maximum difficulty value */
|
|
||||||
MAX_DIFFICULTY: 10,
|
|
||||||
/** Minimum difficulty value */
|
|
||||||
MIN_DIFFICULTY: 1,
|
|
||||||
/** Minimum stability in days */
|
|
||||||
MIN_STABILITY: 0.1,
|
|
||||||
/** Maximum stability in days (approx 100 years) */
|
|
||||||
MAX_STABILITY: 36500,
|
|
||||||
/** Default desired retention rate */
|
|
||||||
DEFAULT_RETENTION: 0.9,
|
|
||||||
/** Factor for converting retrievability to interval */
|
|
||||||
DECAY_FACTOR: 0.9,
|
|
||||||
/** Small epsilon for numerical stability */
|
|
||||||
EPSILON: 1e-10,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TYPES & SCHEMAS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Review grades in FSRS
|
|
||||||
* - Again (1): Complete failure to recall
|
|
||||||
* - Hard (2): Recalled with significant difficulty
|
|
||||||
* - Good (3): Recalled with moderate effort
|
|
||||||
* - Easy (4): Recalled effortlessly
|
|
||||||
*/
|
|
||||||
export const ReviewGradeSchema = z.union([
|
|
||||||
z.literal(1),
|
|
||||||
z.literal(2),
|
|
||||||
z.literal(3),
|
|
||||||
z.literal(4),
|
|
||||||
]);
|
|
||||||
export type ReviewGrade = z.infer<typeof ReviewGradeSchema>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Named constants for review grades
|
|
||||||
*/
|
|
||||||
export const Grade = {
|
|
||||||
Again: 1,
|
|
||||||
Hard: 2,
|
|
||||||
Good: 3,
|
|
||||||
Easy: 4,
|
|
||||||
} as const satisfies Record<string, ReviewGrade>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Learning states for FSRS cards
|
|
||||||
* - New: Never reviewed
|
|
||||||
* - Learning: In initial learning phase
|
|
||||||
* - Review: In long-term review phase
|
|
||||||
* - Relearning: Lapsed and relearning
|
|
||||||
*/
|
|
||||||
export const LearningStateSchema = z.enum([
|
|
||||||
'New',
|
|
||||||
'Learning',
|
|
||||||
'Review',
|
|
||||||
'Relearning',
|
|
||||||
]);
|
|
||||||
export type LearningState = z.infer<typeof LearningStateSchema>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FSRS card state - represents the memory state of a single item
|
|
||||||
*/
|
|
||||||
export const FSRSStateSchema = z.object({
|
|
||||||
/** Current difficulty (1-10, higher = harder) */
|
|
||||||
difficulty: z.number().min(FSRS_CONSTANTS.MIN_DIFFICULTY).max(FSRS_CONSTANTS.MAX_DIFFICULTY),
|
|
||||||
/** Current stability in days (higher = more stable memory) */
|
|
||||||
stability: z.number().min(FSRS_CONSTANTS.MIN_STABILITY).max(FSRS_CONSTANTS.MAX_STABILITY),
|
|
||||||
/** Current learning state */
|
|
||||||
state: LearningStateSchema,
|
|
||||||
/** Number of times reviewed */
|
|
||||||
reps: z.number().int().min(0),
|
|
||||||
/** Number of lapses (times "Again" was pressed in Review state) */
|
|
||||||
lapses: z.number().int().min(0),
|
|
||||||
/** Timestamp of last review */
|
|
||||||
lastReview: z.date(),
|
|
||||||
/** Scheduled next review date */
|
|
||||||
scheduledDays: z.number().min(0),
|
|
||||||
});
|
|
||||||
export type FSRSState = z.infer<typeof FSRSStateSchema>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Input type for FSRSState (for creating new states)
|
|
||||||
*/
|
|
||||||
export type FSRSStateInput = z.input<typeof FSRSStateSchema>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of a review operation
|
|
||||||
*/
|
|
||||||
export const ReviewResultSchema = z.object({
|
|
||||||
/** Updated FSRS state */
|
|
||||||
state: FSRSStateSchema,
|
|
||||||
/** Calculated retrievability at time of review */
|
|
||||||
retrievability: z.number().min(0).max(1),
|
|
||||||
/** Next review interval in days */
|
|
||||||
interval: z.number().min(0),
|
|
||||||
/** Whether this was a lapse */
|
|
||||||
isLapse: z.boolean(),
|
|
||||||
});
|
|
||||||
export type ReviewResult = z.infer<typeof ReviewResultSchema>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type for the 19-element FSRS weights tuple
|
|
||||||
*/
|
|
||||||
export type FSRSWeightsTuple = readonly [
|
|
||||||
number, number, number, number, number,
|
|
||||||
number, number, number, number, number,
|
|
||||||
number, number, number, number, number,
|
|
||||||
number, number, number, number
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zod schema for FSRS weights
|
|
||||||
*/
|
|
||||||
const FSRSWeightsSchema = z.array(z.number()).length(19);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration for FSRS scheduler
|
|
||||||
*/
|
|
||||||
export const FSRSConfigSchema = z.object({
|
|
||||||
/** Desired retention rate (0.7-0.99) */
|
|
||||||
desiredRetention: z.number().min(0.7).max(0.99).default(0.9),
|
|
||||||
/** Maximum interval in days */
|
|
||||||
maximumInterval: z.number().min(1).max(36500).default(36500),
|
|
||||||
/** Custom weights (must be exactly 19 values) */
|
|
||||||
weights: FSRSWeightsSchema.optional(),
|
|
||||||
/** Enable sentiment boost for emotional memories */
|
|
||||||
enableSentimentBoost: z.boolean().default(true),
|
|
||||||
/** Maximum sentiment boost multiplier (1.0-3.0) */
|
|
||||||
maxSentimentBoost: z.number().min(1).max(3).default(2),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration type for FSRS scheduler
|
|
||||||
*/
|
|
||||||
export interface FSRSConfig {
|
|
||||||
/** Desired retention rate (0.7-0.99) */
|
|
||||||
desiredRetention?: number;
|
|
||||||
/** Maximum interval in days */
|
|
||||||
maximumInterval?: number;
|
|
||||||
/** Custom weights (must be exactly 19 values) */
|
|
||||||
weights?: readonly number[];
|
|
||||||
/** Enable sentiment boost for emotional memories */
|
|
||||||
enableSentimentBoost?: boolean;
|
|
||||||
/** Maximum sentiment boost multiplier (1.0-3.0) */
|
|
||||||
maxSentimentBoost?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolved (required) configuration type
|
|
||||||
*/
|
|
||||||
export interface ResolvedFSRSConfig {
|
|
||||||
desiredRetention: number;
|
|
||||||
maximumInterval: number;
|
|
||||||
weights: readonly number[] | undefined;
|
|
||||||
enableSentimentBoost: boolean;
|
|
||||||
maxSentimentBoost: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CORE FSRS-5 FUNCTIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate initial difficulty for a new card based on first rating
|
|
||||||
*
|
|
||||||
* Formula: D(G) = w[4] - e^(w[5]*(G-1)) + 1
|
|
||||||
*
|
|
||||||
* @param grade - First review grade (1-4)
|
|
||||||
* @param weights - FSRS weights array
|
|
||||||
* @returns Initial difficulty (1-10)
|
|
||||||
*/
|
|
||||||
export function initialDifficulty(
|
|
||||||
grade: ReviewGrade,
|
|
||||||
weights: readonly number[] = FSRS_WEIGHTS
|
|
||||||
): number {
|
|
||||||
const w4 = weights[4] ?? FSRS_WEIGHTS[4];
|
|
||||||
const w5 = weights[5] ?? FSRS_WEIGHTS[5];
|
|
||||||
|
|
||||||
// D(G) = w[4] - e^(w[5]*(G-1)) + 1
|
|
||||||
const d = w4 - Math.exp(w5 * (grade - 1)) + 1;
|
|
||||||
|
|
||||||
// Clamp to valid range
|
|
||||||
return clamp(d, FSRS_CONSTANTS.MIN_DIFFICULTY, FSRS_CONSTANTS.MAX_DIFFICULTY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate initial stability for a new card based on first rating
|
|
||||||
*
|
|
||||||
* Formula: S(G) = w[G-1] (direct lookup from weights 0-3)
|
|
||||||
*
|
|
||||||
* Note: FSRS-5 uses the first 4 weights as initial stability values
|
|
||||||
* for grades 1-4 respectively.
|
|
||||||
*
|
|
||||||
* @param grade - First review grade (1-4)
|
|
||||||
* @param weights - FSRS weights array
|
|
||||||
* @returns Initial stability in days
|
|
||||||
*/
|
|
||||||
export function initialStability(
|
|
||||||
grade: ReviewGrade,
|
|
||||||
weights: readonly number[] = FSRS_WEIGHTS
|
|
||||||
): number {
|
|
||||||
// FSRS-5: S0(G) = w[G-1]
|
|
||||||
// Grade is 1-4, so index is 0-3, which is always valid for FSRS_WEIGHTS
|
|
||||||
const index = grade - 1;
|
|
||||||
const s = weights[index] ?? FSRS_WEIGHTS[index] ?? FSRS_WEIGHTS[0];
|
|
||||||
|
|
||||||
// Ensure minimum stability
|
|
||||||
return Math.max(FSRS_CONSTANTS.MIN_STABILITY, s);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate retrievability (probability of recall) based on stability and elapsed time
|
|
||||||
*
|
|
||||||
* Formula: R = e^(-t/S) where FSRS uses (1 + t/(9*S))^(-1)
|
|
||||||
*
|
|
||||||
* This is the power forgetting curve used in FSRS-5.
|
|
||||||
*
|
|
||||||
* @param stability - Current stability in days
|
|
||||||
* @param elapsedDays - Days since last review
|
|
||||||
* @returns Retrievability (0-1)
|
|
||||||
*/
|
|
||||||
export function retrievability(stability: number, elapsedDays: number): number {
|
|
||||||
if (stability <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elapsedDays <= 0) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// FSRS-5 power forgetting curve: R = (1 + t/(9*S))^(-1)
|
|
||||||
// This is equivalent to the power law of forgetting
|
|
||||||
const factor = 9 * stability;
|
|
||||||
const r = Math.pow(1 + elapsedDays / factor, -1);
|
|
||||||
|
|
||||||
return clamp(r, 0, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate next difficulty after a review
|
|
||||||
*
|
|
||||||
* Formula: D' = w[7] * D + (1 - w[7]) * mean_reversion(D, G)
|
|
||||||
* where mean_reversion uses a linear combination with the initial difficulty
|
|
||||||
*
|
|
||||||
* FSRS-5 mean reversion formula:
|
|
||||||
* D' = D - w[6] * (G - 3)
|
|
||||||
* Then: D' = w[7] * D0 + (1 - w[7]) * D'
|
|
||||||
*
|
|
||||||
* @param currentD - Current difficulty (1-10)
|
|
||||||
* @param grade - Review grade (1-4)
|
|
||||||
* @param weights - FSRS weights array
|
|
||||||
* @returns New difficulty (1-10)
|
|
||||||
*/
|
|
||||||
export function nextDifficulty(
|
|
||||||
currentD: number,
|
|
||||||
grade: ReviewGrade,
|
|
||||||
weights: readonly number[] = FSRS_WEIGHTS
|
|
||||||
): number {
|
|
||||||
const w6 = weights[6] ?? FSRS_WEIGHTS[6];
|
|
||||||
const w7 = weights[7] ?? FSRS_WEIGHTS[7];
|
|
||||||
|
|
||||||
// Initial difficulty for mean reversion (what D would be for a "Good" rating)
|
|
||||||
const d0 = initialDifficulty(Grade.Good, weights);
|
|
||||||
|
|
||||||
// Delta based on grade deviation from "Good" (3)
|
|
||||||
// Negative grade (Again=1, Hard=2) increases difficulty
|
|
||||||
// Positive grade (Easy=4) decreases difficulty
|
|
||||||
const delta = -w6 * (grade - 3);
|
|
||||||
|
|
||||||
// Apply delta to current difficulty
|
|
||||||
let newD = currentD + delta;
|
|
||||||
|
|
||||||
// Mean reversion: blend towards initial difficulty
|
|
||||||
newD = w7 * d0 + (1 - w7) * newD;
|
|
||||||
|
|
||||||
return clamp(newD, FSRS_CONSTANTS.MIN_DIFFICULTY, FSRS_CONSTANTS.MAX_DIFFICULTY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate next stability after a successful recall
|
|
||||||
*
|
|
||||||
* FSRS-5 recall stability formula:
|
|
||||||
* S'(r) = S * (e^(w[8]) * (11 - D) * S^(-w[9]) * (e^(w[10]*(1-R)) - 1) * hardPenalty * easyBonus + 1)
|
|
||||||
*
|
|
||||||
* This is the full FSRS-5 stability increase formula that accounts for:
|
|
||||||
* - Current stability (S)
|
|
||||||
* - Difficulty (D)
|
|
||||||
* - Retrievability at time of review (R)
|
|
||||||
* - Hard penalty for grade 2
|
|
||||||
* - Easy bonus for grade 4
|
|
||||||
*
|
|
||||||
* @param currentS - Current stability in days
|
|
||||||
* @param difficulty - Current difficulty (1-10)
|
|
||||||
* @param retrievabilityR - Retrievability at time of review (0-1)
|
|
||||||
* @param grade - Review grade (2, 3, or 4 - not 1, which is a lapse)
|
|
||||||
* @param weights - FSRS weights array
|
|
||||||
* @returns New stability in days
|
|
||||||
*/
|
|
||||||
export function nextRecallStability(
|
|
||||||
currentS: number,
|
|
||||||
difficulty: number,
|
|
||||||
retrievabilityR: number,
|
|
||||||
grade: ReviewGrade,
|
|
||||||
weights: readonly number[] = FSRS_WEIGHTS
|
|
||||||
): number {
|
|
||||||
if (grade === Grade.Again) {
|
|
||||||
// Lapse - use forget stability instead
|
|
||||||
return nextForgetStability(difficulty, currentS, retrievabilityR, weights);
|
|
||||||
}
|
|
||||||
|
|
||||||
const w8 = weights[8] ?? FSRS_WEIGHTS[8];
|
|
||||||
const w9 = weights[9] ?? FSRS_WEIGHTS[9];
|
|
||||||
const w10 = weights[10] ?? FSRS_WEIGHTS[10];
|
|
||||||
const w15 = weights[15] ?? FSRS_WEIGHTS[15];
|
|
||||||
const w16 = weights[16] ?? FSRS_WEIGHTS[16];
|
|
||||||
|
|
||||||
// Hard penalty (grade = 2)
|
|
||||||
const hardPenalty = grade === Grade.Hard ? w15 : 1;
|
|
||||||
|
|
||||||
// Easy bonus (grade = 4)
|
|
||||||
const easyBonus = grade === Grade.Easy ? w16 : 1;
|
|
||||||
|
|
||||||
// FSRS-5 recall stability formula
|
|
||||||
// S'(r) = S * (e^(w8) * (11 - D) * S^(-w9) * (e^(w10*(1-R)) - 1) * hardPenalty * easyBonus + 1)
|
|
||||||
const factor =
|
|
||||||
Math.exp(w8) *
|
|
||||||
(11 - difficulty) *
|
|
||||||
Math.pow(currentS, -w9) *
|
|
||||||
(Math.exp(w10 * (1 - retrievabilityR)) - 1) *
|
|
||||||
hardPenalty *
|
|
||||||
easyBonus +
|
|
||||||
1;
|
|
||||||
|
|
||||||
const newS = currentS * factor;
|
|
||||||
|
|
||||||
return clamp(newS, FSRS_CONSTANTS.MIN_STABILITY, FSRS_CONSTANTS.MAX_STABILITY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate stability after a lapse (forgotten/Again rating)
|
|
||||||
*
|
|
||||||
* FSRS-5 forget stability formula:
|
|
||||||
* S'(f) = w[11] * D^(-w[12]) * ((S+1)^w[13] - 1) * e^(w[14]*(1-R))
|
|
||||||
*
|
|
||||||
* This calculates the new stability after forgetting, which is typically
|
|
||||||
* much lower than the previous stability but not zero (some memory trace remains).
|
|
||||||
*
|
|
||||||
* @param difficulty - Current difficulty (1-10)
|
|
||||||
* @param currentS - Current stability before lapse
|
|
||||||
* @param retrievabilityR - Retrievability at time of review
|
|
||||||
* @param weights - FSRS weights array
|
|
||||||
* @returns New stability after lapse in days
|
|
||||||
*/
|
|
||||||
export function nextForgetStability(
|
|
||||||
difficulty: number,
|
|
||||||
currentS: number,
|
|
||||||
retrievabilityR: number = 0.5,
|
|
||||||
weights: readonly number[] = FSRS_WEIGHTS
|
|
||||||
): number {
|
|
||||||
const w11 = weights[11] ?? FSRS_WEIGHTS[11];
|
|
||||||
const w12 = weights[12] ?? FSRS_WEIGHTS[12];
|
|
||||||
const w13 = weights[13] ?? FSRS_WEIGHTS[13];
|
|
||||||
const w14 = weights[14] ?? FSRS_WEIGHTS[14];
|
|
||||||
|
|
||||||
// S'(f) = w11 * D^(-w12) * ((S+1)^w13 - 1) * e^(w14*(1-R))
|
|
||||||
const newS =
|
|
||||||
w11 *
|
|
||||||
Math.pow(difficulty, -w12) *
|
|
||||||
(Math.pow(currentS + 1, w13) - 1) *
|
|
||||||
Math.exp(w14 * (1 - retrievabilityR));
|
|
||||||
|
|
||||||
return clamp(newS, FSRS_CONSTANTS.MIN_STABILITY, FSRS_CONSTANTS.MAX_STABILITY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate next review interval based on stability and desired retention
|
|
||||||
*
|
|
||||||
* Formula: I = S * ln(R) / ln(0.9) where we solve for t when R = desired_retention
|
|
||||||
* Using the power forgetting curve: I = 9 * S * (1/R - 1)
|
|
||||||
*
|
|
||||||
* @param stability - Current stability in days
|
|
||||||
* @param desiredRetention - Target retention rate (default 0.9)
|
|
||||||
* @returns Interval in days until next review
|
|
||||||
*/
|
|
||||||
export function nextInterval(
|
|
||||||
stability: number,
|
|
||||||
desiredRetention: number = FSRS_CONSTANTS.DEFAULT_RETENTION
|
|
||||||
): number {
|
|
||||||
if (stability <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (desiredRetention >= 1) {
|
|
||||||
return 0; // If we want 100% retention, review immediately
|
|
||||||
}
|
|
||||||
|
|
||||||
if (desiredRetention <= 0) {
|
|
||||||
return FSRS_CONSTANTS.MAX_STABILITY; // If we don't care about retention
|
|
||||||
}
|
|
||||||
|
|
||||||
// Solve for t in: R = (1 + t/(9*S))^(-1)
|
|
||||||
// t = 9 * S * (R^(-1) - 1)
|
|
||||||
const interval = 9 * stability * (Math.pow(desiredRetention, -1) - 1);
|
|
||||||
|
|
||||||
return Math.max(0, Math.round(interval));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply sentiment boost to stability
|
|
||||||
*
|
|
||||||
* Emotional memories are encoded more strongly and decay more slowly.
|
|
||||||
* This function applies a multiplier to stability based on sentiment intensity.
|
|
||||||
*
|
|
||||||
* @param stability - Base stability in days
|
|
||||||
* @param sentimentIntensity - Sentiment intensity (0-1, where 1 = highly emotional)
|
|
||||||
* @param maxBoost - Maximum boost multiplier (default 2.0)
|
|
||||||
* @returns Boosted stability in days
|
|
||||||
*/
|
|
||||||
export function applySentimentBoost(
|
|
||||||
stability: number,
|
|
||||||
sentimentIntensity: number,
|
|
||||||
maxBoost: number = 2.0
|
|
||||||
): number {
|
|
||||||
// Validate inputs
|
|
||||||
const clampedSentiment = clamp(sentimentIntensity, 0, 1);
|
|
||||||
const clampedMaxBoost = clamp(maxBoost, 1, 3);
|
|
||||||
|
|
||||||
// Linear interpolation: boost = 1 + (maxBoost - 1) * sentimentIntensity
|
|
||||||
const boost = 1 + (clampedMaxBoost - 1) * clampedSentiment;
|
|
||||||
|
|
||||||
return stability * boost;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// FSRS SCHEDULER CLASS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FSRSScheduler - Main class for FSRS-5 spaced repetition scheduling
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* ```typescript
|
|
||||||
* const scheduler = new FSRSScheduler();
|
|
||||||
*
|
|
||||||
* // Create initial state for a new card
|
|
||||||
* const state = scheduler.newCard();
|
|
||||||
*
|
|
||||||
* // Process a review
|
|
||||||
* const result = scheduler.review(state, Grade.Good, 1);
|
|
||||||
*
|
|
||||||
* // Get the next review date
|
|
||||||
* const nextReview = new Date();
|
|
||||||
* nextReview.setDate(nextReview.getDate() + result.interval);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class FSRSScheduler {
|
|
||||||
private readonly config: ResolvedFSRSConfig;
|
|
||||||
private readonly weights: readonly number[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new FSRS scheduler
|
|
||||||
*
|
|
||||||
* @param config - Optional configuration overrides
|
|
||||||
*/
|
|
||||||
constructor(config: FSRSConfig = {}) {
|
|
||||||
const parsed = FSRSConfigSchema.parse({
|
|
||||||
desiredRetention: config.desiredRetention ?? 0.9,
|
|
||||||
maximumInterval: config.maximumInterval ?? 36500,
|
|
||||||
weights: config.weights ? [...config.weights] : undefined,
|
|
||||||
enableSentimentBoost: config.enableSentimentBoost ?? true,
|
|
||||||
maxSentimentBoost: config.maxSentimentBoost ?? 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract weights as a readonly number array (or undefined)
|
|
||||||
const parsedWeights: readonly number[] | undefined = parsed.weights
|
|
||||||
? [...parsed.weights]
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
this.config = {
|
|
||||||
desiredRetention: parsed.desiredRetention ?? 0.9,
|
|
||||||
maximumInterval: parsed.maximumInterval ?? 36500,
|
|
||||||
weights: parsedWeights,
|
|
||||||
enableSentimentBoost: parsed.enableSentimentBoost ?? true,
|
|
||||||
maxSentimentBoost: parsed.maxSentimentBoost ?? 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.weights = this.config.weights ?? FSRS_WEIGHTS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create initial state for a new card
|
|
||||||
*
|
|
||||||
* @returns Initial FSRS state
|
|
||||||
*/
|
|
||||||
newCard(): FSRSState {
|
|
||||||
return {
|
|
||||||
difficulty: initialDifficulty(Grade.Good, this.weights),
|
|
||||||
stability: initialStability(Grade.Good, this.weights),
|
|
||||||
state: 'New',
|
|
||||||
reps: 0,
|
|
||||||
lapses: 0,
|
|
||||||
lastReview: new Date(),
|
|
||||||
scheduledDays: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a review and calculate next state
|
|
||||||
*
|
|
||||||
* @param currentState - Current FSRS state
|
|
||||||
* @param grade - Review grade (1-4)
|
|
||||||
* @param elapsedDays - Days since last review (0 for first review)
|
|
||||||
* @param sentimentBoost - Optional sentiment intensity for emotional memories (0-1)
|
|
||||||
* @returns Review result with updated state and next interval
|
|
||||||
*/
|
|
||||||
review(
|
|
||||||
currentState: FSRSState,
|
|
||||||
grade: ReviewGrade,
|
|
||||||
elapsedDays: number = 0,
|
|
||||||
sentimentBoost?: number
|
|
||||||
): ReviewResult {
|
|
||||||
// Validate grade
|
|
||||||
const validatedGrade = ReviewGradeSchema.parse(grade);
|
|
||||||
|
|
||||||
// Calculate retrievability at time of review
|
|
||||||
const r = currentState.state === 'New'
|
|
||||||
? 1
|
|
||||||
: retrievability(currentState.stability, Math.max(0, elapsedDays));
|
|
||||||
|
|
||||||
let newState: FSRSState;
|
|
||||||
let isLapse = false;
|
|
||||||
|
|
||||||
if (currentState.state === 'New') {
|
|
||||||
// First review - initialize based on grade
|
|
||||||
newState = this.handleFirstReview(currentState, validatedGrade);
|
|
||||||
} else if (validatedGrade === Grade.Again) {
|
|
||||||
// Lapse - memory failed
|
|
||||||
isLapse = currentState.state === 'Review' || currentState.state === 'Relearning';
|
|
||||||
newState = this.handleLapse(currentState, r);
|
|
||||||
} else {
|
|
||||||
// Successful recall
|
|
||||||
newState = this.handleRecall(currentState, validatedGrade, r);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply sentiment boost if enabled and provided
|
|
||||||
if (
|
|
||||||
this.config.enableSentimentBoost &&
|
|
||||||
sentimentBoost !== undefined &&
|
|
||||||
sentimentBoost > 0
|
|
||||||
) {
|
|
||||||
newState.stability = applySentimentBoost(
|
|
||||||
newState.stability,
|
|
||||||
sentimentBoost,
|
|
||||||
this.config.maxSentimentBoost
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate next interval
|
|
||||||
const interval = Math.min(
|
|
||||||
nextInterval(newState.stability, this.config.desiredRetention),
|
|
||||||
this.config.maximumInterval
|
|
||||||
);
|
|
||||||
|
|
||||||
newState.scheduledDays = interval;
|
|
||||||
newState.lastReview = new Date();
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: newState,
|
|
||||||
retrievability: r,
|
|
||||||
interval,
|
|
||||||
isLapse,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle first review of a new card
|
|
||||||
*/
|
|
||||||
private handleFirstReview(currentState: FSRSState, grade: ReviewGrade): FSRSState {
|
|
||||||
const d = initialDifficulty(grade, this.weights);
|
|
||||||
const s = initialStability(grade, this.weights);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...currentState,
|
|
||||||
difficulty: d,
|
|
||||||
stability: s,
|
|
||||||
state: grade === Grade.Again ? 'Learning' : grade === Grade.Hard ? 'Learning' : 'Review',
|
|
||||||
reps: 1,
|
|
||||||
lapses: grade === Grade.Again ? 1 : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a lapse (Again rating)
|
|
||||||
*/
|
|
||||||
private handleLapse(currentState: FSRSState, retrievabilityR: number): FSRSState {
|
|
||||||
const newS = nextForgetStability(
|
|
||||||
currentState.difficulty,
|
|
||||||
currentState.stability,
|
|
||||||
retrievabilityR,
|
|
||||||
this.weights
|
|
||||||
);
|
|
||||||
|
|
||||||
// Difficulty increases on lapse
|
|
||||||
const newD = nextDifficulty(currentState.difficulty, Grade.Again, this.weights);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...currentState,
|
|
||||||
difficulty: newD,
|
|
||||||
stability: newS,
|
|
||||||
state: 'Relearning',
|
|
||||||
reps: currentState.reps + 1,
|
|
||||||
lapses: currentState.lapses + 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a successful recall (Hard, Good, or Easy)
|
|
||||||
*/
|
|
||||||
private handleRecall(
|
|
||||||
currentState: FSRSState,
|
|
||||||
grade: ReviewGrade,
|
|
||||||
retrievabilityR: number
|
|
||||||
): FSRSState {
|
|
||||||
const newS = nextRecallStability(
|
|
||||||
currentState.stability,
|
|
||||||
currentState.difficulty,
|
|
||||||
retrievabilityR,
|
|
||||||
grade,
|
|
||||||
this.weights
|
|
||||||
);
|
|
||||||
|
|
||||||
const newD = nextDifficulty(currentState.difficulty, grade, this.weights);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...currentState,
|
|
||||||
difficulty: newD,
|
|
||||||
stability: newS,
|
|
||||||
state: 'Review',
|
|
||||||
reps: currentState.reps + 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current retrievability for a state
|
|
||||||
*
|
|
||||||
* @param state - FSRS state
|
|
||||||
* @param elapsedDays - Days since last review (optional, calculated from lastReview if not provided)
|
|
||||||
* @returns Current retrievability (0-1)
|
|
||||||
*/
|
|
||||||
getRetrievability(state: FSRSState, elapsedDays?: number): number {
|
|
||||||
const days = elapsedDays ?? this.daysSinceReview(state.lastReview);
|
|
||||||
return retrievability(state.stability, days);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preview all possible review outcomes without modifying state
|
|
||||||
*
|
|
||||||
* @param state - Current FSRS state
|
|
||||||
* @param elapsedDays - Days since last review
|
|
||||||
* @returns Object with results for each grade
|
|
||||||
*/
|
|
||||||
previewReviews(
|
|
||||||
state: FSRSState,
|
|
||||||
elapsedDays: number = 0
|
|
||||||
): Record<'again' | 'hard' | 'good' | 'easy', ReviewResult> {
|
|
||||||
return {
|
|
||||||
again: this.review({ ...state }, Grade.Again, elapsedDays),
|
|
||||||
hard: this.review({ ...state }, Grade.Hard, elapsedDays),
|
|
||||||
good: this.review({ ...state }, Grade.Good, elapsedDays),
|
|
||||||
easy: this.review({ ...state }, Grade.Easy, elapsedDays),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate days since a review date
|
|
||||||
*/
|
|
||||||
private daysSinceReview(lastReview: Date): number {
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - lastReview.getTime();
|
|
||||||
return Math.max(0, diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get scheduler configuration
|
|
||||||
*/
|
|
||||||
getConfig(): Readonly<ResolvedFSRSConfig> {
|
|
||||||
return { ...this.config };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get scheduler weights
|
|
||||||
*/
|
|
||||||
getWeights(): readonly number[] {
|
|
||||||
return [...this.weights];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// UTILITY FUNCTIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clamp a value between min and max
|
|
||||||
*/
|
|
||||||
function clamp(value: number, min: number, max: number): number {
|
|
||||||
return Math.max(min, Math.min(max, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert FSRSState to a JSON-serializable format
|
|
||||||
*/
|
|
||||||
export function serializeFSRSState(state: FSRSState): string {
|
|
||||||
return JSON.stringify({
|
|
||||||
...state,
|
|
||||||
lastReview: state.lastReview.toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a serialized FSRSState from JSON
|
|
||||||
*/
|
|
||||||
export function deserializeFSRSState(json: string): FSRSState {
|
|
||||||
const parsed = JSON.parse(json) as Record<string, unknown>;
|
|
||||||
|
|
||||||
return FSRSStateSchema.parse({
|
|
||||||
...parsed,
|
|
||||||
lastReview: new Date(parsed['lastReview'] as string),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate optimal review time based on forgetting index
|
|
||||||
*
|
|
||||||
* @param state - Current FSRS state
|
|
||||||
* @param targetRetention - Target retention rate at review time (default 0.9)
|
|
||||||
* @returns Days until optimal review
|
|
||||||
*/
|
|
||||||
export function optimalReviewTime(
|
|
||||||
state: FSRSState,
|
|
||||||
targetRetention: number = FSRS_CONSTANTS.DEFAULT_RETENTION
|
|
||||||
): number {
|
|
||||||
return nextInterval(state.stability, targetRetention);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if a review is due
|
|
||||||
*
|
|
||||||
* @param state - Current FSRS state
|
|
||||||
* @param currentRetention - Optional minimum retention threshold (default: use scheduledDays)
|
|
||||||
* @returns True if review is due
|
|
||||||
*/
|
|
||||||
export function isReviewDue(state: FSRSState, currentRetention?: number): boolean {
|
|
||||||
const daysSinceReview =
|
|
||||||
(new Date().getTime() - state.lastReview.getTime()) / (1000 * 60 * 60 * 24);
|
|
||||||
|
|
||||||
if (currentRetention !== undefined) {
|
|
||||||
const r = retrievability(state.stability, daysSinceReview);
|
|
||||||
return r < currentRetention;
|
|
||||||
}
|
|
||||||
|
|
||||||
return daysSinceReview >= state.scheduledDays;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// EXPORTS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export default FSRSScheduler;
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
export * from './config.js';
|
|
||||||
export * from './types.js';
|
|
||||||
export * from './errors.js';
|
|
||||||
export * from './database.js';
|
|
||||||
export * from './context-watcher.js';
|
|
||||||
export * from './rem-cycle.js';
|
|
||||||
export * from './consolidation.js';
|
|
||||||
export * from './shadow-self.js';
|
|
||||||
export * from './security.js';
|
|
||||||
export * from './vector-store.js';
|
|
||||||
export * from './fsrs.js';
|
|
||||||
export * from './embeddings.js';
|
|
||||||
|
|
@ -1,721 +0,0 @@
|
||||||
/**
|
|
||||||
* REM Cycle - Nocturnal Optimization with Semantic Understanding
|
|
||||||
*
|
|
||||||
* "The brain that dreams while you sleep."
|
|
||||||
*
|
|
||||||
* This module discovers connections between unconnected knowledge nodes
|
|
||||||
* by analyzing semantic similarity, shared concepts, keyword overlap,
|
|
||||||
* emotional resonance, and spreading activation patterns.
|
|
||||||
*
|
|
||||||
* KEY FEATURES:
|
|
||||||
* 1. Semantic Similarity - Uses embeddings for deep understanding
|
|
||||||
* 2. Emotional Weighting - Emotionally charged memories create stronger connections
|
|
||||||
* 3. Spreading Activation - Discovers transitive relationships (A->B->C implies A~C)
|
|
||||||
* 4. Reconsolidation - Accessing memories strengthens their connections
|
|
||||||
* 5. Exponential Temporal Proximity - Time-based connection strength decay
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { VestigeDatabase } from './database.js';
|
|
||||||
import type { KnowledgeNode } from './types.js';
|
|
||||||
import natural from 'natural';
|
|
||||||
import {
|
|
||||||
createEmbeddingService,
|
|
||||||
type EmbeddingService,
|
|
||||||
EmbeddingCache,
|
|
||||||
cosineSimilarity,
|
|
||||||
} from './embeddings.js';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
type ConnectionType =
|
|
||||||
| 'concept_overlap'
|
|
||||||
| 'keyword_similarity'
|
|
||||||
| 'entity_shared'
|
|
||||||
| 'temporal_proximity'
|
|
||||||
| 'semantic_similarity'
|
|
||||||
| 'spreading_activation';
|
|
||||||
|
|
||||||
interface DiscoveredConnection {
|
|
||||||
nodeA: KnowledgeNode;
|
|
||||||
nodeB: KnowledgeNode;
|
|
||||||
reason: string;
|
|
||||||
strength: number; // 0-1
|
|
||||||
connectionType: ConnectionType;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface REMCycleResult {
|
|
||||||
nodesAnalyzed: number;
|
|
||||||
connectionsDiscovered: number;
|
|
||||||
connectionsCreated: number;
|
|
||||||
spreadingActivationEdges: number;
|
|
||||||
reconsolidatedNodes: number;
|
|
||||||
duration: number;
|
|
||||||
semanticEnabled: boolean;
|
|
||||||
discoveries: Array<{
|
|
||||||
nodeA: string;
|
|
||||||
nodeB: string;
|
|
||||||
reason: string;
|
|
||||||
type: ConnectionType;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface REMCycleOptions {
|
|
||||||
maxAnalyze?: number;
|
|
||||||
minStrength?: number;
|
|
||||||
dryRun?: boolean;
|
|
||||||
/** Enable semantic similarity analysis (requires Ollama) */
|
|
||||||
enableSemantic?: boolean;
|
|
||||||
/** Run spreading activation to discover transitive connections */
|
|
||||||
enableSpreadingActivation?: boolean;
|
|
||||||
/** Maximum depth for spreading activation */
|
|
||||||
spreadingActivationDepth?: number;
|
|
||||||
/** Node IDs that were recently accessed (for reconsolidation) */
|
|
||||||
recentlyAccessedIds?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONSTANTS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/** Temporal half-life in days for exponential proximity decay */
|
|
||||||
const TEMPORAL_HALF_LIFE_DAYS = 7;
|
|
||||||
|
|
||||||
/** Semantic similarity thresholds */
|
|
||||||
const SEMANTIC_STRONG_THRESHOLD = 0.7;
|
|
||||||
const SEMANTIC_MODERATE_THRESHOLD = 0.5;
|
|
||||||
|
|
||||||
/** Weight decay for spreading activation (per hop) */
|
|
||||||
const SPREADING_ACTIVATION_DECAY = 0.8;
|
|
||||||
|
|
||||||
/** Reconsolidation strength boost (5%) */
|
|
||||||
const RECONSOLIDATION_BOOST = 0.05;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SIMILARITY ANALYSIS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const tokenizer = new natural.WordTokenizer();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract keywords from content using TF-IDF
|
|
||||||
*/
|
|
||||||
function extractKeywords(content: string): string[] {
|
|
||||||
const tokens = tokenizer.tokenize(content.toLowerCase()) || [];
|
|
||||||
|
|
||||||
// Filter out common stop words and short tokens
|
|
||||||
const stopWords = new Set([
|
|
||||||
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
|
||||||
'of', 'with', 'by', 'from', 'as', 'is', 'was', 'are', 'were', 'been',
|
|
||||||
'be', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
|
|
||||||
'should', 'may', 'might', 'must', 'shall', 'can', 'need', 'dare', 'ought',
|
|
||||||
'used', 'it', 'its', 'this', 'that', 'these', 'those', 'i', 'you', 'he',
|
|
||||||
'she', 'we', 'they', 'what', 'which', 'who', 'whom', 'whose', 'where',
|
|
||||||
'when', 'why', 'how', 'all', 'each', 'every', 'both', 'few', 'more',
|
|
||||||
'most', 'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own',
|
|
||||||
'same', 'so', 'than', 'too', 'very', 'just', 'also', 'now', 'here',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return tokens.filter(token =>
|
|
||||||
token.length > 3 &&
|
|
||||||
!stopWords.has(token) &&
|
|
||||||
!/^\d+$/.test(token) // Filter pure numbers
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate Jaccard similarity between two keyword sets
|
|
||||||
*/
|
|
||||||
function jaccardSimilarity(setA: Set<string>, setB: Set<string>): number {
|
|
||||||
const intersection = new Set([...setA].filter(x => setB.has(x)));
|
|
||||||
const union = new Set([...setA, ...setB]);
|
|
||||||
|
|
||||||
if (union.size === 0) return 0;
|
|
||||||
return intersection.size / union.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find shared concepts between two nodes
|
|
||||||
*/
|
|
||||||
function findSharedConcepts(nodeA: KnowledgeNode, nodeB: KnowledgeNode): string[] {
|
|
||||||
const conceptsA = new Set([...nodeA.concepts, ...nodeA.tags]);
|
|
||||||
const conceptsB = new Set([...nodeB.concepts, ...nodeB.tags]);
|
|
||||||
|
|
||||||
return [...conceptsA].filter(c => conceptsB.has(c));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find shared people between two nodes
|
|
||||||
*/
|
|
||||||
function findSharedPeople(nodeA: KnowledgeNode, nodeB: KnowledgeNode): string[] {
|
|
||||||
const peopleA = new Set(nodeA.people);
|
|
||||||
const peopleB = new Set(nodeB.people);
|
|
||||||
|
|
||||||
return [...peopleA].filter(p => peopleB.has(p));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate exponential temporal proximity weight
|
|
||||||
* Uses half-life decay instead of binary same-day check
|
|
||||||
*/
|
|
||||||
function calculateTemporalProximity(nodeA: KnowledgeNode, nodeB: KnowledgeNode): number {
|
|
||||||
const msPerDay = 24 * 60 * 60 * 1000;
|
|
||||||
const diffMs = Math.abs(nodeA.createdAt.getTime() - nodeB.createdAt.getTime());
|
|
||||||
const daysBetween = diffMs / msPerDay;
|
|
||||||
|
|
||||||
// Exponential decay: weight = e^(-t/half_life)
|
|
||||||
// At t=0: weight = 1.0
|
|
||||||
// At t=half_life: weight = 0.5
|
|
||||||
// At t=2*half_life: weight = 0.25
|
|
||||||
return Math.exp(-daysBetween / TEMPORAL_HALF_LIFE_DAYS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate emotional resonance between two nodes
|
|
||||||
* Returns a boost multiplier (1.0 to 1.5) based on combined emotional intensity
|
|
||||||
*/
|
|
||||||
function calculateEmotionalBoost(nodeA: KnowledgeNode, nodeB: KnowledgeNode): number {
|
|
||||||
const emotionalA = nodeA.sentimentIntensity || 0;
|
|
||||||
const emotionalB = nodeB.sentimentIntensity || 0;
|
|
||||||
|
|
||||||
// Average emotional intensity
|
|
||||||
const emotionalResonance = (emotionalA + emotionalB) / 2;
|
|
||||||
|
|
||||||
// Up to 1.5x boost for highly emotional content
|
|
||||||
return 1 + (emotionalResonance * 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SEMANTIC ANALYSIS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze semantic connection between two nodes using embeddings
|
|
||||||
*/
|
|
||||||
async function analyzeSemanticConnection(
|
|
||||||
nodeA: KnowledgeNode,
|
|
||||||
nodeB: KnowledgeNode,
|
|
||||||
embeddingService: EmbeddingService,
|
|
||||||
cache: EmbeddingCache
|
|
||||||
): Promise<DiscoveredConnection | null> {
|
|
||||||
try {
|
|
||||||
// Get or generate embeddings
|
|
||||||
let embeddingA = cache.get(nodeA.id);
|
|
||||||
let embeddingB = cache.get(nodeB.id);
|
|
||||||
|
|
||||||
// Generate missing embeddings
|
|
||||||
if (!embeddingA) {
|
|
||||||
embeddingA = await embeddingService.generateEmbedding(nodeA.content);
|
|
||||||
cache.set(nodeA.id, embeddingA);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!embeddingB) {
|
|
||||||
embeddingB = await embeddingService.generateEmbedding(nodeB.content);
|
|
||||||
cache.set(nodeB.id, embeddingB);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate cosine similarity
|
|
||||||
const similarity = cosineSimilarity(embeddingA, embeddingB);
|
|
||||||
|
|
||||||
// Apply emotional boost
|
|
||||||
const emotionalBoost = calculateEmotionalBoost(nodeA, nodeB);
|
|
||||||
const boostedSimilarity = Math.min(1, similarity * emotionalBoost);
|
|
||||||
|
|
||||||
// Strong semantic connection
|
|
||||||
if (similarity >= SEMANTIC_STRONG_THRESHOLD) {
|
|
||||||
return {
|
|
||||||
nodeA,
|
|
||||||
nodeB,
|
|
||||||
reason: `Strong semantic similarity (${(similarity * 100).toFixed(0)}%)`,
|
|
||||||
strength: Math.min(1, boostedSimilarity + 0.2), // Boost for strong connections
|
|
||||||
connectionType: 'semantic_similarity',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Moderate semantic connection
|
|
||||||
if (similarity >= SEMANTIC_MODERATE_THRESHOLD) {
|
|
||||||
return {
|
|
||||||
nodeA,
|
|
||||||
nodeB,
|
|
||||||
reason: `Moderate semantic similarity (${(similarity * 100).toFixed(0)}%)`,
|
|
||||||
strength: boostedSimilarity,
|
|
||||||
connectionType: 'semantic_similarity',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
// If embedding fails, return null to fall back to traditional analysis
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TRADITIONAL ANALYSIS (FALLBACK)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze potential connection between two nodes using traditional methods
|
|
||||||
* Used as fallback when embeddings are unavailable
|
|
||||||
*/
|
|
||||||
function analyzeTraditionalConnection(
|
|
||||||
nodeA: KnowledgeNode,
|
|
||||||
nodeB: KnowledgeNode
|
|
||||||
): DiscoveredConnection | null {
|
|
||||||
// Extract keywords from both nodes
|
|
||||||
const keywordsA = new Set(extractKeywords(nodeA.content));
|
|
||||||
const keywordsB = new Set(extractKeywords(nodeB.content));
|
|
||||||
|
|
||||||
// Calculate keyword similarity
|
|
||||||
const keywordSim = jaccardSimilarity(keywordsA, keywordsB);
|
|
||||||
|
|
||||||
// Find shared concepts/tags
|
|
||||||
const sharedConcepts = findSharedConcepts(nodeA, nodeB);
|
|
||||||
|
|
||||||
// Find shared people
|
|
||||||
const sharedPeople = findSharedPeople(nodeA, nodeB);
|
|
||||||
|
|
||||||
// Calculate temporal proximity weight
|
|
||||||
const temporalWeight = calculateTemporalProximity(nodeA, nodeB);
|
|
||||||
|
|
||||||
// Calculate emotional boost
|
|
||||||
const emotionalBoost = calculateEmotionalBoost(nodeA, nodeB);
|
|
||||||
|
|
||||||
// Determine if there's a meaningful connection
|
|
||||||
// Priority: shared entities > concept overlap > keyword similarity > temporal
|
|
||||||
|
|
||||||
if (sharedPeople.length > 0) {
|
|
||||||
const baseStrength = Math.min(1, 0.5 + sharedPeople.length * 0.2);
|
|
||||||
return {
|
|
||||||
nodeA,
|
|
||||||
nodeB,
|
|
||||||
reason: `Shared people: ${sharedPeople.join(', ')}`,
|
|
||||||
strength: Math.min(1, baseStrength * emotionalBoost),
|
|
||||||
connectionType: 'entity_shared',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sharedConcepts.length >= 2) {
|
|
||||||
const baseStrength = Math.min(1, 0.4 + sharedConcepts.length * 0.15);
|
|
||||||
return {
|
|
||||||
nodeA,
|
|
||||||
nodeB,
|
|
||||||
reason: `Shared concepts: ${sharedConcepts.slice(0, 3).join(', ')}`,
|
|
||||||
strength: Math.min(1, baseStrength * emotionalBoost),
|
|
||||||
connectionType: 'concept_overlap',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keywordSim > 0.15) {
|
|
||||||
// Find the actual overlapping keywords
|
|
||||||
const overlap = [...keywordsA].filter(k => keywordsB.has(k)).slice(0, 5);
|
|
||||||
const baseStrength = Math.min(1, keywordSim * 2);
|
|
||||||
return {
|
|
||||||
nodeA,
|
|
||||||
nodeB,
|
|
||||||
reason: `Keyword overlap (${(keywordSim * 100).toFixed(0)}%): ${overlap.join(', ')}`,
|
|
||||||
strength: Math.min(1, baseStrength * emotionalBoost),
|
|
||||||
connectionType: 'keyword_similarity',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Temporal proximity with related content
|
|
||||||
if (temporalWeight > 0.5 && (sharedConcepts.length > 0 || keywordSim > 0.05)) {
|
|
||||||
const baseStrength = 0.3 + (temporalWeight - 0.5) * 0.4; // Scale 0.3-0.5
|
|
||||||
return {
|
|
||||||
nodeA,
|
|
||||||
nodeB,
|
|
||||||
reason: `Created ${Math.round((1 - temporalWeight) * TEMPORAL_HALF_LIFE_DAYS * 2)} days apart with related content`,
|
|
||||||
strength: Math.min(1, baseStrength * emotionalBoost),
|
|
||||||
connectionType: 'temporal_proximity',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SPREADING ACTIVATION
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
interface SpreadingActivationResult {
|
|
||||||
edgesCreated: number;
|
|
||||||
paths: Array<{
|
|
||||||
from: string;
|
|
||||||
via: string;
|
|
||||||
to: string;
|
|
||||||
weight: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply spreading activation to discover transitive connections
|
|
||||||
* If A -> B and B -> C exist, creates A -> C with decayed weight
|
|
||||||
*/
|
|
||||||
function applySpreadingActivation(
|
|
||||||
db: VestigeDatabase,
|
|
||||||
maxDepth: number = 2,
|
|
||||||
minWeight: number = 0.2
|
|
||||||
): SpreadingActivationResult {
|
|
||||||
const result: SpreadingActivationResult = {
|
|
||||||
edgesCreated: 0,
|
|
||||||
paths: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get all existing edges
|
|
||||||
const edges = db['db'].prepare(`
|
|
||||||
SELECT from_id, to_id, weight FROM graph_edges
|
|
||||||
WHERE edge_type = 'similar_to'
|
|
||||||
`).all() as { from_id: string; to_id: string; weight: number }[];
|
|
||||||
|
|
||||||
// Build adjacency map (bidirectional)
|
|
||||||
const adjacency = new Map<string, Map<string, number>>();
|
|
||||||
|
|
||||||
for (const edge of edges) {
|
|
||||||
// Forward direction
|
|
||||||
if (!adjacency.has(edge.from_id)) {
|
|
||||||
adjacency.set(edge.from_id, new Map());
|
|
||||||
}
|
|
||||||
adjacency.get(edge.from_id)!.set(edge.to_id, edge.weight);
|
|
||||||
|
|
||||||
// Reverse direction (treat as undirected)
|
|
||||||
if (!adjacency.has(edge.to_id)) {
|
|
||||||
adjacency.set(edge.to_id, new Map());
|
|
||||||
}
|
|
||||||
adjacency.get(edge.to_id)!.set(edge.from_id, edge.weight);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find existing direct connections (to avoid duplicates)
|
|
||||||
const existingConnections = new Set<string>();
|
|
||||||
for (const edge of edges) {
|
|
||||||
existingConnections.add(`${edge.from_id}-${edge.to_id}`);
|
|
||||||
existingConnections.add(`${edge.to_id}-${edge.from_id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For each node, find 2-hop paths
|
|
||||||
const newConnections: Array<{
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
via: string;
|
|
||||||
weight: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (const [nodeA, neighborsA] of adjacency) {
|
|
||||||
for (const [nodeB, weightAB] of neighborsA) {
|
|
||||||
const neighborsB = adjacency.get(nodeB);
|
|
||||||
if (!neighborsB) continue;
|
|
||||||
|
|
||||||
for (const [nodeC, weightBC] of neighborsB) {
|
|
||||||
// Skip if A == C or if direct connection already exists
|
|
||||||
if (nodeA === nodeC) continue;
|
|
||||||
|
|
||||||
const connectionKey = `${nodeA}-${nodeC}`;
|
|
||||||
const reverseKey = `${nodeC}-${nodeA}`;
|
|
||||||
|
|
||||||
if (existingConnections.has(connectionKey) || existingConnections.has(reverseKey)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate transitive weight with decay
|
|
||||||
const transitiveWeight = weightAB * weightBC * SPREADING_ACTIVATION_DECAY;
|
|
||||||
|
|
||||||
if (transitiveWeight >= minWeight) {
|
|
||||||
newConnections.push({
|
|
||||||
from: nodeA,
|
|
||||||
to: nodeC,
|
|
||||||
via: nodeB,
|
|
||||||
weight: transitiveWeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark as existing to avoid duplicates
|
|
||||||
existingConnections.add(connectionKey);
|
|
||||||
existingConnections.add(reverseKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the new edges
|
|
||||||
for (const conn of newConnections) {
|
|
||||||
try {
|
|
||||||
db.insertEdge({
|
|
||||||
fromId: conn.from,
|
|
||||||
toId: conn.to,
|
|
||||||
edgeType: 'similar_to',
|
|
||||||
weight: conn.weight,
|
|
||||||
metadata: {
|
|
||||||
discoveredBy: 'spreading_activation',
|
|
||||||
viaNode: conn.via,
|
|
||||||
connectionType: 'spreading_activation',
|
|
||||||
},
|
|
||||||
createdAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
result.edgesCreated++;
|
|
||||||
result.paths.push(conn);
|
|
||||||
} catch {
|
|
||||||
// Edge might already exist, skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// RECONSOLIDATION
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strengthen connections for recently accessed nodes
|
|
||||||
* Implements memory reconsolidation - accessing memories makes them stronger
|
|
||||||
*/
|
|
||||||
function reconsolidateConnections(db: VestigeDatabase, nodeId: string): number {
|
|
||||||
let strengthened = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get all edges involving this node
|
|
||||||
const edges = db['db'].prepare(`
|
|
||||||
SELECT id, weight FROM graph_edges
|
|
||||||
WHERE from_id = ? OR to_id = ?
|
|
||||||
`).all(nodeId, nodeId) as { id: string; weight: number }[];
|
|
||||||
|
|
||||||
// Strengthen each edge by RECONSOLIDATION_BOOST (5%)
|
|
||||||
const updateStmt = db['db'].prepare(`
|
|
||||||
UPDATE graph_edges
|
|
||||||
SET weight = MIN(1.0, weight * ?)
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
|
|
||||||
for (const edge of edges) {
|
|
||||||
const newWeight = Math.min(1.0, edge.weight * (1 + RECONSOLIDATION_BOOST));
|
|
||||||
if (newWeight > edge.weight) {
|
|
||||||
updateStmt.run(newWeight, edge.id);
|
|
||||||
strengthened++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Reconsolidation is optional, don't fail the cycle
|
|
||||||
}
|
|
||||||
|
|
||||||
return strengthened;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// REM CYCLE MAIN LOGIC
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get nodes that have few or no connections
|
|
||||||
*/
|
|
||||||
function getDisconnectedNodes(db: VestigeDatabase, maxEdges: number = 1): KnowledgeNode[] {
|
|
||||||
// Get all nodes
|
|
||||||
const result = db.getRecentNodes({ limit: 500 });
|
|
||||||
const allNodes = result.items;
|
|
||||||
|
|
||||||
// Filter to nodes with few connections
|
|
||||||
const disconnected: KnowledgeNode[] = [];
|
|
||||||
|
|
||||||
for (const node of allNodes) {
|
|
||||||
const related = db.getRelatedNodes(node.id, 1);
|
|
||||||
if (related.length <= maxEdges) {
|
|
||||||
disconnected.push(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return disconnected;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run one REM cycle - discover and create connections
|
|
||||||
*
|
|
||||||
* The cycle performs these steps:
|
|
||||||
* 1. Reconsolidate recently accessed nodes (strengthen existing connections)
|
|
||||||
* 2. Find disconnected nodes
|
|
||||||
* 3. Try semantic similarity first (if enabled and available)
|
|
||||||
* 4. Fall back to traditional analysis (Jaccard, shared concepts, etc.)
|
|
||||||
* 5. Apply emotional weighting to all connections
|
|
||||||
* 6. Run spreading activation to find transitive connections
|
|
||||||
*/
|
|
||||||
export async function runREMCycle(
|
|
||||||
db: VestigeDatabase,
|
|
||||||
options: REMCycleOptions = {}
|
|
||||||
): Promise<REMCycleResult> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
const {
|
|
||||||
maxAnalyze = 50,
|
|
||||||
minStrength = 0.3,
|
|
||||||
dryRun = false,
|
|
||||||
enableSemantic = true,
|
|
||||||
enableSpreadingActivation = true,
|
|
||||||
spreadingActivationDepth = 2,
|
|
||||||
recentlyAccessedIds = [],
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const result: REMCycleResult = {
|
|
||||||
nodesAnalyzed: 0,
|
|
||||||
connectionsDiscovered: 0,
|
|
||||||
connectionsCreated: 0,
|
|
||||||
spreadingActivationEdges: 0,
|
|
||||||
reconsolidatedNodes: 0,
|
|
||||||
duration: 0,
|
|
||||||
semanticEnabled: false,
|
|
||||||
discoveries: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Step 1: Reconsolidate recently accessed nodes
|
|
||||||
if (!dryRun && recentlyAccessedIds.length > 0) {
|
|
||||||
for (const nodeId of recentlyAccessedIds) {
|
|
||||||
const strengthened = reconsolidateConnections(db, nodeId);
|
|
||||||
if (strengthened > 0) {
|
|
||||||
result.reconsolidatedNodes++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Initialize embedding service if semantic analysis is enabled
|
|
||||||
let embeddingService: EmbeddingService | null = null;
|
|
||||||
let embeddingCache: EmbeddingCache | null = null;
|
|
||||||
|
|
||||||
if (enableSemantic) {
|
|
||||||
try {
|
|
||||||
embeddingService = await createEmbeddingService();
|
|
||||||
const isAvailable = await embeddingService.isAvailable();
|
|
||||||
result.semanticEnabled = isAvailable;
|
|
||||||
|
|
||||||
if (isAvailable) {
|
|
||||||
embeddingCache = new EmbeddingCache(500, 30); // 500 entries, 30 min TTL
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Semantic analysis not available, continue without it
|
|
||||||
result.semanticEnabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Get disconnected nodes
|
|
||||||
const disconnected = getDisconnectedNodes(db, 2);
|
|
||||||
|
|
||||||
if (disconnected.length < 2) {
|
|
||||||
result.duration = Date.now() - startTime;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit analysis
|
|
||||||
const toAnalyze = disconnected.slice(0, maxAnalyze);
|
|
||||||
result.nodesAnalyzed = toAnalyze.length;
|
|
||||||
|
|
||||||
// Step 4: Compare pairs
|
|
||||||
const discoveries: DiscoveredConnection[] = [];
|
|
||||||
const analyzed = new Set<string>();
|
|
||||||
|
|
||||||
for (let i = 0; i < toAnalyze.length; i++) {
|
|
||||||
for (let j = i + 1; j < toAnalyze.length; j++) {
|
|
||||||
const nodeA = toAnalyze[i];
|
|
||||||
const nodeB = toAnalyze[j];
|
|
||||||
|
|
||||||
if (!nodeA || !nodeB) continue;
|
|
||||||
|
|
||||||
// Skip if already have an edge
|
|
||||||
const pairKey = [nodeA.id, nodeB.id].sort().join('-');
|
|
||||||
if (analyzed.has(pairKey)) continue;
|
|
||||||
analyzed.add(pairKey);
|
|
||||||
|
|
||||||
let connection: DiscoveredConnection | null = null;
|
|
||||||
|
|
||||||
// Try semantic similarity first if available
|
|
||||||
if (result.semanticEnabled && embeddingService && embeddingCache) {
|
|
||||||
connection = await analyzeSemanticConnection(
|
|
||||||
nodeA,
|
|
||||||
nodeB,
|
|
||||||
embeddingService,
|
|
||||||
embeddingCache
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to traditional analysis if no semantic connection found
|
|
||||||
if (!connection) {
|
|
||||||
connection = analyzeTraditionalConnection(nodeA, nodeB);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connection && connection.strength >= minStrength) {
|
|
||||||
discoveries.push(connection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.connectionsDiscovered = discoveries.length;
|
|
||||||
|
|
||||||
// Step 5: Create edges for discovered connections
|
|
||||||
if (!dryRun) {
|
|
||||||
for (const discovery of discoveries) {
|
|
||||||
try {
|
|
||||||
db.insertEdge({
|
|
||||||
fromId: discovery.nodeA.id,
|
|
||||||
toId: discovery.nodeB.id,
|
|
||||||
edgeType: 'similar_to',
|
|
||||||
weight: discovery.strength,
|
|
||||||
metadata: {
|
|
||||||
discoveredBy: 'rem_cycle',
|
|
||||||
reason: discovery.reason,
|
|
||||||
connectionType: discovery.connectionType,
|
|
||||||
},
|
|
||||||
createdAt: new Date(),
|
|
||||||
});
|
|
||||||
result.connectionsCreated++;
|
|
||||||
|
|
||||||
result.discoveries.push({
|
|
||||||
nodeA: discovery.nodeA.content.slice(0, 50),
|
|
||||||
nodeB: discovery.nodeB.content.slice(0, 50),
|
|
||||||
reason: discovery.reason,
|
|
||||||
type: discovery.connectionType,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Edge might already exist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 6: Apply spreading activation
|
|
||||||
if (enableSpreadingActivation) {
|
|
||||||
const spreadingResult = applySpreadingActivation(db, spreadingActivationDepth, minStrength);
|
|
||||||
result.spreadingActivationEdges = spreadingResult.edgesCreated;
|
|
||||||
|
|
||||||
// Add spreading activation discoveries to results
|
|
||||||
for (const path of spreadingResult.paths) {
|
|
||||||
result.discoveries.push({
|
|
||||||
nodeA: path.from.slice(0, 20),
|
|
||||||
nodeB: path.to.slice(0, 20),
|
|
||||||
reason: `Transitive via ${path.via.slice(0, 20)} (${(path.weight * 100).toFixed(0)}%)`,
|
|
||||||
type: 'spreading_activation',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Dry run - just record discoveries
|
|
||||||
for (const discovery of discoveries) {
|
|
||||||
result.discoveries.push({
|
|
||||||
nodeA: discovery.nodeA.content.slice(0, 50),
|
|
||||||
nodeB: discovery.nodeB.content.slice(0, 50),
|
|
||||||
reason: discovery.reason,
|
|
||||||
type: discovery.connectionType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.duration = Date.now() - startTime;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a summary of potential discoveries without creating edges
|
|
||||||
*/
|
|
||||||
export async function previewREMCycle(db: VestigeDatabase): Promise<REMCycleResult> {
|
|
||||||
return runREMCycle(db, { dryRun: true, maxAnalyze: 100 });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger reconsolidation for a specific node
|
|
||||||
* Call this when a node is accessed to strengthen its connections
|
|
||||||
*/
|
|
||||||
export function triggerReconsolidation(db: VestigeDatabase, nodeId: string): number {
|
|
||||||
return reconsolidateConnections(db, nodeId);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,403 +0,0 @@
|
||||||
/**
|
|
||||||
* The Shadow Self - Unsolved Problems Queue
|
|
||||||
*
|
|
||||||
* "Your subconscious that keeps working while you're not looking."
|
|
||||||
*
|
|
||||||
* When you say "I don't know how to fix this," Vestige logs it.
|
|
||||||
* The Shadow periodically re-attacks these problems with new context.
|
|
||||||
*
|
|
||||||
* This turns Vestige from a passive memory into an active problem-solver.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Database from 'better-sqlite3';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import path from 'path';
|
|
||||||
import fs from 'fs';
|
|
||||||
import os from 'os';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface UnsolvedProblem {
|
|
||||||
id: string;
|
|
||||||
description: string;
|
|
||||||
context: string; // Original context when problem was logged
|
|
||||||
tags: string[];
|
|
||||||
status: 'open' | 'investigating' | 'solved' | 'abandoned';
|
|
||||||
priority: number; // 1-5, higher = more urgent
|
|
||||||
attempts: number; // How many times Shadow has tried to solve it
|
|
||||||
lastAttemptAt: Date | null;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
solution: string | null; // If solved, what was the solution?
|
|
||||||
relatedNodeIds: string[]; // Knowledge nodes that might help
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShadowInsight {
|
|
||||||
problemId: string;
|
|
||||||
insight: string;
|
|
||||||
source: 'keyword_match' | 'new_knowledge' | 'pattern_recognition';
|
|
||||||
confidence: number;
|
|
||||||
relatedNodeIds: string[];
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// DATABASE SETUP
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const SHADOW_DB_PATH = path.join(os.homedir(), '.vestige', 'shadow.db');
|
|
||||||
|
|
||||||
function initializeShadowDb(): Database.Database {
|
|
||||||
const dir = path.dirname(SHADOW_DB_PATH);
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = new Database(SHADOW_DB_PATH);
|
|
||||||
|
|
||||||
db.pragma('journal_mode = WAL');
|
|
||||||
db.pragma('busy_timeout = 5000');
|
|
||||||
|
|
||||||
// Unsolved problems table
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS unsolved_problems (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
description TEXT NOT NULL,
|
|
||||||
context TEXT,
|
|
||||||
tags TEXT DEFAULT '[]',
|
|
||||||
status TEXT DEFAULT 'open',
|
|
||||||
priority INTEGER DEFAULT 3,
|
|
||||||
attempts INTEGER DEFAULT 0,
|
|
||||||
last_attempt_at TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
solution TEXT,
|
|
||||||
related_node_ids TEXT DEFAULT '[]'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_problems_status ON unsolved_problems(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_problems_priority ON unsolved_problems(priority);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Insights discovered by Shadow
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS shadow_insights (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
problem_id TEXT NOT NULL,
|
|
||||||
insight TEXT NOT NULL,
|
|
||||||
source TEXT NOT NULL,
|
|
||||||
confidence REAL DEFAULT 0.5,
|
|
||||||
related_node_ids TEXT DEFAULT '[]',
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
|
|
||||||
FOREIGN KEY (problem_id) REFERENCES unsolved_problems(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_insights_problem ON shadow_insights(problem_id);
|
|
||||||
`);
|
|
||||||
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SHADOW SELF CLASS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export class ShadowSelf {
|
|
||||||
private db: Database.Database;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.db = initializeShadowDb();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log a new unsolved problem
|
|
||||||
*/
|
|
||||||
logProblem(description: string, options: {
|
|
||||||
context?: string;
|
|
||||||
tags?: string[];
|
|
||||||
priority?: number;
|
|
||||||
} = {}): UnsolvedProblem {
|
|
||||||
const id = nanoid();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
INSERT INTO unsolved_problems (
|
|
||||||
id, description, context, tags, status, priority,
|
|
||||||
attempts, last_attempt_at, created_at, updated_at,
|
|
||||||
solution, related_node_ids
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
|
|
||||||
stmt.run(
|
|
||||||
id,
|
|
||||||
description,
|
|
||||||
options.context || '',
|
|
||||||
JSON.stringify(options.tags || []),
|
|
||||||
'open',
|
|
||||||
options.priority || 3,
|
|
||||||
0,
|
|
||||||
null,
|
|
||||||
now,
|
|
||||||
now,
|
|
||||||
null,
|
|
||||||
'[]'
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.getProblem(id)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a specific problem
|
|
||||||
*/
|
|
||||||
getProblem(id: string): UnsolvedProblem | null {
|
|
||||||
const stmt = this.db.prepare('SELECT * FROM unsolved_problems WHERE id = ?');
|
|
||||||
const row = stmt.get(id) as Record<string, unknown> | undefined;
|
|
||||||
if (!row) return null;
|
|
||||||
return this.rowToProblem(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all open problems
|
|
||||||
*/
|
|
||||||
getOpenProblems(): UnsolvedProblem[] {
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT * FROM unsolved_problems
|
|
||||||
WHERE status IN ('open', 'investigating')
|
|
||||||
ORDER BY priority DESC, created_at ASC
|
|
||||||
`);
|
|
||||||
const rows = stmt.all() as Record<string, unknown>[];
|
|
||||||
return rows.map(row => this.rowToProblem(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update problem status
|
|
||||||
*/
|
|
||||||
updateStatus(id: string, status: UnsolvedProblem['status'], solution?: string): void {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
UPDATE unsolved_problems
|
|
||||||
SET status = ?, solution = ?, updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
stmt.run(status, solution || null, now, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark problem as solved
|
|
||||||
*/
|
|
||||||
markSolved(id: string, solution: string): void {
|
|
||||||
this.updateStatus(id, 'solved', solution);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add insight to a problem
|
|
||||||
*/
|
|
||||||
addInsight(problemId: string, insight: string, options: {
|
|
||||||
source?: ShadowInsight['source'];
|
|
||||||
confidence?: number;
|
|
||||||
relatedNodeIds?: string[];
|
|
||||||
} = {}): ShadowInsight {
|
|
||||||
const id = nanoid();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
INSERT INTO shadow_insights (
|
|
||||||
id, problem_id, insight, source, confidence, related_node_ids, created_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
|
|
||||||
stmt.run(
|
|
||||||
id,
|
|
||||||
problemId,
|
|
||||||
insight,
|
|
||||||
options.source || 'keyword_match',
|
|
||||||
options.confidence || 0.5,
|
|
||||||
JSON.stringify(options.relatedNodeIds || []),
|
|
||||||
now
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update problem attempt count
|
|
||||||
this.db.prepare(`
|
|
||||||
UPDATE unsolved_problems
|
|
||||||
SET attempts = attempts + 1,
|
|
||||||
last_attempt_at = ?,
|
|
||||||
status = 'investigating',
|
|
||||||
updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`).run(now, now, problemId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
problemId,
|
|
||||||
insight,
|
|
||||||
source: options.source || 'keyword_match',
|
|
||||||
confidence: options.confidence || 0.5,
|
|
||||||
relatedNodeIds: options.relatedNodeIds || [],
|
|
||||||
createdAt: new Date(now),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get insights for a problem
|
|
||||||
*/
|
|
||||||
getInsights(problemId: string): ShadowInsight[] {
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT * FROM shadow_insights
|
|
||||||
WHERE problem_id = ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
`);
|
|
||||||
const rows = stmt.all(problemId) as Record<string, unknown>[];
|
|
||||||
|
|
||||||
return rows.map(row => ({
|
|
||||||
id: row['id'] as string,
|
|
||||||
problemId: row['problem_id'] as string,
|
|
||||||
insight: row['insight'] as string,
|
|
||||||
source: row['source'] as ShadowInsight['source'],
|
|
||||||
confidence: row['confidence'] as number,
|
|
||||||
relatedNodeIds: JSON.parse(row['related_node_ids'] as string || '[]'),
|
|
||||||
createdAt: new Date(row['created_at'] as string),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get problems that haven't been worked on recently
|
|
||||||
*/
|
|
||||||
getStaleProblems(hoursSinceLastAttempt: number = 24): UnsolvedProblem[] {
|
|
||||||
const cutoff = new Date(Date.now() - hoursSinceLastAttempt * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT * FROM unsolved_problems
|
|
||||||
WHERE status IN ('open', 'investigating')
|
|
||||||
AND (last_attempt_at IS NULL OR last_attempt_at < ?)
|
|
||||||
ORDER BY priority DESC
|
|
||||||
`);
|
|
||||||
const rows = stmt.all(cutoff.toISOString()) as Record<string, unknown>[];
|
|
||||||
return rows.map(row => this.rowToProblem(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get statistics
|
|
||||||
*/
|
|
||||||
getStats(): {
|
|
||||||
total: number;
|
|
||||||
open: number;
|
|
||||||
investigating: number;
|
|
||||||
solved: number;
|
|
||||||
abandoned: number;
|
|
||||||
totalInsights: number;
|
|
||||||
} {
|
|
||||||
const statusCounts = this.db.prepare(`
|
|
||||||
SELECT status, COUNT(*) as count FROM unsolved_problems GROUP BY status
|
|
||||||
`).all() as { status: string; count: number }[];
|
|
||||||
|
|
||||||
const insightCount = this.db.prepare(`
|
|
||||||
SELECT COUNT(*) as count FROM shadow_insights
|
|
||||||
`).get() as { count: number };
|
|
||||||
|
|
||||||
const stats = {
|
|
||||||
total: 0,
|
|
||||||
open: 0,
|
|
||||||
investigating: 0,
|
|
||||||
solved: 0,
|
|
||||||
abandoned: 0,
|
|
||||||
totalInsights: insightCount.count,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const { status, count } of statusCounts) {
|
|
||||||
stats.total += count;
|
|
||||||
if (status === 'open') stats.open = count;
|
|
||||||
if (status === 'investigating') stats.investigating = count;
|
|
||||||
if (status === 'solved') stats.solved = count;
|
|
||||||
if (status === 'abandoned') stats.abandoned = count;
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
private rowToProblem(row: Record<string, unknown>): UnsolvedProblem {
|
|
||||||
return {
|
|
||||||
id: row['id'] as string,
|
|
||||||
description: row['description'] as string,
|
|
||||||
context: row['context'] as string,
|
|
||||||
tags: JSON.parse(row['tags'] as string || '[]'),
|
|
||||||
status: row['status'] as UnsolvedProblem['status'],
|
|
||||||
priority: row['priority'] as number,
|
|
||||||
attempts: row['attempts'] as number,
|
|
||||||
lastAttemptAt: row['last_attempt_at'] ? new Date(row['last_attempt_at'] as string) : null,
|
|
||||||
createdAt: new Date(row['created_at'] as string),
|
|
||||||
updatedAt: new Date(row['updated_at'] as string),
|
|
||||||
solution: row['solution'] as string | null,
|
|
||||||
relatedNodeIds: JSON.parse(row['related_node_ids'] as string || '[]'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
this.db.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SHADOW WORK - Background processing
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import { VestigeDatabase } from './database.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run Shadow work cycle - look for new insights on unsolved problems
|
|
||||||
*/
|
|
||||||
export function runShadowCycle(shadow: ShadowSelf, vestige: VestigeDatabase): {
|
|
||||||
problemsAnalyzed: number;
|
|
||||||
insightsGenerated: number;
|
|
||||||
insights: Array<{ problem: string; insight: string }>;
|
|
||||||
} {
|
|
||||||
const result = {
|
|
||||||
problemsAnalyzed: 0,
|
|
||||||
insightsGenerated: 0,
|
|
||||||
insights: [] as Array<{ problem: string; insight: string }>,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get stale problems that need attention
|
|
||||||
const problems = shadow.getStaleProblems(1); // Haven't been worked on in 1 hour
|
|
||||||
|
|
||||||
for (const problem of problems) {
|
|
||||||
result.problemsAnalyzed++;
|
|
||||||
|
|
||||||
// Extract keywords from problem description
|
|
||||||
const keywords = problem.description
|
|
||||||
.toLowerCase()
|
|
||||||
.split(/\W+/)
|
|
||||||
.filter(w => w.length > 4);
|
|
||||||
|
|
||||||
// Search knowledge base for related content
|
|
||||||
for (const keyword of keywords.slice(0, 5)) {
|
|
||||||
try {
|
|
||||||
const searchResult = vestige.searchNodes(keyword, { limit: 3 });
|
|
||||||
|
|
||||||
for (const node of searchResult.items) {
|
|
||||||
// Check if this node was added after the problem
|
|
||||||
if (node.createdAt > problem.createdAt) {
|
|
||||||
// New knowledge! This might help
|
|
||||||
shadow.addInsight(problem.id, `New knowledge found: "${node.content.slice(0, 100)}..."`, {
|
|
||||||
source: 'new_knowledge',
|
|
||||||
confidence: 0.6,
|
|
||||||
relatedNodeIds: [node.id],
|
|
||||||
});
|
|
||||||
|
|
||||||
result.insightsGenerated++;
|
|
||||||
result.insights.push({
|
|
||||||
problem: problem.description.slice(0, 50),
|
|
||||||
insight: `Found related: ${node.content.slice(0, 50)}...`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore search errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
@ -1,314 +0,0 @@
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SOURCE TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const SourceTypeSchema = z.enum([
|
|
||||||
'note',
|
|
||||||
'conversation',
|
|
||||||
'email',
|
|
||||||
'book',
|
|
||||||
'article',
|
|
||||||
'highlight',
|
|
||||||
'meeting',
|
|
||||||
'manual',
|
|
||||||
'webpage',
|
|
||||||
]);
|
|
||||||
export type SourceType = z.infer<typeof SourceTypeSchema>;
|
|
||||||
|
|
||||||
export const SourcePlatformSchema = z.enum([
|
|
||||||
'obsidian',
|
|
||||||
'notion',
|
|
||||||
'roam',
|
|
||||||
'logseq',
|
|
||||||
'claude',
|
|
||||||
'chatgpt',
|
|
||||||
'gmail',
|
|
||||||
'outlook',
|
|
||||||
'kindle',
|
|
||||||
'readwise',
|
|
||||||
'pocket',
|
|
||||||
'instapaper',
|
|
||||||
'manual',
|
|
||||||
'browser',
|
|
||||||
]);
|
|
||||||
export type SourcePlatform = z.infer<typeof SourcePlatformSchema>;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// KNOWLEDGE NODE
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const KnowledgeNodeSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
content: z.string(),
|
|
||||||
summary: z.string().optional(),
|
|
||||||
|
|
||||||
// Temporal metadata
|
|
||||||
createdAt: z.date(),
|
|
||||||
updatedAt: z.date(),
|
|
||||||
lastAccessedAt: z.date(),
|
|
||||||
accessCount: z.number().default(0),
|
|
||||||
|
|
||||||
// Decay modeling (SM-2 inspired spaced repetition)
|
|
||||||
retentionStrength: z.number().min(0).max(1).default(1),
|
|
||||||
stabilityFactor: z.number().min(1).optional().default(1), // Grows with reviews, flattens decay curve
|
|
||||||
sentimentIntensity: z.number().min(0).max(1).optional().default(0), // Emotional weight - higher = decays slower
|
|
||||||
nextReviewDate: z.date().optional(),
|
|
||||||
reviewCount: z.number().default(0),
|
|
||||||
|
|
||||||
// Dual-Strength Memory Model (Bjork & Bjork, 1992)
|
|
||||||
storageStrength: z.number().min(1).default(1), // How well encoded (never decreases)
|
|
||||||
retrievalStrength: z.number().min(0).max(1).default(1), // How accessible now (decays)
|
|
||||||
|
|
||||||
// Provenance
|
|
||||||
sourceType: SourceTypeSchema,
|
|
||||||
sourcePlatform: SourcePlatformSchema,
|
|
||||||
sourceId: z.string().optional(), // Original source reference
|
|
||||||
sourceUrl: z.string().optional(),
|
|
||||||
sourceChain: z.array(z.string()).default([]), // Full provenance path
|
|
||||||
|
|
||||||
// Git-Blame for Thoughts - what code was being worked on when this memory was created?
|
|
||||||
gitContext: z.object({
|
|
||||||
branch: z.string().optional(),
|
|
||||||
commit: z.string().optional(), // Short SHA
|
|
||||||
commitMessage: z.string().optional(), // First line of commit message
|
|
||||||
repoPath: z.string().optional(), // Repository root path
|
|
||||||
dirty: z.boolean().optional(), // Had uncommitted changes?
|
|
||||||
changedFiles: z.array(z.string()).optional(), // Files with uncommitted changes
|
|
||||||
}).optional(),
|
|
||||||
|
|
||||||
// Confidence & quality
|
|
||||||
confidence: z.number().min(0).max(1).default(0.8),
|
|
||||||
isContradicted: z.boolean().default(false),
|
|
||||||
contradictionIds: z.array(z.string()).default([]),
|
|
||||||
|
|
||||||
// Extracted entities
|
|
||||||
people: z.array(z.string()).default([]),
|
|
||||||
concepts: z.array(z.string()).default([]),
|
|
||||||
events: z.array(z.string()).default([]),
|
|
||||||
tags: z.array(z.string()).default([]),
|
|
||||||
});
|
|
||||||
export type KnowledgeNode = z.infer<typeof KnowledgeNodeSchema>;
|
|
||||||
// Input type where optional/default fields are truly optional (for insertNode)
|
|
||||||
export type KnowledgeNodeInput = z.input<typeof KnowledgeNodeSchema>;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// PERSON NODE (People Memory / Mini-CRM)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const InteractionTypeSchema = z.enum([
|
|
||||||
'meeting',
|
|
||||||
'email',
|
|
||||||
'call',
|
|
||||||
'message',
|
|
||||||
'social',
|
|
||||||
'collaboration',
|
|
||||||
'mention', // Referenced in notes but not direct interaction
|
|
||||||
]);
|
|
||||||
export type InteractionType = z.infer<typeof InteractionTypeSchema>;
|
|
||||||
|
|
||||||
export const InteractionSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
personId: z.string(),
|
|
||||||
type: InteractionTypeSchema,
|
|
||||||
date: z.date(),
|
|
||||||
summary: z.string(),
|
|
||||||
topics: z.array(z.string()).default([]),
|
|
||||||
sentiment: z.number().min(-1).max(1).optional(), // -1 negative, 0 neutral, 1 positive
|
|
||||||
actionItems: z.array(z.string()).default([]),
|
|
||||||
sourceNodeId: z.string().optional(), // Link to knowledge node if derived
|
|
||||||
});
|
|
||||||
export type Interaction = z.infer<typeof InteractionSchema>;
|
|
||||||
|
|
||||||
export const PersonNodeSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
aliases: z.array(z.string()).default([]),
|
|
||||||
|
|
||||||
// Relationship context
|
|
||||||
howWeMet: z.string().optional(),
|
|
||||||
relationshipType: z.string().optional(), // colleague, friend, mentor, family, etc.
|
|
||||||
organization: z.string().optional(),
|
|
||||||
role: z.string().optional(),
|
|
||||||
location: z.string().optional(),
|
|
||||||
|
|
||||||
// Contact info
|
|
||||||
email: z.string().optional(),
|
|
||||||
phone: z.string().optional(),
|
|
||||||
socialLinks: z.record(z.string()).default({}),
|
|
||||||
|
|
||||||
// Communication patterns
|
|
||||||
lastContactAt: z.date().optional(),
|
|
||||||
contactFrequency: z.number().default(0), // Interactions per month (calculated)
|
|
||||||
preferredChannel: z.string().optional(),
|
|
||||||
|
|
||||||
// Shared context
|
|
||||||
sharedTopics: z.array(z.string()).default([]),
|
|
||||||
sharedProjects: z.array(z.string()).default([]),
|
|
||||||
|
|
||||||
// Meta
|
|
||||||
notes: z.string().optional(),
|
|
||||||
relationshipHealth: z.number().min(0).max(1).default(0.5), // Calculated from recency + frequency
|
|
||||||
|
|
||||||
createdAt: z.date(),
|
|
||||||
updatedAt: z.date(),
|
|
||||||
});
|
|
||||||
export type PersonNode = z.infer<typeof PersonNodeSchema>;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// GRAPH EDGES (Relationships)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const EdgeTypeSchema = z.enum([
|
|
||||||
'relates_to',
|
|
||||||
'derived_from',
|
|
||||||
'contradicts',
|
|
||||||
'supports',
|
|
||||||
'references',
|
|
||||||
'part_of',
|
|
||||||
'follows', // Temporal sequence
|
|
||||||
'person_mentioned',
|
|
||||||
'concept_instance',
|
|
||||||
'similar_to',
|
|
||||||
]);
|
|
||||||
export type EdgeType = z.infer<typeof EdgeTypeSchema>;
|
|
||||||
|
|
||||||
export const GraphEdgeSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
fromId: z.string(),
|
|
||||||
toId: z.string(),
|
|
||||||
edgeType: EdgeTypeSchema,
|
|
||||||
weight: z.number().min(0).max(1).default(0.5),
|
|
||||||
metadata: z.record(z.unknown()).default({}),
|
|
||||||
createdAt: z.date(),
|
|
||||||
});
|
|
||||||
export type GraphEdge = z.infer<typeof GraphEdgeSchema>;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SOURCE TRACKING
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const SourceSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
type: SourceTypeSchema,
|
|
||||||
platform: SourcePlatformSchema,
|
|
||||||
originalId: z.string().optional(),
|
|
||||||
url: z.string().optional(),
|
|
||||||
filePath: z.string().optional(),
|
|
||||||
title: z.string().optional(),
|
|
||||||
author: z.string().optional(),
|
|
||||||
publicationDate: z.date().optional(),
|
|
||||||
|
|
||||||
// Sync tracking
|
|
||||||
ingestedAt: z.date(),
|
|
||||||
lastSyncedAt: z.date(),
|
|
||||||
contentHash: z.string().optional(), // For change detection
|
|
||||||
|
|
||||||
// Stats
|
|
||||||
nodeCount: z.number().default(0),
|
|
||||||
});
|
|
||||||
export type Source = z.infer<typeof SourceSchema>;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TOOL INPUT/OUTPUT SCHEMAS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const IngestInputSchema = z.object({
|
|
||||||
content: z.string(),
|
|
||||||
source: SourceTypeSchema.optional().default('manual'),
|
|
||||||
platform: SourcePlatformSchema.optional().default('manual'),
|
|
||||||
sourceId: z.string().optional(),
|
|
||||||
sourceUrl: z.string().optional(),
|
|
||||||
timestamp: z.string().datetime().optional(),
|
|
||||||
people: z.array(z.string()).optional(),
|
|
||||||
tags: z.array(z.string()).optional(),
|
|
||||||
title: z.string().optional(),
|
|
||||||
});
|
|
||||||
export type IngestInput = z.infer<typeof IngestInputSchema>;
|
|
||||||
|
|
||||||
export const RecallOptionsSchema = z.object({
|
|
||||||
query: z.string(),
|
|
||||||
timeRange: z.object({
|
|
||||||
start: z.string().datetime().optional(),
|
|
||||||
end: z.string().datetime().optional(),
|
|
||||||
}).optional(),
|
|
||||||
sources: z.array(SourceTypeSchema).optional(),
|
|
||||||
platforms: z.array(SourcePlatformSchema).optional(),
|
|
||||||
people: z.array(z.string()).optional(),
|
|
||||||
minConfidence: z.number().min(0).max(1).optional(),
|
|
||||||
limit: z.number().min(1).max(100).optional().default(10),
|
|
||||||
includeContext: z.boolean().optional().default(true),
|
|
||||||
});
|
|
||||||
export type RecallOptions = z.infer<typeof RecallOptionsSchema>;
|
|
||||||
|
|
||||||
export const RecallResultSchema = z.object({
|
|
||||||
node: KnowledgeNodeSchema,
|
|
||||||
score: z.number(),
|
|
||||||
matchType: z.enum(['semantic', 'keyword', 'graph']),
|
|
||||||
context: z.string().optional(),
|
|
||||||
relatedNodes: z.array(z.string()).optional(),
|
|
||||||
});
|
|
||||||
export type RecallResult = z.infer<typeof RecallResultSchema>;
|
|
||||||
|
|
||||||
export const SynthesisOptionsSchema = z.object({
|
|
||||||
topic: z.string(),
|
|
||||||
depth: z.enum(['shallow', 'deep']).optional().default('shallow'),
|
|
||||||
format: z.enum(['summary', 'outline', 'narrative']).optional().default('summary'),
|
|
||||||
maxSources: z.number().optional().default(20),
|
|
||||||
});
|
|
||||||
export type SynthesisOptions = z.infer<typeof SynthesisOptionsSchema>;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// DECAY MODELING
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface DecayConfig {
|
|
||||||
// Ebbinghaus forgetting curve parameters
|
|
||||||
initialRetention: number; // Starting retention (default 1.0)
|
|
||||||
decayRate: number; // Base decay rate (default ~0.9 for typical forgetting)
|
|
||||||
minRetention: number; // Floor retention (default 0.1)
|
|
||||||
reviewBoost: number; // How much review increases retention (default 0.3)
|
|
||||||
accessBoost: number; // How much access slows decay (default 0.1)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_DECAY_CONFIG: DecayConfig = {
|
|
||||||
initialRetention: 1.0,
|
|
||||||
decayRate: 0.9,
|
|
||||||
minRetention: 0.1,
|
|
||||||
reviewBoost: 0.3,
|
|
||||||
accessBoost: 0.1,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// DAILY BRIEF
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const DailyBriefSchema = z.object({
|
|
||||||
date: z.date(),
|
|
||||||
stats: z.object({
|
|
||||||
totalNodes: z.number(),
|
|
||||||
addedToday: z.number(),
|
|
||||||
addedThisWeek: z.number(),
|
|
||||||
connectionsDiscovered: z.number(),
|
|
||||||
}),
|
|
||||||
reviewDue: z.array(z.object({
|
|
||||||
nodeId: z.string(),
|
|
||||||
summary: z.string(),
|
|
||||||
lastAccessed: z.date(),
|
|
||||||
retentionStrength: z.number(),
|
|
||||||
})),
|
|
||||||
peopleToReconnect: z.array(z.object({
|
|
||||||
personId: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
daysSinceContact: z.number(),
|
|
||||||
sharedTopics: z.array(z.string()),
|
|
||||||
})),
|
|
||||||
interestingConnections: z.array(z.object({
|
|
||||||
nodeA: z.string(),
|
|
||||||
nodeB: z.string(),
|
|
||||||
connectionReason: z.string(),
|
|
||||||
})),
|
|
||||||
recentThemes: z.array(z.string()),
|
|
||||||
});
|
|
||||||
export type DailyBrief = z.infer<typeof DailyBriefSchema>;
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,181 +0,0 @@
|
||||||
/**
|
|
||||||
* ConsolidationJob - Knowledge Consolidation Processing
|
|
||||||
*
|
|
||||||
* Consolidates related knowledge nodes by:
|
|
||||||
* - Merging highly similar nodes
|
|
||||||
* - Strengthening frequently co-accessed node clusters
|
|
||||||
* - Pruning orphaned edges
|
|
||||||
* - Optimizing the database
|
|
||||||
*
|
|
||||||
* Designed to run as a scheduled background job (e.g., weekly).
|
|
||||||
*
|
|
||||||
* @module jobs/ConsolidationJob
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { VestigeDatabase } from '../core/database.js';
|
|
||||||
import type { Job, JobHandler } from './JobQueue.js';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface ConsolidationJobData {
|
|
||||||
/** Minimum similarity threshold for merging nodes (0-1). Default: 0.95 */
|
|
||||||
mergeThreshold?: number;
|
|
||||||
/** Whether to prune orphaned edges. Default: true */
|
|
||||||
pruneOrphanedEdges?: boolean;
|
|
||||||
/** Whether to optimize database after consolidation. Default: true */
|
|
||||||
optimizeDb?: boolean;
|
|
||||||
/** Whether to run in dry-run mode (analysis only). Default: false */
|
|
||||||
dryRun?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConsolidationJobResult {
|
|
||||||
/** Number of node pairs analyzed for similarity */
|
|
||||||
pairsAnalyzed: number;
|
|
||||||
/** Number of nodes merged (dry run: would be merged) */
|
|
||||||
nodesMerged: number;
|
|
||||||
/** Number of orphaned edges pruned */
|
|
||||||
edgesPruned: number;
|
|
||||||
/** Number of edge weights updated (strengthened) */
|
|
||||||
edgesStrengthened: number;
|
|
||||||
/** Whether database optimization was performed */
|
|
||||||
databaseOptimized: boolean;
|
|
||||||
/** Time taken in milliseconds */
|
|
||||||
duration: number;
|
|
||||||
/** Timestamp when the job ran */
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONSOLIDATION LOGIC
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run knowledge consolidation on the database
|
|
||||||
*/
|
|
||||||
async function runConsolidation(
|
|
||||||
db: VestigeDatabase,
|
|
||||||
options: {
|
|
||||||
mergeThreshold?: number;
|
|
||||||
pruneOrphanedEdges?: boolean;
|
|
||||||
optimizeDb?: boolean;
|
|
||||||
dryRun?: boolean;
|
|
||||||
} = {}
|
|
||||||
): Promise<ConsolidationJobResult> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
const {
|
|
||||||
mergeThreshold = 0.95,
|
|
||||||
pruneOrphanedEdges = true,
|
|
||||||
optimizeDb = true,
|
|
||||||
dryRun = false,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const result: ConsolidationJobResult = {
|
|
||||||
pairsAnalyzed: 0,
|
|
||||||
nodesMerged: 0,
|
|
||||||
edgesPruned: 0,
|
|
||||||
edgesStrengthened: 0,
|
|
||||||
databaseOptimized: false,
|
|
||||||
duration: 0,
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Step 1: Analyze and strengthen co-accessed clusters
|
|
||||||
// (Nodes accessed together frequently should have stronger edges)
|
|
||||||
const stats = db.getStats();
|
|
||||||
result.pairsAnalyzed = Math.min(stats.totalNodes * (stats.totalNodes - 1) / 2, 10000);
|
|
||||||
|
|
||||||
// Step 2: Prune orphaned edges (edges pointing to deleted nodes)
|
|
||||||
// In a real implementation, this would query for edges with invalid node references
|
|
||||||
if (pruneOrphanedEdges && !dryRun) {
|
|
||||||
// The database foreign keys should handle this, but we can do a sanity check
|
|
||||||
// For now, we just report 0 pruned as SQLite handles this via ON DELETE CASCADE
|
|
||||||
result.edgesPruned = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Optimize database
|
|
||||||
if (optimizeDb && !dryRun) {
|
|
||||||
try {
|
|
||||||
db.optimize();
|
|
||||||
result.databaseOptimized = true;
|
|
||||||
} catch {
|
|
||||||
// Log but don't fail the job
|
|
||||||
result.databaseOptimized = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.duration = Date.now() - startTime;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// JOB HANDLER FACTORY
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a consolidation job handler
|
|
||||||
*
|
|
||||||
* @param db - VestigeDatabase instance
|
|
||||||
* @returns Job handler function
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const db = new VestigeDatabase();
|
|
||||||
* const queue = new JobQueue();
|
|
||||||
*
|
|
||||||
* queue.register('consolidation', createConsolidationJobHandler(db), {
|
|
||||||
* concurrency: 1, // Only one consolidation at a time
|
|
||||||
* retryDelay: 3600000, // Wait 1 hour before retry
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Schedule to run weekly on Sunday at 4 AM
|
|
||||||
* queue.schedule('consolidation', '0 4 * * 0', {});
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function createConsolidationJobHandler(
|
|
||||||
db: VestigeDatabase
|
|
||||||
): JobHandler<ConsolidationJobData, ConsolidationJobResult> {
|
|
||||||
return async (job: Job<ConsolidationJobData>): Promise<ConsolidationJobResult> => {
|
|
||||||
return runConsolidation(db, {
|
|
||||||
mergeThreshold: job.data.mergeThreshold,
|
|
||||||
pruneOrphanedEdges: job.data.pruneOrphanedEdges,
|
|
||||||
optimizeDb: job.data.optimizeDb,
|
|
||||||
dryRun: job.data.dryRun,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// HELPER FUNCTIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preview what consolidation would do without making changes
|
|
||||||
*/
|
|
||||||
export async function previewConsolidation(
|
|
||||||
db: VestigeDatabase
|
|
||||||
): Promise<ConsolidationJobResult> {
|
|
||||||
return runConsolidation(db, { dryRun: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get database health metrics relevant to consolidation
|
|
||||||
*/
|
|
||||||
export function getConsolidationMetrics(db: VestigeDatabase): {
|
|
||||||
totalNodes: number;
|
|
||||||
totalEdges: number;
|
|
||||||
databaseSizeMB: number;
|
|
||||||
needsOptimization: boolean;
|
|
||||||
} {
|
|
||||||
const stats = db.getStats();
|
|
||||||
const size = db.getDatabaseSize();
|
|
||||||
const health = db.checkHealth();
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalNodes: stats.totalNodes,
|
|
||||||
totalEdges: stats.totalEdges,
|
|
||||||
databaseSizeMB: size.mb,
|
|
||||||
needsOptimization: health.status !== 'healthy' || size.mb > 50,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
/**
|
|
||||||
* DecayJob - Memory Decay Processing
|
|
||||||
*
|
|
||||||
* Applies the Ebbinghaus forgetting curve to all knowledge nodes,
|
|
||||||
* updating their retention strength based on time since last access.
|
|
||||||
*
|
|
||||||
* Designed to run as a scheduled background job (e.g., daily at 3 AM).
|
|
||||||
*
|
|
||||||
* @module jobs/DecayJob
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { VestigeDatabase } from '../core/database.js';
|
|
||||||
import type { Job, JobHandler } from './JobQueue.js';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface DecayJobData {
|
|
||||||
/** Optional: Minimum retention threshold to skip already-decayed nodes */
|
|
||||||
minRetention?: number;
|
|
||||||
/** Optional: Maximum number of nodes to process in one batch */
|
|
||||||
batchSize?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DecayJobResult {
|
|
||||||
/** Number of nodes whose retention was updated */
|
|
||||||
updatedCount: number;
|
|
||||||
/** Total time taken in milliseconds */
|
|
||||||
processingTime: number;
|
|
||||||
/** Timestamp when the job ran */
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// JOB HANDLER FACTORY
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a decay job handler
|
|
||||||
*
|
|
||||||
* @param db - VestigeDatabase instance
|
|
||||||
* @returns Job handler function
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const db = new VestigeDatabase();
|
|
||||||
* const queue = new JobQueue();
|
|
||||||
*
|
|
||||||
* queue.register('decay', createDecayJobHandler(db), {
|
|
||||||
* concurrency: 1, // Only one decay job at a time
|
|
||||||
* retryDelay: 60000, // Wait 1 minute before retry
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Schedule to run daily at 3 AM
|
|
||||||
* queue.schedule('decay', '0 3 * * *', {});
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function createDecayJobHandler(
|
|
||||||
db: VestigeDatabase
|
|
||||||
): JobHandler<DecayJobData, DecayJobResult> {
|
|
||||||
return async (job: Job<DecayJobData>): Promise<DecayJobResult> => {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
// Apply decay to all nodes
|
|
||||||
// The database method handles the Ebbinghaus curve calculation
|
|
||||||
const updatedCount = db.applyDecay();
|
|
||||||
|
|
||||||
const result: DecayJobResult = {
|
|
||||||
updatedCount,
|
|
||||||
processingTime: Date.now() - startTime,
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// HELPER FUNCTIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get nodes that are critically decayed (retention < threshold)
|
|
||||||
* Useful for generating review notifications
|
|
||||||
*/
|
|
||||||
export async function getCriticallyDecayedNodes(
|
|
||||||
db: VestigeDatabase,
|
|
||||||
threshold: number = 0.3
|
|
||||||
): Promise<{ nodeId: string; retention: number; content: string }[]> {
|
|
||||||
const result = db.getDecayingNodes(threshold, { limit: 50 });
|
|
||||||
|
|
||||||
return result.items.map(node => ({
|
|
||||||
nodeId: node.id,
|
|
||||||
retention: node.retentionStrength,
|
|
||||||
content: node.content.slice(0, 100),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
@ -1,809 +0,0 @@
|
||||||
/**
|
|
||||||
* JobQueue - Background Job Processing for Vestige MCP
|
|
||||||
*
|
|
||||||
* A production-ready in-memory job queue with:
|
|
||||||
* - Priority-based job scheduling
|
|
||||||
* - Retry logic with exponential backoff
|
|
||||||
* - Concurrency control per job type
|
|
||||||
* - Event-driven architecture
|
|
||||||
* - Cron-like scheduling support
|
|
||||||
*
|
|
||||||
* @module jobs/JobQueue
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { EventEmitter } from 'events';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export type JobStatus = 'pending' | 'running' | 'completed' | 'failed';
|
|
||||||
|
|
||||||
export interface Job<T = unknown> {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
data: T;
|
|
||||||
priority: number;
|
|
||||||
createdAt: Date;
|
|
||||||
scheduledAt?: Date;
|
|
||||||
startedAt?: Date;
|
|
||||||
completedAt?: Date;
|
|
||||||
retryCount: number;
|
|
||||||
maxRetries: number;
|
|
||||||
status: JobStatus;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JobResult<R = unknown> {
|
|
||||||
jobId: string;
|
|
||||||
success: boolean;
|
|
||||||
result?: R;
|
|
||||||
error?: Error;
|
|
||||||
duration: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type JobHandler<T, R> = (job: Job<T>) => Promise<R>;
|
|
||||||
|
|
||||||
export interface JobOptions {
|
|
||||||
/** Priority (higher = processed first). Default: 0 */
|
|
||||||
priority?: number;
|
|
||||||
/** Delay in milliseconds before job becomes eligible. Default: 0 */
|
|
||||||
delay?: number;
|
|
||||||
/** Maximum retry attempts on failure. Default: 3 */
|
|
||||||
maxRetries?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JobDefinition<T = unknown, R = unknown> {
|
|
||||||
name: string;
|
|
||||||
handler: JobHandler<T, R>;
|
|
||||||
concurrency: number;
|
|
||||||
retryDelay: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScheduledJob {
|
|
||||||
name: string;
|
|
||||||
cronExpression: string;
|
|
||||||
data: unknown;
|
|
||||||
lastRun?: Date;
|
|
||||||
nextRun?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueueStats {
|
|
||||||
pending: number;
|
|
||||||
running: number;
|
|
||||||
completed: number;
|
|
||||||
failed: number;
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// JOB QUEUE EVENTS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface JobQueueEvents {
|
|
||||||
'job:added': (job: Job) => void;
|
|
||||||
'job:started': (job: Job) => void;
|
|
||||||
'job:completed': (job: Job, result: JobResult) => void;
|
|
||||||
'job:failed': (job: Job, error: Error) => void;
|
|
||||||
'job:retry': (job: Job, attempt: number, error: Error) => void;
|
|
||||||
'queue:drained': () => void;
|
|
||||||
'queue:error': (error: Error) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CRON PARSER (Simple Implementation)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
interface CronFields {
|
|
||||||
minute: number[];
|
|
||||||
hour: number[];
|
|
||||||
dayOfMonth: number[];
|
|
||||||
month: number[];
|
|
||||||
dayOfWeek: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a simple cron expression
|
|
||||||
* Format: minute hour day-of-month month day-of-week
|
|
||||||
* Supports: numbers, *, /step, ranges (-)
|
|
||||||
*/
|
|
||||||
function parseCronField(field: string, min: number, max: number): number[] {
|
|
||||||
const values: number[] = [];
|
|
||||||
|
|
||||||
// Handle wildcard
|
|
||||||
if (field === '*') {
|
|
||||||
for (let i = min; i <= max; i++) {
|
|
||||||
values.push(i);
|
|
||||||
}
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle step values (*/n or n/m)
|
|
||||||
if (field.includes('/')) {
|
|
||||||
const [range, stepStr] = field.split('/');
|
|
||||||
const step = parseInt(stepStr || '1', 10);
|
|
||||||
let start = min;
|
|
||||||
let end = max;
|
|
||||||
|
|
||||||
if (range && range !== '*') {
|
|
||||||
if (range.includes('-')) {
|
|
||||||
const [s, e] = range.split('-');
|
|
||||||
start = parseInt(s || String(min), 10);
|
|
||||||
end = parseInt(e || String(max), 10);
|
|
||||||
} else {
|
|
||||||
start = parseInt(range, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = start; i <= end; i += step) {
|
|
||||||
values.push(i);
|
|
||||||
}
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle ranges (n-m)
|
|
||||||
if (field.includes('-')) {
|
|
||||||
const [start, end] = field.split('-');
|
|
||||||
const s = parseInt(start || String(min), 10);
|
|
||||||
const e = parseInt(end || String(max), 10);
|
|
||||||
for (let i = s; i <= e; i++) {
|
|
||||||
values.push(i);
|
|
||||||
}
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle comma-separated values
|
|
||||||
if (field.includes(',')) {
|
|
||||||
return field.split(',').map(v => parseInt(v.trim(), 10));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single value
|
|
||||||
values.push(parseInt(field, 10));
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCronExpression(expression: string): CronFields {
|
|
||||||
const parts = expression.trim().split(/\s+/);
|
|
||||||
|
|
||||||
if (parts.length !== 5) {
|
|
||||||
throw new Error(`Invalid cron expression: ${expression}. Expected 5 fields.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
minute: parseCronField(parts[0] || '*', 0, 59),
|
|
||||||
hour: parseCronField(parts[1] || '*', 0, 23),
|
|
||||||
dayOfMonth: parseCronField(parts[2] || '*', 1, 31),
|
|
||||||
month: parseCronField(parts[3] || '*', 1, 12),
|
|
||||||
dayOfWeek: parseCronField(parts[4] || '*', 0, 6),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNextCronDate(expression: string, after: Date = new Date()): Date {
|
|
||||||
const fields = parseCronExpression(expression);
|
|
||||||
const next = new Date(after);
|
|
||||||
next.setSeconds(0);
|
|
||||||
next.setMilliseconds(0);
|
|
||||||
|
|
||||||
// Start from next minute
|
|
||||||
next.setMinutes(next.getMinutes() + 1);
|
|
||||||
|
|
||||||
// Find next matching time (limit iterations to prevent infinite loops)
|
|
||||||
for (let iterations = 0; iterations < 525600; iterations++) { // Max 1 year of minutes
|
|
||||||
const minute = next.getMinutes();
|
|
||||||
const hour = next.getHours();
|
|
||||||
const dayOfMonth = next.getDate();
|
|
||||||
const month = next.getMonth() + 1; // JS months are 0-indexed
|
|
||||||
const dayOfWeek = next.getDay();
|
|
||||||
|
|
||||||
// Check if current time matches cron expression
|
|
||||||
if (
|
|
||||||
fields.minute.includes(minute) &&
|
|
||||||
fields.hour.includes(hour) &&
|
|
||||||
fields.dayOfMonth.includes(dayOfMonth) &&
|
|
||||||
fields.month.includes(month) &&
|
|
||||||
fields.dayOfWeek.includes(dayOfWeek)
|
|
||||||
) {
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Advance by one minute
|
|
||||||
next.setMinutes(next.getMinutes() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Could not find next cron date within 1 year for: ${expression}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// JOB QUEUE IMPLEMENTATION
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export class JobQueue extends EventEmitter {
|
|
||||||
private jobs: Map<string, Job> = new Map();
|
|
||||||
private handlers: Map<string, JobDefinition> = new Map();
|
|
||||||
private running: Map<string, number> = new Map();
|
|
||||||
private interval: NodeJS.Timeout | null = null;
|
|
||||||
private scheduledJobs: Map<string, ScheduledJob> = new Map();
|
|
||||||
private schedulerInterval: NodeJS.Timeout | null = null;
|
|
||||||
private isProcessing = false;
|
|
||||||
private isPaused = false;
|
|
||||||
|
|
||||||
// Completed/failed job history (limited size)
|
|
||||||
private readonly maxHistorySize = 1000;
|
|
||||||
private completedJobIds: Set<string> = new Set();
|
|
||||||
private failedJobIds: Set<string> = new Set();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.setMaxListeners(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// HANDLER REGISTRATION
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a job handler
|
|
||||||
*
|
|
||||||
* @param name - Unique job type name
|
|
||||||
* @param handler - Async function to process the job
|
|
||||||
* @param options - Handler options (concurrency, retryDelay)
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* queue.register('send-email', async (job) => {
|
|
||||||
* await sendEmail(job.data);
|
|
||||||
* return { sent: true };
|
|
||||||
* }, { concurrency: 5, retryDelay: 5000 });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
register<T, R>(
|
|
||||||
name: string,
|
|
||||||
handler: JobHandler<T, R>,
|
|
||||||
options?: { concurrency?: number; retryDelay?: number }
|
|
||||||
): void {
|
|
||||||
if (this.handlers.has(name)) {
|
|
||||||
throw new Error(`Handler already registered for job type: ${name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store as JobDefinition<unknown, unknown> since we type-erase at runtime
|
|
||||||
// The type safety is maintained at the call site (add/register)
|
|
||||||
const definition: JobDefinition = {
|
|
||||||
name,
|
|
||||||
handler: handler as unknown as JobHandler<unknown, unknown>,
|
|
||||||
concurrency: options?.concurrency ?? 1,
|
|
||||||
retryDelay: options?.retryDelay ?? 1000,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handlers.set(name, definition);
|
|
||||||
this.running.set(name, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unregister a job handler
|
|
||||||
*/
|
|
||||||
unregister(name: string): boolean {
|
|
||||||
const deleted = this.handlers.delete(name);
|
|
||||||
this.running.delete(name);
|
|
||||||
return deleted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// JOB MANAGEMENT
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a job to the queue
|
|
||||||
*
|
|
||||||
* @param name - Job type name (must have registered handler)
|
|
||||||
* @param data - Job data payload
|
|
||||||
* @param options - Job options (priority, delay, maxRetries)
|
|
||||||
* @returns Job ID
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const jobId = queue.add('send-email', {
|
|
||||||
* to: 'user@example.com',
|
|
||||||
* subject: 'Hello'
|
|
||||||
* }, { priority: 10, maxRetries: 5 });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
add<T>(
|
|
||||||
name: string,
|
|
||||||
data: T,
|
|
||||||
options?: JobOptions
|
|
||||||
): string {
|
|
||||||
if (!this.handlers.has(name)) {
|
|
||||||
throw new Error(`No handler registered for job type: ${name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = nanoid();
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
let scheduledAt: Date | undefined;
|
|
||||||
if (options?.delay && options.delay > 0) {
|
|
||||||
scheduledAt = new Date(now.getTime() + options.delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
const job: Job<T> = {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
data,
|
|
||||||
priority: options?.priority ?? 0,
|
|
||||||
createdAt: now,
|
|
||||||
scheduledAt,
|
|
||||||
retryCount: 0,
|
|
||||||
maxRetries: options?.maxRetries ?? 3,
|
|
||||||
status: 'pending',
|
|
||||||
};
|
|
||||||
|
|
||||||
this.jobs.set(id, job as Job);
|
|
||||||
this.emit('job:added', job);
|
|
||||||
|
|
||||||
// Trigger processing if running
|
|
||||||
if (this.isProcessing && !this.isPaused) {
|
|
||||||
this.processNextJobs();
|
|
||||||
}
|
|
||||||
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a job by ID
|
|
||||||
*/
|
|
||||||
getJob(id: string): Job | undefined {
|
|
||||||
return this.jobs.get(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all jobs matching a filter
|
|
||||||
*/
|
|
||||||
getJobs(filter?: { name?: string; status?: JobStatus }): Job[] {
|
|
||||||
let jobs = Array.from(this.jobs.values());
|
|
||||||
|
|
||||||
if (filter?.name) {
|
|
||||||
jobs = jobs.filter(j => j.name === filter.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter?.status) {
|
|
||||||
jobs = jobs.filter(j => j.status === filter.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jobs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a job from the queue
|
|
||||||
* Can only remove pending jobs
|
|
||||||
*/
|
|
||||||
removeJob(id: string): boolean {
|
|
||||||
const job = this.jobs.get(id);
|
|
||||||
if (!job) return false;
|
|
||||||
|
|
||||||
if (job.status === 'running') {
|
|
||||||
throw new Error('Cannot remove a running job');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.jobs.delete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all completed/failed jobs from history
|
|
||||||
*/
|
|
||||||
clearHistory(): void {
|
|
||||||
for (const id of this.completedJobIds) {
|
|
||||||
this.jobs.delete(id);
|
|
||||||
}
|
|
||||||
for (const id of this.failedJobIds) {
|
|
||||||
this.jobs.delete(id);
|
|
||||||
}
|
|
||||||
this.completedJobIds.clear();
|
|
||||||
this.failedJobIds.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// QUEUE STATISTICS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get queue statistics
|
|
||||||
*/
|
|
||||||
getStats(): QueueStats {
|
|
||||||
let pending = 0;
|
|
||||||
let running = 0;
|
|
||||||
let completed = 0;
|
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
for (const job of this.jobs.values()) {
|
|
||||||
switch (job.status) {
|
|
||||||
case 'pending':
|
|
||||||
pending++;
|
|
||||||
break;
|
|
||||||
case 'running':
|
|
||||||
running++;
|
|
||||||
break;
|
|
||||||
case 'completed':
|
|
||||||
completed++;
|
|
||||||
break;
|
|
||||||
case 'failed':
|
|
||||||
failed++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
pending,
|
|
||||||
running,
|
|
||||||
completed,
|
|
||||||
failed,
|
|
||||||
total: this.jobs.size,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if queue is empty (no pending or running jobs)
|
|
||||||
*/
|
|
||||||
isEmpty(): boolean {
|
|
||||||
for (const job of this.jobs.values()) {
|
|
||||||
if (job.status === 'pending' || job.status === 'running') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// PROCESSING
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start processing jobs
|
|
||||||
*
|
|
||||||
* @param pollInterval - How often to check for new jobs (ms). Default: 100
|
|
||||||
*/
|
|
||||||
start(pollInterval: number = 100): void {
|
|
||||||
if (this.isProcessing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isProcessing = true;
|
|
||||||
this.isPaused = false;
|
|
||||||
|
|
||||||
this.interval = setInterval(() => {
|
|
||||||
if (!this.isPaused) {
|
|
||||||
this.processNextJobs();
|
|
||||||
}
|
|
||||||
}, pollInterval);
|
|
||||||
|
|
||||||
// Start scheduler for cron jobs
|
|
||||||
this.startScheduler();
|
|
||||||
|
|
||||||
// Process immediately
|
|
||||||
this.processNextJobs();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop processing jobs
|
|
||||||
*/
|
|
||||||
stop(): void {
|
|
||||||
this.isProcessing = false;
|
|
||||||
|
|
||||||
if (this.interval) {
|
|
||||||
clearInterval(this.interval);
|
|
||||||
this.interval = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.stopScheduler();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pause processing (jobs stay in queue)
|
|
||||||
*/
|
|
||||||
pause(): void {
|
|
||||||
this.isPaused = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resume processing
|
|
||||||
*/
|
|
||||||
resume(): void {
|
|
||||||
this.isPaused = false;
|
|
||||||
this.processNextJobs();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for all pending jobs to complete
|
|
||||||
*/
|
|
||||||
async drain(): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const check = () => {
|
|
||||||
if (this.isEmpty()) {
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
setTimeout(check, 50);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
check();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process next eligible jobs
|
|
||||||
*/
|
|
||||||
private processNextJobs(): void {
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
// Get pending jobs sorted by priority (descending)
|
|
||||||
const pendingJobs = Array.from(this.jobs.values())
|
|
||||||
.filter(job => {
|
|
||||||
if (job.status !== 'pending') return false;
|
|
||||||
if (job.scheduledAt && job.scheduledAt > now) return false;
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.priority - a.priority);
|
|
||||||
|
|
||||||
// Process jobs respecting concurrency limits
|
|
||||||
for (const job of pendingJobs) {
|
|
||||||
const definition = this.handlers.get(job.name);
|
|
||||||
if (!definition) continue;
|
|
||||||
|
|
||||||
const currentRunning = this.running.get(job.name) ?? 0;
|
|
||||||
if (currentRunning >= definition.concurrency) continue;
|
|
||||||
|
|
||||||
// Start processing this job
|
|
||||||
this.processJob(job, definition);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a single job
|
|
||||||
*/
|
|
||||||
private async processJob(job: Job, definition: JobDefinition): Promise<void> {
|
|
||||||
// Update job status
|
|
||||||
job.status = 'running';
|
|
||||||
job.startedAt = new Date();
|
|
||||||
|
|
||||||
// Track running count
|
|
||||||
const currentRunning = this.running.get(job.name) ?? 0;
|
|
||||||
this.running.set(job.name, currentRunning + 1);
|
|
||||||
|
|
||||||
this.emit('job:started', job);
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await definition.handler(job);
|
|
||||||
|
|
||||||
// Job completed successfully
|
|
||||||
job.status = 'completed';
|
|
||||||
job.completedAt = new Date();
|
|
||||||
|
|
||||||
const jobResult: JobResult = {
|
|
||||||
jobId: job.id,
|
|
||||||
success: true,
|
|
||||||
result,
|
|
||||||
duration: Date.now() - startTime,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.emit('job:completed', job, jobResult);
|
|
||||||
|
|
||||||
// Track in history
|
|
||||||
this.addToHistory(job.id, 'completed');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
|
||||||
|
|
||||||
// Check if we should retry
|
|
||||||
if (job.retryCount < job.maxRetries) {
|
|
||||||
job.retryCount++;
|
|
||||||
job.status = 'pending';
|
|
||||||
|
|
||||||
// Schedule retry with exponential backoff
|
|
||||||
const backoffDelay = definition.retryDelay * Math.pow(2, job.retryCount - 1);
|
|
||||||
job.scheduledAt = new Date(Date.now() + backoffDelay);
|
|
||||||
|
|
||||||
this.emit('job:retry', job, job.retryCount, err);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Max retries exceeded - mark as failed
|
|
||||||
job.status = 'failed';
|
|
||||||
job.completedAt = new Date();
|
|
||||||
job.error = err.message;
|
|
||||||
|
|
||||||
this.emit('job:failed', job, err);
|
|
||||||
|
|
||||||
// Track in history
|
|
||||||
this.addToHistory(job.id, 'failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
// Update running count
|
|
||||||
const runningCount = this.running.get(job.name) ?? 1;
|
|
||||||
this.running.set(job.name, Math.max(0, runningCount - 1));
|
|
||||||
|
|
||||||
// Check if queue is drained
|
|
||||||
if (this.isEmpty()) {
|
|
||||||
this.emit('queue:drained');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add job to history tracking (with size limit)
|
|
||||||
*/
|
|
||||||
private addToHistory(jobId: string, type: 'completed' | 'failed'): void {
|
|
||||||
const targetSet = type === 'completed' ? this.completedJobIds : this.failedJobIds;
|
|
||||||
targetSet.add(jobId);
|
|
||||||
|
|
||||||
// Trim history if too large
|
|
||||||
if (targetSet.size > this.maxHistorySize) {
|
|
||||||
const iterator = targetSet.values();
|
|
||||||
const firstValue = iterator.next().value;
|
|
||||||
if (firstValue) {
|
|
||||||
targetSet.delete(firstValue);
|
|
||||||
this.jobs.delete(firstValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SCHEDULING (CRON-LIKE)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedule a recurring job
|
|
||||||
*
|
|
||||||
* @param name - Job type name
|
|
||||||
* @param cronExpression - Cron expression (minute hour day-of-month month day-of-week)
|
|
||||||
* @param data - Job data payload
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Run decay at 3 AM daily
|
|
||||||
* queue.schedule('decay', '0 3 * * *', {});
|
|
||||||
*
|
|
||||||
* // Run REM cycle every 6 hours
|
|
||||||
* queue.schedule('rem-cycle', '0 *\\/6 * * *', {});
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
schedule<T>(name: string, cronExpression: string, data: T): void {
|
|
||||||
if (!this.handlers.has(name)) {
|
|
||||||
throw new Error(`No handler registered for job type: ${name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate cron expression by parsing it
|
|
||||||
try {
|
|
||||||
parseCronExpression(cronExpression);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Invalid cron expression for ${name}: ${cronExpression}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduledJob: ScheduledJob = {
|
|
||||||
name,
|
|
||||||
cronExpression,
|
|
||||||
data,
|
|
||||||
nextRun: getNextCronDate(cronExpression),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.scheduledJobs.set(name, scheduledJob);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a scheduled job
|
|
||||||
*/
|
|
||||||
unschedule(name: string): boolean {
|
|
||||||
return this.scheduledJobs.delete(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all scheduled jobs
|
|
||||||
*/
|
|
||||||
getScheduledJobs(): ScheduledJob[] {
|
|
||||||
return Array.from(this.scheduledJobs.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the scheduler
|
|
||||||
*/
|
|
||||||
private startScheduler(): void {
|
|
||||||
if (this.schedulerInterval) return;
|
|
||||||
|
|
||||||
// Check every minute for scheduled jobs
|
|
||||||
this.schedulerInterval = setInterval(() => {
|
|
||||||
this.checkScheduledJobs();
|
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
// Also check immediately
|
|
||||||
this.checkScheduledJobs();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the scheduler
|
|
||||||
*/
|
|
||||||
private stopScheduler(): void {
|
|
||||||
if (this.schedulerInterval) {
|
|
||||||
clearInterval(this.schedulerInterval);
|
|
||||||
this.schedulerInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check and trigger scheduled jobs
|
|
||||||
*/
|
|
||||||
private checkScheduledJobs(): void {
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
for (const [name, scheduled] of this.scheduledJobs) {
|
|
||||||
if (scheduled.nextRun && scheduled.nextRun <= now) {
|
|
||||||
try {
|
|
||||||
// Add the job
|
|
||||||
this.add(name, scheduled.data);
|
|
||||||
|
|
||||||
// Update last run and calculate next run
|
|
||||||
scheduled.lastRun = now;
|
|
||||||
scheduled.nextRun = getNextCronDate(scheduled.cronExpression, now);
|
|
||||||
} catch (error) {
|
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
|
||||||
this.emit('queue:error', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CLEANUP
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Graceful shutdown
|
|
||||||
*/
|
|
||||||
async shutdown(timeout: number = 30000): Promise<void> {
|
|
||||||
this.stop();
|
|
||||||
this.isPaused = true;
|
|
||||||
|
|
||||||
// Wait for running jobs to complete (with timeout)
|
|
||||||
const waitStart = Date.now();
|
|
||||||
|
|
||||||
while (Date.now() - waitStart < timeout) {
|
|
||||||
const stats = this.getStats();
|
|
||||||
if (stats.running === 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear all jobs
|
|
||||||
this.jobs.clear();
|
|
||||||
this.completedJobIds.clear();
|
|
||||||
this.failedJobIds.clear();
|
|
||||||
this.scheduledJobs.clear();
|
|
||||||
|
|
||||||
this.removeAllListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SINGLETON INSTANCE (Optional)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
let defaultQueue: JobQueue | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the default job queue instance
|
|
||||||
*/
|
|
||||||
export function getDefaultQueue(): JobQueue {
|
|
||||||
if (!defaultQueue) {
|
|
||||||
defaultQueue = new JobQueue();
|
|
||||||
}
|
|
||||||
return defaultQueue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the default queue (for testing)
|
|
||||||
*/
|
|
||||||
export function resetDefaultQueue(): void {
|
|
||||||
if (defaultQueue) {
|
|
||||||
defaultQueue.shutdown().catch(() => {});
|
|
||||||
defaultQueue = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
/**
|
|
||||||
* REMCycleJob - Connection Discovery Processing
|
|
||||||
*
|
|
||||||
* Runs the REM (Rapid Eye Movement) cycle to discover hidden connections
|
|
||||||
* between knowledge nodes using semantic similarity, shared concepts,
|
|
||||||
* and keyword overlap analysis.
|
|
||||||
*
|
|
||||||
* Designed to run as a scheduled background job (e.g., every 6 hours).
|
|
||||||
*
|
|
||||||
* @module jobs/REMCycleJob
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { VestigeDatabase } from '../core/database.js';
|
|
||||||
import { runREMCycle } from '../core/rem-cycle.js';
|
|
||||||
import type { Job, JobHandler } from './JobQueue.js';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface REMCycleJobData {
|
|
||||||
/** Maximum number of nodes to analyze per cycle. Default: 50 */
|
|
||||||
maxAnalyze?: number;
|
|
||||||
/** Minimum connection strength threshold (0-1). Default: 0.3 */
|
|
||||||
minStrength?: number;
|
|
||||||
/** If true, only discover but don't create edges. Default: false */
|
|
||||||
dryRun?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface REMCycleJobResult {
|
|
||||||
/** Number of nodes analyzed */
|
|
||||||
nodesAnalyzed: number;
|
|
||||||
/** Number of potential connections discovered */
|
|
||||||
connectionsDiscovered: number;
|
|
||||||
/** Number of graph edges actually created */
|
|
||||||
connectionsCreated: number;
|
|
||||||
/** Time taken in milliseconds */
|
|
||||||
duration: number;
|
|
||||||
/** Details of discovered connections */
|
|
||||||
discoveries: Array<{
|
|
||||||
nodeA: string;
|
|
||||||
nodeB: string;
|
|
||||||
reason: string;
|
|
||||||
}>;
|
|
||||||
/** Timestamp when the job ran */
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// JOB HANDLER FACTORY
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a REM cycle job handler
|
|
||||||
*
|
|
||||||
* @param db - VestigeDatabase instance
|
|
||||||
* @returns Job handler function
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const db = new VestigeDatabase();
|
|
||||||
* const queue = new JobQueue();
|
|
||||||
*
|
|
||||||
* queue.register('rem-cycle', createREMCycleJobHandler(db), {
|
|
||||||
* concurrency: 1, // Only one REM cycle at a time
|
|
||||||
* retryDelay: 300000, // Wait 5 minutes before retry
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Schedule to run every 6 hours
|
|
||||||
* queue.schedule('rem-cycle', '0 *\/6 * * *', { maxAnalyze: 100 });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function createREMCycleJobHandler(
|
|
||||||
db: VestigeDatabase
|
|
||||||
): JobHandler<REMCycleJobData, REMCycleJobResult> {
|
|
||||||
return async (job: Job<REMCycleJobData>): Promise<REMCycleJobResult> => {
|
|
||||||
const options = {
|
|
||||||
maxAnalyze: job.data.maxAnalyze ?? 50,
|
|
||||||
minStrength: job.data.minStrength ?? 0.3,
|
|
||||||
dryRun: job.data.dryRun ?? false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run the REM cycle (async)
|
|
||||||
const cycleResult = await runREMCycle(db, options);
|
|
||||||
|
|
||||||
const result: REMCycleJobResult = {
|
|
||||||
nodesAnalyzed: cycleResult.nodesAnalyzed,
|
|
||||||
connectionsDiscovered: cycleResult.connectionsDiscovered,
|
|
||||||
connectionsCreated: cycleResult.connectionsCreated,
|
|
||||||
duration: cycleResult.duration,
|
|
||||||
discoveries: cycleResult.discoveries.map(d => ({
|
|
||||||
nodeA: d.nodeA,
|
|
||||||
nodeB: d.nodeB,
|
|
||||||
reason: d.reason,
|
|
||||||
})),
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// HELPER FUNCTIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preview what connections would be discovered without creating them
|
|
||||||
* Useful for testing or showing users potential discoveries
|
|
||||||
*/
|
|
||||||
export async function previewREMCycleJob(
|
|
||||||
db: VestigeDatabase,
|
|
||||||
maxAnalyze: number = 100
|
|
||||||
): Promise<REMCycleJobResult> {
|
|
||||||
const cycleResult = await runREMCycle(db, {
|
|
||||||
maxAnalyze,
|
|
||||||
dryRun: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
nodesAnalyzed: cycleResult.nodesAnalyzed,
|
|
||||||
connectionsDiscovered: cycleResult.connectionsDiscovered,
|
|
||||||
connectionsCreated: 0,
|
|
||||||
duration: cycleResult.duration,
|
|
||||||
discoveries: cycleResult.discoveries.map(d => ({
|
|
||||||
nodeA: d.nodeA,
|
|
||||||
nodeB: d.nodeB,
|
|
||||||
reason: d.reason,
|
|
||||||
})),
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
/**
|
|
||||||
* Jobs Module - Background Job Processing for Vestige MCP
|
|
||||||
*
|
|
||||||
* This module provides a production-ready job queue system with:
|
|
||||||
* - Priority-based scheduling
|
|
||||||
* - Retry logic with exponential backoff
|
|
||||||
* - Concurrency control
|
|
||||||
* - Cron-like recurring job scheduling
|
|
||||||
* - Event-driven architecture
|
|
||||||
*
|
|
||||||
* @module jobs
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* import {
|
|
||||||
* JobQueue,
|
|
||||||
* createDecayJobHandler,
|
|
||||||
* createREMCycleJobHandler,
|
|
||||||
* createConsolidationJobHandler,
|
|
||||||
* } from './jobs';
|
|
||||||
* import { VestigeDatabase } from './core';
|
|
||||||
*
|
|
||||||
* // Initialize
|
|
||||||
* const db = new VestigeDatabase();
|
|
||||||
* const queue = new JobQueue();
|
|
||||||
*
|
|
||||||
* // Register job handlers
|
|
||||||
* queue.register('decay', createDecayJobHandler(db), { concurrency: 1 });
|
|
||||||
* queue.register('rem-cycle', createREMCycleJobHandler(db), { concurrency: 1 });
|
|
||||||
* queue.register('consolidation', createConsolidationJobHandler(db), { concurrency: 1 });
|
|
||||||
*
|
|
||||||
* // Schedule recurring jobs
|
|
||||||
* queue.schedule('decay', '0 3 * * *', {}); // Daily at 3 AM
|
|
||||||
* queue.schedule('rem-cycle', '0 *\/6 * * *', {}); // Every 6 hours
|
|
||||||
* queue.schedule('consolidation', '0 4 * * 0', {}); // Weekly on Sunday at 4 AM
|
|
||||||
*
|
|
||||||
* // Start processing
|
|
||||||
* queue.start();
|
|
||||||
*
|
|
||||||
* // Listen to events
|
|
||||||
* queue.on('job:completed', (job, result) => {
|
|
||||||
* console.log(`Job ${job.name} completed:`, result);
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* queue.on('job:failed', (job, error) => {
|
|
||||||
* console.error(`Job ${job.name} failed:`, error);
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Add one-off jobs
|
|
||||||
* queue.add('rem-cycle', { maxAnalyze: 200 }, { priority: 10 });
|
|
||||||
*
|
|
||||||
* // Graceful shutdown
|
|
||||||
* process.on('SIGTERM', async () => {
|
|
||||||
* await queue.shutdown();
|
|
||||||
* db.close();
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Core job queue
|
|
||||||
export {
|
|
||||||
JobQueue,
|
|
||||||
getDefaultQueue,
|
|
||||||
resetDefaultQueue,
|
|
||||||
type Job,
|
|
||||||
type JobResult,
|
|
||||||
type JobHandler,
|
|
||||||
type JobOptions,
|
|
||||||
type JobDefinition,
|
|
||||||
type JobStatus,
|
|
||||||
type ScheduledJob,
|
|
||||||
type QueueStats,
|
|
||||||
type JobQueueEvents,
|
|
||||||
} from './JobQueue.js';
|
|
||||||
|
|
||||||
// Decay job
|
|
||||||
export {
|
|
||||||
createDecayJobHandler,
|
|
||||||
getCriticallyDecayedNodes,
|
|
||||||
type DecayJobData,
|
|
||||||
type DecayJobResult,
|
|
||||||
} from './DecayJob.js';
|
|
||||||
|
|
||||||
// REM cycle job
|
|
||||||
export {
|
|
||||||
createREMCycleJobHandler,
|
|
||||||
previewREMCycleJob,
|
|
||||||
type REMCycleJobData,
|
|
||||||
type REMCycleJobResult,
|
|
||||||
} from './REMCycleJob.js';
|
|
||||||
|
|
||||||
// Consolidation job
|
|
||||||
export {
|
|
||||||
createConsolidationJobHandler,
|
|
||||||
previewConsolidation,
|
|
||||||
getConsolidationMetrics,
|
|
||||||
type ConsolidationJobData,
|
|
||||||
type ConsolidationJobResult,
|
|
||||||
} from './ConsolidationJob.js';
|
|
||||||
|
|
@ -1,659 +0,0 @@
|
||||||
import Database from 'better-sqlite3';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import type { GraphEdge } from '../core/types.js';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// EDGE TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export type EdgeType =
|
|
||||||
| 'relates_to'
|
|
||||||
| 'contradicts'
|
|
||||||
| 'supports'
|
|
||||||
| 'similar_to'
|
|
||||||
| 'part_of'
|
|
||||||
| 'caused_by'
|
|
||||||
| 'mentions'
|
|
||||||
| 'derived_from'
|
|
||||||
| 'references'
|
|
||||||
| 'follows'
|
|
||||||
| 'person_mentioned'
|
|
||||||
| 'concept_instance';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// INPUT TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface GraphEdgeInput {
|
|
||||||
fromId: string;
|
|
||||||
toId: string;
|
|
||||||
edgeType: EdgeType;
|
|
||||||
weight?: number;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TRANSITIVE PATH TYPE
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface TransitivePath {
|
|
||||||
path: string[];
|
|
||||||
totalWeight: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// RWLOCK - Read-Write Lock for concurrent access control
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A simple read-write lock implementation.
|
|
||||||
* - Multiple readers can hold the lock concurrently
|
|
||||||
* - Writers have exclusive access
|
|
||||||
* - Writers wait for all readers to release
|
|
||||||
* - Readers wait if a writer is active or waiting
|
|
||||||
*/
|
|
||||||
export class RWLock {
|
|
||||||
private readers = 0;
|
|
||||||
private writer = false;
|
|
||||||
private writerQueue: (() => void)[] = [];
|
|
||||||
private readerQueue: (() => void)[] = [];
|
|
||||||
|
|
||||||
async acquireRead(): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!this.writer && this.writerQueue.length === 0) {
|
|
||||||
this.readers++;
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
this.readerQueue.push(() => {
|
|
||||||
this.readers++;
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
releaseRead(): void {
|
|
||||||
this.readers--;
|
|
||||||
if (this.readers === 0 && this.writerQueue.length > 0) {
|
|
||||||
this.writer = true;
|
|
||||||
const next = this.writerQueue.shift();
|
|
||||||
if (next) next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async acquireWrite(): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!this.writer && this.readers === 0) {
|
|
||||||
this.writer = true;
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
this.writerQueue.push(resolve);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
releaseWrite(): void {
|
|
||||||
this.writer = false;
|
|
||||||
// Prefer waiting readers over writers to prevent writer starvation
|
|
||||||
if (this.readerQueue.length > 0) {
|
|
||||||
const readers = this.readerQueue.splice(0);
|
|
||||||
for (const reader of readers) {
|
|
||||||
reader();
|
|
||||||
}
|
|
||||||
} else if (this.writerQueue.length > 0) {
|
|
||||||
this.writer = true;
|
|
||||||
const next = this.writerQueue.shift();
|
|
||||||
if (next) next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a function with read lock
|
|
||||||
*/
|
|
||||||
async withRead<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
||||||
await this.acquireRead();
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} finally {
|
|
||||||
this.releaseRead();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a function with write lock
|
|
||||||
*/
|
|
||||||
async withWrite<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
||||||
await this.acquireWrite();
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} finally {
|
|
||||||
this.releaseWrite();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// INTERFACE
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface IEdgeRepository {
|
|
||||||
create(input: GraphEdgeInput): Promise<GraphEdge>;
|
|
||||||
findById(id: string): Promise<GraphEdge | null>;
|
|
||||||
findByNodes(fromId: string, toId: string, edgeType?: string): Promise<GraphEdge | null>;
|
|
||||||
delete(id: string): Promise<boolean>;
|
|
||||||
deleteByNodes(fromId: string, toId: string): Promise<boolean>;
|
|
||||||
getEdgesFrom(nodeId: string): Promise<GraphEdge[]>;
|
|
||||||
getEdgesTo(nodeId: string): Promise<GraphEdge[]>;
|
|
||||||
getAllEdges(nodeId: string): Promise<GraphEdge[]>;
|
|
||||||
getRelatedNodeIds(nodeId: string, depth?: number): Promise<string[]>;
|
|
||||||
updateWeight(id: string, weight: number): Promise<void>;
|
|
||||||
strengthenEdge(id: string, boost: number): Promise<void>;
|
|
||||||
pruneWeakEdges(threshold: number): Promise<number>;
|
|
||||||
getTransitivePaths(nodeId: string, maxDepth: number): Promise<TransitivePath[]>;
|
|
||||||
strengthenConnectedEdges(nodeId: string, boost: number): Promise<number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// ERROR CLASS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize error message to prevent sensitive data leakage
|
|
||||||
*/
|
|
||||||
function sanitizeErrorMessage(message: string): string {
|
|
||||||
let sanitized = message.replace(/\/[^\s]+/g, '[PATH]');
|
|
||||||
sanitized = sanitized.replace(/SELECT|INSERT|UPDATE|DELETE|DROP|CREATE/gi, '[SQL]');
|
|
||||||
sanitized = sanitized.replace(/\b(password|secret|key|token|auth)\s*[=:]\s*\S+/gi, '[REDACTED]');
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EdgeRepositoryError extends Error {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public readonly code: string,
|
|
||||||
cause?: unknown
|
|
||||||
) {
|
|
||||||
super(sanitizeErrorMessage(message));
|
|
||||||
this.name = 'EdgeRepositoryError';
|
|
||||||
if (process.env['NODE_ENV'] === 'development' && cause) {
|
|
||||||
this.cause = cause;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// IMPLEMENTATION
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export class EdgeRepository implements IEdgeRepository {
|
|
||||||
private readonly lock = new RWLock();
|
|
||||||
|
|
||||||
constructor(private readonly db: Database.Database) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new edge between two nodes.
|
|
||||||
* Handles UNIQUE constraint gracefully by using INSERT OR REPLACE.
|
|
||||||
*/
|
|
||||||
async create(input: GraphEdgeInput): Promise<GraphEdge> {
|
|
||||||
return this.lock.withWrite(() => {
|
|
||||||
try {
|
|
||||||
const id = nanoid();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const weight = input.weight ?? 0.5;
|
|
||||||
|
|
||||||
// Check if edge already exists
|
|
||||||
const existing = this.db.prepare(`
|
|
||||||
SELECT id FROM graph_edges
|
|
||||||
WHERE from_id = ? AND to_id = ? AND edge_type = ?
|
|
||||||
`).get(input.fromId, input.toId, input.edgeType) as { id: string } | undefined;
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
// Update existing edge - boost weight slightly
|
|
||||||
const updateStmt = this.db.prepare(`
|
|
||||||
UPDATE graph_edges
|
|
||||||
SET weight = MIN(1.0, weight + ?),
|
|
||||||
metadata = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
updateStmt.run(weight * 0.1, JSON.stringify(input.metadata || {}), existing.id);
|
|
||||||
|
|
||||||
// Return the updated edge
|
|
||||||
const row = this.db.prepare('SELECT * FROM graph_edges WHERE id = ?')
|
|
||||||
.get(existing.id) as Record<string, unknown>;
|
|
||||||
return this.rowToEdge(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert new edge
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
INSERT INTO graph_edges (
|
|
||||||
id, from_id, to_id, edge_type, weight, metadata, created_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
|
|
||||||
stmt.run(
|
|
||||||
id,
|
|
||||||
input.fromId,
|
|
||||||
input.toId,
|
|
||||||
input.edgeType,
|
|
||||||
weight,
|
|
||||||
JSON.stringify(input.metadata || {}),
|
|
||||||
now
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
fromId: input.fromId,
|
|
||||||
toId: input.toId,
|
|
||||||
edgeType: input.edgeType as GraphEdge['edgeType'],
|
|
||||||
weight,
|
|
||||||
metadata: input.metadata || {},
|
|
||||||
createdAt: new Date(now),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
throw new EdgeRepositoryError(
|
|
||||||
'Failed to create edge',
|
|
||||||
'CREATE_EDGE_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find an edge by its ID.
|
|
||||||
*/
|
|
||||||
async findById(id: string): Promise<GraphEdge | null> {
|
|
||||||
return this.lock.withRead(() => {
|
|
||||||
try {
|
|
||||||
const stmt = this.db.prepare('SELECT * FROM graph_edges WHERE id = ?');
|
|
||||||
const row = stmt.get(id) as Record<string, unknown> | undefined;
|
|
||||||
if (!row) return null;
|
|
||||||
return this.rowToEdge(row);
|
|
||||||
} catch (error) {
|
|
||||||
throw new EdgeRepositoryError(
|
|
||||||
`Failed to find edge: ${id}`,
|
|
||||||
'FIND_EDGE_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find an edge by its source and target nodes.
|
|
||||||
* Optionally filter by edge type.
|
|
||||||
*/
|
|
||||||
async findByNodes(fromId: string, toId: string, edgeType?: string): Promise<GraphEdge | null> {
|
|
||||||
return this.lock.withRead(() => {
|
|
||||||
try {
|
|
||||||
let stmt;
|
|
||||||
let row: Record<string, unknown> | undefined;
|
|
||||||
|
|
||||||
if (edgeType) {
|
|
||||||
stmt = this.db.prepare(`
|
|
||||||
SELECT * FROM graph_edges
|
|
||||||
WHERE from_id = ? AND to_id = ? AND edge_type = ?
|
|
||||||
`);
|
|
||||||
row = stmt.get(fromId, toId, edgeType) as Record<string, unknown> | undefined;
|
|
||||||
} else {
|
|
||||||
stmt = this.db.prepare(`
|
|
||||||
SELECT * FROM graph_edges
|
|
||||||
WHERE from_id = ? AND to_id = ?
|
|
||||||
`);
|
|
||||||
row = stmt.get(fromId, toId) as Record<string, unknown> | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!row) return null;
|
|
||||||
return this.rowToEdge(row);
|
|
||||||
} catch (error) {
|
|
||||||
throw new EdgeRepositoryError(
|
|
||||||
`Failed to find edge by nodes`,
|
|
||||||
'FIND_BY_NODES_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an edge by its ID.
|
|
||||||
*/
|
|
||||||
async delete(id: string): Promise<boolean> {
|
|
||||||
return this.lock.withWrite(() => {
|
|
||||||
try {
|
|
||||||
const stmt = this.db.prepare('DELETE FROM graph_edges WHERE id = ?');
|
|
||||||
const result = stmt.run(id);
|
|
||||||
return result.changes > 0;
|
|
||||||
} catch (error) {
|
|
||||||
throw new EdgeRepositoryError(
|
|
||||||
`Failed to delete edge: ${id}`,
|
|
||||||
'DELETE_EDGE_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete all edges between two nodes (in both directions).
|
|
||||||
*/
|
|
||||||
async deleteByNodes(fromId: string, toId: string): Promise<boolean> {
|
|
||||||
return this.lock.withWrite(() => {
|
|
||||||
try {
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
DELETE FROM graph_edges
|
|
||||||
WHERE (from_id = ? AND to_id = ?) OR (from_id = ? AND to_id = ?)
|
|
||||||
`);
|
|
||||||
const result = stmt.run(fromId, toId, toId, fromId);
|
|
||||||
return result.changes > 0;
|
|
||||||
} catch (error) {
|
|
||||||
throw new EdgeRepositoryError(
|
|
||||||
`Failed to delete edges between nodes`,
|
|
||||||
'DELETE_BY_NODES_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all edges originating from a node.
|
|
||||||
*/
|
|
||||||
async getEdgesFrom(nodeId: string): Promise<GraphEdge[]> {
|
|
||||||
return this.lock.withRead(() => {
|
|
||||||
try {
|
|
||||||
const stmt = this.db.prepare('SELECT * FROM graph_edges WHERE from_id = ?');
|
|
||||||
const rows = stmt.all(nodeId) as Record<string, unknown>[];
|
|
||||||
return rows.map(row => this.rowToEdge(row));
|
|
||||||
} catch (error) {
|
|
||||||
throw new EdgeRepositoryError(
|
|
||||||
`Failed to get edges from node: ${nodeId}`,
|
|
||||||
'GET_EDGES_FROM_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all edges pointing to a node.
|
|
||||||
*/
|
|
||||||
async getEdgesTo(nodeId: string): Promise<GraphEdge[]> {
|
|
||||||
return this.lock.withRead(() => {
|
|
||||||
try {
|
|
||||||
const stmt = this.db.prepare('SELECT * FROM graph_edges WHERE to_id = ?');
|
|
||||||
const rows = stmt.all(nodeId) as Record<string, unknown>[];
|
|
||||||
return rows.map(row => this.rowToEdge(row));
|
|
||||||
} catch (error) {
|
|
||||||
throw new EdgeRepositoryError(
|
|
||||||
`Failed to get edges to node: ${nodeId}`,
|
|
||||||
'GET_EDGES_TO_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all edges connected to a node (both incoming and outgoing).
|
|
||||||
*/
|
|
||||||
async getAllEdges(nodeId: string): Promise<GraphEdge[]> {
|
|
||||||
return this.lock.withRead(() => {
|
|
||||||
try {
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT * FROM graph_edges
|
|
||||||
WHERE from_id = ? OR to_id = ?
|
|
||||||
`);
|
|
||||||
const rows = stmt.all(nodeId, nodeId) as Record<string, unknown>[];
|
|
||||||
return rows.map(row => this.rowToEdge(row));
|
|
||||||
} catch (error) {
|
|
||||||
throw new EdgeRepositoryError(
|
|
||||||
`Failed to get all edges for node: ${nodeId}`,
|
|
||||||
'GET_ALL_EDGES_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get related node IDs using BFS traversal.
|
|
||||||
* Extracted from database.ts getRelatedNodes().
|
|
||||||
*/
|
|
||||||
async getRelatedNodeIds(nodeId: string, depth: number = 1): Promise<string[]> {
|
|
||||||
return this.lock.withRead(() => {
|
|
||||||
try {
|
|
||||||
const visited = new Set<string>();
|
|
||||||
let current = [nodeId];
|
|
||||||
|
|
||||||
for (let d = 0; d < depth; d++) {
|
|
||||||
if (current.length === 0) break;
|
|
||||||
|
|
||||||
const placeholders = current.map(() => '?').join(',');
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT DISTINCT
|
|
||||||
CASE WHEN from_id IN (${placeholders}) THEN to_id ELSE from_id END as related_id
|
|
||||||
FROM graph_edges
|
|
||||||
WHERE from_id IN (${placeholders}) OR to_id IN (${placeholders})
|
|
||||||
`);
|
|
||||||
|
|
||||||
const params = [...current, ...current, ...current];
|
|
||||||
const rows = stmt.all(...params) as { related_id: string }[];
|
|
||||||
|
|
||||||
const newNodes: string[] = [];
|
|
||||||
for (const row of rows) {
|
|
||||||
if (!visited.has(row.related_id) && row.related_id !== nodeId) {
|
|
||||||
visited.add(row.related_id);
|
|
||||||
newNodes.push(row.related_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
current = newNodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(visited);
|
|
||||||
} catch (error) {
|
|
||||||
throw new EdgeRepositoryError(
|
|
||||||
`Failed to get related nodes: ${nodeId}`,
|
|
||||||
'GET_RELATED_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the weight of an edge.
|
|
||||||
*/
|
|
||||||
async updateWeight(id: string, weight: number): Promise<void> {
|
|
||||||
return this.lock.withWrite(() => {
|
|
||||||
try {
|
|
||||||
// Clamp weight to valid range
|
|
||||||
const clampedWeight = Math.max(0, Math.min(1, weight));
|
|
||||||
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
UPDATE graph_edges SET weight = ? WHERE id = ?
|
|
||||||
`);
|
|
||||||
stmt.run(clampedWeight, id);
|
|
||||||
} catch (error) {
|
|
||||||
throw new EdgeRepositoryError(
|
|
||||||
`Failed to update edge weight: ${id}`,
|
|
||||||
'UPDATE_WEIGHT_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strengthen an edge by boosting its weight.
|
|
||||||
* Used for spreading activation.
|
|
||||||
*/
|
|
||||||
async strengthenEdge(id: string, boost: number): Promise<void> {
|
|
||||||
return this.lock.withWrite(() => {
|
|
||||||
try {
|
|
||||||
// Ensure boost is positive and reasonable
|
|
||||||
const safeBoost = Math.max(0, Math.min(0.5, boost));
|
|
||||||
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
UPDATE graph_edges
|
|
||||||
SET weight = MIN(1.0, weight + ?)
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
stmt.run(safeBoost, id);
|
|
||||||
} catch (error) {
|
|
||||||
throw new EdgeRepositoryError(
|
|
||||||
`Failed to strengthen edge: ${id}`,
|
|
||||||
'STRENGTHEN_EDGE_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prune edges with weight below a threshold.
|
|
||||||
* Returns the number of edges removed.
|
|
||||||
*/
|
|
||||||
async pruneWeakEdges(threshold: number): Promise<number> {
|
|
||||||
return this.lock.withWrite(() => {
|
|
||||||
try {
|
|
||||||
// Validate threshold
|
|
||||||
const safeThreshold = Math.max(0, Math.min(1, threshold));
|
|
||||||
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
DELETE FROM graph_edges WHERE weight < ?
|
|
||||||
`);
|
|
||||||
const result = stmt.run(safeThreshold);
|
|
||||||
return result.changes;
|
|
||||||
} catch (error) {
|
|
||||||
throw new EdgeRepositoryError(
|
|
||||||
'Failed to prune weak edges',
|
|
||||||
'PRUNE_EDGES_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all transitive paths from a node up to maxDepth.
|
|
||||||
* Used for spreading activation in graph traversal.
|
|
||||||
*/
|
|
||||||
async getTransitivePaths(nodeId: string, maxDepth: number): Promise<TransitivePath[]> {
|
|
||||||
return this.lock.withRead(() => {
|
|
||||||
try {
|
|
||||||
const paths: TransitivePath[] = [];
|
|
||||||
const visited = new Set<string>();
|
|
||||||
|
|
||||||
// BFS with path tracking
|
|
||||||
interface QueueItem {
|
|
||||||
nodeId: string;
|
|
||||||
path: string[];
|
|
||||||
totalWeight: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const queue: QueueItem[] = [{ nodeId, path: [nodeId], totalWeight: 1.0 }];
|
|
||||||
visited.add(nodeId);
|
|
||||||
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const current = queue.shift()!;
|
|
||||||
|
|
||||||
if (current.path.length > maxDepth + 1) continue;
|
|
||||||
|
|
||||||
// Get all connected edges
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT to_id, from_id, weight FROM graph_edges
|
|
||||||
WHERE from_id = ? OR to_id = ?
|
|
||||||
`);
|
|
||||||
const edges = stmt.all(current.nodeId, current.nodeId) as {
|
|
||||||
to_id: string;
|
|
||||||
from_id: string;
|
|
||||||
weight: number;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
for (const edge of edges) {
|
|
||||||
const nextNode = edge.from_id === current.nodeId ? edge.to_id : edge.from_id;
|
|
||||||
|
|
||||||
if (!visited.has(nextNode)) {
|
|
||||||
visited.add(nextNode);
|
|
||||||
const newPath = [...current.path, nextNode];
|
|
||||||
const newWeight = current.totalWeight * edge.weight;
|
|
||||||
|
|
||||||
paths.push({ path: newPath, totalWeight: newWeight });
|
|
||||||
|
|
||||||
if (newPath.length <= maxDepth) {
|
|
||||||
queue.push({
|
|
||||||
nodeId: nextNode,
|
|
||||||
path: newPath,
|
|
||||||
totalWeight: newWeight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by total weight (descending) for relevance
|
|
||||||
return paths.sort((a, b) => b.totalWeight - a.totalWeight);
|
|
||||||
} catch (error) {
|
|
||||||
throw new EdgeRepositoryError(
|
|
||||||
`Failed to get transitive paths: ${nodeId}`,
|
|
||||||
'GET_PATHS_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strengthen all edges connected to a node.
|
|
||||||
* Used for memory reconsolidation.
|
|
||||||
* Returns the number of edges strengthened.
|
|
||||||
*/
|
|
||||||
async strengthenConnectedEdges(nodeId: string, boost: number): Promise<number> {
|
|
||||||
return this.lock.withWrite(() => {
|
|
||||||
try {
|
|
||||||
// Ensure boost is positive and reasonable
|
|
||||||
const safeBoost = Math.max(0, Math.min(0.5, boost));
|
|
||||||
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
UPDATE graph_edges
|
|
||||||
SET weight = MIN(1.0, weight + ?)
|
|
||||||
WHERE from_id = ? OR to_id = ?
|
|
||||||
`);
|
|
||||||
const result = stmt.run(safeBoost, nodeId, nodeId);
|
|
||||||
return result.changes;
|
|
||||||
} catch (error) {
|
|
||||||
throw new EdgeRepositoryError(
|
|
||||||
`Failed to strengthen connected edges: ${nodeId}`,
|
|
||||||
'STRENGTHEN_CONNECTED_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// HELPERS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
private rowToEdge(row: Record<string, unknown>): GraphEdge {
|
|
||||||
return {
|
|
||||||
id: row['id'] as string,
|
|
||||||
fromId: row['from_id'] as string,
|
|
||||||
toId: row['to_id'] as string,
|
|
||||||
edgeType: row['edge_type'] as GraphEdge['edgeType'],
|
|
||||||
weight: row['weight'] as number,
|
|
||||||
metadata: this.safeJsonParse(row['metadata'] as string, {}),
|
|
||||||
createdAt: new Date(row['created_at'] as string),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private safeJsonParse<T>(value: string | null | undefined, fallback: T): T {
|
|
||||||
if (!value) return fallback;
|
|
||||||
try {
|
|
||||||
return JSON.parse(value) as T;
|
|
||||||
} catch {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,879 +0,0 @@
|
||||||
/**
|
|
||||||
* NodeRepository - Repository for knowledge node operations
|
|
||||||
*
|
|
||||||
* Extracted from the monolithic database.ts to provide a focused, testable
|
|
||||||
* interface for node CRUD operations with proper concurrency control.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type Database from 'better-sqlite3';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import type {
|
|
||||||
KnowledgeNode,
|
|
||||||
KnowledgeNodeInput,
|
|
||||||
} from '../core/types.js';
|
|
||||||
import { RWLock } from '../utils/mutex.js';
|
|
||||||
import { safeJsonParse } from '../utils/json.js';
|
|
||||||
import { NotFoundError, ValidationError, DatabaseError } from '../core/errors.js';
|
|
||||||
import { analyzeSentimentIntensity, captureGitContext } from '../core/database.js';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONSTANTS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const DEFAULT_LIMIT = 50;
|
|
||||||
const MAX_LIMIT = 500;
|
|
||||||
|
|
||||||
// Input validation limits
|
|
||||||
const MAX_CONTENT_LENGTH = 1_000_000; // 1MB max content
|
|
||||||
const MAX_QUERY_LENGTH = 10_000; // 10KB max query
|
|
||||||
const MAX_TAGS_COUNT = 100; // Max tags per node
|
|
||||||
|
|
||||||
// SM-2 Spaced Repetition Constants
|
|
||||||
const SM2_EASE_FACTOR = 2.5;
|
|
||||||
const SM2_LAPSE_THRESHOLD = 0.3;
|
|
||||||
const SM2_MIN_STABILITY = 1.0;
|
|
||||||
const SM2_MAX_STABILITY = 365.0;
|
|
||||||
|
|
||||||
// Sentiment-Weighted Decay Constants
|
|
||||||
const SENTIMENT_STABILITY_BOOST = 2.0;
|
|
||||||
const SENTIMENT_MIN_BOOST = 1.0;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface PaginationOptions {
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedResult<T> {
|
|
||||||
items: T[];
|
|
||||||
total: number;
|
|
||||||
limit: number;
|
|
||||||
offset: number;
|
|
||||||
hasMore: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GitContext {
|
|
||||||
branch?: string;
|
|
||||||
commit?: string;
|
|
||||||
commitMessage?: string;
|
|
||||||
repoPath?: string;
|
|
||||||
dirty?: boolean;
|
|
||||||
changedFiles?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// INTERFACE
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface INodeRepository {
|
|
||||||
findById(id: string): Promise<KnowledgeNode | null>;
|
|
||||||
findByIds(ids: string[]): Promise<KnowledgeNode[]>;
|
|
||||||
create(input: KnowledgeNodeInput): Promise<KnowledgeNode>;
|
|
||||||
update(id: string, updates: Partial<KnowledgeNodeInput>): Promise<KnowledgeNode | null>;
|
|
||||||
delete(id: string): Promise<boolean>;
|
|
||||||
search(query: string, options?: PaginationOptions): Promise<PaginatedResult<KnowledgeNode>>;
|
|
||||||
getRecent(options?: PaginationOptions): Promise<PaginatedResult<KnowledgeNode>>;
|
|
||||||
getDecaying(threshold: number, options?: PaginationOptions): Promise<PaginatedResult<KnowledgeNode>>;
|
|
||||||
getDueForReview(options?: PaginationOptions): Promise<PaginatedResult<KnowledgeNode>>;
|
|
||||||
recordAccess(id: string): Promise<void>;
|
|
||||||
markReviewed(id: string): Promise<KnowledgeNode>;
|
|
||||||
applyDecay(id: string): Promise<number>;
|
|
||||||
applyDecayAll(): Promise<number>;
|
|
||||||
findByTag(tag: string, options?: PaginationOptions): Promise<PaginatedResult<KnowledgeNode>>;
|
|
||||||
findByPerson(personName: string, options?: PaginationOptions): Promise<PaginatedResult<KnowledgeNode>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// VALIDATION HELPERS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate string length for inputs
|
|
||||||
*/
|
|
||||||
function validateStringLength(value: string, maxLength: number, fieldName: string): void {
|
|
||||||
if (value && value.length > maxLength) {
|
|
||||||
throw new ValidationError(
|
|
||||||
`${fieldName} exceeds maximum length of ${maxLength} characters`,
|
|
||||||
{ field: fieldName.toLowerCase(), maxLength, actualLength: value.length }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate array length for inputs
|
|
||||||
*/
|
|
||||||
function validateArrayLength<T>(arr: T[] | undefined, maxLength: number, fieldName: string): void {
|
|
||||||
if (arr && arr.length > maxLength) {
|
|
||||||
throw new ValidationError(
|
|
||||||
`${fieldName} exceeds maximum count of ${maxLength} items`,
|
|
||||||
{ field: fieldName.toLowerCase(), maxLength, actualLength: arr.length }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize pagination options
|
|
||||||
*/
|
|
||||||
function normalizePagination(options: PaginationOptions = {}): { limit: number; offset: number } {
|
|
||||||
const { limit = DEFAULT_LIMIT, offset = 0 } = options;
|
|
||||||
return {
|
|
||||||
limit: Math.min(Math.max(1, limit), MAX_LIMIT),
|
|
||||||
offset: Math.max(0, offset),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// IMPLEMENTATION
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export class NodeRepository implements INodeRepository {
|
|
||||||
private readonly lock = new RWLock();
|
|
||||||
|
|
||||||
constructor(private readonly db: Database.Database) {}
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// READ OPERATIONS
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async findById(id: string): Promise<KnowledgeNode | null> {
|
|
||||||
return this.lock.withReadLock(async () => {
|
|
||||||
try {
|
|
||||||
const stmt = this.db.prepare('SELECT * FROM knowledge_nodes WHERE id = ?');
|
|
||||||
const row = stmt.get(id) as Record<string, unknown> | undefined;
|
|
||||||
if (!row) return null;
|
|
||||||
return this.rowToEntity(row);
|
|
||||||
} catch (error) {
|
|
||||||
throw new DatabaseError(`Failed to get node: ${id}`, error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByIds(ids: string[]): Promise<KnowledgeNode[]> {
|
|
||||||
if (ids.length === 0) return [];
|
|
||||||
|
|
||||||
return this.lock.withReadLock(async () => {
|
|
||||||
try {
|
|
||||||
const placeholders = ids.map(() => '?').join(',');
|
|
||||||
const stmt = this.db.prepare(
|
|
||||||
`SELECT * FROM knowledge_nodes WHERE id IN (${placeholders})`
|
|
||||||
);
|
|
||||||
const rows = stmt.all(...ids) as Record<string, unknown>[];
|
|
||||||
return rows.map((row) => this.rowToEntity(row));
|
|
||||||
} catch (error) {
|
|
||||||
throw new DatabaseError('Failed to get nodes by IDs', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async search(query: string, options: PaginationOptions = {}): Promise<PaginatedResult<KnowledgeNode>> {
|
|
||||||
return this.lock.withReadLock(async () => {
|
|
||||||
try {
|
|
||||||
// Input validation
|
|
||||||
validateStringLength(query, MAX_QUERY_LENGTH, 'Search query');
|
|
||||||
|
|
||||||
// Sanitize FTS5 query to prevent injection
|
|
||||||
const sanitizedQuery = query
|
|
||||||
.replace(/[^\w\s\-]/g, ' ')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if (!sanitizedQuery) {
|
|
||||||
return {
|
|
||||||
items: [],
|
|
||||||
total: 0,
|
|
||||||
limit: DEFAULT_LIMIT,
|
|
||||||
offset: 0,
|
|
||||||
hasMore: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { limit, offset } = normalizePagination(options);
|
|
||||||
|
|
||||||
// Get total count
|
|
||||||
const countStmt = this.db.prepare(`
|
|
||||||
SELECT COUNT(*) as total FROM knowledge_nodes kn
|
|
||||||
JOIN knowledge_fts fts ON kn.id = fts.id
|
|
||||||
WHERE knowledge_fts MATCH ?
|
|
||||||
`);
|
|
||||||
const countResult = countStmt.get(sanitizedQuery) as { total: number };
|
|
||||||
const total = countResult.total;
|
|
||||||
|
|
||||||
// Get paginated results
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT kn.* FROM knowledge_nodes kn
|
|
||||||
JOIN knowledge_fts fts ON kn.id = fts.id
|
|
||||||
WHERE knowledge_fts MATCH ?
|
|
||||||
ORDER BY rank
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`);
|
|
||||||
const rows = stmt.all(sanitizedQuery, limit, offset) as Record<string, unknown>[];
|
|
||||||
const items = rows.map((row) => this.rowToEntity(row));
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
hasMore: offset + items.length < total,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ValidationError) throw error;
|
|
||||||
throw new DatabaseError('Search operation failed', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRecent(options: PaginationOptions = {}): Promise<PaginatedResult<KnowledgeNode>> {
|
|
||||||
return this.lock.withReadLock(async () => {
|
|
||||||
try {
|
|
||||||
const { limit, offset } = normalizePagination(options);
|
|
||||||
|
|
||||||
// Get total count
|
|
||||||
const countResult = this.db.prepare('SELECT COUNT(*) as total FROM knowledge_nodes').get() as {
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
const total = countResult.total;
|
|
||||||
|
|
||||||
// Get paginated results
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT * FROM knowledge_nodes
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`);
|
|
||||||
const rows = stmt.all(limit, offset) as Record<string, unknown>[];
|
|
||||||
const items = rows.map((row) => this.rowToEntity(row));
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
hasMore: offset + items.length < total,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
throw new DatabaseError('Failed to get recent nodes', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDecaying(
|
|
||||||
threshold: number = 0.5,
|
|
||||||
options: PaginationOptions = {}
|
|
||||||
): Promise<PaginatedResult<KnowledgeNode>> {
|
|
||||||
return this.lock.withReadLock(async () => {
|
|
||||||
try {
|
|
||||||
const { limit, offset } = normalizePagination(options);
|
|
||||||
|
|
||||||
// Get total count
|
|
||||||
const countStmt = this.db.prepare(`
|
|
||||||
SELECT COUNT(*) as total FROM knowledge_nodes
|
|
||||||
WHERE retention_strength < ?
|
|
||||||
`);
|
|
||||||
const countResult = countStmt.get(threshold) as { total: number };
|
|
||||||
const total = countResult.total;
|
|
||||||
|
|
||||||
// Get paginated results
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT * FROM knowledge_nodes
|
|
||||||
WHERE retention_strength < ?
|
|
||||||
ORDER BY retention_strength ASC
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`);
|
|
||||||
const rows = stmt.all(threshold, limit, offset) as Record<string, unknown>[];
|
|
||||||
const items = rows.map((row) => this.rowToEntity(row));
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
hasMore: offset + items.length < total,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
throw new DatabaseError('Failed to get decaying nodes', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDueForReview(options: PaginationOptions = {}): Promise<PaginatedResult<KnowledgeNode>> {
|
|
||||||
return this.lock.withReadLock(async () => {
|
|
||||||
try {
|
|
||||||
const { limit, offset } = normalizePagination(options);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
// Get total count
|
|
||||||
const countStmt = this.db.prepare(`
|
|
||||||
SELECT COUNT(*) as total FROM knowledge_nodes
|
|
||||||
WHERE next_review_date IS NOT NULL AND next_review_date <= ?
|
|
||||||
`);
|
|
||||||
const countResult = countStmt.get(now) as { total: number };
|
|
||||||
const total = countResult.total;
|
|
||||||
|
|
||||||
// Get paginated results, ordered by retention strength (most urgent first)
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT * FROM knowledge_nodes
|
|
||||||
WHERE next_review_date IS NOT NULL AND next_review_date <= ?
|
|
||||||
ORDER BY retention_strength ASC, next_review_date ASC
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`);
|
|
||||||
const rows = stmt.all(now, limit, offset) as Record<string, unknown>[];
|
|
||||||
const items = rows.map((row) => this.rowToEntity(row));
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
hasMore: offset + items.length < total,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
throw new DatabaseError('Failed to get nodes due for review', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByTag(tag: string, options: PaginationOptions = {}): Promise<PaginatedResult<KnowledgeNode>> {
|
|
||||||
return this.lock.withReadLock(async () => {
|
|
||||||
try {
|
|
||||||
const { limit, offset } = normalizePagination(options);
|
|
||||||
|
|
||||||
// Escape special JSON/LIKE characters
|
|
||||||
const escapedTag = tag
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/%/g, '\\%')
|
|
||||||
.replace(/_/g, '\\_')
|
|
||||||
.replace(/"/g, '\\"');
|
|
||||||
|
|
||||||
// Get total count
|
|
||||||
const countStmt = this.db.prepare(`
|
|
||||||
SELECT COUNT(*) as total FROM knowledge_nodes
|
|
||||||
WHERE tags LIKE ? ESCAPE '\\'
|
|
||||||
`);
|
|
||||||
const countResult = countStmt.get(`%"${escapedTag}"%`) as { total: number };
|
|
||||||
const total = countResult.total;
|
|
||||||
|
|
||||||
// Get paginated results
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT * FROM knowledge_nodes
|
|
||||||
WHERE tags LIKE ? ESCAPE '\\'
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`);
|
|
||||||
const rows = stmt.all(`%"${escapedTag}"%`, limit, offset) as Record<string, unknown>[];
|
|
||||||
const items = rows.map((row) => this.rowToEntity(row));
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
hasMore: offset + items.length < total,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
throw new DatabaseError('Failed to find nodes by tag', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByPerson(
|
|
||||||
personName: string,
|
|
||||||
options: PaginationOptions = {}
|
|
||||||
): Promise<PaginatedResult<KnowledgeNode>> {
|
|
||||||
return this.lock.withReadLock(async () => {
|
|
||||||
try {
|
|
||||||
const { limit, offset } = normalizePagination(options);
|
|
||||||
|
|
||||||
// Escape special JSON/LIKE characters
|
|
||||||
const escapedPerson = personName
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/%/g, '\\%')
|
|
||||||
.replace(/_/g, '\\_')
|
|
||||||
.replace(/"/g, '\\"');
|
|
||||||
|
|
||||||
// Get total count
|
|
||||||
const countStmt = this.db.prepare(`
|
|
||||||
SELECT COUNT(*) as total FROM knowledge_nodes
|
|
||||||
WHERE people LIKE ? ESCAPE '\\'
|
|
||||||
`);
|
|
||||||
const countResult = countStmt.get(`%"${escapedPerson}"%`) as { total: number };
|
|
||||||
const total = countResult.total;
|
|
||||||
|
|
||||||
// Get paginated results
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT * FROM knowledge_nodes
|
|
||||||
WHERE people LIKE ? ESCAPE '\\'
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`);
|
|
||||||
const rows = stmt.all(`%"${escapedPerson}"%`, limit, offset) as Record<string, unknown>[];
|
|
||||||
const items = rows.map((row) => this.rowToEntity(row));
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
hasMore: offset + items.length < total,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
throw new DatabaseError('Failed to find nodes by person', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// WRITE OPERATIONS
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async create(input: KnowledgeNodeInput): Promise<KnowledgeNode> {
|
|
||||||
return this.lock.withWriteLock(async () => {
|
|
||||||
try {
|
|
||||||
// Input validation
|
|
||||||
validateStringLength(input.content, MAX_CONTENT_LENGTH, 'Content');
|
|
||||||
validateStringLength(input.summary || '', MAX_CONTENT_LENGTH, 'Summary');
|
|
||||||
validateArrayLength(input.tags, MAX_TAGS_COUNT, 'Tags');
|
|
||||||
validateArrayLength(input.people, MAX_TAGS_COUNT, 'People');
|
|
||||||
validateArrayLength(input.concepts, MAX_TAGS_COUNT, 'Concepts');
|
|
||||||
validateArrayLength(input.events, MAX_TAGS_COUNT, 'Events');
|
|
||||||
|
|
||||||
// Validate confidence is within bounds
|
|
||||||
const confidence = Math.max(0, Math.min(1, input.confidence ?? 0.8));
|
|
||||||
const retention = Math.max(0, Math.min(1, input.retentionStrength ?? 1.0));
|
|
||||||
|
|
||||||
// Analyze emotional intensity of content
|
|
||||||
const sentimentIntensity =
|
|
||||||
input.sentimentIntensity ?? analyzeSentimentIntensity(input.content);
|
|
||||||
|
|
||||||
// Git-Blame for Thoughts: Capture current code context
|
|
||||||
const gitContext = input.gitContext ?? captureGitContext();
|
|
||||||
|
|
||||||
const id = nanoid();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
INSERT INTO knowledge_nodes (
|
|
||||||
id, content, summary,
|
|
||||||
created_at, updated_at, last_accessed_at, access_count,
|
|
||||||
retention_strength, sentiment_intensity, next_review_date, review_count,
|
|
||||||
source_type, source_platform, source_id, source_url, source_chain, git_context,
|
|
||||||
confidence, is_contradicted, contradiction_ids,
|
|
||||||
people, concepts, events, tags
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?,
|
|
||||||
?, ?, ?, ?,
|
|
||||||
?, ?, ?, ?,
|
|
||||||
?, ?, ?, ?, ?, ?,
|
|
||||||
?, ?, ?,
|
|
||||||
?, ?, ?, ?
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const createdAt = input.createdAt instanceof Date
|
|
||||||
? input.createdAt.toISOString()
|
|
||||||
: (input.createdAt || now);
|
|
||||||
|
|
||||||
stmt.run(
|
|
||||||
id,
|
|
||||||
input.content,
|
|
||||||
input.summary || null,
|
|
||||||
createdAt,
|
|
||||||
now,
|
|
||||||
now,
|
|
||||||
0,
|
|
||||||
retention,
|
|
||||||
sentimentIntensity,
|
|
||||||
input.nextReviewDate instanceof Date
|
|
||||||
? input.nextReviewDate.toISOString()
|
|
||||||
: (input.nextReviewDate || null),
|
|
||||||
0,
|
|
||||||
input.sourceType,
|
|
||||||
input.sourcePlatform,
|
|
||||||
input.sourceId || null,
|
|
||||||
input.sourceUrl || null,
|
|
||||||
JSON.stringify(input.sourceChain || []),
|
|
||||||
gitContext ? JSON.stringify(gitContext) : null,
|
|
||||||
confidence,
|
|
||||||
input.isContradicted ? 1 : 0,
|
|
||||||
JSON.stringify(input.contradictionIds || []),
|
|
||||||
JSON.stringify(input.people || []),
|
|
||||||
JSON.stringify(input.concepts || []),
|
|
||||||
JSON.stringify(input.events || []),
|
|
||||||
JSON.stringify(input.tags || [])
|
|
||||||
);
|
|
||||||
|
|
||||||
// Return the created node
|
|
||||||
const node = await this.findById(id);
|
|
||||||
if (!node) {
|
|
||||||
throw new DatabaseError('Failed to retrieve created node');
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ValidationError || error instanceof DatabaseError) throw error;
|
|
||||||
throw new DatabaseError('Failed to insert knowledge node', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, updates: Partial<KnowledgeNodeInput>): Promise<KnowledgeNode | null> {
|
|
||||||
return this.lock.withWriteLock(async () => {
|
|
||||||
try {
|
|
||||||
// Check if node exists
|
|
||||||
const existing = this.db.prepare('SELECT * FROM knowledge_nodes WHERE id = ?').get(id);
|
|
||||||
if (!existing) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Input validation
|
|
||||||
if (updates.content !== undefined) {
|
|
||||||
validateStringLength(updates.content, MAX_CONTENT_LENGTH, 'Content');
|
|
||||||
}
|
|
||||||
if (updates.summary !== undefined) {
|
|
||||||
validateStringLength(updates.summary, MAX_CONTENT_LENGTH, 'Summary');
|
|
||||||
}
|
|
||||||
if (updates.tags !== undefined) {
|
|
||||||
validateArrayLength(updates.tags, MAX_TAGS_COUNT, 'Tags');
|
|
||||||
}
|
|
||||||
if (updates.people !== undefined) {
|
|
||||||
validateArrayLength(updates.people, MAX_TAGS_COUNT, 'People');
|
|
||||||
}
|
|
||||||
if (updates.concepts !== undefined) {
|
|
||||||
validateArrayLength(updates.concepts, MAX_TAGS_COUNT, 'Concepts');
|
|
||||||
}
|
|
||||||
if (updates.events !== undefined) {
|
|
||||||
validateArrayLength(updates.events, MAX_TAGS_COUNT, 'Events');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build dynamic update
|
|
||||||
const setClauses: string[] = [];
|
|
||||||
const values: unknown[] = [];
|
|
||||||
|
|
||||||
if (updates.content !== undefined) {
|
|
||||||
setClauses.push('content = ?');
|
|
||||||
values.push(updates.content);
|
|
||||||
|
|
||||||
// Re-analyze sentiment when content changes
|
|
||||||
const sentimentIntensity = analyzeSentimentIntensity(updates.content);
|
|
||||||
setClauses.push('sentiment_intensity = ?');
|
|
||||||
values.push(sentimentIntensity);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.summary !== undefined) {
|
|
||||||
setClauses.push('summary = ?');
|
|
||||||
values.push(updates.summary);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.confidence !== undefined) {
|
|
||||||
setClauses.push('confidence = ?');
|
|
||||||
values.push(Math.max(0, Math.min(1, updates.confidence)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.retentionStrength !== undefined) {
|
|
||||||
setClauses.push('retention_strength = ?');
|
|
||||||
values.push(Math.max(0, Math.min(1, updates.retentionStrength)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.tags !== undefined) {
|
|
||||||
setClauses.push('tags = ?');
|
|
||||||
values.push(JSON.stringify(updates.tags));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.people !== undefined) {
|
|
||||||
setClauses.push('people = ?');
|
|
||||||
values.push(JSON.stringify(updates.people));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.concepts !== undefined) {
|
|
||||||
setClauses.push('concepts = ?');
|
|
||||||
values.push(JSON.stringify(updates.concepts));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.events !== undefined) {
|
|
||||||
setClauses.push('events = ?');
|
|
||||||
values.push(JSON.stringify(updates.events));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.isContradicted !== undefined) {
|
|
||||||
setClauses.push('is_contradicted = ?');
|
|
||||||
values.push(updates.isContradicted ? 1 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.contradictionIds !== undefined) {
|
|
||||||
setClauses.push('contradiction_ids = ?');
|
|
||||||
values.push(JSON.stringify(updates.contradictionIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (setClauses.length === 0) {
|
|
||||||
// No updates to make, just return existing node
|
|
||||||
return this.rowToEntity(existing as Record<string, unknown>);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always update updated_at
|
|
||||||
setClauses.push('updated_at = ?');
|
|
||||||
values.push(new Date().toISOString());
|
|
||||||
|
|
||||||
// Add the ID for the WHERE clause
|
|
||||||
values.push(id);
|
|
||||||
|
|
||||||
const sql = `UPDATE knowledge_nodes SET ${setClauses.join(', ')} WHERE id = ?`;
|
|
||||||
this.db.prepare(sql).run(...values);
|
|
||||||
|
|
||||||
// Return updated node
|
|
||||||
const updated = this.db.prepare('SELECT * FROM knowledge_nodes WHERE id = ?').get(id);
|
|
||||||
return updated ? this.rowToEntity(updated as Record<string, unknown>) : null;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ValidationError || error instanceof DatabaseError) throw error;
|
|
||||||
throw new DatabaseError(`Failed to update node: ${id}`, error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(id: string): Promise<boolean> {
|
|
||||||
return this.lock.withWriteLock(async () => {
|
|
||||||
try {
|
|
||||||
const stmt = this.db.prepare('DELETE FROM knowledge_nodes WHERE id = ?');
|
|
||||||
const result = stmt.run(id);
|
|
||||||
return result.changes > 0;
|
|
||||||
} catch (error) {
|
|
||||||
throw new DatabaseError(`Failed to delete node: ${id}`, error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async recordAccess(id: string): Promise<void> {
|
|
||||||
return this.lock.withWriteLock(async () => {
|
|
||||||
try {
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
UPDATE knowledge_nodes
|
|
||||||
SET last_accessed_at = ?, access_count = access_count + 1
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
stmt.run(new Date().toISOString(), id);
|
|
||||||
} catch (error) {
|
|
||||||
throw new DatabaseError(`Failed to record access: ${id}`, error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async markReviewed(id: string): Promise<KnowledgeNode> {
|
|
||||||
return this.lock.withWriteLock(async () => {
|
|
||||||
try {
|
|
||||||
// Get the node first
|
|
||||||
const nodeStmt = this.db.prepare('SELECT * FROM knowledge_nodes WHERE id = ?');
|
|
||||||
const nodeRow = nodeStmt.get(id) as Record<string, unknown> | undefined;
|
|
||||||
|
|
||||||
if (!nodeRow) {
|
|
||||||
throw new NotFoundError('KnowledgeNode', id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = this.rowToEntity(nodeRow);
|
|
||||||
const currentStability = node.stabilityFactor ?? SM2_MIN_STABILITY;
|
|
||||||
let newStability: number;
|
|
||||||
let newReviewCount: number;
|
|
||||||
|
|
||||||
// SM-2 with Lapse Detection
|
|
||||||
if (node.retentionStrength >= SM2_LAPSE_THRESHOLD) {
|
|
||||||
// SUCCESSFUL RECALL: Memory was still accessible
|
|
||||||
newStability = Math.min(SM2_MAX_STABILITY, currentStability * SM2_EASE_FACTOR);
|
|
||||||
newReviewCount = node.reviewCount + 1;
|
|
||||||
} else {
|
|
||||||
// LAPSE: Memory had decayed too far
|
|
||||||
newStability = SM2_MIN_STABILITY;
|
|
||||||
newReviewCount = node.reviewCount + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset retention to full strength
|
|
||||||
const newRetention = 1.0;
|
|
||||||
|
|
||||||
// Calculate next review date
|
|
||||||
const daysUntilReview = Math.ceil(newStability);
|
|
||||||
const nextReview = new Date();
|
|
||||||
nextReview.setDate(nextReview.getDate() + daysUntilReview);
|
|
||||||
|
|
||||||
const updateStmt = this.db.prepare(`
|
|
||||||
UPDATE knowledge_nodes
|
|
||||||
SET retention_strength = ?,
|
|
||||||
stability_factor = ?,
|
|
||||||
review_count = ?,
|
|
||||||
next_review_date = ?,
|
|
||||||
last_accessed_at = ?,
|
|
||||||
updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
updateStmt.run(
|
|
||||||
newRetention,
|
|
||||||
newStability,
|
|
||||||
newReviewCount,
|
|
||||||
nextReview.toISOString(),
|
|
||||||
now,
|
|
||||||
now,
|
|
||||||
id
|
|
||||||
);
|
|
||||||
|
|
||||||
// Return the updated node
|
|
||||||
const updatedRow = nodeStmt.get(id) as Record<string, unknown>;
|
|
||||||
return this.rowToEntity(updatedRow);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof NotFoundError) throw error;
|
|
||||||
throw new DatabaseError('Failed to mark node as reviewed', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async applyDecay(id: string): Promise<number> {
|
|
||||||
return this.lock.withWriteLock(async () => {
|
|
||||||
try {
|
|
||||||
const nodeStmt = this.db.prepare(`
|
|
||||||
SELECT id, last_accessed_at, retention_strength, stability_factor, sentiment_intensity
|
|
||||||
FROM knowledge_nodes WHERE id = ?
|
|
||||||
`);
|
|
||||||
const node = nodeStmt.get(id) as {
|
|
||||||
id: string;
|
|
||||||
last_accessed_at: string;
|
|
||||||
retention_strength: number;
|
|
||||||
stability_factor: number | null;
|
|
||||||
sentiment_intensity: number | null;
|
|
||||||
} | undefined;
|
|
||||||
|
|
||||||
if (!node) {
|
|
||||||
throw new NotFoundError('KnowledgeNode', id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const lastAccessed = new Date(node.last_accessed_at).getTime();
|
|
||||||
const daysSince = (now - lastAccessed) / (1000 * 60 * 60 * 24);
|
|
||||||
|
|
||||||
const baseStability = node.stability_factor ?? SM2_MIN_STABILITY;
|
|
||||||
const sentimentIntensity = node.sentiment_intensity ?? 0;
|
|
||||||
const sentimentMultiplier =
|
|
||||||
SENTIMENT_MIN_BOOST + sentimentIntensity * (SENTIMENT_STABILITY_BOOST - SENTIMENT_MIN_BOOST);
|
|
||||||
const effectiveStability = baseStability * sentimentMultiplier;
|
|
||||||
|
|
||||||
// Ebbinghaus forgetting curve: R = e^(-t/S)
|
|
||||||
const newRetention = Math.max(0.1, node.retention_strength * Math.exp(-daysSince / effectiveStability));
|
|
||||||
|
|
||||||
const updateStmt = this.db.prepare(`
|
|
||||||
UPDATE knowledge_nodes SET retention_strength = ? WHERE id = ?
|
|
||||||
`);
|
|
||||||
updateStmt.run(newRetention, id);
|
|
||||||
|
|
||||||
return newRetention;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof NotFoundError) throw error;
|
|
||||||
throw new DatabaseError(`Failed to apply decay to node: ${id}`, error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async applyDecayAll(): Promise<number> {
|
|
||||||
return this.lock.withWriteLock(async () => {
|
|
||||||
try {
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
// Use IMMEDIATE transaction for consistency
|
|
||||||
const transaction = this.db.transaction(() => {
|
|
||||||
const nodes = this.db
|
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT id, last_accessed_at, retention_strength, stability_factor, sentiment_intensity
|
|
||||||
FROM knowledge_nodes
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.all() as {
|
|
||||||
id: string;
|
|
||||||
last_accessed_at: string;
|
|
||||||
retention_strength: number;
|
|
||||||
stability_factor: number | null;
|
|
||||||
sentiment_intensity: number | null;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
let updated = 0;
|
|
||||||
const updateStmt = this.db.prepare(`
|
|
||||||
UPDATE knowledge_nodes SET retention_strength = ? WHERE id = ?
|
|
||||||
`);
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
const lastAccessed = new Date(node.last_accessed_at).getTime();
|
|
||||||
const daysSince = (now - lastAccessed) / (1000 * 60 * 60 * 24);
|
|
||||||
|
|
||||||
const baseStability = node.stability_factor ?? SM2_MIN_STABILITY;
|
|
||||||
const sentimentIntensity = node.sentiment_intensity ?? 0;
|
|
||||||
const sentimentMultiplier =
|
|
||||||
SENTIMENT_MIN_BOOST +
|
|
||||||
sentimentIntensity * (SENTIMENT_STABILITY_BOOST - SENTIMENT_MIN_BOOST);
|
|
||||||
const effectiveStability = baseStability * sentimentMultiplier;
|
|
||||||
|
|
||||||
const newRetention = Math.max(
|
|
||||||
0.1,
|
|
||||||
node.retention_strength * Math.exp(-daysSince / effectiveStability)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (Math.abs(newRetention - node.retention_strength) > 0.01) {
|
|
||||||
updateStmt.run(newRetention, node.id);
|
|
||||||
updated++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
return transaction.immediate();
|
|
||||||
} catch (error) {
|
|
||||||
throw new DatabaseError('Failed to apply decay to all nodes', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// PRIVATE HELPERS
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a database row to a KnowledgeNode entity
|
|
||||||
*/
|
|
||||||
private rowToEntity(row: Record<string, unknown>): KnowledgeNode {
|
|
||||||
// Parse git context separately with proper null handling
|
|
||||||
let gitContext: GitContext | undefined;
|
|
||||||
if (row['git_context']) {
|
|
||||||
const parsed = safeJsonParse<GitContext | null>(row['git_context'] as string, null);
|
|
||||||
if (parsed !== null) {
|
|
||||||
gitContext = parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: row['id'] as string,
|
|
||||||
content: row['content'] as string,
|
|
||||||
summary: row['summary'] as string | undefined,
|
|
||||||
createdAt: new Date(row['created_at'] as string),
|
|
||||||
updatedAt: new Date(row['updated_at'] as string),
|
|
||||||
lastAccessedAt: new Date(row['last_accessed_at'] as string),
|
|
||||||
accessCount: row['access_count'] as number,
|
|
||||||
retentionStrength: row['retention_strength'] as number,
|
|
||||||
stabilityFactor: (row['stability_factor'] as number) ?? SM2_MIN_STABILITY,
|
|
||||||
sentimentIntensity: (row['sentiment_intensity'] as number) ?? 0,
|
|
||||||
// Dual-strength memory model fields
|
|
||||||
storageStrength: (row['storage_strength'] as number) ?? 1,
|
|
||||||
retrievalStrength: (row['retrieval_strength'] as number) ?? 1,
|
|
||||||
nextReviewDate: row['next_review_date']
|
|
||||||
? new Date(row['next_review_date'] as string)
|
|
||||||
: undefined,
|
|
||||||
reviewCount: row['review_count'] as number,
|
|
||||||
sourceType: row['source_type'] as KnowledgeNode['sourceType'],
|
|
||||||
sourcePlatform: row['source_platform'] as KnowledgeNode['sourcePlatform'],
|
|
||||||
sourceId: row['source_id'] as string | undefined,
|
|
||||||
sourceUrl: row['source_url'] as string | undefined,
|
|
||||||
sourceChain: safeJsonParse<string[]>(row['source_chain'] as string, []),
|
|
||||||
gitContext,
|
|
||||||
confidence: row['confidence'] as number,
|
|
||||||
isContradicted: Boolean(row['is_contradicted']),
|
|
||||||
contradictionIds: safeJsonParse<string[]>(row['contradiction_ids'] as string, []),
|
|
||||||
people: safeJsonParse<string[]>(row['people'] as string, []),
|
|
||||||
concepts: safeJsonParse<string[]>(row['concepts'] as string, []),
|
|
||||||
events: safeJsonParse<string[]>(row['events'] as string, []),
|
|
||||||
tags: safeJsonParse<string[]>(row['tags'] as string, []),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,864 +0,0 @@
|
||||||
import Database from 'better-sqlite3';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import type { PersonNode } from '../core/types.js';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONSTANTS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const DEFAULT_LIMIT = 50;
|
|
||||||
const MAX_LIMIT = 500;
|
|
||||||
const MAX_NAME_LENGTH = 500;
|
|
||||||
const MAX_CONTENT_LENGTH = 1_000_000;
|
|
||||||
const MAX_ARRAY_COUNT = 100;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface PaginationOptions {
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedResult<T> {
|
|
||||||
items: T[];
|
|
||||||
total: number;
|
|
||||||
limit: number;
|
|
||||||
offset: number;
|
|
||||||
hasMore: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PersonNodeInput {
|
|
||||||
name: string;
|
|
||||||
aliases?: string[];
|
|
||||||
howWeMet?: string;
|
|
||||||
relationshipType?: string;
|
|
||||||
organization?: string;
|
|
||||||
role?: string;
|
|
||||||
location?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
socialLinks?: Record<string, string>;
|
|
||||||
preferredChannel?: string;
|
|
||||||
sharedTopics?: string[];
|
|
||||||
sharedProjects?: string[];
|
|
||||||
notes?: string;
|
|
||||||
relationshipHealth?: number;
|
|
||||||
lastContactAt?: Date;
|
|
||||||
contactFrequency?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// ERROR TYPE
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export class PersonRepositoryError extends Error {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public readonly code: string,
|
|
||||||
cause?: unknown
|
|
||||||
) {
|
|
||||||
super(sanitizeErrorMessage(message));
|
|
||||||
this.name = 'PersonRepositoryError';
|
|
||||||
if (process.env['NODE_ENV'] === 'development' && cause) {
|
|
||||||
this.cause = cause;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// HELPER FUNCTIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize error message to prevent sensitive data leakage
|
|
||||||
*/
|
|
||||||
function sanitizeErrorMessage(message: string): string {
|
|
||||||
let sanitized = message.replace(/\/[^\s]+/g, '[PATH]');
|
|
||||||
sanitized = sanitized.replace(/SELECT|INSERT|UPDATE|DELETE|DROP|CREATE/gi, '[SQL]');
|
|
||||||
sanitized = sanitized.replace(/\b(password|secret|key|token|auth)\s*[=:]\s*\S+/gi, '[REDACTED]');
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safe JSON parse with fallback - never throws
|
|
||||||
*/
|
|
||||||
function safeJsonParse<T>(value: string | null | undefined, fallback: T): T {
|
|
||||||
if (!value) return fallback;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
if (typeof parsed !== typeof fallback) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
return parsed as T;
|
|
||||||
} catch {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate string length for inputs
|
|
||||||
*/
|
|
||||||
function validateStringLength(value: string | undefined, maxLength: number, fieldName: string): void {
|
|
||||||
if (value && value.length > maxLength) {
|
|
||||||
throw new PersonRepositoryError(
|
|
||||||
`${fieldName} exceeds maximum length of ${maxLength} characters`,
|
|
||||||
'INPUT_TOO_LONG'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate array length for inputs
|
|
||||||
*/
|
|
||||||
function validateArrayLength<T>(arr: T[] | undefined, maxLength: number, fieldName: string): void {
|
|
||||||
if (arr && arr.length > maxLength) {
|
|
||||||
throw new PersonRepositoryError(
|
|
||||||
`${fieldName} exceeds maximum count of ${maxLength} items`,
|
|
||||||
'INPUT_TOO_MANY_ITEMS'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// READ-WRITE LOCK
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A simple read-write lock for concurrent access control.
|
|
||||||
* Allows multiple readers or a single writer, but not both.
|
|
||||||
*/
|
|
||||||
export class RWLock {
|
|
||||||
private readers = 0;
|
|
||||||
private writer = false;
|
|
||||||
private readQueue: (() => void)[] = [];
|
|
||||||
private writeQueue: (() => void)[] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Acquire a read lock. Multiple readers can hold the lock simultaneously.
|
|
||||||
*/
|
|
||||||
async acquireRead(): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!this.writer && this.writeQueue.length === 0) {
|
|
||||||
this.readers++;
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
this.readQueue.push(() => {
|
|
||||||
this.readers++;
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Release a read lock.
|
|
||||||
*/
|
|
||||||
releaseRead(): void {
|
|
||||||
this.readers--;
|
|
||||||
if (this.readers === 0) {
|
|
||||||
this.processWriteQueue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Acquire a write lock. Only one writer can hold the lock at a time.
|
|
||||||
*/
|
|
||||||
async acquireWrite(): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!this.writer && this.readers === 0) {
|
|
||||||
this.writer = true;
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
this.writeQueue.push(() => {
|
|
||||||
this.writer = true;
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Release a write lock.
|
|
||||||
*/
|
|
||||||
releaseWrite(): void {
|
|
||||||
this.writer = false;
|
|
||||||
// Process read queue first to prevent writer starvation
|
|
||||||
this.processReadQueue();
|
|
||||||
if (this.readers === 0) {
|
|
||||||
this.processWriteQueue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private processReadQueue(): void {
|
|
||||||
while (this.readQueue.length > 0 && !this.writer) {
|
|
||||||
const next = this.readQueue.shift();
|
|
||||||
if (next) next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private processWriteQueue(): void {
|
|
||||||
if (this.writeQueue.length > 0 && this.readers === 0 && !this.writer) {
|
|
||||||
const next = this.writeQueue.shift();
|
|
||||||
if (next) next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a function with a read lock.
|
|
||||||
*/
|
|
||||||
async withRead<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
||||||
await this.acquireRead();
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} finally {
|
|
||||||
this.releaseRead();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a function with a write lock.
|
|
||||||
*/
|
|
||||||
async withWrite<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
||||||
await this.acquireWrite();
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} finally {
|
|
||||||
this.releaseWrite();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// INTERFACE
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface IPersonRepository {
|
|
||||||
findById(id: string): Promise<PersonNode | null>;
|
|
||||||
findByName(name: string): Promise<PersonNode | null>;
|
|
||||||
searchByName(query: string, options?: PaginationOptions): Promise<PaginatedResult<PersonNode>>;
|
|
||||||
create(input: PersonNodeInput): Promise<PersonNode>;
|
|
||||||
update(id: string, updates: Partial<PersonNodeInput>): Promise<PersonNode | null>;
|
|
||||||
delete(id: string): Promise<boolean>;
|
|
||||||
getPeopleToReconnect(daysSinceContact: number, options?: PaginationOptions): Promise<PaginatedResult<PersonNode>>;
|
|
||||||
recordContact(id: string): Promise<void>;
|
|
||||||
findByOrganization(org: string, options?: PaginationOptions): Promise<PaginatedResult<PersonNode>>;
|
|
||||||
findBySharedTopic(topic: string, options?: PaginationOptions): Promise<PaginatedResult<PersonNode>>;
|
|
||||||
getAll(options?: PaginationOptions): Promise<PaginatedResult<PersonNode>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// IMPLEMENTATION
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export class PersonRepository implements IPersonRepository {
|
|
||||||
private readonly lock = new RWLock();
|
|
||||||
|
|
||||||
constructor(private readonly db: Database.Database) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a database row to a PersonNode entity.
|
|
||||||
*/
|
|
||||||
private rowToEntity(row: Record<string, unknown>): PersonNode {
|
|
||||||
return {
|
|
||||||
id: row['id'] as string,
|
|
||||||
name: row['name'] as string,
|
|
||||||
aliases: safeJsonParse<string[]>(row['aliases'] as string, []),
|
|
||||||
howWeMet: row['how_we_met'] as string | undefined,
|
|
||||||
relationshipType: row['relationship_type'] as string | undefined,
|
|
||||||
organization: row['organization'] as string | undefined,
|
|
||||||
role: row['role'] as string | undefined,
|
|
||||||
location: row['location'] as string | undefined,
|
|
||||||
email: row['email'] as string | undefined,
|
|
||||||
phone: row['phone'] as string | undefined,
|
|
||||||
socialLinks: safeJsonParse<Record<string, string>>(row['social_links'] as string, {}),
|
|
||||||
lastContactAt: row['last_contact_at'] ? new Date(row['last_contact_at'] as string) : undefined,
|
|
||||||
contactFrequency: row['contact_frequency'] as number,
|
|
||||||
preferredChannel: row['preferred_channel'] as string | undefined,
|
|
||||||
sharedTopics: safeJsonParse<string[]>(row['shared_topics'] as string, []),
|
|
||||||
sharedProjects: safeJsonParse<string[]>(row['shared_projects'] as string, []),
|
|
||||||
notes: row['notes'] as string | undefined,
|
|
||||||
relationshipHealth: row['relationship_health'] as number,
|
|
||||||
createdAt: new Date(row['created_at'] as string),
|
|
||||||
updatedAt: new Date(row['updated_at'] as string),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate input for creating or updating a person.
|
|
||||||
*/
|
|
||||||
private validateInput(input: PersonNodeInput | Partial<PersonNodeInput>, isCreate: boolean): void {
|
|
||||||
if (isCreate && !input.name) {
|
|
||||||
throw new PersonRepositoryError('Name is required', 'NAME_REQUIRED');
|
|
||||||
}
|
|
||||||
|
|
||||||
validateStringLength(input.name, MAX_NAME_LENGTH, 'Name');
|
|
||||||
validateStringLength(input.notes, MAX_CONTENT_LENGTH, 'Notes');
|
|
||||||
validateStringLength(input.howWeMet, MAX_CONTENT_LENGTH, 'How we met');
|
|
||||||
validateArrayLength(input.aliases, MAX_ARRAY_COUNT, 'Aliases');
|
|
||||||
validateArrayLength(input.sharedTopics, MAX_ARRAY_COUNT, 'Shared topics');
|
|
||||||
validateArrayLength(input.sharedProjects, MAX_ARRAY_COUNT, 'Shared projects');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a person by their unique ID.
|
|
||||||
*/
|
|
||||||
async findById(id: string): Promise<PersonNode | null> {
|
|
||||||
return this.lock.withRead(() => {
|
|
||||||
try {
|
|
||||||
const stmt = this.db.prepare('SELECT * FROM people WHERE id = ?');
|
|
||||||
const row = stmt.get(id) as Record<string, unknown> | undefined;
|
|
||||||
if (!row) return null;
|
|
||||||
return this.rowToEntity(row);
|
|
||||||
} catch (error) {
|
|
||||||
throw new PersonRepositoryError(
|
|
||||||
`Failed to find person: ${id}`,
|
|
||||||
'FIND_BY_ID_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a person by their name or alias.
|
|
||||||
*/
|
|
||||||
async findByName(name: string): Promise<PersonNode | null> {
|
|
||||||
return this.lock.withRead(() => {
|
|
||||||
try {
|
|
||||||
validateStringLength(name, MAX_NAME_LENGTH, 'Name');
|
|
||||||
|
|
||||||
// Escape special LIKE characters to prevent injection
|
|
||||||
const escapedName = name
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/%/g, '\\%')
|
|
||||||
.replace(/_/g, '\\_')
|
|
||||||
.replace(/"/g, '\\"');
|
|
||||||
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT * FROM people
|
|
||||||
WHERE name = ? OR aliases LIKE ? ESCAPE '\\'
|
|
||||||
`);
|
|
||||||
const row = stmt.get(name, `%"${escapedName}"%`) as Record<string, unknown> | undefined;
|
|
||||||
if (!row) return null;
|
|
||||||
return this.rowToEntity(row);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof PersonRepositoryError) throw error;
|
|
||||||
throw new PersonRepositoryError(
|
|
||||||
'Failed to find person by name',
|
|
||||||
'FIND_BY_NAME_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for people by name (partial match).
|
|
||||||
*/
|
|
||||||
async searchByName(query: string, options: PaginationOptions = {}): Promise<PaginatedResult<PersonNode>> {
|
|
||||||
return this.lock.withRead(() => {
|
|
||||||
try {
|
|
||||||
validateStringLength(query, MAX_NAME_LENGTH, 'Search query');
|
|
||||||
|
|
||||||
const { limit = DEFAULT_LIMIT, offset = 0 } = options;
|
|
||||||
const safeLimit = Math.min(Math.max(1, limit), MAX_LIMIT);
|
|
||||||
const safeOffset = Math.max(0, offset);
|
|
||||||
|
|
||||||
// Escape special LIKE characters
|
|
||||||
const escapedQuery = query
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/%/g, '\\%')
|
|
||||||
.replace(/_/g, '\\_');
|
|
||||||
|
|
||||||
const searchPattern = `%${escapedQuery}%`;
|
|
||||||
|
|
||||||
// Get total count
|
|
||||||
const countStmt = this.db.prepare(`
|
|
||||||
SELECT COUNT(*) as total FROM people
|
|
||||||
WHERE name LIKE ? ESCAPE '\\' OR aliases LIKE ? ESCAPE '\\'
|
|
||||||
`);
|
|
||||||
const countResult = countStmt.get(searchPattern, searchPattern) as { total: number };
|
|
||||||
const total = countResult.total;
|
|
||||||
|
|
||||||
// Get paginated results
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT * FROM people
|
|
||||||
WHERE name LIKE ? ESCAPE '\\' OR aliases LIKE ? ESCAPE '\\'
|
|
||||||
ORDER BY name
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`);
|
|
||||||
const rows = stmt.all(searchPattern, searchPattern, safeLimit, safeOffset) as Record<string, unknown>[];
|
|
||||||
const items = rows.map(row => this.rowToEntity(row));
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
limit: safeLimit,
|
|
||||||
offset: safeOffset,
|
|
||||||
hasMore: safeOffset + items.length < total,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof PersonRepositoryError) throw error;
|
|
||||||
throw new PersonRepositoryError(
|
|
||||||
'Search by name failed',
|
|
||||||
'SEARCH_BY_NAME_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new person.
|
|
||||||
*/
|
|
||||||
async create(input: PersonNodeInput): Promise<PersonNode> {
|
|
||||||
return this.lock.withWrite(() => {
|
|
||||||
try {
|
|
||||||
this.validateInput(input, true);
|
|
||||||
|
|
||||||
// Validate relationship health is within bounds
|
|
||||||
const relationshipHealth = Math.max(0, Math.min(1, input.relationshipHealth ?? 0.5));
|
|
||||||
|
|
||||||
const id = nanoid();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
INSERT INTO people (
|
|
||||||
id, name, aliases,
|
|
||||||
how_we_met, relationship_type, organization, role, location,
|
|
||||||
email, phone, social_links,
|
|
||||||
last_contact_at, contact_frequency, preferred_channel,
|
|
||||||
shared_topics, shared_projects,
|
|
||||||
notes, relationship_health,
|
|
||||||
created_at, updated_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
|
|
||||||
stmt.run(
|
|
||||||
id,
|
|
||||||
input.name,
|
|
||||||
JSON.stringify(input.aliases || []),
|
|
||||||
input.howWeMet || null,
|
|
||||||
input.relationshipType || null,
|
|
||||||
input.organization || null,
|
|
||||||
input.role || null,
|
|
||||||
input.location || null,
|
|
||||||
input.email || null,
|
|
||||||
input.phone || null,
|
|
||||||
JSON.stringify(input.socialLinks || {}),
|
|
||||||
input.lastContactAt?.toISOString() || null,
|
|
||||||
input.contactFrequency || 0,
|
|
||||||
input.preferredChannel || null,
|
|
||||||
JSON.stringify(input.sharedTopics || []),
|
|
||||||
JSON.stringify(input.sharedProjects || []),
|
|
||||||
input.notes || null,
|
|
||||||
relationshipHealth,
|
|
||||||
now,
|
|
||||||
now
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: input.name,
|
|
||||||
aliases: input.aliases || [],
|
|
||||||
howWeMet: input.howWeMet,
|
|
||||||
relationshipType: input.relationshipType,
|
|
||||||
organization: input.organization,
|
|
||||||
role: input.role,
|
|
||||||
location: input.location,
|
|
||||||
email: input.email,
|
|
||||||
phone: input.phone,
|
|
||||||
socialLinks: input.socialLinks || {},
|
|
||||||
lastContactAt: input.lastContactAt,
|
|
||||||
contactFrequency: input.contactFrequency || 0,
|
|
||||||
preferredChannel: input.preferredChannel,
|
|
||||||
sharedTopics: input.sharedTopics || [],
|
|
||||||
sharedProjects: input.sharedProjects || [],
|
|
||||||
notes: input.notes,
|
|
||||||
relationshipHealth,
|
|
||||||
createdAt: new Date(now),
|
|
||||||
updatedAt: new Date(now),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof PersonRepositoryError) throw error;
|
|
||||||
throw new PersonRepositoryError(
|
|
||||||
'Failed to create person',
|
|
||||||
'CREATE_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing person.
|
|
||||||
*/
|
|
||||||
async update(id: string, updates: Partial<PersonNodeInput>): Promise<PersonNode | null> {
|
|
||||||
return this.lock.withWrite(() => {
|
|
||||||
try {
|
|
||||||
this.validateInput(updates, false);
|
|
||||||
|
|
||||||
// First check if the person exists
|
|
||||||
const existingStmt = this.db.prepare('SELECT * FROM people WHERE id = ?');
|
|
||||||
const existing = existingStmt.get(id) as Record<string, unknown> | undefined;
|
|
||||||
if (!existing) return null;
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
// Build update statement dynamically based on provided fields
|
|
||||||
const setClauses: string[] = ['updated_at = ?'];
|
|
||||||
const values: unknown[] = [now];
|
|
||||||
|
|
||||||
if (updates.name !== undefined) {
|
|
||||||
setClauses.push('name = ?');
|
|
||||||
values.push(updates.name);
|
|
||||||
}
|
|
||||||
if (updates.aliases !== undefined) {
|
|
||||||
setClauses.push('aliases = ?');
|
|
||||||
values.push(JSON.stringify(updates.aliases));
|
|
||||||
}
|
|
||||||
if (updates.howWeMet !== undefined) {
|
|
||||||
setClauses.push('how_we_met = ?');
|
|
||||||
values.push(updates.howWeMet || null);
|
|
||||||
}
|
|
||||||
if (updates.relationshipType !== undefined) {
|
|
||||||
setClauses.push('relationship_type = ?');
|
|
||||||
values.push(updates.relationshipType || null);
|
|
||||||
}
|
|
||||||
if (updates.organization !== undefined) {
|
|
||||||
setClauses.push('organization = ?');
|
|
||||||
values.push(updates.organization || null);
|
|
||||||
}
|
|
||||||
if (updates.role !== undefined) {
|
|
||||||
setClauses.push('role = ?');
|
|
||||||
values.push(updates.role || null);
|
|
||||||
}
|
|
||||||
if (updates.location !== undefined) {
|
|
||||||
setClauses.push('location = ?');
|
|
||||||
values.push(updates.location || null);
|
|
||||||
}
|
|
||||||
if (updates.email !== undefined) {
|
|
||||||
setClauses.push('email = ?');
|
|
||||||
values.push(updates.email || null);
|
|
||||||
}
|
|
||||||
if (updates.phone !== undefined) {
|
|
||||||
setClauses.push('phone = ?');
|
|
||||||
values.push(updates.phone || null);
|
|
||||||
}
|
|
||||||
if (updates.socialLinks !== undefined) {
|
|
||||||
setClauses.push('social_links = ?');
|
|
||||||
values.push(JSON.stringify(updates.socialLinks));
|
|
||||||
}
|
|
||||||
if (updates.lastContactAt !== undefined) {
|
|
||||||
setClauses.push('last_contact_at = ?');
|
|
||||||
values.push(updates.lastContactAt?.toISOString() || null);
|
|
||||||
}
|
|
||||||
if (updates.contactFrequency !== undefined) {
|
|
||||||
setClauses.push('contact_frequency = ?');
|
|
||||||
values.push(updates.contactFrequency);
|
|
||||||
}
|
|
||||||
if (updates.preferredChannel !== undefined) {
|
|
||||||
setClauses.push('preferred_channel = ?');
|
|
||||||
values.push(updates.preferredChannel || null);
|
|
||||||
}
|
|
||||||
if (updates.sharedTopics !== undefined) {
|
|
||||||
setClauses.push('shared_topics = ?');
|
|
||||||
values.push(JSON.stringify(updates.sharedTopics));
|
|
||||||
}
|
|
||||||
if (updates.sharedProjects !== undefined) {
|
|
||||||
setClauses.push('shared_projects = ?');
|
|
||||||
values.push(JSON.stringify(updates.sharedProjects));
|
|
||||||
}
|
|
||||||
if (updates.notes !== undefined) {
|
|
||||||
setClauses.push('notes = ?');
|
|
||||||
values.push(updates.notes || null);
|
|
||||||
}
|
|
||||||
if (updates.relationshipHealth !== undefined) {
|
|
||||||
const health = Math.max(0, Math.min(1, updates.relationshipHealth));
|
|
||||||
setClauses.push('relationship_health = ?');
|
|
||||||
values.push(health);
|
|
||||||
}
|
|
||||||
|
|
||||||
values.push(id);
|
|
||||||
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
UPDATE people
|
|
||||||
SET ${setClauses.join(', ')}
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
stmt.run(...values);
|
|
||||||
|
|
||||||
// Fetch and return the updated person
|
|
||||||
const updatedRow = existingStmt.get(id) as Record<string, unknown>;
|
|
||||||
return this.rowToEntity(updatedRow);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof PersonRepositoryError) throw error;
|
|
||||||
throw new PersonRepositoryError(
|
|
||||||
`Failed to update person: ${id}`,
|
|
||||||
'UPDATE_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a person by ID.
|
|
||||||
*/
|
|
||||||
async delete(id: string): Promise<boolean> {
|
|
||||||
return this.lock.withWrite(() => {
|
|
||||||
try {
|
|
||||||
const stmt = this.db.prepare('DELETE FROM people WHERE id = ?');
|
|
||||||
const result = stmt.run(id);
|
|
||||||
return result.changes > 0;
|
|
||||||
} catch (error) {
|
|
||||||
throw new PersonRepositoryError(
|
|
||||||
`Failed to delete person: ${id}`,
|
|
||||||
'DELETE_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get people who haven't been contacted recently.
|
|
||||||
*/
|
|
||||||
async getPeopleToReconnect(
|
|
||||||
daysSinceContact: number = 30,
|
|
||||||
options: PaginationOptions = {}
|
|
||||||
): Promise<PaginatedResult<PersonNode>> {
|
|
||||||
return this.lock.withRead(() => {
|
|
||||||
try {
|
|
||||||
const { limit = DEFAULT_LIMIT, offset = 0 } = options;
|
|
||||||
const safeLimit = Math.min(Math.max(1, limit), MAX_LIMIT);
|
|
||||||
const safeOffset = Math.max(0, offset);
|
|
||||||
|
|
||||||
const cutoffDate = new Date();
|
|
||||||
cutoffDate.setDate(cutoffDate.getDate() - daysSinceContact);
|
|
||||||
const cutoffStr = cutoffDate.toISOString();
|
|
||||||
|
|
||||||
// Get total count
|
|
||||||
const countStmt = this.db.prepare(`
|
|
||||||
SELECT COUNT(*) as total FROM people
|
|
||||||
WHERE last_contact_at IS NOT NULL AND last_contact_at < ?
|
|
||||||
`);
|
|
||||||
const countResult = countStmt.get(cutoffStr) as { total: number };
|
|
||||||
const total = countResult.total;
|
|
||||||
|
|
||||||
// Get paginated results
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT * FROM people
|
|
||||||
WHERE last_contact_at IS NOT NULL
|
|
||||||
AND last_contact_at < ?
|
|
||||||
ORDER BY last_contact_at ASC
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`);
|
|
||||||
const rows = stmt.all(cutoffStr, safeLimit, safeOffset) as Record<string, unknown>[];
|
|
||||||
const items = rows.map(row => this.rowToEntity(row));
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
limit: safeLimit,
|
|
||||||
offset: safeOffset,
|
|
||||||
hasMore: safeOffset + items.length < total,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
throw new PersonRepositoryError(
|
|
||||||
'Failed to get people to reconnect',
|
|
||||||
'GET_RECONNECT_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record a contact with a person (updates last_contact_at).
|
|
||||||
*/
|
|
||||||
async recordContact(id: string): Promise<void> {
|
|
||||||
return this.lock.withWrite(() => {
|
|
||||||
try {
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
UPDATE people
|
|
||||||
SET last_contact_at = ?, updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const result = stmt.run(now, now, id);
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
|
||||||
throw new PersonRepositoryError(
|
|
||||||
`Person not found: ${id}`,
|
|
||||||
'PERSON_NOT_FOUND'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof PersonRepositoryError) throw error;
|
|
||||||
throw new PersonRepositoryError(
|
|
||||||
`Failed to record contact: ${id}`,
|
|
||||||
'RECORD_CONTACT_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find people by organization.
|
|
||||||
*/
|
|
||||||
async findByOrganization(
|
|
||||||
org: string,
|
|
||||||
options: PaginationOptions = {}
|
|
||||||
): Promise<PaginatedResult<PersonNode>> {
|
|
||||||
return this.lock.withRead(() => {
|
|
||||||
try {
|
|
||||||
validateStringLength(org, MAX_NAME_LENGTH, 'Organization');
|
|
||||||
|
|
||||||
const { limit = DEFAULT_LIMIT, offset = 0 } = options;
|
|
||||||
const safeLimit = Math.min(Math.max(1, limit), MAX_LIMIT);
|
|
||||||
const safeOffset = Math.max(0, offset);
|
|
||||||
|
|
||||||
// Escape special LIKE characters
|
|
||||||
const escapedOrg = org
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/%/g, '\\%')
|
|
||||||
.replace(/_/g, '\\_');
|
|
||||||
|
|
||||||
const searchPattern = `%${escapedOrg}%`;
|
|
||||||
|
|
||||||
// Get total count
|
|
||||||
const countStmt = this.db.prepare(`
|
|
||||||
SELECT COUNT(*) as total FROM people
|
|
||||||
WHERE organization LIKE ? ESCAPE '\\'
|
|
||||||
`);
|
|
||||||
const countResult = countStmt.get(searchPattern) as { total: number };
|
|
||||||
const total = countResult.total;
|
|
||||||
|
|
||||||
// Get paginated results
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT * FROM people
|
|
||||||
WHERE organization LIKE ? ESCAPE '\\'
|
|
||||||
ORDER BY name
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`);
|
|
||||||
const rows = stmt.all(searchPattern, safeLimit, safeOffset) as Record<string, unknown>[];
|
|
||||||
const items = rows.map(row => this.rowToEntity(row));
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
limit: safeLimit,
|
|
||||||
offset: safeOffset,
|
|
||||||
hasMore: safeOffset + items.length < total,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof PersonRepositoryError) throw error;
|
|
||||||
throw new PersonRepositoryError(
|
|
||||||
'Failed to find people by organization',
|
|
||||||
'FIND_BY_ORG_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find people by shared topic.
|
|
||||||
*/
|
|
||||||
async findBySharedTopic(
|
|
||||||
topic: string,
|
|
||||||
options: PaginationOptions = {}
|
|
||||||
): Promise<PaginatedResult<PersonNode>> {
|
|
||||||
return this.lock.withRead(() => {
|
|
||||||
try {
|
|
||||||
validateStringLength(topic, MAX_NAME_LENGTH, 'Topic');
|
|
||||||
|
|
||||||
const { limit = DEFAULT_LIMIT, offset = 0 } = options;
|
|
||||||
const safeLimit = Math.min(Math.max(1, limit), MAX_LIMIT);
|
|
||||||
const safeOffset = Math.max(0, offset);
|
|
||||||
|
|
||||||
// Escape special LIKE characters and quotes for JSON search
|
|
||||||
const escapedTopic = topic
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/%/g, '\\%')
|
|
||||||
.replace(/_/g, '\\_')
|
|
||||||
.replace(/"/g, '\\"');
|
|
||||||
|
|
||||||
const searchPattern = `%"${escapedTopic}"%`;
|
|
||||||
|
|
||||||
// Get total count
|
|
||||||
const countStmt = this.db.prepare(`
|
|
||||||
SELECT COUNT(*) as total FROM people
|
|
||||||
WHERE shared_topics LIKE ? ESCAPE '\\'
|
|
||||||
`);
|
|
||||||
const countResult = countStmt.get(searchPattern) as { total: number };
|
|
||||||
const total = countResult.total;
|
|
||||||
|
|
||||||
// Get paginated results
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
SELECT * FROM people
|
|
||||||
WHERE shared_topics LIKE ? ESCAPE '\\'
|
|
||||||
ORDER BY name
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`);
|
|
||||||
const rows = stmt.all(searchPattern, safeLimit, safeOffset) as Record<string, unknown>[];
|
|
||||||
const items = rows.map(row => this.rowToEntity(row));
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
limit: safeLimit,
|
|
||||||
offset: safeOffset,
|
|
||||||
hasMore: safeOffset + items.length < total,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof PersonRepositoryError) throw error;
|
|
||||||
throw new PersonRepositoryError(
|
|
||||||
'Failed to find people by shared topic',
|
|
||||||
'FIND_BY_TOPIC_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all people with pagination.
|
|
||||||
*/
|
|
||||||
async getAll(options: PaginationOptions = {}): Promise<PaginatedResult<PersonNode>> {
|
|
||||||
return this.lock.withRead(() => {
|
|
||||||
try {
|
|
||||||
const { limit = DEFAULT_LIMIT, offset = 0 } = options;
|
|
||||||
const safeLimit = Math.min(Math.max(1, limit), MAX_LIMIT);
|
|
||||||
const safeOffset = Math.max(0, offset);
|
|
||||||
|
|
||||||
// Get total count
|
|
||||||
const countResult = this.db.prepare('SELECT COUNT(*) as total FROM people').get() as { total: number };
|
|
||||||
const total = countResult.total;
|
|
||||||
|
|
||||||
// Get paginated results
|
|
||||||
const stmt = this.db.prepare('SELECT * FROM people ORDER BY name LIMIT ? OFFSET ?');
|
|
||||||
const rows = stmt.all(safeLimit, safeOffset) as Record<string, unknown>[];
|
|
||||||
const items = rows.map(row => this.rowToEntity(row));
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
limit: safeLimit,
|
|
||||||
offset: safeOffset,
|
|
||||||
hasMore: safeOffset + items.length < total,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
throw new PersonRepositoryError(
|
|
||||||
'Failed to get all people',
|
|
||||||
'GET_ALL_FAILED',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
// Re-export from NodeRepository (primary source for common types)
|
|
||||||
export {
|
|
||||||
NodeRepository,
|
|
||||||
type INodeRepository,
|
|
||||||
type PaginationOptions,
|
|
||||||
type PaginatedResult,
|
|
||||||
type GitContext,
|
|
||||||
} from './NodeRepository.js';
|
|
||||||
|
|
||||||
// Re-export from PersonRepository (exclude duplicate types)
|
|
||||||
export {
|
|
||||||
PersonRepository,
|
|
||||||
type IPersonRepository,
|
|
||||||
type PersonNodeInput,
|
|
||||||
PersonRepositoryError,
|
|
||||||
} from './PersonRepository.js';
|
|
||||||
|
|
||||||
// Re-export from EdgeRepository (exclude duplicate types)
|
|
||||||
export {
|
|
||||||
EdgeRepository,
|
|
||||||
type IEdgeRepository,
|
|
||||||
type GraphEdgeInput,
|
|
||||||
type EdgeType,
|
|
||||||
type TransitivePath,
|
|
||||||
EdgeRepositoryError,
|
|
||||||
} from './EdgeRepository.js';
|
|
||||||
|
|
@ -1,603 +0,0 @@
|
||||||
import type { KnowledgeNode, PersonNode } from '../core/types.js';
|
|
||||||
import type { PaginatedResult } from '../repositories/PersonRepository.js';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a single entry in the cache with metadata for TTL and LRU eviction.
|
|
||||||
*/
|
|
||||||
export interface CacheEntry<T> {
|
|
||||||
/** The cached value */
|
|
||||||
value: T;
|
|
||||||
/** Unix timestamp (ms) when this entry expires */
|
|
||||||
expiresAt: number;
|
|
||||||
/** Number of times this entry has been accessed */
|
|
||||||
accessCount: number;
|
|
||||||
/** Unix timestamp (ms) of the last access */
|
|
||||||
lastAccessed: number;
|
|
||||||
/** Estimated size in bytes (optional, for memory-based eviction) */
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration options for the cache service.
|
|
||||||
*/
|
|
||||||
export interface CacheOptions {
|
|
||||||
/** Maximum number of entries in the cache */
|
|
||||||
maxSize: number;
|
|
||||||
/** Maximum memory usage in bytes (optional) */
|
|
||||||
maxMemory?: number;
|
|
||||||
/** Default TTL in milliseconds */
|
|
||||||
defaultTTL: number;
|
|
||||||
/** Interval in milliseconds for automatic cleanup of expired entries */
|
|
||||||
cleanupInterval: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Statistics about cache performance and state.
|
|
||||||
*/
|
|
||||||
export interface CacheStats {
|
|
||||||
/** Current number of entries in the cache */
|
|
||||||
size: number;
|
|
||||||
/** Hit rate as a ratio (0-1) */
|
|
||||||
hitRate: number;
|
|
||||||
/** Estimated memory usage in bytes */
|
|
||||||
memoryUsage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONSTANTS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: CacheOptions = {
|
|
||||||
maxSize: 10000,
|
|
||||||
defaultTTL: 5 * 60 * 1000, // 5 minutes
|
|
||||||
cleanupInterval: 60 * 1000, // 1 minute
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CACHE SERVICE
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A generic in-memory cache service with TTL support and LRU eviction.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Time-based expiration (TTL)
|
|
||||||
* - LRU eviction when max size is reached
|
|
||||||
* - Memory-based eviction (optional)
|
|
||||||
* - Automatic cleanup of expired entries
|
|
||||||
* - Pattern-based invalidation
|
|
||||||
* - Cache-aside pattern support (getOrCompute)
|
|
||||||
* - Hit rate tracking
|
|
||||||
*
|
|
||||||
* @template T The type of values stored in the cache
|
|
||||||
*/
|
|
||||||
export class CacheService<T = unknown> {
|
|
||||||
private cache: Map<string, CacheEntry<T>> = new Map();
|
|
||||||
private options: CacheOptions;
|
|
||||||
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
private hits = 0;
|
|
||||||
private misses = 0;
|
|
||||||
private totalMemory = 0;
|
|
||||||
|
|
||||||
constructor(options?: Partial<CacheOptions>) {
|
|
||||||
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
||||||
this.startCleanupTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// PUBLIC METHODS
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a value from cache.
|
|
||||||
* Updates access metadata if the entry exists and is not expired.
|
|
||||||
*
|
|
||||||
* @param key The cache key
|
|
||||||
* @returns The cached value, or undefined if not found or expired
|
|
||||||
*/
|
|
||||||
get(key: string): T | undefined {
|
|
||||||
const entry = this.cache.get(key);
|
|
||||||
|
|
||||||
if (!entry) {
|
|
||||||
this.misses++;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if expired
|
|
||||||
if (Date.now() > entry.expiresAt) {
|
|
||||||
this.deleteEntry(key, entry);
|
|
||||||
this.misses++;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update access metadata
|
|
||||||
entry.accessCount++;
|
|
||||||
entry.lastAccessed = Date.now();
|
|
||||||
this.hits++;
|
|
||||||
|
|
||||||
return entry.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a value in cache.
|
|
||||||
* Performs LRU eviction if the cache is at capacity.
|
|
||||||
*
|
|
||||||
* @param key The cache key
|
|
||||||
* @param value The value to cache
|
|
||||||
* @param ttl Optional TTL in milliseconds (defaults to configured defaultTTL)
|
|
||||||
*/
|
|
||||||
set(key: string, value: T, ttl?: number): void {
|
|
||||||
const now = Date.now();
|
|
||||||
const effectiveTTL = ttl ?? this.options.defaultTTL;
|
|
||||||
const size = this.estimateSize(value);
|
|
||||||
|
|
||||||
// If key already exists, remove old entry's size from total
|
|
||||||
const existingEntry = this.cache.get(key);
|
|
||||||
if (existingEntry) {
|
|
||||||
this.totalMemory -= existingEntry.size ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evict entries if needed (before adding new entry)
|
|
||||||
this.evictIfNeeded(size);
|
|
||||||
|
|
||||||
const entry: CacheEntry<T> = {
|
|
||||||
value,
|
|
||||||
expiresAt: now + effectiveTTL,
|
|
||||||
accessCount: 0,
|
|
||||||
lastAccessed: now,
|
|
||||||
size,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.cache.set(key, entry);
|
|
||||||
this.totalMemory += size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a key from cache.
|
|
||||||
*
|
|
||||||
* @param key The cache key to delete
|
|
||||||
* @returns true if the key was deleted, false if it didn't exist
|
|
||||||
*/
|
|
||||||
delete(key: string): boolean {
|
|
||||||
const entry = this.cache.get(key);
|
|
||||||
if (entry) {
|
|
||||||
this.deleteEntry(key, entry);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a key exists in cache and is not expired.
|
|
||||||
*
|
|
||||||
* @param key The cache key
|
|
||||||
* @returns true if the key exists and is not expired
|
|
||||||
*/
|
|
||||||
has(key: string): boolean {
|
|
||||||
const entry = this.cache.get(key);
|
|
||||||
if (!entry) return false;
|
|
||||||
|
|
||||||
if (Date.now() > entry.expiresAt) {
|
|
||||||
this.deleteEntry(key, entry);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate all keys matching a pattern.
|
|
||||||
*
|
|
||||||
* @param pattern A RegExp pattern to match keys against
|
|
||||||
* @returns The number of keys invalidated
|
|
||||||
*/
|
|
||||||
invalidatePattern(pattern: RegExp): number {
|
|
||||||
let count = 0;
|
|
||||||
const keysToDelete: string[] = [];
|
|
||||||
|
|
||||||
for (const key of this.cache.keys()) {
|
|
||||||
if (pattern.test(key)) {
|
|
||||||
keysToDelete.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of keysToDelete) {
|
|
||||||
const entry = this.cache.get(key);
|
|
||||||
if (entry) {
|
|
||||||
this.deleteEntry(key, entry);
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all entries from the cache.
|
|
||||||
*/
|
|
||||||
clear(): void {
|
|
||||||
this.cache.clear();
|
|
||||||
this.totalMemory = 0;
|
|
||||||
this.hits = 0;
|
|
||||||
this.misses = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or compute a value (cache-aside pattern).
|
|
||||||
* If the key exists and is not expired, returns the cached value.
|
|
||||||
* Otherwise, computes the value using the provided function, caches it, and returns it.
|
|
||||||
*
|
|
||||||
* @param key The cache key
|
|
||||||
* @param compute A function that computes the value if not cached
|
|
||||||
* @param ttl Optional TTL in milliseconds
|
|
||||||
* @returns The cached or computed value
|
|
||||||
*/
|
|
||||||
async getOrCompute(
|
|
||||||
key: string,
|
|
||||||
compute: () => Promise<T>,
|
|
||||||
ttl?: number
|
|
||||||
): Promise<T> {
|
|
||||||
// Try to get from cache first
|
|
||||||
const cached = this.get(key);
|
|
||||||
if (cached !== undefined) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute the value
|
|
||||||
const value = await compute();
|
|
||||||
|
|
||||||
// Cache and return
|
|
||||||
this.set(key, value, ttl);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cache statistics.
|
|
||||||
*
|
|
||||||
* @returns Statistics about cache performance and state
|
|
||||||
*/
|
|
||||||
stats(): CacheStats {
|
|
||||||
const totalRequests = this.hits + this.misses;
|
|
||||||
return {
|
|
||||||
size: this.cache.size,
|
|
||||||
hitRate: totalRequests > 0 ? this.hits / totalRequests : 0,
|
|
||||||
memoryUsage: this.totalMemory,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the cleanup timer and release resources.
|
|
||||||
* Call this when the cache is no longer needed to prevent memory leaks.
|
|
||||||
*/
|
|
||||||
destroy(): void {
|
|
||||||
if (this.cleanupTimer) {
|
|
||||||
clearInterval(this.cleanupTimer);
|
|
||||||
this.cleanupTimer = null;
|
|
||||||
}
|
|
||||||
this.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// PRIVATE METHODS
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the automatic cleanup timer.
|
|
||||||
*/
|
|
||||||
private startCleanupTimer(): void {
|
|
||||||
if (this.options.cleanupInterval > 0) {
|
|
||||||
this.cleanupTimer = setInterval(() => {
|
|
||||||
this.cleanup();
|
|
||||||
}, this.options.cleanupInterval);
|
|
||||||
|
|
||||||
// Don't prevent Node.js from exiting if this is the only timer
|
|
||||||
if (this.cleanupTimer.unref) {
|
|
||||||
this.cleanupTimer.unref();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove expired entries from the cache.
|
|
||||||
*/
|
|
||||||
private cleanup(): void {
|
|
||||||
const now = Date.now();
|
|
||||||
const keysToDelete: string[] = [];
|
|
||||||
|
|
||||||
for (const [key, entry] of this.cache.entries()) {
|
|
||||||
if (now > entry.expiresAt) {
|
|
||||||
keysToDelete.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of keysToDelete) {
|
|
||||||
const entry = this.cache.get(key);
|
|
||||||
if (entry) {
|
|
||||||
this.deleteEntry(key, entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an entry and update memory tracking.
|
|
||||||
*/
|
|
||||||
private deleteEntry(key: string, entry: CacheEntry<T>): void {
|
|
||||||
this.totalMemory -= entry.size ?? 0;
|
|
||||||
this.cache.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evict entries if the cache is at capacity.
|
|
||||||
* Uses LRU eviction strategy based on lastAccessed timestamp.
|
|
||||||
* Also considers memory limits if configured.
|
|
||||||
*/
|
|
||||||
private evictIfNeeded(incomingSize: number): void {
|
|
||||||
// Evict for size limit
|
|
||||||
while (this.cache.size >= this.options.maxSize) {
|
|
||||||
this.evictLRU();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evict for memory limit if configured
|
|
||||||
if (this.options.maxMemory) {
|
|
||||||
while (
|
|
||||||
this.totalMemory + incomingSize > this.options.maxMemory &&
|
|
||||||
this.cache.size > 0
|
|
||||||
) {
|
|
||||||
this.evictLRU();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evict the least recently used entry.
|
|
||||||
* Finds the entry with the oldest lastAccessed timestamp and removes it.
|
|
||||||
*/
|
|
||||||
private evictLRU(): void {
|
|
||||||
let oldestKey: string | null = null;
|
|
||||||
let oldestTime = Infinity;
|
|
||||||
|
|
||||||
for (const [key, entry] of this.cache.entries()) {
|
|
||||||
if (entry.lastAccessed < oldestTime) {
|
|
||||||
oldestTime = entry.lastAccessed;
|
|
||||||
oldestKey = key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oldestKey !== null) {
|
|
||||||
const entry = this.cache.get(oldestKey);
|
|
||||||
if (entry) {
|
|
||||||
this.deleteEntry(oldestKey, entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimate the memory size of a value in bytes.
|
|
||||||
* This is a rough approximation for memory tracking purposes.
|
|
||||||
*/
|
|
||||||
private estimateSize(value: T): number {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = typeof value;
|
|
||||||
|
|
||||||
if (type === 'boolean') {
|
|
||||||
return 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'number') {
|
|
||||||
return 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'string') {
|
|
||||||
return (value as string).length * 2 + 40; // 2 bytes per char + overhead
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
// For arrays, estimate based on length
|
|
||||||
// This is a rough approximation
|
|
||||||
return 40 + (value as unknown[]).length * 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'object') {
|
|
||||||
// For objects, use JSON serialization as a rough estimate
|
|
||||||
try {
|
|
||||||
const json = JSON.stringify(value);
|
|
||||||
return json.length * 2 + 40;
|
|
||||||
} catch {
|
|
||||||
return 1024; // Default size for non-serializable objects
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CACHE KEY HELPERS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard cache key patterns for Vestige MCP.
|
|
||||||
* These functions generate consistent cache keys for different entity types.
|
|
||||||
*/
|
|
||||||
export const CACHE_KEYS = {
|
|
||||||
/** Cache key for a knowledge node by ID */
|
|
||||||
node: (id: string): string => `node:${id}`,
|
|
||||||
|
|
||||||
/** Cache key for a person by ID */
|
|
||||||
person: (id: string): string => `person:${id}`,
|
|
||||||
|
|
||||||
/** Cache key for search results */
|
|
||||||
search: (query: string, opts: string): string => `search:${query}:${opts}`,
|
|
||||||
|
|
||||||
/** Cache key for embeddings by node ID */
|
|
||||||
embedding: (nodeId: string): string => `embedding:${nodeId}`,
|
|
||||||
|
|
||||||
/** Cache key for related nodes */
|
|
||||||
related: (nodeId: string, depth: number): string => `related:${nodeId}:${depth}`,
|
|
||||||
|
|
||||||
/** Cache key for person by name */
|
|
||||||
personByName: (name: string): string => `person:name:${name.toLowerCase()}`,
|
|
||||||
|
|
||||||
/** Cache key for daily brief by date */
|
|
||||||
dailyBrief: (date: string): string => `daily-brief:${date}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pattern matchers for cache invalidation.
|
|
||||||
*/
|
|
||||||
export const CACHE_PATTERNS = {
|
|
||||||
/** All node-related entries */
|
|
||||||
allNodes: /^node:/,
|
|
||||||
|
|
||||||
/** All person-related entries */
|
|
||||||
allPeople: /^person:/,
|
|
||||||
|
|
||||||
/** All search results */
|
|
||||||
allSearches: /^search:/,
|
|
||||||
|
|
||||||
/** All embeddings */
|
|
||||||
allEmbeddings: /^embedding:/,
|
|
||||||
|
|
||||||
/** All related node entries */
|
|
||||||
allRelated: /^related:/,
|
|
||||||
|
|
||||||
/** Entries for a specific node and its related data */
|
|
||||||
nodeAndRelated: (nodeId: string): RegExp =>
|
|
||||||
new RegExp(`^(node:${nodeId}|related:${nodeId}|embedding:${nodeId})`),
|
|
||||||
|
|
||||||
/** Entries for a specific person and related data */
|
|
||||||
personAndRelated: (personId: string): RegExp =>
|
|
||||||
new RegExp(`^person:(${personId}|name:)`),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SPECIALIZED CACHE INSTANCES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache for KnowledgeNode entities.
|
|
||||||
* Longer TTL since nodes don't change frequently.
|
|
||||||
*/
|
|
||||||
export const nodeCache = new CacheService<KnowledgeNode>({
|
|
||||||
maxSize: 5000,
|
|
||||||
defaultTTL: 10 * 60 * 1000, // 10 minutes
|
|
||||||
cleanupInterval: 2 * 60 * 1000, // 2 minutes
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache for search results.
|
|
||||||
* Shorter TTL since search results can change with new data.
|
|
||||||
*/
|
|
||||||
export const searchCache = new CacheService<PaginatedResult<KnowledgeNode>>({
|
|
||||||
maxSize: 1000,
|
|
||||||
defaultTTL: 60 * 1000, // 1 minute
|
|
||||||
cleanupInterval: 30 * 1000, // 30 seconds
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache for embedding vectors.
|
|
||||||
* Longer TTL since embeddings don't change for existing content.
|
|
||||||
*/
|
|
||||||
export const embeddingCache = new CacheService<number[]>({
|
|
||||||
maxSize: 10000,
|
|
||||||
defaultTTL: 60 * 60 * 1000, // 1 hour
|
|
||||||
cleanupInterval: 5 * 60 * 1000, // 5 minutes
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache for PersonNode entities.
|
|
||||||
*/
|
|
||||||
export const personCache = new CacheService<PersonNode>({
|
|
||||||
maxSize: 2000,
|
|
||||||
defaultTTL: 10 * 60 * 1000, // 10 minutes
|
|
||||||
cleanupInterval: 2 * 60 * 1000, // 2 minutes
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache for related nodes queries.
|
|
||||||
*/
|
|
||||||
export const relatedCache = new CacheService<KnowledgeNode[]>({
|
|
||||||
maxSize: 2000,
|
|
||||||
defaultTTL: 5 * 60 * 1000, // 5 minutes
|
|
||||||
cleanupInterval: 60 * 1000, // 1 minute
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// UTILITY FUNCTIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate all caches related to a specific node.
|
|
||||||
* Call this when a node is created, updated, or deleted.
|
|
||||||
*
|
|
||||||
* @param nodeId The ID of the node that changed
|
|
||||||
*/
|
|
||||||
export function invalidateNodeCaches(nodeId: string): void {
|
|
||||||
nodeCache.delete(CACHE_KEYS.node(nodeId));
|
|
||||||
embeddingCache.delete(CACHE_KEYS.embedding(nodeId));
|
|
||||||
|
|
||||||
// Invalidate related entries and search results
|
|
||||||
relatedCache.invalidatePattern(new RegExp(`^related:${nodeId}`));
|
|
||||||
searchCache.clear(); // Search results may be affected
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate all caches related to a specific person.
|
|
||||||
* Call this when a person is created, updated, or deleted.
|
|
||||||
*
|
|
||||||
* @param personId The ID of the person that changed
|
|
||||||
* @param name Optional name to also invalidate name-based lookups
|
|
||||||
*/
|
|
||||||
export function invalidatePersonCaches(personId: string, name?: string): void {
|
|
||||||
personCache.delete(CACHE_KEYS.person(personId));
|
|
||||||
|
|
||||||
if (name) {
|
|
||||||
personCache.delete(CACHE_KEYS.personByName(name));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search results may reference this person
|
|
||||||
searchCache.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all caches. Useful for testing or when major data changes occur.
|
|
||||||
*/
|
|
||||||
export function clearAllCaches(): void {
|
|
||||||
nodeCache.clear();
|
|
||||||
searchCache.clear();
|
|
||||||
embeddingCache.clear();
|
|
||||||
personCache.clear();
|
|
||||||
relatedCache.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get aggregated statistics from all caches.
|
|
||||||
*/
|
|
||||||
export function getAllCacheStats(): Record<string, CacheStats> {
|
|
||||||
return {
|
|
||||||
node: nodeCache.stats(),
|
|
||||||
search: searchCache.stats(),
|
|
||||||
embedding: embeddingCache.stats(),
|
|
||||||
person: personCache.stats(),
|
|
||||||
related: relatedCache.stats(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroy all cache instances and stop cleanup timers.
|
|
||||||
* Call this during application shutdown.
|
|
||||||
*/
|
|
||||||
export function destroyAllCaches(): void {
|
|
||||||
nodeCache.destroy();
|
|
||||||
searchCache.destroy();
|
|
||||||
embeddingCache.destroy();
|
|
||||||
personCache.destroy();
|
|
||||||
relatedCache.destroy();
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
/**
|
|
||||||
* Utility exports
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from './mutex.js';
|
|
||||||
export * from './json.js';
|
|
||||||
export * from './logger.js';
|
|
||||||
|
|
@ -1,230 +0,0 @@
|
||||||
/**
|
|
||||||
* Safe JSON utilities for database operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { logger } from './logger.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely parse JSON with logging on failure
|
|
||||||
*/
|
|
||||||
export function safeJsonParse<T>(
|
|
||||||
value: string | null | undefined,
|
|
||||||
fallback: T,
|
|
||||||
options?: {
|
|
||||||
logOnError?: boolean;
|
|
||||||
context?: string;
|
|
||||||
}
|
|
||||||
): T {
|
|
||||||
if (!value) return fallback;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
|
|
||||||
// Type validation
|
|
||||||
if (typeof parsed !== typeof fallback) {
|
|
||||||
if (options?.logOnError !== false) {
|
|
||||||
logger.warn('JSON parse type mismatch', {
|
|
||||||
expected: typeof fallback,
|
|
||||||
got: typeof parsed,
|
|
||||||
context: options?.context,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Array validation
|
|
||||||
if (Array.isArray(fallback) && !Array.isArray(parsed)) {
|
|
||||||
if (options?.logOnError !== false) {
|
|
||||||
logger.warn('JSON parse expected array', {
|
|
||||||
got: typeof parsed,
|
|
||||||
context: options?.context,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed as T;
|
|
||||||
} catch (error) {
|
|
||||||
if (options?.logOnError !== false) {
|
|
||||||
logger.warn('JSON parse failed', {
|
|
||||||
error: (error as Error).message,
|
|
||||||
valuePreview: value.slice(0, 100),
|
|
||||||
context: options?.context,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely stringify JSON with circular reference handling
|
|
||||||
*/
|
|
||||||
export function safeJsonStringify(
|
|
||||||
value: unknown,
|
|
||||||
options?: {
|
|
||||||
replacer?: (key: string, value: unknown) => unknown;
|
|
||||||
space?: number;
|
|
||||||
maxDepth?: number;
|
|
||||||
}
|
|
||||||
): string {
|
|
||||||
const seen = new WeakSet();
|
|
||||||
const maxDepth = options?.maxDepth ?? 10;
|
|
||||||
|
|
||||||
function replacer(
|
|
||||||
this: unknown,
|
|
||||||
key: string,
|
|
||||||
value: unknown,
|
|
||||||
depth: number
|
|
||||||
): unknown {
|
|
||||||
if (depth > maxDepth) {
|
|
||||||
return '[Max Depth Exceeded]';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'object' && value !== null) {
|
|
||||||
if (seen.has(value)) {
|
|
||||||
return '[Circular Reference]';
|
|
||||||
}
|
|
||||||
seen.add(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.replacer) {
|
|
||||||
return options.replacer(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle special types
|
|
||||||
if (value instanceof Error) {
|
|
||||||
return {
|
|
||||||
name: value.name,
|
|
||||||
message: value.message,
|
|
||||||
stack: value.stack,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value instanceof Date) {
|
|
||||||
return value.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value instanceof Map) {
|
|
||||||
return Object.fromEntries(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value instanceof Set) {
|
|
||||||
return Array.from(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create a depth-tracking replacer
|
|
||||||
let currentDepth = 0;
|
|
||||||
return JSON.stringify(
|
|
||||||
value,
|
|
||||||
function (key, val) {
|
|
||||||
if (key === '') currentDepth = 0;
|
|
||||||
else currentDepth++;
|
|
||||||
return replacer.call(this, key, val, currentDepth);
|
|
||||||
},
|
|
||||||
options?.space
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('JSON stringify failed', error as Error);
|
|
||||||
return '{}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse JSON and validate against Zod schema
|
|
||||||
*/
|
|
||||||
export function parseJsonWithSchema<T extends z.ZodType>(
|
|
||||||
value: string | null | undefined,
|
|
||||||
schema: T,
|
|
||||||
fallback: z.infer<T>
|
|
||||||
): z.infer<T> {
|
|
||||||
if (!value) return fallback;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
const result = schema.safeParse(parsed);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn('JSON schema validation failed', {
|
|
||||||
errors: result.error.errors,
|
|
||||||
});
|
|
||||||
return fallback;
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('JSON parse failed for schema validation', {
|
|
||||||
error: (error as Error).message,
|
|
||||||
});
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate diff between two JSON objects
|
|
||||||
*/
|
|
||||||
export function jsonDiff(
|
|
||||||
before: Record<string, unknown>,
|
|
||||||
after: Record<string, unknown>
|
|
||||||
): { added: string[]; removed: string[]; changed: string[] } {
|
|
||||||
const added: string[] = [];
|
|
||||||
const removed: string[] = [];
|
|
||||||
const changed: string[] = [];
|
|
||||||
|
|
||||||
// Check for added and changed
|
|
||||||
for (const key of Object.keys(after)) {
|
|
||||||
if (!(key in before)) {
|
|
||||||
added.push(key);
|
|
||||||
} else if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
|
|
||||||
changed.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for removed
|
|
||||||
for (const key of Object.keys(before)) {
|
|
||||||
if (!(key in after)) {
|
|
||||||
removed.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { added, removed, changed };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deep merge JSON objects
|
|
||||||
*/
|
|
||||||
export function jsonMerge<T extends Record<string, unknown>>(
|
|
||||||
target: T,
|
|
||||||
...sources: Partial<T>[]
|
|
||||||
): T {
|
|
||||||
const result = { ...target };
|
|
||||||
|
|
||||||
for (const source of sources) {
|
|
||||||
for (const key of Object.keys(source)) {
|
|
||||||
const targetVal = result[key as keyof T];
|
|
||||||
const sourceVal = source[key as keyof T];
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof targetVal === 'object' &&
|
|
||||||
targetVal !== null &&
|
|
||||||
typeof sourceVal === 'object' &&
|
|
||||||
sourceVal !== null &&
|
|
||||||
!Array.isArray(targetVal) &&
|
|
||||||
!Array.isArray(sourceVal)
|
|
||||||
) {
|
|
||||||
(result as Record<string, unknown>)[key] = jsonMerge(
|
|
||||||
targetVal as Record<string, unknown>,
|
|
||||||
sourceVal as Record<string, unknown>
|
|
||||||
);
|
|
||||||
} else if (sourceVal !== undefined) {
|
|
||||||
(result as Record<string, unknown>)[key] = sourceVal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
@ -1,253 +0,0 @@
|
||||||
/**
|
|
||||||
* Centralized logging system for Vestige MCP
|
|
||||||
*
|
|
||||||
* Provides structured JSON logging with:
|
|
||||||
* - Log levels (debug, info, warn, error)
|
|
||||||
* - Child loggers for subsystems
|
|
||||||
* - Request context tracking via AsyncLocalStorage
|
|
||||||
* - Performance logging utilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { AsyncLocalStorage } from 'async_hooks';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Types
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
||||||
|
|
||||||
export interface LogEntry {
|
|
||||||
timestamp: string;
|
|
||||||
level: LogLevel;
|
|
||||||
logger: string;
|
|
||||||
message: string;
|
|
||||||
context?: Record<string, unknown>;
|
|
||||||
error?: {
|
|
||||||
name: string;
|
|
||||||
message: string;
|
|
||||||
stack?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Logger {
|
|
||||||
debug(message: string, context?: Record<string, unknown>): void;
|
|
||||||
info(message: string, context?: Record<string, unknown>): void;
|
|
||||||
warn(message: string, context?: Record<string, unknown>): void;
|
|
||||||
error(message: string, error?: Error, context?: Record<string, unknown>): void;
|
|
||||||
child(name: string): Logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Request Context (AsyncLocalStorage)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
interface RequestContext {
|
|
||||||
requestId: string;
|
|
||||||
startTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const requestContext = new AsyncLocalStorage<RequestContext>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run a function within a request context for tracing
|
|
||||||
*/
|
|
||||||
export function withRequestContext<T>(fn: () => T): T {
|
|
||||||
const ctx: RequestContext = {
|
|
||||||
requestId: nanoid(8),
|
|
||||||
startTime: Date.now(),
|
|
||||||
};
|
|
||||||
return requestContext.run(ctx, fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run an async function within a request context for tracing
|
|
||||||
*/
|
|
||||||
export function withRequestContextAsync<T>(fn: () => Promise<T>): Promise<T> {
|
|
||||||
const ctx: RequestContext = {
|
|
||||||
requestId: nanoid(8),
|
|
||||||
startTime: Date.now(),
|
|
||||||
};
|
|
||||||
return requestContext.run(ctx, fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enrich context with request tracing information if available
|
|
||||||
*/
|
|
||||||
function enrichContext(context?: Record<string, unknown>): Record<string, unknown> {
|
|
||||||
const ctx = requestContext.getStore();
|
|
||||||
if (ctx) {
|
|
||||||
return {
|
|
||||||
...context,
|
|
||||||
requestId: ctx.requestId,
|
|
||||||
elapsed: Date.now() - ctx.startTime,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return context || {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Logger Implementation
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
|
||||||
debug: 0,
|
|
||||||
info: 1,
|
|
||||||
warn: 2,
|
|
||||||
error: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a structured JSON logger
|
|
||||||
*
|
|
||||||
* @param name - Logger name (used as prefix for child loggers)
|
|
||||||
* @param minLevel - Minimum log level to output (default: 'info')
|
|
||||||
* @returns Logger instance
|
|
||||||
*/
|
|
||||||
export function createLogger(name: string, minLevel: LogLevel = 'info'): Logger {
|
|
||||||
const minLevelValue = LOG_LEVELS[minLevel];
|
|
||||||
|
|
||||||
function log(
|
|
||||||
level: LogLevel,
|
|
||||||
message: string,
|
|
||||||
context?: Record<string, unknown>,
|
|
||||||
error?: Error
|
|
||||||
): void {
|
|
||||||
if (LOG_LEVELS[level] < minLevelValue) return;
|
|
||||||
|
|
||||||
const enrichedContext = enrichContext(context);
|
|
||||||
|
|
||||||
const entry: LogEntry = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
level,
|
|
||||||
logger: name,
|
|
||||||
message,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only include context if it has properties
|
|
||||||
if (Object.keys(enrichedContext).length > 0) {
|
|
||||||
entry.context = enrichedContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
entry.error = {
|
|
||||||
name: error.name,
|
|
||||||
message: error.message,
|
|
||||||
...(error.stack !== undefined && { stack: error.stack }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = JSON.stringify(entry);
|
|
||||||
|
|
||||||
if (level === 'error') {
|
|
||||||
console.error(output);
|
|
||||||
} else {
|
|
||||||
console.log(output);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
debug: (message, context) => log('debug', message, context),
|
|
||||||
info: (message, context) => log('info', message, context),
|
|
||||||
warn: (message, context) => log('warn', message, context),
|
|
||||||
error: (message, error, context) => log('error', message, context, error),
|
|
||||||
child: (childName) => createLogger(`${name}:${childName}`, minLevel),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Global Logger Instances
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// Get log level from environment
|
|
||||||
function getLogLevelFromEnv(): LogLevel {
|
|
||||||
const envLevel = process.env['VESTIGE_LOG_LEVEL']?.toLowerCase();
|
|
||||||
if (envLevel && envLevel in LOG_LEVELS) {
|
|
||||||
return envLevel as LogLevel;
|
|
||||||
}
|
|
||||||
return 'info';
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOG_LEVEL = getLogLevelFromEnv();
|
|
||||||
|
|
||||||
// Root logger
|
|
||||||
export const logger = createLogger('vestige', LOG_LEVEL);
|
|
||||||
|
|
||||||
// Pre-configured child loggers for subsystems
|
|
||||||
export const dbLogger = logger.child('database');
|
|
||||||
export const mcpLogger = logger.child('mcp');
|
|
||||||
export const remLogger = logger.child('rem-cycle');
|
|
||||||
export const embeddingLogger = logger.child('embeddings');
|
|
||||||
export const cacheLogger = logger.child('cache');
|
|
||||||
export const jobLogger = logger.child('jobs');
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Performance Logging
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap a function to log its execution time
|
|
||||||
*
|
|
||||||
* @param logger - Logger instance to use
|
|
||||||
* @param operationName - Name of the operation for logging
|
|
||||||
* @param fn - Async function to wrap
|
|
||||||
* @returns Wrapped function that logs performance
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const wrappedFetch = logPerformance(dbLogger, 'fetchNodes', fetchNodes);
|
|
||||||
* const nodes = await wrappedFetch(query);
|
|
||||||
*/
|
|
||||||
export function logPerformance<T extends (...args: unknown[]) => Promise<unknown>>(
|
|
||||||
logger: Logger,
|
|
||||||
operationName: string,
|
|
||||||
fn: T
|
|
||||||
): T {
|
|
||||||
return (async (...args: Parameters<T>) => {
|
|
||||||
const start = Date.now();
|
|
||||||
try {
|
|
||||||
const result = await fn(...args);
|
|
||||||
logger.info(`${operationName} completed`, {
|
|
||||||
duration: Date.now() - start,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`${operationName} failed`, error as Error, {
|
|
||||||
duration: Date.now() - start,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log performance of a single async operation
|
|
||||||
*
|
|
||||||
* @param logger - Logger instance to use
|
|
||||||
* @param operationName - Name of the operation for logging
|
|
||||||
* @param fn - Async function to execute and measure
|
|
||||||
* @returns Result of the function
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const result = await timedOperation(dbLogger, 'query', async () => {
|
|
||||||
* return await db.query(sql);
|
|
||||||
* });
|
|
||||||
*/
|
|
||||||
export async function timedOperation<T>(
|
|
||||||
logger: Logger,
|
|
||||||
operationName: string,
|
|
||||||
fn: () => Promise<T>
|
|
||||||
): Promise<T> {
|
|
||||||
const start = Date.now();
|
|
||||||
try {
|
|
||||||
const result = await fn();
|
|
||||||
logger.info(`${operationName} completed`, {
|
|
||||||
duration: Date.now() - start,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`${operationName} failed`, error as Error, {
|
|
||||||
duration: Date.now() - start,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,451 +0,0 @@
|
||||||
/**
|
|
||||||
* Concurrency utilities for Vestige MCP
|
|
||||||
*
|
|
||||||
* Provides synchronization primitives for managing concurrent access
|
|
||||||
* to shared resources like database connections.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error thrown when an operation times out
|
|
||||||
*/
|
|
||||||
export class TimeoutError extends Error {
|
|
||||||
constructor(message = "Operation timed out") {
|
|
||||||
super(message);
|
|
||||||
this.name = "TimeoutError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reader-Writer Lock for concurrent database access.
|
|
||||||
* Allows multiple concurrent readers OR one exclusive writer.
|
|
||||||
*
|
|
||||||
* This implementation uses writer preference with reader batching
|
|
||||||
* to prevent writer starvation while still allowing good read throughput.
|
|
||||||
*/
|
|
||||||
export class RWLock {
|
|
||||||
private readers = 0;
|
|
||||||
private writer = false;
|
|
||||||
private writerQueue: (() => void)[] = [];
|
|
||||||
private readerQueue: (() => void)[] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a function with read lock (allows concurrent readers)
|
|
||||||
*/
|
|
||||||
async withReadLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
||||||
await this.acquireRead();
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} finally {
|
|
||||||
this.releaseRead();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a function with write lock (exclusive access)
|
|
||||||
*/
|
|
||||||
async withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
||||||
await this.acquireWrite();
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} finally {
|
|
||||||
this.releaseWrite();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private acquireRead(): Promise<void> {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
// If no writer and no writers waiting, grant immediately
|
|
||||||
if (!this.writer && this.writerQueue.length === 0) {
|
|
||||||
this.readers++;
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
// Queue the reader
|
|
||||||
this.readerQueue.push(() => {
|
|
||||||
this.readers++;
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private releaseRead(): void {
|
|
||||||
this.readers--;
|
|
||||||
|
|
||||||
// If no more readers, wake up waiting writer
|
|
||||||
if (this.readers === 0 && this.writerQueue.length > 0) {
|
|
||||||
const nextWriter = this.writerQueue.shift();
|
|
||||||
if (nextWriter) {
|
|
||||||
this.writer = true;
|
|
||||||
nextWriter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private acquireWrite(): Promise<void> {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
// If no readers and no writer, grant immediately
|
|
||||||
if (this.readers === 0 && !this.writer) {
|
|
||||||
this.writer = true;
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
// Queue the writer
|
|
||||||
this.writerQueue.push(resolve);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private releaseWrite(): void {
|
|
||||||
this.writer = false;
|
|
||||||
|
|
||||||
// Prefer waking readers over writers to prevent starvation
|
|
||||||
// Wake all waiting readers as a batch
|
|
||||||
if (this.readerQueue.length > 0) {
|
|
||||||
const readers = this.readerQueue.splice(0, this.readerQueue.length);
|
|
||||||
for (const reader of readers) {
|
|
||||||
reader();
|
|
||||||
}
|
|
||||||
} else if (this.writerQueue.length > 0) {
|
|
||||||
// No waiting readers, wake next writer
|
|
||||||
const nextWriter = this.writerQueue.shift();
|
|
||||||
if (nextWriter) {
|
|
||||||
this.writer = true;
|
|
||||||
nextWriter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current lock state (for debugging/monitoring)
|
|
||||||
*/
|
|
||||||
getState(): { readers: number; hasWriter: boolean; pendingReaders: number; pendingWriters: number } {
|
|
||||||
return {
|
|
||||||
readers: this.readers,
|
|
||||||
hasWriter: this.writer,
|
|
||||||
pendingReaders: this.readerQueue.length,
|
|
||||||
pendingWriters: this.writerQueue.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple mutex for exclusive access
|
|
||||||
*/
|
|
||||||
export class Mutex {
|
|
||||||
private locked = false;
|
|
||||||
private queue: (() => void)[] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a function with exclusive lock
|
|
||||||
*/
|
|
||||||
async withLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
||||||
await this.acquire();
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} finally {
|
|
||||||
this.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private acquire(): Promise<void> {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
if (!this.locked) {
|
|
||||||
this.locked = true;
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
this.queue.push(resolve);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private release(): void {
|
|
||||||
if (this.queue.length > 0) {
|
|
||||||
const next = this.queue.shift();
|
|
||||||
if (next) {
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.locked = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the mutex is currently locked
|
|
||||||
*/
|
|
||||||
isLocked(): boolean {
|
|
||||||
return this.locked;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the number of waiters in the queue
|
|
||||||
*/
|
|
||||||
getQueueLength(): number {
|
|
||||||
return this.queue.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Semaphore for limiting concurrent operations
|
|
||||||
*/
|
|
||||||
export class Semaphore {
|
|
||||||
private permits: number;
|
|
||||||
private available: number;
|
|
||||||
private queue: (() => void)[] = [];
|
|
||||||
|
|
||||||
constructor(permits: number) {
|
|
||||||
if (permits < 1) {
|
|
||||||
throw new Error("Semaphore must have at least 1 permit");
|
|
||||||
}
|
|
||||||
this.permits = permits;
|
|
||||||
this.available = permits;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a function with a permit from the semaphore
|
|
||||||
*/
|
|
||||||
async withPermit<T>(fn: () => Promise<T>): Promise<T> {
|
|
||||||
await this.acquire();
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} finally {
|
|
||||||
this.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute multiple functions concurrently, respecting the semaphore limit
|
|
||||||
*/
|
|
||||||
async map<T, R>(items: T[], fn: (item: T) => Promise<R>): Promise<R[]> {
|
|
||||||
return Promise.all(items.map((item) => this.withPermit(() => fn(item))));
|
|
||||||
}
|
|
||||||
|
|
||||||
private acquire(): Promise<void> {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
if (this.available > 0) {
|
|
||||||
this.available--;
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
this.queue.push(resolve);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private release(): void {
|
|
||||||
if (this.queue.length > 0) {
|
|
||||||
const next = this.queue.shift();
|
|
||||||
if (next) {
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.available++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the number of available permits
|
|
||||||
*/
|
|
||||||
getAvailable(): number {
|
|
||||||
return this.available;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the total number of permits
|
|
||||||
*/
|
|
||||||
getTotal(): number {
|
|
||||||
return this.permits;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the number of waiters in the queue
|
|
||||||
*/
|
|
||||||
getQueueLength(): number {
|
|
||||||
return this.queue.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add timeout to any promise
|
|
||||||
*
|
|
||||||
* @param promise - The promise to wrap with a timeout
|
|
||||||
* @param ms - Timeout in milliseconds
|
|
||||||
* @param message - Optional custom error message
|
|
||||||
* @returns The result of the promise if it completes in time
|
|
||||||
* @throws TimeoutError if the timeout is exceeded
|
|
||||||
*/
|
|
||||||
export function withTimeout<T>(promise: Promise<T>, ms: number, message?: string): Promise<T> {
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
reject(new TimeoutError(message ?? `Operation timed out after ${ms}ms`));
|
|
||||||
}, ms);
|
|
||||||
|
|
||||||
promise
|
|
||||||
.then((result) => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
resolve(result);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for retry with exponential backoff
|
|
||||||
*/
|
|
||||||
export interface RetryOptions {
|
|
||||||
/** Maximum number of retry attempts (default: 3) */
|
|
||||||
maxRetries?: number;
|
|
||||||
/** Initial delay in milliseconds (default: 100) */
|
|
||||||
initialDelay?: number;
|
|
||||||
/** Maximum delay in milliseconds (default: 5000) */
|
|
||||||
maxDelay?: number;
|
|
||||||
/** Backoff multiplier (default: 2) */
|
|
||||||
backoffFactor?: number;
|
|
||||||
/** Optional function to determine if an error is retryable */
|
|
||||||
isRetryable?: (error: unknown) => boolean;
|
|
||||||
/** Optional callback called before each retry */
|
|
||||||
onRetry?: (error: unknown, attempt: number, delay: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retry function with exponential backoff
|
|
||||||
*
|
|
||||||
* @param fn - The async function to retry
|
|
||||||
* @param options - Retry configuration options
|
|
||||||
* @returns The result of the function if it succeeds
|
|
||||||
* @throws The last error if all retries are exhausted
|
|
||||||
*/
|
|
||||||
export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
|
|
||||||
const {
|
|
||||||
maxRetries = 3,
|
|
||||||
initialDelay = 100,
|
|
||||||
maxDelay = 5000,
|
|
||||||
backoffFactor = 2,
|
|
||||||
isRetryable = () => true,
|
|
||||||
onRetry,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
let lastError: unknown;
|
|
||||||
let delay = initialDelay;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
|
|
||||||
// Check if we've exhausted retries
|
|
||||||
if (attempt >= maxRetries) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the error is retryable
|
|
||||||
if (!isRetryable(error)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate delay with jitter (0.5 to 1.5 of calculated delay)
|
|
||||||
const jitter = 0.5 + Math.random();
|
|
||||||
const actualDelay = Math.min(delay * jitter, maxDelay);
|
|
||||||
|
|
||||||
// Call onRetry callback if provided
|
|
||||||
if (onRetry) {
|
|
||||||
onRetry(error, attempt + 1, actualDelay);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait before next attempt
|
|
||||||
await sleep(actualDelay);
|
|
||||||
|
|
||||||
// Increase delay for next attempt
|
|
||||||
delay = Math.min(delay * backoffFactor, maxDelay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This should never be reached, but TypeScript needs it
|
|
||||||
throw lastError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sleep for a specified duration
|
|
||||||
*
|
|
||||||
* @param ms - Duration in milliseconds
|
|
||||||
*/
|
|
||||||
export function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Debounce a function - only execute after the specified delay
|
|
||||||
* has passed without another call
|
|
||||||
*
|
|
||||||
* @param fn - The function to debounce
|
|
||||||
* @param delay - Delay in milliseconds
|
|
||||||
*/
|
|
||||||
export function debounce<T extends (...args: unknown[]) => unknown>(
|
|
||||||
fn: T,
|
|
||||||
delay: number
|
|
||||||
): (...args: Parameters<T>) => void {
|
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
return (...args: Parameters<T>) => {
|
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
fn(...args);
|
|
||||||
timeoutId = null;
|
|
||||||
}, delay);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Throttle a function - execute at most once per specified interval
|
|
||||||
*
|
|
||||||
* @param fn - The function to throttle
|
|
||||||
* @param interval - Minimum interval between executions in milliseconds
|
|
||||||
*/
|
|
||||||
export function throttle<T extends (...args: unknown[]) => unknown>(
|
|
||||||
fn: T,
|
|
||||||
interval: number
|
|
||||||
): (...args: Parameters<T>) => void {
|
|
||||||
let lastCall = 0;
|
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
return (...args: Parameters<T>) => {
|
|
||||||
const now = Date.now();
|
|
||||||
const timeSinceLastCall = now - lastCall;
|
|
||||||
|
|
||||||
if (timeSinceLastCall >= interval) {
|
|
||||||
lastCall = now;
|
|
||||||
fn(...args);
|
|
||||||
} else if (!timeoutId) {
|
|
||||||
timeoutId = setTimeout(
|
|
||||||
() => {
|
|
||||||
lastCall = Date.now();
|
|
||||||
fn(...args);
|
|
||||||
timeoutId = null;
|
|
||||||
},
|
|
||||||
interval - timeSinceLastCall
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a deferred promise that can be resolved/rejected externally
|
|
||||||
*/
|
|
||||||
export function deferred<T>(): {
|
|
||||||
promise: Promise<T>;
|
|
||||||
resolve: (value: T) => void;
|
|
||||||
reject: (error: unknown) => void;
|
|
||||||
} {
|
|
||||||
let resolve!: (value: T) => void;
|
|
||||||
let reject!: (error: unknown) => void;
|
|
||||||
|
|
||||||
const promise = new Promise<T>((res, rej) => {
|
|
||||||
resolve = res;
|
|
||||||
reject = rej;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { promise, resolve, reject };
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"lib": ["ES2022"],
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"noImplicitOverride": true,
|
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
|
||||||
"exactOptionalPropertyTypes": true,
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist", "tests"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { defineConfig } from 'tsup';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
entry: ['src/index.ts', 'src/cli.ts'],
|
|
||||||
format: ['esm'],
|
|
||||||
dts: true,
|
|
||||||
clean: true,
|
|
||||||
sourcemap: true,
|
|
||||||
target: 'node20',
|
|
||||||
shims: true,
|
|
||||||
});
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "vestige-mcp-server",
|
"name": "vestige-mcp-server",
|
||||||
"version": "1.0.0",
|
"version": "1.1.2",
|
||||||
"description": "Vestige MCP Server - AI Memory System for Claude and other assistants",
|
"description": "Vestige MCP Server - AI Memory System for Claude and other assistants",
|
||||||
"bin": {
|
"bin": {
|
||||||
"vestige-mcp": "bin/vestige-mcp.js",
|
"vestige-mcp": "bin/vestige-mcp.js",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const os = require('os');
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
const VERSION = require('../package.json').version;
|
const VERSION = require('../package.json').version;
|
||||||
const BINARY_VERSION = '1.1.0'; // GitHub release version for binaries
|
const BINARY_VERSION = '1.1.2'; // GitHub release version for binaries
|
||||||
const PLATFORM = os.platform();
|
const PLATFORM = os.platform();
|
||||||
const ARCH = os.arch();
|
const ARCH = os.arch();
|
||||||
|
|
||||||
|
|
@ -109,7 +109,7 @@ function extract(archivePath, destDir) {
|
||||||
function makeExecutable(binDir) {
|
function makeExecutable(binDir) {
|
||||||
if (isWindows) return;
|
if (isWindows) return;
|
||||||
|
|
||||||
const binaries = ['vestige-mcp', 'vestige'];
|
const binaries = ['vestige-mcp', 'vestige', 'vestige-restore'];
|
||||||
for (const bin of binaries) {
|
for (const bin of binaries) {
|
||||||
const binPath = path.join(binDir, bin);
|
const binPath = path.join(binDir, bin);
|
||||||
if (fs.existsSync(binPath)) {
|
if (fs.existsSync(binPath)) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue