mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bfec8a88e | |||
| 9c3d2eb335 | |||
| 5fcbae3fdb | |||
| 68ee310a55 | |||
| 3a7fca8a9e | |||
| 4f2b1b2fd3 | |||
| a70b355838 | |||
| 32acbfe982 | |||
| 1831397861 | |||
| 28cc5225a7 | |||
| 9b7bbc7116 | |||
| 138c283d6c | |||
| 7a8954c37e | |||
| 10c7c19c03 | |||
| fb5e9e1d77 | |||
| 66b91b2847 | |||
| b004565df9 |
+16
-20
@@ -1,17 +1,19 @@
|
||||
---
|
||||
allowed-tools: mcp__basic-memory__write_note, mcp__basic-memory__read_note, mcp__basic-memory__search_notes, mcp__basic-memory__edit_note, Task
|
||||
argument-hint: [create|status|implement|review] [spec-name]
|
||||
allowed-tools: mcp__basic-memory__write_note, mcp__basic-memory__read_note, mcp__basic-memory__search_notes, mcp__basic-memory__edit_note
|
||||
argument-hint: [create|status|show|review] [spec-name]
|
||||
description: Manage specifications in our development process
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
You are managing specifications using our specification-driven development process defined in @docs/specs/SPEC-001.md.
|
||||
Specifications are managed in the Basic Memory "specs" project. All specs live in a centralized location accessible across all repositories via MCP tools.
|
||||
|
||||
See SPEC-1 and SPEC-2 in the "specs" project for the full specification-driven development process.
|
||||
|
||||
Available commands:
|
||||
- `create [name]` - Create new specification
|
||||
- `status` - Show all spec statuses
|
||||
- `implement [spec-name]` - Hand spec to appropriate agent
|
||||
- `show [spec-name]` - Read a specific spec
|
||||
- `review [spec-name]` - Review implementation against spec
|
||||
|
||||
## Your task
|
||||
@@ -19,23 +21,19 @@ Available commands:
|
||||
Execute the spec command: `/spec $ARGUMENTS`
|
||||
|
||||
### If command is "create":
|
||||
1. Get next SPEC number by searching existing specs
|
||||
2. Create new spec using template from @docs/specs/Slash\ Commands\ Reference.md
|
||||
3. Place in `/specs` folder with title "SPEC-XXX: [name]"
|
||||
1. Get next SPEC number by searching existing specs in "specs" project
|
||||
2. Create new spec using template from SPEC-2
|
||||
3. Use mcp__basic-memory__write_note with project="specs"
|
||||
4. Include standard sections: Why, What, How, How to Evaluate
|
||||
|
||||
### If command is "status":
|
||||
1. Search all notes in `/specs` folder
|
||||
2. Display table with spec number, title, and status
|
||||
3. Show any dependencies or assigned agents
|
||||
1. Use mcp__basic-memory__search_notes with project="specs"
|
||||
2. Display table with spec number, title, and progress
|
||||
3. Show completion status from checkboxes in content
|
||||
|
||||
### If command is "implement":
|
||||
1. Read the specified spec
|
||||
2. Determine appropriate agent based on content:
|
||||
- Frontend/UI → vue-developer
|
||||
- Architecture/system → system-architect
|
||||
- Backend/API → python-developer
|
||||
3. Launch Task tool with appropriate agent and spec context
|
||||
### If command is "show":
|
||||
1. Use mcp__basic-memory__read_note with project="specs"
|
||||
2. Display the full spec content
|
||||
|
||||
### If command is "review":
|
||||
1. Read the specified spec and its "How to Evaluate" section
|
||||
@@ -49,7 +47,5 @@ Execute the spec command: `/spec $ARGUMENTS`
|
||||
- **Architecture compliance** - Component isolation, state management patterns
|
||||
- **Documentation completeness** - Implementation matches specification
|
||||
3. Provide honest, accurate assessment - do not overstate completeness
|
||||
4. Document findings and update spec with review results
|
||||
4. Document findings and update spec with review results using mcp__basic-memory__edit_note
|
||||
5. If gaps found, clearly identify what still needs to be implemented/tested
|
||||
|
||||
Use the agent definitions from @docs/specs/Agent\ Definitions.md for implementation handoffs.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Basic Memory Environment Variables Example
|
||||
# Copy this file to .env and customize as needed
|
||||
# Note: .env files are gitignored and should never be committed
|
||||
|
||||
# ============================================================================
|
||||
# PostgreSQL Test Database Configuration
|
||||
# ============================================================================
|
||||
# These variables allow you to override the default test database credentials
|
||||
# Default values match docker-compose-postgres.yml for local development
|
||||
#
|
||||
# Only needed if you want to use different credentials or a remote test database
|
||||
# By default, tests use: postgresql://basic_memory_user:dev_password@localhost:5433/basic_memory_test
|
||||
|
||||
# Full PostgreSQL test database URL (used by tests and migrations)
|
||||
# POSTGRES_TEST_URL=postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test
|
||||
|
||||
# Individual components (used by justfile postgres-reset command)
|
||||
# POSTGRES_USER=basic_memory_user
|
||||
# POSTGRES_TEST_DB=basic_memory_test
|
||||
|
||||
# ============================================================================
|
||||
# Production Database Configuration
|
||||
# ============================================================================
|
||||
# For production use, set these in your deployment environment
|
||||
# DO NOT use the test credentials above in production!
|
||||
|
||||
# BASIC_MEMORY_DATABASE_BACKEND=postgres # or "sqlite"
|
||||
# BASIC_MEMORY_DATABASE_URL=postgresql+asyncpg://user:password@host:port/database
|
||||
@@ -13,7 +13,8 @@ on:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
test-sqlite:
|
||||
name: Test SQLite (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -64,7 +65,63 @@ jobs:
|
||||
run: |
|
||||
just lint
|
||||
|
||||
- name: Run tests
|
||||
- name: Run tests (SQLite)
|
||||
run: |
|
||||
uv pip install pytest pytest-cov
|
||||
just test
|
||||
just test-sqlite
|
||||
|
||||
test-postgres:
|
||||
name: Test Postgres (Python ${{ matrix.python-version }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: [ "3.12", "3.13" ]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Postgres service (only available on Linux runners)
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
env:
|
||||
POSTGRES_DB: basic_memory_test
|
||||
POSTGRES_USER: basic_memory_user
|
||||
POSTGRES_PASSWORD: dev_password
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5433:5432
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Install just
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
uv venv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install -e .[dev]
|
||||
|
||||
- name: Run tests (Postgres)
|
||||
run: |
|
||||
uv pip install pytest pytest-cov
|
||||
just test-postgres
|
||||
+2
-1
@@ -52,4 +52,5 @@ ENV/
|
||||
|
||||
# claude action
|
||||
claude-output
|
||||
**/.claude/settings.local.json
|
||||
**/.claude/settings.local.json
|
||||
.mcp.json
|
||||
|
||||
@@ -264,5 +264,6 @@ With GitHub integration, the development workflow includes:
|
||||
2. **Contribution tracking** - All of Claude's contributions are properly attributed in the Git history
|
||||
3. **Branch management** - Claude can create feature branches for implementations
|
||||
4. **Documentation maintenance** - Claude can keep documentation updated as the code evolves
|
||||
5. **Code Commits**: ALWAYS sign off commits with `git commit -s`
|
||||
|
||||
This level of integration represents a new paradigm in AI-human collaboration, where the AI assistant becomes a full-fledged team member rather than just a tool for generating code snippets.
|
||||
|
||||
@@ -433,6 +433,57 @@ See the [Documentation](https://memory.basicmachines.co/) for more info, includi
|
||||
- [Managing multiple Projects](https://docs.basicmemory.com/guides/cli-reference/#project)
|
||||
- [Importing data from OpenAI/Claude Projects](https://docs.basicmemory.com/guides/cli-reference/#import)
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
Basic Memory supports dual database backends (SQLite and Postgres). Tests are parametrized to run against both backends automatically.
|
||||
|
||||
**Quick Start:**
|
||||
```bash
|
||||
# Run SQLite tests (default, no Docker needed)
|
||||
just test-sqlite
|
||||
|
||||
# Run Postgres tests (requires Docker)
|
||||
just test-postgres
|
||||
```
|
||||
|
||||
**Available Test Commands:**
|
||||
|
||||
- `just test-sqlite` - Run tests against SQLite only (fastest, no Docker needed)
|
||||
- `just test-postgres` - Run tests against Postgres only (requires Docker)
|
||||
- `just test-windows` - Run Windows-specific tests (auto-skips on other platforms)
|
||||
- `just test-benchmark` - Run performance benchmark tests
|
||||
- `just test-all` - Run all tests including Windows, Postgres, and benchmarks
|
||||
|
||||
**Postgres Testing Requirements:**
|
||||
|
||||
To run Postgres tests, you need to start the test database:
|
||||
```bash
|
||||
docker-compose -f docker-compose-postgres.yml up -d
|
||||
```
|
||||
|
||||
Tests will connect to `localhost:5433/basic_memory_test`.
|
||||
|
||||
**Test Markers:**
|
||||
|
||||
Tests use pytest markers for selective execution:
|
||||
- `postgres` - Tests that run against Postgres backend
|
||||
- `windows` - Windows-specific database optimizations
|
||||
- `benchmark` - Performance tests (excluded from default runs)
|
||||
|
||||
**Other Development Commands:**
|
||||
```bash
|
||||
just install # Install with dev dependencies
|
||||
just lint # Run linting checks
|
||||
just typecheck # Run type checking
|
||||
just format # Format code with ruff
|
||||
just check # Run all quality checks
|
||||
just migration "msg" # Create database migration
|
||||
```
|
||||
|
||||
See the [justfile](justfile) for the complete list of development commands.
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "basicmachines",
|
||||
"owner": {
|
||||
"name": "Basic Machines",
|
||||
"email": "hello@basicmachines.co"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Official plugins from Basic Machines for knowledge management and AI-assisted development",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "basic-memory",
|
||||
"source": ".",
|
||||
"description": "Skills, commands, and hooks for Basic Memory MCP - capture knowledge, continue conversations, and follow spec-driven development",
|
||||
"version": "0.1.0",
|
||||
"author": {
|
||||
"name": "Basic Machines"
|
||||
},
|
||||
"keywords": ["memory", "knowledge", "mcp", "specs", "context"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "basic-memory",
|
||||
"description": "Claude Code skills for Basic Memory - capture knowledge, continue conversations, and follow spec-driven development using the Basic Memory MCP server",
|
||||
"version": "0.1.0",
|
||||
"author": {
|
||||
"name": "Basic Machines"
|
||||
},
|
||||
"repository": "https://github.com/basicmachines-co/basic-memory"
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
# Basic Memory Plugin for Claude Code
|
||||
|
||||
This plugin provides skills, commands, and hooks for working with [Basic Memory](https://basicmemory.io) - a local-first knowledge management system built on the Model Context Protocol (MCP).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need the Basic Memory MCP server running. Install it via:
|
||||
|
||||
```bash
|
||||
# Install basic-memory
|
||||
pip install basic-memory
|
||||
|
||||
# Or with pipx
|
||||
pipx install basic-memory
|
||||
```
|
||||
|
||||
Then add it to your Claude Code MCP configuration.
|
||||
|
||||
## Installation
|
||||
|
||||
### Add the Marketplace
|
||||
|
||||
```
|
||||
/plugin marketplace add basicmachines-co/basic-memory/claude-code-plugin
|
||||
```
|
||||
|
||||
### Install the Plugin
|
||||
|
||||
```
|
||||
/plugin install basic-memory@basicmachines
|
||||
```
|
||||
|
||||
### Or via Repository Settings
|
||||
|
||||
Add to your `.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"extraKnownMarketplaces": {
|
||||
"basicmachines": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "basicmachines-co/basic-memory",
|
||||
"path": "claude-code-plugin"
|
||||
}
|
||||
}
|
||||
},
|
||||
"installed": ["basic-memory@basicmachines"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Slash Commands
|
||||
|
||||
User-invoked commands for explicit interaction with Basic Memory.
|
||||
|
||||
### `/remember [title] [folder]`
|
||||
|
||||
Capture insights, decisions, or learnings from the current conversation.
|
||||
|
||||
```
|
||||
/remember "FastAPI Async Pattern"
|
||||
/remember "Auth Decision" decisions
|
||||
```
|
||||
|
||||
Creates a structured note with:
|
||||
- Context from the conversation
|
||||
- Observations with `[decision]`, `[insight]`, `[pattern]` categories
|
||||
- Relations linking to related concepts
|
||||
|
||||
### `/continue [topic]`
|
||||
|
||||
Resume previous work by building context from Basic Memory.
|
||||
|
||||
```
|
||||
/continue postgres migration
|
||||
/continue SPEC-24
|
||||
/continue
|
||||
```
|
||||
|
||||
If no topic is provided, shows recent activity and asks what to dive into.
|
||||
|
||||
### `/context <memory://url> [depth] [timeframe]`
|
||||
|
||||
Build context from a specific memory:// URL.
|
||||
|
||||
```
|
||||
/context memory://SPEC-24
|
||||
/context memory://architecture/* 3 2weeks
|
||||
```
|
||||
|
||||
### `/recent [timeframe] [project]`
|
||||
|
||||
Show recent activity in Basic Memory.
|
||||
|
||||
```
|
||||
/recent
|
||||
/recent 1week
|
||||
/recent today specs
|
||||
```
|
||||
|
||||
### `/organize [action] [project]`
|
||||
|
||||
Organize and maintain your knowledge graph.
|
||||
|
||||
```
|
||||
/organize # Quick health check
|
||||
/organize orphans # Find unlinked notes
|
||||
/organize duplicates # Find similar notes
|
||||
/organize relations "Note" # Suggest links for a note
|
||||
/organize tags # Review tag consistency
|
||||
```
|
||||
|
||||
Actions:
|
||||
- `health` - Overview of knowledge base status (default)
|
||||
- `orphans` - Find notes with no relations
|
||||
- `duplicates` - Find overlapping notes
|
||||
- `relations` - Suggest connections
|
||||
- `tags` - Review tag consistency
|
||||
|
||||
### `/research <topic> [folder]`
|
||||
|
||||
Research a topic and save a structured report to Basic Memory.
|
||||
|
||||
```
|
||||
/research MCP protocol
|
||||
/research "database migrations"
|
||||
/research "auth options" decisions
|
||||
```
|
||||
|
||||
Produces a report with:
|
||||
- Summary and key findings
|
||||
- Analysis and recommendations
|
||||
- Sources and related notes
|
||||
- Saved to `research/` folder by default
|
||||
|
||||
---
|
||||
|
||||
## Skills
|
||||
|
||||
Model-invoked capabilities that Claude uses automatically based on context.
|
||||
|
||||
### knowledge-capture
|
||||
|
||||
Automatically captures insights, decisions, and learnings into structured notes.
|
||||
|
||||
**Triggers when:**
|
||||
- Important decisions are made
|
||||
- Technical insights are discovered
|
||||
- Problems are solved
|
||||
- Design trade-offs are discussed
|
||||
|
||||
### continue-conversation
|
||||
|
||||
Resumes previous work by building context from the knowledge graph.
|
||||
|
||||
**Triggers when:**
|
||||
- Starting a new session
|
||||
- User mentions previous work ("continue with...", "back to...")
|
||||
- Need context about ongoing projects
|
||||
|
||||
### spec-driven-development
|
||||
|
||||
Guides implementation based on specifications stored in Basic Memory.
|
||||
|
||||
**Triggers when:**
|
||||
- Implementing a feature defined by a spec
|
||||
- Creating new specifications
|
||||
- Reviewing implementation against criteria
|
||||
|
||||
### edit-note
|
||||
|
||||
Interactively edit notes using MCP tools in a conversational workflow.
|
||||
|
||||
**Triggers when:**
|
||||
- User wants to edit, update, or modify a note
|
||||
- User asks to change specific content in a note
|
||||
- User wants to add observations or relations
|
||||
|
||||
**How it works:**
|
||||
1. Fetches the note via MCP
|
||||
2. Shows current content
|
||||
3. Applies edits using `edit_note` operations (append, prepend, find_replace, replace_section)
|
||||
4. Shows the updated result
|
||||
|
||||
**Best for:** Cloud users or when you want conversational editing.
|
||||
|
||||
### edit-note-local
|
||||
|
||||
Edit notes directly as local markdown files with automatic sync.
|
||||
|
||||
**Triggers when:**
|
||||
- User has local Basic Memory installation
|
||||
- User wants to make substantial file edits
|
||||
- User prefers working with full file content
|
||||
|
||||
**How it works:**
|
||||
1. Finds the note's file path via MCP
|
||||
2. Uses Claude Code's Read/Edit/Write tools on the actual file
|
||||
3. Basic Memory's `sync --watch` picks up changes automatically
|
||||
|
||||
**Best for:** Local users who want full file access and git integration.
|
||||
|
||||
### knowledge-organize
|
||||
|
||||
Help organize, link, and maintain the knowledge graph.
|
||||
|
||||
**Triggers when:**
|
||||
- User wants to organize their notes
|
||||
- User asks about orphan or unlinked notes
|
||||
- User wants to find connections between notes
|
||||
- User mentions duplicates or similar notes
|
||||
- User asks for help with folder organization
|
||||
|
||||
**Capabilities:**
|
||||
- **Find orphan notes** - Identify notes with no relations
|
||||
- **Suggest relations** - Propose meaningful links between notes
|
||||
- **Identify duplicates** - Find notes covering similar topics
|
||||
- **Folder organization** - Review and suggest folder structure
|
||||
- **Tag consistency** - Normalize and improve tagging
|
||||
- **Create index notes** - Generate hub notes linking related topics
|
||||
- **Enrich sparse notes** - Suggest observations and structure
|
||||
|
||||
**Best for:** Periodic knowledge base maintenance and improving discoverability.
|
||||
|
||||
### research
|
||||
|
||||
Research topics thoroughly and produce structured reports saved to Basic Memory.
|
||||
|
||||
**Triggers when:**
|
||||
- User asks to research or investigate something
|
||||
- User wants to understand a concept or technology
|
||||
- User needs context before making a decision
|
||||
- Phrases like "research", "look into", "explore", "investigate"
|
||||
|
||||
**What it produces:**
|
||||
- Structured report with summary, findings, and analysis
|
||||
- Recommendations when applicable
|
||||
- Links to sources and related notes
|
||||
- Saved to `research/` folder
|
||||
|
||||
**Best for:** Building knowledge base through investigation and documentation.
|
||||
|
||||
---
|
||||
|
||||
## Hooks
|
||||
|
||||
Automated behaviors that enhance the Basic Memory workflow.
|
||||
|
||||
### PostToolUse: write_note
|
||||
|
||||
Confirms when notes are saved to Basic Memory.
|
||||
|
||||
### Stop
|
||||
|
||||
After significant conversations, suggests using `/remember` to capture valuable insights (only when genuinely useful).
|
||||
|
||||
---
|
||||
|
||||
## MCP Tools Used
|
||||
|
||||
This plugin leverages Basic Memory's MCP tools:
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `write_note` | Create/update markdown notes |
|
||||
| `read_note` | Read notes by title or permalink |
|
||||
| `search_notes` | Full-text search across content |
|
||||
| `build_context` | Navigate knowledge graph via memory:// URLs |
|
||||
| `recent_activity` | Get recently updated information |
|
||||
| `edit_note` | Incrementally update notes |
|
||||
|
||||
---
|
||||
|
||||
## Plugin Structure
|
||||
|
||||
```
|
||||
claude-code-plugin/
|
||||
├── .claude-plugin/
|
||||
│ ├── plugin.json # Plugin manifest
|
||||
│ └── marketplace.json # Self-hosted marketplace
|
||||
├── commands/
|
||||
│ ├── remember.md # /remember command
|
||||
│ ├── continue.md # /continue command
|
||||
│ ├── context.md # /context command
|
||||
│ ├── recent.md # /recent command
|
||||
│ ├── organize.md # /organize command
|
||||
│ └── research.md # /research command
|
||||
├── skills/
|
||||
│ ├── knowledge-capture/
|
||||
│ ├── continue-conversation/
|
||||
│ ├── spec-driven-development/
|
||||
│ ├── edit-note/
|
||||
│ ├── edit-note-local/
|
||||
│ ├── knowledge-organize/
|
||||
│ └── research/
|
||||
├── hooks/
|
||||
│ └── hooks.json # Hook definitions
|
||||
├── README.md # Quick start guide
|
||||
└── PLUGIN.md # Full documentation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [Basic Memory Documentation](https://docs.basicmemory.io)
|
||||
- [Basic Memory GitHub](https://github.com/basicmachines-co/basic-memory)
|
||||
- [Model Context Protocol](https://modelcontextprotocol.io)
|
||||
- [Claude Code Plugins](https://code.claude.com/docs/en/plugins)
|
||||
@@ -0,0 +1,100 @@
|
||||
# Basic Memory Plugin for Claude Code
|
||||
|
||||
A Claude Code plugin that integrates [Basic Memory](https://basicmemory.io) - a local-first knowledge management system built on the Model Context Protocol (MCP).
|
||||
|
||||
## What This Plugin Does
|
||||
|
||||
This plugin helps Claude Code work seamlessly with your Basic Memory knowledge base:
|
||||
|
||||
- **Capture knowledge** from conversations automatically
|
||||
- **Resume previous work** by building context from your knowledge graph
|
||||
- **Edit notes** interactively through conversation
|
||||
- **Organize your knowledge** by finding orphans, suggesting links, and maintaining structure
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Install Basic Memory
|
||||
|
||||
```bash
|
||||
pip install basic-memory
|
||||
# or
|
||||
pipx install basic-memory
|
||||
```
|
||||
|
||||
### 2. Add the Marketplace
|
||||
|
||||
```
|
||||
/plugin marketplace add basicmachines-co/basic-memory/claude-code-plugin
|
||||
```
|
||||
|
||||
### 3. Install the Plugin
|
||||
|
||||
```
|
||||
/plugin install basic-memory@basicmachines
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/remember [title]` | Capture insights from the current conversation |
|
||||
| `/continue [topic]` | Resume previous work with context |
|
||||
| `/context <memory://url>` | Build context from a specific note |
|
||||
| `/recent [timeframe]` | Show recent activity |
|
||||
| `/organize [action]` | Maintain your knowledge graph |
|
||||
| `/research <topic>` | Research a topic and save a report |
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Capture what we just discussed
|
||||
/remember "Database Design Decision"
|
||||
|
||||
# Pick up where we left off
|
||||
/continue postgres migration
|
||||
|
||||
# Check recent changes
|
||||
/recent 1week
|
||||
|
||||
# Find orphan notes and suggest links
|
||||
/organize orphans
|
||||
|
||||
# Research a topic and save findings
|
||||
/research "MCP protocol"
|
||||
```
|
||||
|
||||
## Skills
|
||||
|
||||
Skills are model-invoked - Claude uses them automatically when the context fits.
|
||||
|
||||
| Skill | What It Does |
|
||||
|-------|--------------|
|
||||
| `knowledge-capture` | Auto-captures decisions and insights into structured notes |
|
||||
| `continue-conversation` | Builds context when resuming previous work |
|
||||
| `spec-driven-development` | Guides implementation based on specs in Basic Memory |
|
||||
| `edit-note` | Edits notes via MCP tools (cloud-compatible) |
|
||||
| `edit-note-local` | Edits notes as files (local installations) |
|
||||
| `knowledge-organize` | Helps organize and link notes |
|
||||
| `research` | Researches topics and produces saved reports |
|
||||
|
||||
## Hooks
|
||||
|
||||
| Event | Behavior |
|
||||
|-------|----------|
|
||||
| `PostToolUse: write_note` | Confirms when notes are saved |
|
||||
| `Stop` | Suggests capturing valuable insights after conversations |
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Claude Code](https://claude.com/claude-code)
|
||||
- [Basic Memory](https://basicmemory.io) with MCP server configured
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Full Plugin Documentation](./PLUGIN.md)
|
||||
- [Basic Memory Docs](https://docs.basicmemory.io)
|
||||
- [Claude Code Plugins](https://code.claude.com/docs/en/plugins)
|
||||
|
||||
## License
|
||||
|
||||
MIT - See the [Basic Memory repository](https://github.com/basicmachines-co/basic-memory) for details.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
description: Build context from a Basic Memory URL
|
||||
argument-hint: <memory://url> [depth] [timeframe]
|
||||
allowed-tools: mcp__basic-memory__build_context, mcp__basic-memory__read_note
|
||||
---
|
||||
|
||||
# Context
|
||||
|
||||
Build context from a Basic Memory memory:// URL.
|
||||
|
||||
## Arguments
|
||||
|
||||
- `$1` - Memory URL (e.g., `memory://topic`, `memory://folder/*`, `memory://SPEC-24`)
|
||||
- `$2` - Depth of relation traversal (optional, default: 2)
|
||||
- `$3` - Timeframe for recent changes (optional, default: "7d")
|
||||
|
||||
## Your Task
|
||||
|
||||
Navigate the knowledge graph and build comprehensive context.
|
||||
|
||||
1. **Build context** using `mcp__basic-memory__build_context`:
|
||||
- url: "$1"
|
||||
- depth: $2 or 2
|
||||
- timeframe: "$3" or "7d"
|
||||
|
||||
2. **Present the context**:
|
||||
- Main note content
|
||||
- Related notes found via relations
|
||||
- Recent changes within timeframe
|
||||
- Key observations and decisions
|
||||
|
||||
3. **Read additional notes** if needed for more detail.
|
||||
|
||||
## Memory URL Formats
|
||||
|
||||
- `memory://note-title` - Single note by title
|
||||
- `memory://folder/*` - All notes in a folder
|
||||
- `memory://SPEC-*` - Pattern matching
|
||||
- `memory://specs/SPEC-24` - Note in specific project folder
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
description: Resume previous work from Basic Memory context
|
||||
argument-hint: [topic]
|
||||
allowed-tools: mcp__basic-memory__build_context, mcp__basic-memory__recent_activity, mcp__basic-memory__search_notes, mcp__basic-memory__read_note
|
||||
---
|
||||
|
||||
# Continue
|
||||
|
||||
Resume previous work by building context from Basic Memory.
|
||||
|
||||
## Arguments
|
||||
|
||||
- `$ARGUMENTS` - Topic, note title, or search terms to find previous context
|
||||
|
||||
## Your Task
|
||||
|
||||
Build context to continue previous work seamlessly.
|
||||
|
||||
1. **Find relevant context**:
|
||||
|
||||
If a specific topic is provided ("$ARGUMENTS"):
|
||||
- Search for matching notes: `mcp__basic-memory__search_notes`
|
||||
- Build context from matches: `mcp__basic-memory__build_context`
|
||||
- Read key notes for details: `mcp__basic-memory__read_note`
|
||||
|
||||
If no topic provided:
|
||||
- Get recent activity: `mcp__basic-memory__recent_activity` with timeframe "3d"
|
||||
- Present what's been happening
|
||||
- Ask which topic to dive into
|
||||
|
||||
2. **Present context**:
|
||||
- Summarize current state of the work
|
||||
- Highlight recent changes or progress
|
||||
- List open items or next steps
|
||||
- Show related context from the knowledge graph
|
||||
|
||||
3. **Be ready to continue**:
|
||||
- Understand what was done before
|
||||
- Know what needs to happen next
|
||||
- Have relevant context loaded
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `memory://topic` URL format with `build_context`
|
||||
- Check multiple projects if needed (main, specs)
|
||||
- Follow relations to find connected knowledge
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
description: Organize and maintain your Basic Memory knowledge graph
|
||||
argument-hint: [health|orphans|duplicates|relations|tags] [project]
|
||||
allowed-tools: mcp__basic-memory__search_notes, mcp__basic-memory__read_note, mcp__basic-memory__list_directory, mcp__basic-memory__edit_note, mcp__basic-memory__write_note
|
||||
---
|
||||
|
||||
# Organize
|
||||
|
||||
Help organize, link, and maintain your Basic Memory knowledge graph.
|
||||
|
||||
## Arguments
|
||||
|
||||
- `$1` - Action (optional): `health`, `orphans`, `duplicates`, `relations`, `tags` (default: `health`)
|
||||
- `$2` - Project (optional): defaults to "main"
|
||||
|
||||
## Actions
|
||||
|
||||
### `/organize` or `/organize health`
|
||||
|
||||
Run a quick health check:
|
||||
1. Count total notes
|
||||
2. Identify orphan notes (no relations)
|
||||
3. Check for potential duplicates
|
||||
4. Show folder distribution
|
||||
5. Report any issues found
|
||||
|
||||
### `/organize orphans`
|
||||
|
||||
Find and address orphan notes:
|
||||
1. Search for notes with empty Relations sections
|
||||
2. List orphans found
|
||||
3. For each orphan, suggest potential relations based on content
|
||||
4. Offer to add relations or create index notes
|
||||
|
||||
### `/organize duplicates`
|
||||
|
||||
Find potentially duplicate notes:
|
||||
1. Search for notes with similar titles
|
||||
2. Compare content for overlap
|
||||
3. Suggest: merge, differentiate, or link with `supersedes`
|
||||
|
||||
### `/organize relations [note-title]`
|
||||
|
||||
Suggest relations for a specific note (or recent notes if not specified):
|
||||
1. Read the target note
|
||||
2. Search for related content
|
||||
3. Suggest relation types:
|
||||
- `relates-to` - General connection
|
||||
- `extends` - Builds upon
|
||||
- `implements` - Realizes concept
|
||||
- `depends-on` - Requires understanding of
|
||||
4. Offer to add selected relations
|
||||
|
||||
### `/organize tags`
|
||||
|
||||
Review tag consistency:
|
||||
1. Gather all tags across notes
|
||||
2. Find similar/duplicate tags (e.g., `arch` vs `architecture`)
|
||||
3. Identify over-used or under-used tags
|
||||
4. Suggest normalization
|
||||
|
||||
## Your Task
|
||||
|
||||
Execute: `/organize $ARGUMENTS`
|
||||
|
||||
Based on the action requested:
|
||||
|
||||
1. **Gather data** using search and list tools
|
||||
2. **Analyze** for the specific issue (orphans, duplicates, etc.)
|
||||
3. **Present findings** clearly with counts and examples
|
||||
4. **Offer solutions** - ask before making changes
|
||||
5. **Apply fixes** using edit_note or write_note when user approves
|
||||
|
||||
Always confirm before modifying notes. Show what will change and get approval.
|
||||
|
||||
## Examples
|
||||
|
||||
```
|
||||
/organize # Quick health check
|
||||
/organize health # Same as above
|
||||
/organize orphans # Find unlinked notes
|
||||
/organize duplicates # Find similar notes
|
||||
/organize relations # Suggest links for recent notes
|
||||
/organize relations "My Note" # Suggest links for specific note
|
||||
/organize tags # Review tag consistency
|
||||
/organize health specs # Health check on specs project
|
||||
```
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
description: Show recent activity in Basic Memory
|
||||
argument-hint: [timeframe] [project]
|
||||
allowed-tools: mcp__basic-memory__recent_activity, mcp__basic-memory__read_note
|
||||
---
|
||||
|
||||
# Recent
|
||||
|
||||
Show recent activity in Basic Memory.
|
||||
|
||||
## Arguments
|
||||
|
||||
- `$1` - Timeframe (optional): "today", "1d", "3d", "1 week", "2 weeks" (default: "3d")
|
||||
- `$2` - Project (optional): "main", "specs", etc.
|
||||
|
||||
## Your Task
|
||||
|
||||
Show what's been happening in Basic Memory recently.
|
||||
|
||||
1. **Get recent activity** using `mcp__basic-memory__recent_activity`:
|
||||
- timeframe: "$1" or "3d"
|
||||
- project: "$2" or check all projects
|
||||
|
||||
2. **Present activity**:
|
||||
- List recently modified notes
|
||||
- Group by type or folder if helpful
|
||||
- Highlight key changes
|
||||
- Show dates of modifications
|
||||
|
||||
3. **Offer to dive deeper**:
|
||||
- Ask if user wants to read any specific notes
|
||||
- Suggest continuing work on active items
|
||||
|
||||
## Timeframe Examples
|
||||
|
||||
- `today` - Just today
|
||||
- `1d` or `yesterday` - Last 24 hours
|
||||
- `3d` - Last 3 days
|
||||
- `1 week` - Last week
|
||||
- `2 weeks` - Last 2 weeks
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
description: Capture insights, decisions, or learnings to Basic Memory
|
||||
argument-hint: [title] [optional: folder]
|
||||
allowed-tools: mcp__basic-memory__write_note, mcp__basic-memory__search_notes
|
||||
---
|
||||
|
||||
# Remember
|
||||
|
||||
Capture what we just discussed into a Basic Memory note.
|
||||
|
||||
## Arguments
|
||||
|
||||
- `$1` - Title for the note (required)
|
||||
- `$2` - Folder to save in (optional, defaults to "notes")
|
||||
|
||||
## Your Task
|
||||
|
||||
Create a structured note capturing the key insights from our conversation.
|
||||
|
||||
1. **Analyze the conversation** for:
|
||||
- Decisions made
|
||||
- Insights discovered
|
||||
- Problems solved
|
||||
- Patterns identified
|
||||
- Trade-offs discussed
|
||||
|
||||
2. **Structure the note** with:
|
||||
- Clear title: "$1" (or generate one if not provided)
|
||||
- Context section explaining the situation
|
||||
- Main content with key points
|
||||
- Observations using `[category]` format:
|
||||
- `[decision]` - Choices made
|
||||
- `[insight]` - Understanding gained
|
||||
- `[pattern]` - Reusable approaches
|
||||
- `[learning]` - Lessons learned
|
||||
- Relations to link related concepts with `[[WikiLinks]]`
|
||||
|
||||
3. **Save using** `mcp__basic-memory__write_note`:
|
||||
- folder: "$2" or "notes"
|
||||
- Include relevant tags
|
||||
- Project: use "main" unless user specifies otherwise
|
||||
|
||||
4. **Confirm** what was captured and where it was saved.
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
description: Research a topic and save a structured report to Basic Memory
|
||||
argument-hint: <topic> [folder]
|
||||
allowed-tools: mcp__basic-memory__write_note, mcp__basic-memory__search_notes, mcp__basic-memory__read_note, mcp__basic-memory__build_context, WebSearch, WebFetch, Grep, Glob, Read
|
||||
---
|
||||
|
||||
# Research
|
||||
|
||||
Research a topic thoroughly and produce a structured report saved to Basic Memory.
|
||||
|
||||
## Arguments
|
||||
|
||||
- `$1` - Topic to research (required)
|
||||
- `$2` - Folder to save report (optional, default: "research")
|
||||
|
||||
## Your Task
|
||||
|
||||
Conduct thorough research on: **$ARGUMENTS**
|
||||
|
||||
### 1. Check Existing Knowledge
|
||||
|
||||
First, see what we already know:
|
||||
```python
|
||||
mcp__basic-memory__search_notes(query="$1", project="main")
|
||||
```
|
||||
|
||||
Read any relevant existing notes to avoid duplicating research.
|
||||
|
||||
### 2. Gather Information
|
||||
|
||||
Depending on the topic, use appropriate tools:
|
||||
|
||||
**For codebase topics:**
|
||||
- Search code with Grep/Glob
|
||||
- Read relevant files
|
||||
- Check tests for examples
|
||||
|
||||
**For external topics:**
|
||||
- Use WebSearch for current information
|
||||
- Fetch documentation with WebFetch
|
||||
- Look for official sources
|
||||
|
||||
**For Basic Memory context:**
|
||||
- Build context from related notes
|
||||
- Check for prior decisions or research
|
||||
|
||||
### 3. Analyze Findings
|
||||
|
||||
Synthesize what you learned:
|
||||
- Identify key concepts
|
||||
- Note patterns and trade-offs
|
||||
- Form recommendations if applicable
|
||||
- Flag uncertainties
|
||||
|
||||
### 4. Produce Report
|
||||
|
||||
Create a structured report with this format:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Research: [Topic]"
|
||||
type: research
|
||||
tags:
|
||||
- research
|
||||
- [relevant-tags]
|
||||
---
|
||||
|
||||
# Research: [Topic]
|
||||
|
||||
## Summary
|
||||
|
||||
[2-3 sentence executive summary]
|
||||
|
||||
## Research Question
|
||||
|
||||
[What we investigated and why]
|
||||
|
||||
## Key Findings
|
||||
|
||||
### [Finding 1]
|
||||
[Details and evidence]
|
||||
|
||||
### [Finding 2]
|
||||
[Details and evidence]
|
||||
|
||||
### [Finding 3]
|
||||
[Details and evidence]
|
||||
|
||||
## Analysis
|
||||
|
||||
[Synthesis, patterns, trade-offs, recommendations]
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [Areas needing more investigation]
|
||||
|
||||
## Sources
|
||||
|
||||
- [Links to sources]
|
||||
- [[Related Notes]] from Basic Memory
|
||||
|
||||
## Observations
|
||||
|
||||
- [finding] Key insight #research
|
||||
- [recommendation] Suggested approach based on research
|
||||
|
||||
## Relations
|
||||
|
||||
- researches [[Topic]]
|
||||
- relates-to [[Related Concepts]]
|
||||
```
|
||||
|
||||
### 5. Save Report
|
||||
|
||||
```python
|
||||
mcp__basic-memory__write_note(
|
||||
title="Research: $1",
|
||||
content="[report content]",
|
||||
folder="$2" or "research",
|
||||
tags=["research", ...],
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
### 6. Present Summary
|
||||
|
||||
After saving, present:
|
||||
- Key findings summary
|
||||
- Main recommendation (if applicable)
|
||||
- Where the report was saved
|
||||
- Offer to dive deeper into any aspect
|
||||
|
||||
## Examples
|
||||
|
||||
```
|
||||
/research MCP protocol
|
||||
/research "database migration patterns"
|
||||
/research "authentication options" decisions
|
||||
/research "React vs Vue" architecture
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "mcp__basic-memory__write_note",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo '✓ Note saved to Basic Memory'"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "prompt",
|
||||
"prompt": "If this conversation contained valuable insights, decisions, or learnings that should be preserved, suggest using `/remember [title]` to capture them in Basic Memory. Only suggest this if there's genuinely valuable content worth preserving - don't suggest for trivial interactions."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
---
|
||||
name: continue-conversation
|
||||
description: Resume previous work by building context from Basic Memory knowledge graph using memory URLs and recent activity
|
||||
---
|
||||
|
||||
# Continue Conversation
|
||||
|
||||
This skill helps you resume previous work by building context from the Basic Memory knowledge graph, enabling seamless continuation across sessions.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when:
|
||||
- Starting a new session and need to pick up where you left off
|
||||
- User mentions previous work ("continue with...", "back to...", "where were we with...")
|
||||
- Need context about ongoing projects or specs
|
||||
- User asks about something discussed in a previous conversation
|
||||
- Working on a multi-session task
|
||||
|
||||
## Building Context
|
||||
|
||||
### 1. Identify What to Continue
|
||||
|
||||
Ask if unclear:
|
||||
- What topic or project to resume?
|
||||
- What timeframe to look at?
|
||||
- Any specific aspect to focus on?
|
||||
|
||||
### 2. Gather Context with MCP Tools
|
||||
|
||||
**Option A: Known Topic - Use build_context**
|
||||
|
||||
```python
|
||||
# Navigate knowledge graph from a known starting point
|
||||
mcp__basic-memory__build_context(
|
||||
url="memory://topic-or-note-name",
|
||||
depth=2, # How many relation hops to follow
|
||||
timeframe="7d", # Recent changes
|
||||
project="main" # or "specs" for specifications
|
||||
)
|
||||
```
|
||||
|
||||
Memory URL formats:
|
||||
- `memory://note-title` - Single note
|
||||
- `memory://folder/*` - All notes in folder
|
||||
- `memory://specs/SPEC-24*` - Pattern matching
|
||||
|
||||
**Option B: Recent Activity - What's been happening?**
|
||||
|
||||
```python
|
||||
# See what's changed recently
|
||||
mcp__basic-memory__recent_activity(
|
||||
timeframe="3d", # "1d", "1 week", "2 weeks"
|
||||
depth=1,
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
**Option C: Search for Context**
|
||||
|
||||
```python
|
||||
# Find relevant notes
|
||||
mcp__basic-memory__search_notes(
|
||||
query="search terms",
|
||||
page_size=10,
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Read Key Notes
|
||||
|
||||
Once you identify relevant notes:
|
||||
|
||||
```python
|
||||
mcp__basic-memory__read_note(
|
||||
identifier="note-title-or-permalink",
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Present Context to User
|
||||
|
||||
Summarize what you found:
|
||||
- Current state of the work
|
||||
- Recent changes or progress
|
||||
- Open items or next steps
|
||||
- Related context that might be helpful
|
||||
|
||||
## Context Strategies by Scenario
|
||||
|
||||
### Resuming a Spec Implementation
|
||||
|
||||
```python
|
||||
# 1. Read the spec
|
||||
mcp__basic-memory__read_note(
|
||||
identifier="SPEC-24: Postgres Database Migration",
|
||||
project="specs"
|
||||
)
|
||||
|
||||
# 2. Check recent activity on related topics
|
||||
mcp__basic-memory__build_context(
|
||||
url="memory://SPEC-24*",
|
||||
timeframe="7d",
|
||||
project="specs"
|
||||
)
|
||||
|
||||
# 3. Look at what's been done in the codebase
|
||||
# (Use regular file tools for this)
|
||||
```
|
||||
|
||||
### Continuing General Work
|
||||
|
||||
```python
|
||||
# 1. Check recent activity across projects
|
||||
mcp__basic-memory__recent_activity(
|
||||
timeframe="3d",
|
||||
project="main"
|
||||
)
|
||||
|
||||
# 2. Read any notes from recent sessions
|
||||
mcp__basic-memory__read_note(
|
||||
identifier="relevant-note",
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
### Following Up on a Topic
|
||||
|
||||
```python
|
||||
# 1. Search for the topic
|
||||
mcp__basic-memory__search_notes(
|
||||
query="topic keywords",
|
||||
project="main"
|
||||
)
|
||||
|
||||
# 2. Build context from best match
|
||||
mcp__basic-memory__build_context(
|
||||
url="memory://found-note-permalink",
|
||||
depth=2,
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
## Timeframe Reference
|
||||
|
||||
Natural language timeframes:
|
||||
- `"today"` - Current day
|
||||
- `"yesterday"` - Previous day
|
||||
- `"3d"` or `"3 days"` - Last 3 days
|
||||
- `"1 week"` or `"7d"` - Last week
|
||||
- `"2 weeks"` - Last 2 weeks
|
||||
- `"1 month"` - Last month
|
||||
|
||||
## Project Reference
|
||||
|
||||
Common projects:
|
||||
- `main` - Primary knowledge base
|
||||
- `specs` - Specifications and design docs
|
||||
- `basic-memory-llc` - Business/company notes
|
||||
- `getting-started` - Tutorial content
|
||||
|
||||
List available projects:
|
||||
```python
|
||||
mcp__basic-memory__list_memory_projects()
|
||||
```
|
||||
|
||||
## Example Conversations
|
||||
|
||||
### User: "Let's continue with the Postgres migration"
|
||||
|
||||
```
|
||||
1. Read SPEC-24 from specs project
|
||||
2. Check for related notes about implementation progress
|
||||
3. Summarize:
|
||||
- Spec overview and goals
|
||||
- What's been completed (checkmarks)
|
||||
- What's pending (checkboxes)
|
||||
- Any blockers or decisions needed
|
||||
```
|
||||
|
||||
### User: "What was I working on yesterday?"
|
||||
|
||||
```
|
||||
1. Get recent activity for last 2 days
|
||||
2. List modified notes with brief descriptions
|
||||
3. Ask which topic to dive into
|
||||
```
|
||||
|
||||
### User: "Back to the async client pattern"
|
||||
|
||||
```
|
||||
1. Search for "async client pattern"
|
||||
2. Build context from matching note
|
||||
3. Include related notes via relations
|
||||
4. Present the full picture
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Start broad, then narrow** - Get overview first, then specific details
|
||||
2. **Follow relations** - Knowledge graph connections are valuable
|
||||
3. **Check multiple projects** - Specs might be separate from implementation notes
|
||||
4. **Present incrementally** - Share what you find as you go
|
||||
5. **Confirm understanding** - Verify the context is what user needs
|
||||
6. **Update as you go** - Capture new progress in notes during the session
|
||||
|
||||
## Combining with Other Skills
|
||||
|
||||
After building context, you might:
|
||||
- Use **knowledge-capture** to document new progress
|
||||
- Use **spec-driven-development** if continuing a spec implementation
|
||||
- Create new notes linking to the context you gathered
|
||||
@@ -0,0 +1,261 @@
|
||||
---
|
||||
name: edit-note-local
|
||||
description: Edit Basic Memory notes directly as local files - enables full file editing with automatic sync (local installations only)
|
||||
---
|
||||
|
||||
# Edit Note Local
|
||||
|
||||
This skill enables direct file-based editing of Basic Memory notes. It works by editing the actual markdown files in the knowledge base, which Basic Memory's sync service automatically picks up. This provides a more seamless editing experience for local installations.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when:
|
||||
- User has a local Basic Memory installation (not cloud-only)
|
||||
- User wants to make substantial edits to a note
|
||||
- User prefers working with the full file content
|
||||
- User wants changes to sync automatically via `basic-memory sync --watch`
|
||||
|
||||
**Note:** This skill requires local file access. For cloud-only users, use the `edit-note` skill instead.
|
||||
|
||||
## Editing Workflow
|
||||
|
||||
### 1. Find the Note's File Path
|
||||
|
||||
First, get the note metadata to find its file location:
|
||||
|
||||
```python
|
||||
# Search for the note
|
||||
mcp__basic-memory__search_notes(
|
||||
query="note title or keywords",
|
||||
project="main"
|
||||
)
|
||||
|
||||
# Read the note to get file_path from metadata
|
||||
mcp__basic-memory__read_note(
|
||||
identifier="note-title",
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
The response includes `file_path` which gives the relative path within the knowledge base.
|
||||
|
||||
### 2. Determine Full File Path
|
||||
|
||||
Basic Memory projects have a root directory. Common locations:
|
||||
- Default: `~/basic-memory/`
|
||||
- Custom: Check project configuration
|
||||
|
||||
Construct the full path:
|
||||
```
|
||||
{project_root}/{file_path}
|
||||
```
|
||||
|
||||
For example:
|
||||
- Project root: `/Users/username/basic-memory`
|
||||
- File path from note: `notes/My Note.md`
|
||||
- Full path: `/Users/username/basic-memory/notes/My Note.md`
|
||||
|
||||
### 3. Read the File
|
||||
|
||||
Use Claude Code's Read tool to get the full file content:
|
||||
|
||||
```python
|
||||
Read(file_path="/Users/username/basic-memory/notes/My Note.md")
|
||||
```
|
||||
|
||||
Display the content to the user, explaining the structure:
|
||||
- Frontmatter (YAML between `---` markers)
|
||||
- Main content
|
||||
- Observations section
|
||||
- Relations section
|
||||
|
||||
### 4. Edit the File
|
||||
|
||||
Use Claude Code's Edit tool for precise changes:
|
||||
|
||||
```python
|
||||
Edit(
|
||||
file_path="/Users/username/basic-memory/notes/My Note.md",
|
||||
old_string="text to replace",
|
||||
new_string="new text"
|
||||
)
|
||||
```
|
||||
|
||||
Or use Write for complete rewrites:
|
||||
|
||||
```python
|
||||
Write(
|
||||
file_path="/Users/username/basic-memory/notes/My Note.md",
|
||||
content="Complete new file content..."
|
||||
)
|
||||
```
|
||||
|
||||
### 5. Sync Happens Automatically
|
||||
|
||||
If the user has `basic-memory sync --watch` running, changes are picked up automatically. Otherwise, they can run:
|
||||
```bash
|
||||
basic-memory sync
|
||||
```
|
||||
|
||||
## File Structure Reference
|
||||
|
||||
Basic Memory notes follow this markdown structure:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Note Title
|
||||
type: note
|
||||
permalink: note-title
|
||||
tags:
|
||||
- tag1
|
||||
- tag2
|
||||
---
|
||||
|
||||
# Note Title
|
||||
|
||||
## Context
|
||||
|
||||
Background and situation explanation.
|
||||
|
||||
## Main Content
|
||||
|
||||
The primary content of the note...
|
||||
|
||||
## Observations
|
||||
|
||||
- [category] Observation text #optional-tag
|
||||
- [decision] A decision that was made #tag
|
||||
- [insight] An insight discovered
|
||||
|
||||
## Relations
|
||||
|
||||
- relates-to [[Other Note]]
|
||||
- implements [[Parent Concept]]
|
||||
- learned-from [[Source Note]]
|
||||
```
|
||||
|
||||
## Editing Patterns
|
||||
|
||||
### Edit Frontmatter Tags
|
||||
|
||||
```python
|
||||
Edit(
|
||||
file_path="/path/to/note.md",
|
||||
old_string="tags:\n- old-tag",
|
||||
new_string="tags:\n- old-tag\n- new-tag"
|
||||
)
|
||||
```
|
||||
|
||||
### Add New Section
|
||||
|
||||
```python
|
||||
Edit(
|
||||
file_path="/path/to/note.md",
|
||||
old_string="## Observations",
|
||||
new_string="## New Section\n\nNew content here.\n\n## Observations"
|
||||
)
|
||||
```
|
||||
|
||||
### Modify Observation
|
||||
|
||||
```python
|
||||
Edit(
|
||||
file_path="/path/to/note.md",
|
||||
old_string="- [decision] Old decision",
|
||||
new_string="- [decision] Updated decision with new info #updated"
|
||||
)
|
||||
```
|
||||
|
||||
### Add Relation
|
||||
|
||||
```python
|
||||
Edit(
|
||||
file_path="/path/to/note.md",
|
||||
old_string="## Relations\n",
|
||||
new_string="## Relations\n\n- relates-to [[New Related Note]]\n"
|
||||
)
|
||||
```
|
||||
|
||||
### Complete Rewrite
|
||||
|
||||
For major changes, read the file, construct new content preserving the frontmatter structure, and write:
|
||||
|
||||
```python
|
||||
Write(
|
||||
file_path="/path/to/note.md",
|
||||
content="""---
|
||||
title: Note Title
|
||||
type: note
|
||||
permalink: note-title
|
||||
tags:
|
||||
- updated
|
||||
---
|
||||
|
||||
# Note Title
|
||||
|
||||
Completely rewritten content...
|
||||
|
||||
## Observations
|
||||
|
||||
- [rewrite] Complete rewrite of this note #major-update
|
||||
|
||||
## Relations
|
||||
|
||||
- updates [[Previous Version]]
|
||||
"""
|
||||
)
|
||||
```
|
||||
|
||||
## Finding the Project Root
|
||||
|
||||
To find where Basic Memory stores files, you can:
|
||||
|
||||
1. **Check common locations:**
|
||||
- `~/basic-memory/`
|
||||
- `~/Documents/basic-memory/`
|
||||
- Current working directory
|
||||
|
||||
2. **Use the list_directory tool:**
|
||||
```python
|
||||
mcp__basic-memory__list_directory(
|
||||
dir_name="/",
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
3. **Ask the user:** "Where is your Basic Memory knowledge base located?"
|
||||
|
||||
## Advantages of Local Editing
|
||||
|
||||
1. **Full file access** - Edit any part of the file including frontmatter
|
||||
2. **Multi-line edits** - Make complex structural changes easily
|
||||
3. **Batch operations** - Edit multiple files in sequence
|
||||
4. **Version control** - Changes tracked by git if the folder is a repo
|
||||
5. **Instant preview** - Use any markdown editor alongside
|
||||
6. **Auto-sync** - `sync --watch` picks up changes automatically
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Preserve frontmatter** - Don't break the YAML structure
|
||||
2. **Keep valid markdown** - Maintain proper formatting
|
||||
3. **Preserve permalinks** - Changing them can break links
|
||||
4. **Show diffs** - Tell the user what changed
|
||||
5. **Suggest sync** - Remind about `basic-memory sync` if not watching
|
||||
6. **Handle missing files** - Check if file exists before editing
|
||||
|
||||
## Example Conversation
|
||||
|
||||
**User:** "I want to completely rewrite my architecture decision note"
|
||||
|
||||
**Claude:**
|
||||
1. Searches for the note via MCP
|
||||
2. Gets the file path
|
||||
3. Reads the current file content
|
||||
4. Asks: "Here's the current note. What would you like the new version to say?"
|
||||
|
||||
**User:** Provides new content
|
||||
|
||||
**Claude:**
|
||||
1. Preserves the frontmatter (title, permalink, type)
|
||||
2. Writes the new content using Write tool
|
||||
3. Confirms: "Updated the file at `/path/to/note.md`. If you have sync --watch running, it's already indexed. Otherwise run `basic-memory sync`."
|
||||
@@ -0,0 +1,209 @@
|
||||
---
|
||||
name: edit-note
|
||||
description: Interactively edit Basic Memory notes using MCP tools - view, modify, and update notes in a conversational workflow (works with cloud and local)
|
||||
---
|
||||
|
||||
# Edit Note
|
||||
|
||||
This skill enables interactive editing of Basic Memory notes using MCP tools. It works with both Basic Memory Cloud and local installations since it operates through the MCP interface rather than direct file access.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when:
|
||||
- User wants to edit an existing note
|
||||
- User asks to update, change, or modify note content
|
||||
- User wants to refine observations or relations in a note
|
||||
- User says things like "edit my note about...", "update the...", "change X to Y in..."
|
||||
|
||||
## Editing Workflow
|
||||
|
||||
### 1. Fetch the Current Note
|
||||
|
||||
First, retrieve the note to show the user what exists:
|
||||
|
||||
```python
|
||||
mcp__basic-memory__read_note(
|
||||
identifier="Note Title or permalink",
|
||||
project="main" # or specified project
|
||||
)
|
||||
```
|
||||
|
||||
Present the note content clearly, highlighting:
|
||||
- Current title and metadata
|
||||
- Main content sections
|
||||
- Observations (with categories)
|
||||
- Relations (with link targets)
|
||||
|
||||
### 2. Understand the Edit Request
|
||||
|
||||
Ask clarifying questions if needed:
|
||||
- Which section to modify?
|
||||
- What specifically to change?
|
||||
- Add new content or replace existing?
|
||||
|
||||
### 3. Apply the Edit
|
||||
|
||||
Use the appropriate `edit_note` operation:
|
||||
|
||||
**Append** - Add content to the end:
|
||||
```python
|
||||
mcp__basic-memory__edit_note(
|
||||
identifier="note-title",
|
||||
operation="append",
|
||||
content="\n\n## New Section\n\nNew content here...",
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
**Prepend** - Add content to the beginning:
|
||||
```python
|
||||
mcp__basic-memory__edit_note(
|
||||
identifier="note-title",
|
||||
operation="prepend",
|
||||
content="# Updated Header\n\n",
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
**Find and Replace** - Replace specific text:
|
||||
```python
|
||||
mcp__basic-memory__edit_note(
|
||||
identifier="note-title",
|
||||
operation="find_replace",
|
||||
find_text="old text to find",
|
||||
content="new replacement text",
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
**Replace Section** - Replace an entire section by heading:
|
||||
```python
|
||||
mcp__basic-memory__edit_note(
|
||||
identifier="note-title",
|
||||
operation="replace_section",
|
||||
section="## Section Heading",
|
||||
content="## Section Heading\n\nCompletely new section content...",
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Show the Result
|
||||
|
||||
After editing, fetch and display the updated note:
|
||||
|
||||
```python
|
||||
mcp__basic-memory__read_note(
|
||||
identifier="note-title",
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
Highlight what changed so the user can verify.
|
||||
|
||||
## Edit Operations Reference
|
||||
|
||||
| Operation | Use Case | Required Parameters |
|
||||
|-----------|----------|---------------------|
|
||||
| `append` | Add to end | `content` |
|
||||
| `prepend` | Add to beginning | `content` |
|
||||
| `find_replace` | Change specific text | `find_text`, `content` |
|
||||
| `replace_section` | Rewrite a section | `section`, `content` |
|
||||
|
||||
## Common Edit Patterns
|
||||
|
||||
### Adding a New Observation
|
||||
|
||||
```python
|
||||
mcp__basic-memory__edit_note(
|
||||
identifier="note-title",
|
||||
operation="find_replace",
|
||||
find_text="## Observations",
|
||||
content="## Observations\n\n- [new-category] New observation here #tag",
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
Or append to observations section:
|
||||
```python
|
||||
mcp__basic-memory__edit_note(
|
||||
identifier="note-title",
|
||||
operation="append",
|
||||
content="\n- [insight] Additional insight discovered #tag",
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
### Adding a New Relation
|
||||
|
||||
```python
|
||||
mcp__basic-memory__edit_note(
|
||||
identifier="note-title",
|
||||
operation="find_replace",
|
||||
find_text="## Relations",
|
||||
content="## Relations\n\n- relates-to [[New Related Note]]",
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
### Updating a Specific Observation
|
||||
|
||||
```python
|
||||
mcp__basic-memory__edit_note(
|
||||
identifier="note-title",
|
||||
operation="find_replace",
|
||||
find_text="- [decision] Old decision text",
|
||||
content="- [decision] Updated decision with new context #updated",
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
### Rewriting the Context Section
|
||||
|
||||
```python
|
||||
mcp__basic-memory__edit_note(
|
||||
identifier="note-title",
|
||||
operation="replace_section",
|
||||
section="## Context",
|
||||
content="## Context\n\nCompletely rewritten context explaining the new situation...",
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
## Multi-Step Editing Session
|
||||
|
||||
For complex edits, work iteratively:
|
||||
|
||||
1. **Show current state** → Read and display the note
|
||||
2. **First edit** → Apply one change
|
||||
3. **Show result** → Display updated note
|
||||
4. **Next edit** → Apply another change if needed
|
||||
5. **Confirm complete** → Final display and confirmation
|
||||
|
||||
This keeps the user informed and allows course correction.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always show before and after** - User should see what changed
|
||||
2. **One edit at a time** - For complex changes, do multiple operations
|
||||
3. **Preserve structure** - Maintain the note's markdown format
|
||||
4. **Be careful with find_replace** - Ensure the find_text is unique
|
||||
5. **Confirm destructive changes** - Ask before replacing large sections
|
||||
6. **Keep observations formatted** - Maintain `[category]` prefix format
|
||||
7. **Keep relations formatted** - Maintain `- relation-type [[Target]]` format
|
||||
|
||||
## Example Conversation
|
||||
|
||||
**User:** "Edit my note about the async client pattern - add an observation about testing"
|
||||
|
||||
**Claude:**
|
||||
1. Fetches "Async Client Pattern" note
|
||||
2. Displays current content
|
||||
3. Asks: "What observation about testing would you like to add?"
|
||||
|
||||
**User:** "That the context manager pattern makes mocking easier in tests"
|
||||
|
||||
**Claude:**
|
||||
1. Uses `edit_note` with `append` to add:
|
||||
`- [testing] Context manager pattern simplifies mocking in unit tests #testability`
|
||||
2. Fetches and displays updated note
|
||||
3. Confirms: "Added the testing observation. Here's the updated note..."
|
||||
@@ -0,0 +1,211 @@
|
||||
---
|
||||
name: knowledge-capture
|
||||
description: Capture insights, decisions, and learnings from conversations into structured Basic Memory notes with observations and relations
|
||||
---
|
||||
|
||||
# Knowledge Capture
|
||||
|
||||
This skill helps you capture valuable information from conversations into Basic Memory's knowledge graph using structured notes with observations and relations.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when:
|
||||
- Important decisions are made during a conversation
|
||||
- Technical insights or patterns are discovered
|
||||
- Problems are solved and the solution should be preserved
|
||||
- Design trade-offs are discussed
|
||||
- Architecture or implementation approaches are chosen
|
||||
- Learnings from debugging or investigation emerge
|
||||
|
||||
## Capture Process
|
||||
|
||||
### 1. Identify Valuable Information
|
||||
|
||||
Look for:
|
||||
- **Decisions**: Choices made and their rationale
|
||||
- **Insights**: New understanding or "aha" moments
|
||||
- **Patterns**: Reusable approaches or solutions
|
||||
- **Trade-offs**: Options considered and why one was chosen
|
||||
- **Learnings**: What worked, what didn't, and why
|
||||
- **Context**: Background that would help future understanding
|
||||
|
||||
### 2. Structure the Note
|
||||
|
||||
Use Basic Memory's knowledge format:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Descriptive Title
|
||||
type: note
|
||||
tags:
|
||||
- relevant
|
||||
- tags
|
||||
---
|
||||
|
||||
# Title
|
||||
|
||||
## Context
|
||||
Brief background explaining the situation.
|
||||
|
||||
## Content
|
||||
Main content organized logically.
|
||||
|
||||
## Observations
|
||||
|
||||
- [decision] What was decided and why #tag
|
||||
- [insight] Key understanding gained #tag
|
||||
- [pattern] Reusable approach identified #tag
|
||||
- [learning] What we learned from this #tag
|
||||
- [tradeoff] Option A chosen over B because... #tag
|
||||
|
||||
## Relations
|
||||
|
||||
- relates-to [[Related Concept]]
|
||||
- implements [[Parent Spec or Design]]
|
||||
- learned-from [[Source of Learning]]
|
||||
```
|
||||
|
||||
### 3. Choose Appropriate Categories
|
||||
|
||||
Common observation categories:
|
||||
- `[decision]` - Choices made
|
||||
- `[insight]` - Understanding gained
|
||||
- `[pattern]` - Reusable approaches
|
||||
- `[learning]` - Lessons learned
|
||||
- `[tradeoff]` - Options weighed
|
||||
- `[problem]` - Issues identified
|
||||
- `[solution]` - Fixes applied
|
||||
- `[architecture]` - Structural decisions
|
||||
- `[implementation]` - Code-level choices
|
||||
- `[constraint]` - Limitations discovered
|
||||
- `[requirement]` - Needs identified
|
||||
|
||||
### 4. Create Meaningful Relations
|
||||
|
||||
Link to related knowledge:
|
||||
- `relates-to` - General association
|
||||
- `implements` - Realizes a spec or design
|
||||
- `extends` - Builds upon existing concept
|
||||
- `learned-from` - Source of insight
|
||||
- `enables` - Makes something possible
|
||||
- `depends-on` - Requires another concept
|
||||
- `solves` - Addresses a problem
|
||||
|
||||
## MCP Tools to Use
|
||||
|
||||
```python
|
||||
# Write a new note
|
||||
mcp__basic-memory__write_note(
|
||||
title="Your Note Title",
|
||||
content="Full markdown content...",
|
||||
folder="appropriate/folder",
|
||||
tags=["tag1", "tag2"],
|
||||
project="main" # or appropriate project
|
||||
)
|
||||
|
||||
# Search for related notes to link
|
||||
mcp__basic-memory__search_notes(
|
||||
query="relevant terms",
|
||||
project="main"
|
||||
)
|
||||
|
||||
# Read existing notes for context
|
||||
mcp__basic-memory__read_note(
|
||||
identifier="note-title-or-permalink",
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
## Folder Organization
|
||||
|
||||
Choose appropriate folders:
|
||||
- `decisions/` - Architecture and design decisions
|
||||
- `learnings/` - Insights and lessons learned
|
||||
- `patterns/` - Reusable approaches
|
||||
- `debug-logs/` - Problem investigations
|
||||
- `conversations/` - Imported conversation summaries
|
||||
|
||||
## Examples
|
||||
|
||||
### Capturing a Technical Decision
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: FastAPI Async Client Pattern
|
||||
type: note
|
||||
tags:
|
||||
- architecture
|
||||
- fastapi
|
||||
- async
|
||||
---
|
||||
|
||||
# FastAPI Async Client Pattern
|
||||
|
||||
## Context
|
||||
During implementation of MCP tools, we needed to decide how to handle HTTP client lifecycle.
|
||||
|
||||
## Decision
|
||||
Use context manager pattern for HTTP clients instead of module-level singletons.
|
||||
|
||||
## Rationale
|
||||
- Proper resource management
|
||||
- Supports three deployment modes (local ASGI, CLI cloud, cloud app)
|
||||
- Auth happens at client creation, not per-request
|
||||
- Enables dependency injection for testing
|
||||
|
||||
## Observations
|
||||
|
||||
- [decision] Context manager pattern for HTTP clients enables proper resource cleanup #architecture
|
||||
- [pattern] Factory pattern allows different client configurations per deployment mode #flexibility
|
||||
- [tradeoff] Slightly more verbose than singleton but much more flexible #engineering
|
||||
|
||||
## Relations
|
||||
|
||||
- implements [[SPEC-16 MCP Cloud Service Consolidation]]
|
||||
- enables [[Cloud App Integration]]
|
||||
```
|
||||
|
||||
### Capturing a Debugging Insight
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: SQLite WAL Mode Performance Fix
|
||||
type: note
|
||||
tags:
|
||||
- debugging
|
||||
- sqlite
|
||||
- performance
|
||||
---
|
||||
|
||||
# SQLite WAL Mode Performance Fix
|
||||
|
||||
## Problem
|
||||
Sync operations were slow with multiple concurrent writes.
|
||||
|
||||
## Investigation
|
||||
Found that default SQLite journaling was causing lock contention.
|
||||
|
||||
## Solution
|
||||
Enabled WAL (Write-Ahead Logging) mode for the database connection.
|
||||
|
||||
## Observations
|
||||
|
||||
- [problem] Default SQLite journaling causes lock contention under concurrent writes #performance
|
||||
- [solution] WAL mode significantly improves concurrent write performance #sqlite
|
||||
- [learning] Always consider WAL mode for SQLite in applications with concurrent access #database
|
||||
|
||||
## Relations
|
||||
|
||||
- solves [[Sync Performance Issues]]
|
||||
- relates-to [[SPEC-19 Sync Performance]]
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Capture immediately** - Write notes while context is fresh
|
||||
2. **Be specific** - Include concrete details, not vague summaries
|
||||
3. **Link liberally** - More relations = better knowledge graph
|
||||
4. **Use tags** - Enable discovery via search
|
||||
5. **Include context** - Future you won't remember the situation
|
||||
6. **Prefer facts over opinions** - Observations should be verifiable
|
||||
7. **Keep notes atomic** - One concept per note when possible
|
||||
@@ -0,0 +1,283 @@
|
||||
---
|
||||
name: knowledge-organize
|
||||
description: Help organize, link, and maintain the Basic Memory knowledge graph - find orphan notes, suggest relations, identify duplicates, and improve overall knowledge structure
|
||||
---
|
||||
|
||||
# Knowledge Organize
|
||||
|
||||
This skill helps users maintain a healthy, well-connected knowledge graph. As notes accumulate, it becomes valuable to periodically organize, link, and curate the knowledge base.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when:
|
||||
- User asks to organize their notes
|
||||
- User wants to find connections between notes
|
||||
- User mentions orphan or unlinked notes
|
||||
- User wants to clean up or improve their knowledge base
|
||||
- User asks about duplicate or similar notes
|
||||
- User wants help with folder organization
|
||||
- User asks to review or audit their notes
|
||||
- Phrases like "help me organize", "find related notes", "what's not linked", "clean up my notes"
|
||||
|
||||
## Organization Capabilities
|
||||
|
||||
### 1. Find Orphan Notes
|
||||
|
||||
Identify notes that have no relations to other notes - they're isolated in the knowledge graph.
|
||||
|
||||
```python
|
||||
# Get all notes
|
||||
mcp__basic-memory__search_notes(
|
||||
query="*",
|
||||
page_size=50,
|
||||
project="main"
|
||||
)
|
||||
|
||||
# For each note, check if it has relations
|
||||
# Orphans have empty Relations sections
|
||||
```
|
||||
|
||||
**What to do with orphans:**
|
||||
- Suggest potential relations based on content similarity
|
||||
- Ask if they should be linked to existing topics
|
||||
- Propose creating hub notes to connect related orphans
|
||||
|
||||
### 2. Suggest Relations
|
||||
|
||||
Analyze note content and suggest meaningful connections.
|
||||
|
||||
```python
|
||||
# Read a note
|
||||
mcp__basic-memory__read_note(
|
||||
identifier="note-to-analyze",
|
||||
project="main"
|
||||
)
|
||||
|
||||
# Search for potentially related notes
|
||||
mcp__basic-memory__search_notes(
|
||||
query="key terms from the note",
|
||||
project="main"
|
||||
)
|
||||
|
||||
# Suggest relations based on:
|
||||
# - Shared topics or concepts
|
||||
# - Complementary content (problem/solution, question/answer)
|
||||
# - Sequential relationship (part 1, part 2)
|
||||
# - Hierarchical (parent concept, child detail)
|
||||
```
|
||||
|
||||
**Relation types to suggest:**
|
||||
- `relates-to` - General topical connection
|
||||
- `extends` - Builds upon or expands
|
||||
- `implements` - Realizes a concept
|
||||
- `depends-on` - Requires understanding of
|
||||
- `contradicts` - Presents alternative view
|
||||
- `learned-from` - Source of insight
|
||||
- `enables` - Makes something possible
|
||||
|
||||
### 3. Identify Similar/Duplicate Notes
|
||||
|
||||
Find notes that may cover the same topic.
|
||||
|
||||
```python
|
||||
# Search for notes with similar titles or content
|
||||
mcp__basic-memory__search_notes(
|
||||
query="topic keywords",
|
||||
project="main"
|
||||
)
|
||||
|
||||
# Compare results for overlap
|
||||
# Look for:
|
||||
# - Similar titles
|
||||
# - Overlapping observations
|
||||
# - Same tags
|
||||
# - Related timestamps (created around same time)
|
||||
```
|
||||
|
||||
**Actions for duplicates:**
|
||||
- Merge into a single comprehensive note
|
||||
- Link them with `supersedes` or `updates` relations
|
||||
- Differentiate by adding context about their distinct focus
|
||||
|
||||
### 4. Folder Organization Review
|
||||
|
||||
Analyze folder structure and suggest improvements.
|
||||
|
||||
```python
|
||||
# List directory structure
|
||||
mcp__basic-memory__list_directory(
|
||||
dir_name="/",
|
||||
depth=3,
|
||||
project="main"
|
||||
)
|
||||
|
||||
# Identify:
|
||||
# - Overcrowded folders
|
||||
# - Single-note folders
|
||||
# - Inconsistent naming
|
||||
# - Notes that might belong elsewhere
|
||||
```
|
||||
|
||||
**Organization suggestions:**
|
||||
- Group related notes into topic folders
|
||||
- Create subfolders for large categories
|
||||
- Suggest consistent naming conventions
|
||||
- Move misplaced notes
|
||||
|
||||
### 5. Tag Consistency
|
||||
|
||||
Review and normalize tags across notes.
|
||||
|
||||
```python
|
||||
# Search notes to analyze tag patterns
|
||||
mcp__basic-memory__search_notes(
|
||||
query="*",
|
||||
page_size=100,
|
||||
project="main"
|
||||
)
|
||||
|
||||
# Look for:
|
||||
# - Similar tags (architecture vs arch)
|
||||
# - Unused tags
|
||||
# - Over-used generic tags
|
||||
# - Missing tags on relevant notes
|
||||
```
|
||||
|
||||
**Tag improvements:**
|
||||
- Suggest tag standardization (pick one variant)
|
||||
- Propose new tags for common themes
|
||||
- Identify notes missing obvious tags
|
||||
|
||||
### 6. Create Index/Hub Notes
|
||||
|
||||
Generate notes that serve as navigation hubs for related topics.
|
||||
|
||||
```python
|
||||
# After identifying a cluster of related notes
|
||||
mcp__basic-memory__write_note(
|
||||
title="Architecture Decisions Index",
|
||||
content="""---
|
||||
title: Architecture Decisions Index
|
||||
type: index
|
||||
tags:
|
||||
- architecture
|
||||
- index
|
||||
---
|
||||
|
||||
# Architecture Decisions Index
|
||||
|
||||
A hub linking all architecture-related decisions and patterns.
|
||||
|
||||
## Decisions
|
||||
|
||||
- [[Database Selection Decision]]
|
||||
- [[API Design Patterns]]
|
||||
- [[Authentication Architecture]]
|
||||
|
||||
## Patterns
|
||||
|
||||
- [[Repository Pattern]]
|
||||
- [[Async Client Pattern]]
|
||||
|
||||
## Observations
|
||||
|
||||
- [index] Central hub for architecture knowledge #navigation
|
||||
|
||||
## Relations
|
||||
|
||||
- indexes [[Architecture]]
|
||||
""",
|
||||
folder="indexes",
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
### 7. Enrich Sparse Notes
|
||||
|
||||
Find notes lacking observations or structure and suggest improvements.
|
||||
|
||||
```python
|
||||
# Read a sparse note
|
||||
mcp__basic-memory__read_note(
|
||||
identifier="sparse-note",
|
||||
project="main"
|
||||
)
|
||||
|
||||
# If missing:
|
||||
# - Observations section → suggest categories
|
||||
# - Relations section → suggest links
|
||||
# - Tags → suggest relevant tags
|
||||
# - Context → suggest adding background
|
||||
```
|
||||
|
||||
## Organization Workflows
|
||||
|
||||
### Quick Health Check
|
||||
|
||||
A fast overview of knowledge base status:
|
||||
|
||||
1. Count total notes
|
||||
2. Identify orphan count
|
||||
3. List recently modified
|
||||
4. Check for obvious duplicates
|
||||
5. Report folder distribution
|
||||
|
||||
### Deep Organization Session
|
||||
|
||||
Thorough review and improvement:
|
||||
|
||||
1. **Audit phase** - Catalog all notes, identify issues
|
||||
2. **Orphan phase** - Address unlinked notes
|
||||
3. **Relation phase** - Suggest new connections
|
||||
4. **Duplicate phase** - Merge or differentiate similar notes
|
||||
5. **Structure phase** - Reorganize folders if needed
|
||||
6. **Index phase** - Create hub notes for major topics
|
||||
|
||||
### Topic-Focused Organization
|
||||
|
||||
Organize around a specific subject:
|
||||
|
||||
1. Find all notes related to topic
|
||||
2. Map existing relations
|
||||
3. Identify gaps in the topic graph
|
||||
4. Suggest new notes to fill gaps
|
||||
5. Create topic index note
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Work incrementally** - Don't reorganize everything at once
|
||||
2. **Confirm before changing** - Always ask before moving/editing notes
|
||||
3. **Preserve permalinks** - Moving is okay, changing permalinks breaks links
|
||||
4. **Show the graph** - Help user visualize connections
|
||||
5. **Explain suggestions** - Say why a relation makes sense
|
||||
6. **Respect user's system** - Enhance their organization, don't impose a new one
|
||||
|
||||
## Example Conversations
|
||||
|
||||
**User:** "Help me organize my notes"
|
||||
|
||||
**Claude:**
|
||||
1. Runs health check on the knowledge base
|
||||
2. Reports: "You have 47 notes. I found 12 orphan notes and 3 potential duplicates."
|
||||
3. Asks: "Would you like to start by connecting the orphan notes, or review the duplicates first?"
|
||||
|
||||
**User:** "Find notes that should be linked to my API design note"
|
||||
|
||||
**Claude:**
|
||||
1. Reads the API design note
|
||||
2. Searches for related content
|
||||
3. Suggests: "I found 5 notes that could relate:
|
||||
- 'REST Best Practices' → relates-to
|
||||
- 'Authentication Flow' → implements
|
||||
- 'Rate Limiting Decision' → extends
|
||||
Would you like me to add any of these relations?"
|
||||
|
||||
**User:** "Are there any notes about similar topics?"
|
||||
|
||||
**Claude:**
|
||||
1. Analyzes note titles and content
|
||||
2. Identifies clusters of similar notes
|
||||
3. Reports: "I found these potential overlaps:
|
||||
- 'Auth Flow' and 'Authentication Design' cover similar ground
|
||||
- 'DB Schema v1' and 'DB Schema v2' might need a 'supersedes' relation
|
||||
Would you like to review any of these?"
|
||||
@@ -0,0 +1,213 @@
|
||||
---
|
||||
name: research
|
||||
description: Research a topic thoroughly and produce a structured report saved to Basic Memory - investigate concepts, gather context, and document findings
|
||||
---
|
||||
|
||||
# Research
|
||||
|
||||
This skill helps conduct thorough research on a topic and produces a structured report that gets saved to Basic Memory for future reference.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when:
|
||||
- User asks to research or investigate something
|
||||
- User wants to understand a concept, technology, or approach
|
||||
- User needs context gathered before making a decision
|
||||
- User asks "what is...", "how does... work", "explore...", "investigate..."
|
||||
- User wants findings documented for later
|
||||
- Phrases like "research this", "look into", "find out about", "explore options for"
|
||||
|
||||
## Research Process
|
||||
|
||||
### 1. Understand the Research Question
|
||||
|
||||
Clarify what specifically to investigate:
|
||||
- What is the core question or topic?
|
||||
- What scope - broad overview or deep dive?
|
||||
- Any specific aspects to focus on?
|
||||
- What will the research inform (a decision, implementation, understanding)?
|
||||
|
||||
### 2. Gather Information
|
||||
|
||||
Use available tools to collect information:
|
||||
|
||||
**For codebase research:**
|
||||
- Search the codebase for relevant code
|
||||
- Read documentation and comments
|
||||
- Trace how things connect
|
||||
- Look at tests for usage examples
|
||||
|
||||
**For concept research:**
|
||||
- Use web search for current information
|
||||
- Fetch documentation from official sources
|
||||
- Look for examples and best practices
|
||||
- Compare alternatives if relevant
|
||||
|
||||
**For Basic Memory context:**
|
||||
```python
|
||||
# Check what we already know
|
||||
mcp__basic-memory__search_notes(
|
||||
query="topic keywords",
|
||||
project="main"
|
||||
)
|
||||
|
||||
# Build context from related notes
|
||||
mcp__basic-memory__build_context(
|
||||
url="memory://related-topic",
|
||||
depth=2,
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Analyze and Synthesize
|
||||
|
||||
Organize findings into coherent insights:
|
||||
- Identify key concepts and how they relate
|
||||
- Note patterns, trade-offs, and considerations
|
||||
- Highlight what's most relevant to the user's needs
|
||||
- Flag uncertainties or areas needing more investigation
|
||||
|
||||
### 4. Produce the Report
|
||||
|
||||
Create a structured research report:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Research: [Topic]"
|
||||
type: research
|
||||
tags:
|
||||
- research
|
||||
- [topic-tags]
|
||||
---
|
||||
|
||||
# Research: [Topic]
|
||||
|
||||
## Summary
|
||||
|
||||
[2-3 sentence executive summary of findings]
|
||||
|
||||
## Research Question
|
||||
|
||||
[What we set out to understand]
|
||||
|
||||
## Key Findings
|
||||
|
||||
### [Finding 1]
|
||||
[Details, evidence, implications]
|
||||
|
||||
### [Finding 2]
|
||||
[Details, evidence, implications]
|
||||
|
||||
### [Finding 3]
|
||||
[Details, evidence, implications]
|
||||
|
||||
## Analysis
|
||||
|
||||
[Synthesis of findings - patterns, trade-offs, recommendations]
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [Things that need more investigation]
|
||||
- [Uncertainties or assumptions]
|
||||
|
||||
## Sources
|
||||
|
||||
- [Where information came from]
|
||||
- [[Related Note]] - relevant prior knowledge
|
||||
|
||||
## Observations
|
||||
|
||||
- [finding] Key insight discovered #research
|
||||
- [pattern] Pattern identified during research
|
||||
- [recommendation] Suggested approach based on findings
|
||||
|
||||
## Relations
|
||||
|
||||
- researches [[Topic]]
|
||||
- informs [[Decision or Implementation]]
|
||||
- relates-to [[Related Concepts]]
|
||||
```
|
||||
|
||||
### 5. Save to Basic Memory
|
||||
|
||||
```python
|
||||
mcp__basic-memory__write_note(
|
||||
title="Research: [Topic]",
|
||||
content="[Full report content]",
|
||||
folder="research",
|
||||
tags=["research", "topic-tags"],
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
## Report Styles
|
||||
|
||||
Adjust based on the research type:
|
||||
|
||||
### Quick Investigation
|
||||
- Focused summary
|
||||
- 2-3 key findings
|
||||
- Direct recommendation
|
||||
- Saved to `research/` folder
|
||||
|
||||
### Deep Dive
|
||||
- Comprehensive analysis
|
||||
- Multiple sections
|
||||
- Detailed evidence
|
||||
- Comparison of options
|
||||
- Saved to `research/` folder
|
||||
|
||||
### Decision Support
|
||||
- Options evaluated
|
||||
- Pros/cons for each
|
||||
- Clear recommendation with rationale
|
||||
- Saved to `decisions/` or `research/` folder
|
||||
|
||||
### Technical Exploration
|
||||
- How it works
|
||||
- Architecture/design
|
||||
- Code examples
|
||||
- Integration considerations
|
||||
- Saved to `research/` folder
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Start with what we know** - Check Basic Memory for existing context
|
||||
2. **Be thorough but focused** - Cover the topic well without tangents
|
||||
3. **Cite sources** - Link to where information came from
|
||||
4. **Be honest about uncertainty** - Flag what's unclear or needs verification
|
||||
5. **Make it actionable** - Include recommendations when appropriate
|
||||
6. **Link to related knowledge** - Connect to existing notes
|
||||
7. **Save for future reference** - Always save the report to Basic Memory
|
||||
|
||||
## Example Conversations
|
||||
|
||||
**User:** "Research how other projects handle database migrations"
|
||||
|
||||
**Claude:**
|
||||
1. Searches codebase for migration patterns
|
||||
2. Checks Basic Memory for related decisions
|
||||
3. Looks up best practices online
|
||||
4. Produces report comparing approaches
|
||||
5. Saves to `research/Database Migration Approaches.md`
|
||||
6. Presents summary with recommendation
|
||||
|
||||
**User:** "Investigate the MCP protocol"
|
||||
|
||||
**Claude:**
|
||||
1. Fetches MCP documentation
|
||||
2. Searches for examples in codebase
|
||||
3. Checks Basic Memory for prior context
|
||||
4. Produces comprehensive report on MCP
|
||||
5. Saves to `research/MCP Protocol Overview.md`
|
||||
6. Presents key concepts and how to use them
|
||||
|
||||
**User:** "Look into authentication options for the API"
|
||||
|
||||
**Claude:**
|
||||
1. Researches common auth patterns (JWT, OAuth, API keys)
|
||||
2. Checks existing codebase auth implementation
|
||||
3. Evaluates trade-offs for the use case
|
||||
4. Produces decision-support report
|
||||
5. Saves to `research/API Authentication Options.md`
|
||||
6. Recommends approach with rationale
|
||||
@@ -0,0 +1,292 @@
|
||||
---
|
||||
name: spec-driven-development
|
||||
description: Guide implementation based on specs stored in Basic Memory, following the SPEC-1 specification-driven development process
|
||||
---
|
||||
|
||||
# Spec-Driven Development
|
||||
|
||||
This skill guides implementation work based on specifications stored in the Basic Memory "specs" project, following the process defined in SPEC-1.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when:
|
||||
- Implementing a feature defined by a spec
|
||||
- Creating a new specification before implementation
|
||||
- Reviewing implementation against spec criteria
|
||||
- Need to understand what a spec requires
|
||||
- Updating spec progress as work completes
|
||||
|
||||
## The Spec-Driven Process
|
||||
|
||||
From SPEC-1, the workflow is:
|
||||
|
||||
1. **Create** - Write spec as complete thought in Basic Memory "specs" project
|
||||
2. **Discuss** - Iterate and refine the specification
|
||||
3. **Implement** - Execute implementation directly
|
||||
4. **Validate** - Review implementation against spec criteria
|
||||
5. **Document** - Update spec with learnings and decisions
|
||||
|
||||
## Spec Structure
|
||||
|
||||
Every spec contains:
|
||||
- **Why** - The reasoning and problem being solved
|
||||
- **What** - What is affected or changed
|
||||
- **How** - High-level approach to implementation
|
||||
- **How to Evaluate** - Testing/validation procedure
|
||||
|
||||
### Progress Tracking Format
|
||||
|
||||
Specs use living documentation with checklists:
|
||||
|
||||
```markdown
|
||||
### Feature Area
|
||||
- ✅ Basic functionality implemented
|
||||
- ✅ Props and events defined
|
||||
- [ ] Add sorting controls
|
||||
- [ ] Improve accessibility
|
||||
- [x] Currently implementing responsive design
|
||||
```
|
||||
|
||||
- `✅` - Completed items
|
||||
- `[ ]` - Pending items
|
||||
- `[x]` - In-progress items
|
||||
|
||||
## Working with Specs
|
||||
|
||||
### Reading a Spec
|
||||
|
||||
```python
|
||||
# Get the full spec
|
||||
mcp__basic-memory__read_note(
|
||||
identifier="SPEC-24: Postgres Database Migration",
|
||||
project="specs"
|
||||
)
|
||||
|
||||
# Or search for it
|
||||
mcp__basic-memory__search_notes(
|
||||
query="postgres migration",
|
||||
project="specs"
|
||||
)
|
||||
```
|
||||
|
||||
### Creating a New Spec
|
||||
|
||||
```python
|
||||
# 1. First, find the next spec number
|
||||
mcp__basic-memory__search_notes(
|
||||
query="SPEC-",
|
||||
project="specs"
|
||||
)
|
||||
|
||||
# 2. Create the spec with proper structure
|
||||
mcp__basic-memory__write_note(
|
||||
title="SPEC-30: Your Feature Name",
|
||||
content="""---
|
||||
title: 'SPEC-30: Your Feature Name'
|
||||
type: spec
|
||||
tags:
|
||||
- feature-area
|
||||
- component
|
||||
---
|
||||
|
||||
# SPEC-30: Your Feature Name
|
||||
|
||||
## Why
|
||||
|
||||
[Problem statement and motivation]
|
||||
|
||||
## What
|
||||
|
||||
[What is affected or changed]
|
||||
- Affected areas
|
||||
- Components involved
|
||||
- Scope boundaries
|
||||
|
||||
## How (High Level)
|
||||
|
||||
[Implementation approach]
|
||||
|
||||
### Phase 1: Foundation
|
||||
- [ ] Task 1
|
||||
- [ ] Task 2
|
||||
|
||||
### Phase 2: Core Features
|
||||
- [ ] Task 3
|
||||
- [ ] Task 4
|
||||
|
||||
## How to Evaluate
|
||||
|
||||
### Success Criteria
|
||||
- [ ] Criterion 1
|
||||
- [ ] Criterion 2
|
||||
|
||||
### Testing Procedure
|
||||
1. Step 1
|
||||
2. Step 2
|
||||
|
||||
## Observations
|
||||
|
||||
- [goal] Primary objective #tag
|
||||
- [constraint] Known limitation #tag
|
||||
|
||||
## Relations
|
||||
|
||||
- relates-to [[Related Spec]]
|
||||
- depends-on [[Dependency]]
|
||||
""",
|
||||
folder="", # Root of specs project
|
||||
project="specs"
|
||||
)
|
||||
```
|
||||
|
||||
### Updating Spec Progress
|
||||
|
||||
```python
|
||||
# Mark items complete as you implement
|
||||
mcp__basic-memory__edit_note(
|
||||
identifier="SPEC-24: Postgres Database Migration",
|
||||
operation="find_replace",
|
||||
find_text="- [ ] Create migration scripts",
|
||||
content="- ✅ Create migration scripts",
|
||||
project="specs"
|
||||
)
|
||||
|
||||
# Or add new observations
|
||||
mcp__basic-memory__edit_note(
|
||||
identifier="SPEC-24: Postgres Database Migration",
|
||||
operation="append",
|
||||
content="\n- [learning] Alembic autogenerate works well for model changes #migration",
|
||||
project="specs"
|
||||
)
|
||||
```
|
||||
|
||||
### Reviewing Implementation
|
||||
|
||||
When reviewing against a spec:
|
||||
|
||||
1. **Read the spec's "How to Evaluate" section**
|
||||
2. **Check each success criterion:**
|
||||
- Functional completeness
|
||||
- Test coverage (count test files, check categories)
|
||||
- Code quality (TypeScript, linting, performance)
|
||||
- Architecture compliance
|
||||
- Documentation completeness
|
||||
3. **Be honest** - Don't overstate completeness
|
||||
4. **Document findings** - Update spec with review results
|
||||
5. **Identify gaps** - Clearly note what still needs work
|
||||
|
||||
## Implementation Workflow
|
||||
|
||||
### Starting Implementation
|
||||
|
||||
1. **Read the spec thoroughly**
|
||||
```python
|
||||
mcp__basic-memory__read_note(
|
||||
identifier="SPEC-XX: Feature Name",
|
||||
project="specs"
|
||||
)
|
||||
```
|
||||
|
||||
2. **Understand dependencies**
|
||||
- Check Relations section for dependencies
|
||||
- Read related specs if needed
|
||||
|
||||
3. **Plan your approach**
|
||||
- Break "How" section into concrete tasks
|
||||
- Identify what to implement first
|
||||
|
||||
4. **Mark first item in-progress**
|
||||
```python
|
||||
mcp__basic-memory__edit_note(
|
||||
identifier="SPEC-XX",
|
||||
operation="find_replace",
|
||||
find_text="- [ ] First task",
|
||||
content="- [x] First task",
|
||||
project="specs"
|
||||
)
|
||||
```
|
||||
|
||||
### During Implementation
|
||||
|
||||
1. **Update progress as you complete items**
|
||||
2. **Add observations for decisions made**
|
||||
3. **Note any deviations from the spec**
|
||||
4. **Capture learnings that might help future specs**
|
||||
|
||||
### After Implementation
|
||||
|
||||
1. **Run full evaluation against criteria**
|
||||
2. **Mark all completed items with ✅**
|
||||
3. **Add final observations**
|
||||
4. **Document any follow-up work needed**
|
||||
|
||||
## Spec Naming Convention
|
||||
|
||||
Format: `SPEC-X: Descriptive Title`
|
||||
|
||||
Examples:
|
||||
- `SPEC-24: Postgres Database Migration`
|
||||
- `SPEC-25: Cloud Index Service`
|
||||
- `SPEC-26: Multi-User Security and Permissions`
|
||||
|
||||
## Common Spec Patterns
|
||||
|
||||
### Feature Spec
|
||||
```markdown
|
||||
## Why
|
||||
User need or problem
|
||||
|
||||
## What
|
||||
- New UI components
|
||||
- API endpoints
|
||||
- Database changes
|
||||
|
||||
## How
|
||||
Implementation phases with checkboxes
|
||||
```
|
||||
|
||||
### Architecture Spec
|
||||
```markdown
|
||||
## Why
|
||||
Technical debt or scalability need
|
||||
|
||||
## What
|
||||
- System components affected
|
||||
- Data flow changes
|
||||
- Integration points
|
||||
|
||||
## How
|
||||
Migration strategy with rollback plan
|
||||
```
|
||||
|
||||
### Process Spec
|
||||
```markdown
|
||||
## Why
|
||||
Workflow improvement need
|
||||
|
||||
## What
|
||||
- Process steps changed
|
||||
- Tools involved
|
||||
- Team impact
|
||||
|
||||
## How
|
||||
Rollout plan and adoption strategy
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Spec first, code second** - Write spec before implementation
|
||||
2. **Keep specs living** - Update as understanding evolves
|
||||
3. **Be specific in criteria** - Vague criteria = vague completion
|
||||
4. **Link related specs** - Build the knowledge graph
|
||||
5. **Capture decisions** - Future you will thank you
|
||||
6. **Review honestly** - Incomplete is okay, dishonest isn't
|
||||
7. **Close the loop** - Mark items done as you complete them
|
||||
|
||||
## Using with Slash Commands
|
||||
|
||||
The `/spec` command provides quick access:
|
||||
- `/spec create [name]` - Create new specification
|
||||
- `/spec status` - Show all spec statuses
|
||||
- `/spec show [name]` - Read a specific spec
|
||||
- `/spec review [name]` - Validate implementation
|
||||
@@ -0,0 +1,42 @@
|
||||
# Docker Compose configuration for Basic Memory with PostgreSQL
|
||||
# Use this for local development and testing with Postgres backend
|
||||
#
|
||||
# Usage:
|
||||
# docker-compose -f docker-compose-postgres.yml up -d
|
||||
# docker-compose -f docker-compose-postgres.yml down
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
container_name: basic-memory-postgres
|
||||
environment:
|
||||
# Local development/test credentials - NOT for production
|
||||
# These values are referenced by tests and justfile commands
|
||||
POSTGRES_DB: basic_memory
|
||||
POSTGRES_USER: basic_memory_user
|
||||
POSTGRES_PASSWORD: dev_password # Simple password for local testing only
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U basic_memory_user -d basic_memory"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
# Named volume for Postgres data
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
# Named volume for persistent configuration
|
||||
# Database will be stored in Postgres, not in this volume
|
||||
basic-memory-config:
|
||||
driver: local
|
||||
|
||||
# Network configuration (optional)
|
||||
# networks:
|
||||
# basic-memory-net:
|
||||
# driver: bridge
|
||||
@@ -7,16 +7,78 @@ install:
|
||||
@echo ""
|
||||
@echo "💡 Remember to activate the virtual environment by running: source .venv/bin/activate"
|
||||
|
||||
# Run all tests with unified coverage report
|
||||
test: test-unit test-int
|
||||
|
||||
# Run unit tests only (fast, no coverage)
|
||||
test-unit:
|
||||
uv run pytest -p pytest_mock -v --no-cov -n auto tests
|
||||
uv run pytest -p pytest_mock -v --no-cov tests
|
||||
|
||||
# Run integration tests only (fast, no coverage)
|
||||
test-int:
|
||||
uv run pytest -p pytest_mock -v --no-cov -n auto test-int
|
||||
uv run pytest -p pytest_mock -v --no-cov test-int
|
||||
|
||||
# Run all tests with unified coverage report
|
||||
test: test-unit test-int
|
||||
# ==============================================================================
|
||||
# DATABASE BACKEND TESTING
|
||||
# ==============================================================================
|
||||
# Basic Memory supports dual database backends (SQLite and Postgres).
|
||||
# Tests are parametrized to run against both backends automatically.
|
||||
#
|
||||
# Quick Start:
|
||||
# just test-sqlite # Run SQLite tests (default, no Docker needed)
|
||||
# just test-postgres # Run Postgres tests (requires Docker)
|
||||
#
|
||||
# For Postgres tests, first start the database:
|
||||
# docker-compose -f docker-compose-postgres.yml up -d
|
||||
# ==============================================================================
|
||||
|
||||
# Run tests against SQLite only (default backend, skip Postgres/Benchmark tests)
|
||||
# This is the fastest option and doesn't require any Docker setup.
|
||||
# Use this for local development and quick feedback.
|
||||
# Includes Windows-specific tests which will auto-skip on non-Windows platforms.
|
||||
test-sqlite:
|
||||
uv run pytest -p pytest_mock -v --no-cov -m "not postgres and not benchmark" tests test-int
|
||||
|
||||
# Run tests against Postgres only (requires docker-compose-postgres.yml up)
|
||||
# First start Postgres: docker-compose -f docker-compose-postgres.yml up -d
|
||||
# Tests will connect to localhost:5433/basic_memory_test
|
||||
# To reset the database: just postgres-reset
|
||||
test-postgres:
|
||||
uv run pytest -p pytest_mock -v --no-cov -m "postgres and not benchmark" tests test-int
|
||||
|
||||
# Reset Postgres test database (drops and recreates schema)
|
||||
# Useful when Alembic migration state gets out of sync during development
|
||||
# Uses credentials from docker-compose-postgres.yml
|
||||
postgres-reset:
|
||||
docker exec basic-memory-postgres psql -U ${POSTGRES_USER:-basic_memory_user} -d ${POSTGRES_TEST_DB:-basic_memory_test} -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||
@echo "✅ Postgres test database reset"
|
||||
|
||||
# Run Alembic migrations manually against Postgres test database
|
||||
# Useful for debugging migration issues
|
||||
# Uses credentials from docker-compose-postgres.yml (can override with env vars)
|
||||
postgres-migrate:
|
||||
@cd src/basic_memory/alembic && \
|
||||
BASIC_MEMORY_DATABASE_BACKEND=postgres \
|
||||
BASIC_MEMORY_DATABASE_URL=${POSTGRES_TEST_URL:-postgresql://basic_memory_user:dev_password@localhost:5433/basic_memory_test} \
|
||||
uv run alembic upgrade head
|
||||
@echo "✅ Migrations applied to Postgres test database"
|
||||
|
||||
# Run Windows-specific tests only (only works on Windows platform)
|
||||
# These tests verify Windows-specific database optimizations (locking mode, NullPool)
|
||||
# Will be skipped automatically on non-Windows platforms
|
||||
test-windows:
|
||||
uv run pytest -p pytest_mock -v --no-cov -m windows tests test-int
|
||||
|
||||
# Run benchmark tests only (performance testing)
|
||||
# These are slow tests that measure sync performance with various file counts
|
||||
# Excluded from default test runs to keep CI fast
|
||||
test-benchmark:
|
||||
uv run pytest -p pytest_mock -v --no-cov -m benchmark tests test-int
|
||||
|
||||
# Run all tests including Windows, Postgres, and Benchmarks (for CI/comprehensive testing)
|
||||
# Use this before releasing to ensure everything works across all backends and platforms
|
||||
test-all:
|
||||
uv run pytest -p pytest_mock -v --no-cov tests test-int
|
||||
|
||||
# Generate HTML coverage report
|
||||
coverage:
|
||||
|
||||
+5
-1
@@ -15,7 +15,6 @@ dependencies = [
|
||||
"aiosqlite>=0.20.0",
|
||||
"greenlet>=3.1.1",
|
||||
"pydantic[email,timezone]>=2.10.3",
|
||||
"icecream>=2.1.3",
|
||||
"mcp>=1.2.0",
|
||||
"pydantic-settings>=2.6.1",
|
||||
"loguru>=0.7.3",
|
||||
@@ -36,6 +35,8 @@ dependencies = [
|
||||
"pytest-aio>=1.9.0",
|
||||
"aiofiles>=24.1.0", # Async file I/O
|
||||
"logfire>=0.73.0", # Optional observability (disabled by default via config)
|
||||
"asyncpg>=0.30.0",
|
||||
"nest-asyncio>=1.6.0", # For Alembic migrations with Postgres
|
||||
]
|
||||
|
||||
|
||||
@@ -61,6 +62,8 @@ asyncio_default_fixture_loop_scope = "function"
|
||||
markers = [
|
||||
"benchmark: Performance benchmark tests (deselect with '-m \"not benchmark\"')",
|
||||
"slow: Slow-running tests (deselect with '-m \"not slow\"')",
|
||||
"postgres: Tests that run against Postgres backend (deselect with '-m \"not postgres\"')",
|
||||
"windows: Windows-specific tests (deselect with '-m \"not windows\"')",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
@@ -78,6 +81,7 @@ dev = [
|
||||
"pytest-xdist>=3.0.0",
|
||||
"ruff>=0.1.6",
|
||||
"freezegun>=1.5.5",
|
||||
|
||||
]
|
||||
|
||||
[tool.hatch.version]
|
||||
|
||||
+104
-22
@@ -1,10 +1,21 @@
|
||||
"""Alembic environment configuration."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
# Allow nested event loops (needed for pytest-asyncio and other async contexts)
|
||||
# Note: nest_asyncio doesn't work with uvloop, so we handle that case separately
|
||||
try:
|
||||
import nest_asyncio
|
||||
|
||||
nest_asyncio.apply()
|
||||
except (ImportError, ValueError):
|
||||
# nest_asyncio not available or can't patch this loop type (e.g., uvloop)
|
||||
pass
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
from alembic import context
|
||||
|
||||
@@ -20,12 +31,22 @@ from basic_memory.models import Base # noqa: E402
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Load app config - this will read environment variables (BASIC_MEMORY_DATABASE_BACKEND, etc.)
|
||||
# due to Pydantic's env_prefix="BASIC_MEMORY_" setting
|
||||
app_config = ConfigManager().config
|
||||
# Set the SQLAlchemy URL from our app config
|
||||
sqlalchemy_url = f"sqlite:///{app_config.database_path}"
|
||||
config.set_main_option("sqlalchemy.url", sqlalchemy_url)
|
||||
|
||||
# print(f"Using SQLAlchemy URL: {sqlalchemy_url}")
|
||||
# Set the SQLAlchemy URL based on database backend configuration
|
||||
# If the URL is already set in config (e.g., from run_migrations), use that
|
||||
# Otherwise, get it from app config
|
||||
# Note: alembic.ini has a placeholder URL "driver://user:pass@localhost/dbname" that we need to override
|
||||
current_url = config.get_main_option("sqlalchemy.url")
|
||||
if not current_url or current_url == "driver://user:pass@localhost/dbname":
|
||||
from basic_memory.db import DatabaseType
|
||||
|
||||
sqlalchemy_url = DatabaseType.get_db_url(
|
||||
app_config.database_path, DatabaseType.FILESYSTEM, app_config
|
||||
)
|
||||
config.set_main_option("sqlalchemy.url", sqlalchemy_url)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
if config.config_file_name is not None:
|
||||
@@ -69,28 +90,89 @@ def run_migrations_offline() -> None:
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection):
|
||||
"""Execute migrations with the given connection."""
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
include_object=include_object,
|
||||
render_as_batch=True,
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations(connectable):
|
||||
"""Run migrations asynchronously with AsyncEngine."""
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
Supports both sync engines (SQLite) and async engines (PostgreSQL with asyncpg).
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
# Check if a connection/engine was provided (e.g., from run_migrations)
|
||||
connectable = context.config.attributes.get("connection", None)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
include_object=include_object,
|
||||
render_as_batch=True,
|
||||
)
|
||||
if connectable is None:
|
||||
# No connection provided, create engine from config
|
||||
url = context.config.get_main_option("sqlalchemy.url")
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
# Check if it's an async URL (sqlite+aiosqlite or postgresql+asyncpg)
|
||||
if url and ("+asyncpg" in url or "+aiosqlite" in url):
|
||||
# Create async engine for asyncpg or aiosqlite
|
||||
connectable = create_async_engine(
|
||||
url,
|
||||
poolclass=pool.NullPool,
|
||||
future=True,
|
||||
)
|
||||
else:
|
||||
# Create sync engine for regular sqlite or postgresql
|
||||
connectable = engine_from_config(
|
||||
context.config.get_section(context.config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
# Handle async engines (PostgreSQL with asyncpg)
|
||||
if isinstance(connectable, AsyncEngine):
|
||||
# Try to run async migrations
|
||||
# nest_asyncio allows asyncio.run() from within event loops, but doesn't work with uvloop
|
||||
try:
|
||||
asyncio.run(run_async_migrations(connectable))
|
||||
except RuntimeError as e:
|
||||
if "cannot be called from a running event loop" in str(e):
|
||||
# We're in a running event loop (likely uvloop) - need to use a different approach
|
||||
# Create a new thread to run the async migrations
|
||||
import concurrent.futures
|
||||
|
||||
def run_in_thread():
|
||||
"""Run async migrations in a new event loop in a separate thread."""
|
||||
new_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(new_loop)
|
||||
try:
|
||||
new_loop.run_until_complete(run_async_migrations(connectable))
|
||||
finally:
|
||||
new_loop.close()
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_thread)
|
||||
future.result() # Wait for completion and re-raise any exceptions
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
# Handle sync engines (SQLite) or sync connections
|
||||
if hasattr(connectable, "connect"):
|
||||
# It's an engine, get a connection
|
||||
with connectable.connect() as connection:
|
||||
do_run_migrations(connection)
|
||||
else:
|
||||
# It's already a connection
|
||||
do_run_migrations(connectable)
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
"""Add Postgres full-text search support with tsvector and GIN indexes
|
||||
|
||||
Revision ID: 314f1ea54dc4
|
||||
Revises: e7e1f4367280
|
||||
Create Date: 2025-11-15 18:05:01.025405
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "314f1ea54dc4"
|
||||
down_revision: Union[str, None] = "e7e1f4367280"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add PostgreSQL full-text search support.
|
||||
|
||||
This migration:
|
||||
1. Creates search_index table for Postgres (SQLite uses FTS5 virtual table)
|
||||
2. Adds generated tsvector column for full-text search
|
||||
3. Creates GIN index on the tsvector column for fast text queries
|
||||
4. Creates GIN index on metadata JSONB column for fast containment queries
|
||||
|
||||
Note: These changes only apply to Postgres. SQLite continues to use FTS5 virtual tables.
|
||||
"""
|
||||
# Check if we're using Postgres
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name == "postgresql":
|
||||
# Create search_index table for Postgres
|
||||
# For SQLite, this is a FTS5 virtual table created elsewhere
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
op.create_table(
|
||||
"search_index",
|
||||
sa.Column("id", sa.Integer(), nullable=False), # Entity IDs are integers
|
||||
sa.Column("project_id", sa.Integer(), nullable=False), # Multi-tenant isolation
|
||||
sa.Column("title", sa.Text(), nullable=True),
|
||||
sa.Column("content_stems", sa.Text(), nullable=True),
|
||||
sa.Column("content_snippet", sa.Text(), nullable=True),
|
||||
sa.Column("permalink", sa.String(), nullable=True), # Nullable for non-markdown files
|
||||
sa.Column("file_path", sa.String(), nullable=True),
|
||||
sa.Column("type", sa.String(), nullable=True),
|
||||
sa.Column("from_id", sa.Integer(), nullable=True), # Relation IDs are integers
|
||||
sa.Column("to_id", sa.Integer(), nullable=True), # Relation IDs are integers
|
||||
sa.Column("relation_type", sa.String(), nullable=True),
|
||||
sa.Column("entity_id", sa.Integer(), nullable=True), # Entity IDs are integers
|
||||
sa.Column("category", sa.String(), nullable=True),
|
||||
sa.Column("metadata", JSONB(), nullable=True), # Use JSONB for Postgres
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id", "type", "project_id"
|
||||
), # Composite key: id can repeat across types
|
||||
sa.ForeignKeyConstraint(
|
||||
["project_id"],
|
||||
["project.id"],
|
||||
name="fk_search_index_project_id",
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
# Create index on project_id for efficient multi-tenant queries
|
||||
op.create_index(
|
||||
"ix_search_index_project_id",
|
||||
"search_index",
|
||||
["project_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
# Create unique partial index on permalink for markdown files
|
||||
# Non-markdown files don't have permalinks, so we use a partial index
|
||||
op.execute("""
|
||||
CREATE UNIQUE INDEX uix_search_index_permalink_project
|
||||
ON search_index (permalink, project_id)
|
||||
WHERE permalink IS NOT NULL
|
||||
""")
|
||||
|
||||
# Add tsvector column as a GENERATED ALWAYS column
|
||||
# This automatically updates when title or content_stems change
|
||||
op.execute("""
|
||||
ALTER TABLE search_index
|
||||
ADD COLUMN textsearchable_index_col tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
to_tsvector('english',
|
||||
coalesce(title, '') || ' ' ||
|
||||
coalesce(content_stems, '')
|
||||
)
|
||||
) STORED
|
||||
""")
|
||||
|
||||
# Create GIN index on tsvector column for fast full-text search
|
||||
op.create_index(
|
||||
"idx_search_index_fts",
|
||||
"search_index",
|
||||
["textsearchable_index_col"],
|
||||
unique=False,
|
||||
postgresql_using="gin",
|
||||
)
|
||||
|
||||
# Create GIN index on metadata JSONB for fast containment queries
|
||||
# Using jsonb_path_ops for smaller index size and better performance
|
||||
op.execute("""
|
||||
CREATE INDEX idx_search_index_metadata_gin
|
||||
ON search_index
|
||||
USING GIN (metadata jsonb_path_ops)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove PostgreSQL full-text search support."""
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name == "postgresql":
|
||||
# Drop indexes first
|
||||
op.execute("DROP INDEX IF EXISTS idx_search_index_metadata_gin")
|
||||
op.drop_index("idx_search_index_fts", table_name="search_index")
|
||||
op.execute("DROP INDEX IF EXISTS uix_search_index_permalink_project")
|
||||
op.drop_index("ix_search_index_project_id", table_name="search_index")
|
||||
|
||||
# Drop the generated column
|
||||
op.execute("ALTER TABLE search_index DROP COLUMN IF EXISTS textsearchable_index_col")
|
||||
|
||||
# Drop the search_index table
|
||||
op.drop_table("search_index")
|
||||
@@ -21,6 +21,12 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
|
||||
# SQLite FTS5 virtual table handling is SQLite-specific
|
||||
# For Postgres, search_index is a regular table managed by ORM
|
||||
connection = op.get_bind()
|
||||
is_sqlite = connection.dialect.name == "sqlite"
|
||||
|
||||
op.create_table(
|
||||
"project",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
@@ -55,7 +61,9 @@ def upgrade() -> None:
|
||||
batch_op.add_column(sa.Column("project_id", sa.Integer(), nullable=False))
|
||||
batch_op.drop_index(
|
||||
"uix_entity_permalink",
|
||||
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
|
||||
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL")
|
||||
if is_sqlite
|
||||
else None,
|
||||
)
|
||||
batch_op.drop_index("ix_entity_file_path")
|
||||
batch_op.create_index(batch_op.f("ix_entity_file_path"), ["file_path"], unique=False)
|
||||
@@ -67,12 +75,16 @@ def upgrade() -> None:
|
||||
"uix_entity_permalink_project",
|
||||
["permalink", "project_id"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
|
||||
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL")
|
||||
if is_sqlite
|
||||
else None,
|
||||
)
|
||||
batch_op.create_foreign_key("fk_entity_project_id", "project", ["project_id"], ["id"])
|
||||
|
||||
# drop the search index table. it will be recreated
|
||||
op.drop_table("search_index")
|
||||
# Only drop for SQLite - Postgres uses regular table managed by ORM
|
||||
if is_sqlite:
|
||||
op.drop_table("search_index")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
@@ -25,43 +25,51 @@ def upgrade() -> None:
|
||||
The UNIQUE constraint prevents multiple projects from having is_default=FALSE,
|
||||
which breaks project creation when the service sets is_default=False.
|
||||
|
||||
Since SQLite doesn't support dropping specific constraints easily, we'll
|
||||
recreate the table without the problematic constraint.
|
||||
SQLite: Recreate the table without the constraint (no ALTER TABLE support)
|
||||
Postgres: Use ALTER TABLE to drop the constraint directly
|
||||
"""
|
||||
# For SQLite, we need to recreate the table without the UNIQUE constraint
|
||||
# Create a new table without the UNIQUE constraint on is_default
|
||||
op.create_table(
|
||||
"project_new",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("permalink", sa.String(), nullable=False),
|
||||
sa.Column("path", sa.String(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_default", sa.Boolean(), nullable=True), # No UNIQUE constraint!
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
sa.UniqueConstraint("permalink"),
|
||||
)
|
||||
connection = op.get_bind()
|
||||
is_sqlite = connection.dialect.name == "sqlite"
|
||||
|
||||
# Copy data from old table to new table
|
||||
op.execute("INSERT INTO project_new SELECT * FROM project")
|
||||
if is_sqlite:
|
||||
# For SQLite, we need to recreate the table without the UNIQUE constraint
|
||||
# Create a new table without the UNIQUE constraint on is_default
|
||||
op.create_table(
|
||||
"project_new",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("permalink", sa.String(), nullable=False),
|
||||
sa.Column("path", sa.String(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_default", sa.Boolean(), nullable=True), # No UNIQUE constraint!
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
sa.UniqueConstraint("permalink"),
|
||||
)
|
||||
|
||||
# Drop the old table
|
||||
op.drop_table("project")
|
||||
# Copy data from old table to new table
|
||||
op.execute("INSERT INTO project_new SELECT * FROM project")
|
||||
|
||||
# Rename the new table
|
||||
op.rename_table("project_new", "project")
|
||||
# Drop the old table
|
||||
op.drop_table("project")
|
||||
|
||||
# Recreate the indexes
|
||||
with op.batch_alter_table("project", schema=None) as batch_op:
|
||||
batch_op.create_index("ix_project_created_at", ["created_at"], unique=False)
|
||||
batch_op.create_index("ix_project_name", ["name"], unique=True)
|
||||
batch_op.create_index("ix_project_path", ["path"], unique=False)
|
||||
batch_op.create_index("ix_project_permalink", ["permalink"], unique=True)
|
||||
batch_op.create_index("ix_project_updated_at", ["updated_at"], unique=False)
|
||||
# Rename the new table
|
||||
op.rename_table("project_new", "project")
|
||||
|
||||
# Recreate the indexes
|
||||
with op.batch_alter_table("project", schema=None) as batch_op:
|
||||
batch_op.create_index("ix_project_created_at", ["created_at"], unique=False)
|
||||
batch_op.create_index("ix_project_name", ["name"], unique=True)
|
||||
batch_op.create_index("ix_project_path", ["path"], unique=False)
|
||||
batch_op.create_index("ix_project_permalink", ["permalink"], unique=True)
|
||||
batch_op.create_index("ix_project_updated_at", ["updated_at"], unique=False)
|
||||
else:
|
||||
# For Postgres, we can simply drop the constraint
|
||||
with op.batch_alter_table("project", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("project_is_default_key", type_="unique")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -21,6 +21,12 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
def upgrade() -> None:
|
||||
"""Upgrade database schema to use new search index with content_stems and content_snippet."""
|
||||
|
||||
# This migration is SQLite-specific (FTS5 virtual tables)
|
||||
# For Postgres, the search_index table is created via ORM models
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name != "sqlite":
|
||||
return
|
||||
|
||||
# First, drop the existing search_index table
|
||||
op.execute("DROP TABLE IF EXISTS search_index")
|
||||
|
||||
@@ -59,6 +65,13 @@ def upgrade() -> None:
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade database schema to use old search index."""
|
||||
|
||||
# This migration is SQLite-specific (FTS5 virtual tables)
|
||||
# For Postgres, the search_index table is managed via ORM models
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name != "sqlite":
|
||||
return
|
||||
|
||||
# Drop the updated search_index table
|
||||
op.execute("DROP TABLE IF EXISTS search_index")
|
||||
|
||||
|
||||
@@ -20,6 +20,16 @@ from basic_memory.api.routers import (
|
||||
search,
|
||||
prompt_router,
|
||||
)
|
||||
from basic_memory.api.v2.routers import (
|
||||
knowledge_router as v2_knowledge,
|
||||
project_router as v2_project,
|
||||
memory_router as v2_memory,
|
||||
search_router as v2_search,
|
||||
resource_router as v2_resource,
|
||||
directory_router as v2_directory,
|
||||
prompt_router as v2_prompt,
|
||||
importer_router as v2_importer,
|
||||
)
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.services.initialization import initialize_file_sync, initialize_app
|
||||
|
||||
@@ -66,8 +76,7 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
# Include routers
|
||||
# Include v1 routers
|
||||
app.include_router(knowledge.router, prefix="/{project}")
|
||||
app.include_router(memory.router, prefix="/{project}")
|
||||
app.include_router(resource.router, prefix="/{project}")
|
||||
@@ -77,7 +86,17 @@ app.include_router(directory_router.router, prefix="/{project}")
|
||||
app.include_router(prompt_router.router, prefix="/{project}")
|
||||
app.include_router(importer_router.router, prefix="/{project}")
|
||||
|
||||
# Project resource router works accross projects
|
||||
# Include v2 routers (ID-based paths)
|
||||
app.include_router(v2_knowledge, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_memory, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_search, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_resource, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_directory, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_prompt, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_importer, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_project, prefix="/v2")
|
||||
|
||||
# Project resource router works across projects
|
||||
app.include_router(project.project_resource_router)
|
||||
app.include_router(management.router)
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
"""Router for knowledge graph operations."""
|
||||
"""Router for knowledge graph operations.
|
||||
|
||||
⚠️ DEPRECATED: This v1 API is deprecated and will be removed on June 30, 2026.
|
||||
Please migrate to /v2/{project}/knowledge endpoints which use entity IDs instead
|
||||
of path-based identifiers for improved performance and stability.
|
||||
|
||||
Migration guide: See docs/migration/v1-to-v2.md
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
@@ -25,7 +32,11 @@ from basic_memory.schemas import (
|
||||
from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest
|
||||
from basic_memory.schemas.base import Permalink, Entity
|
||||
|
||||
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
|
||||
router = APIRouter(
|
||||
prefix="/knowledge",
|
||||
tags=["knowledge"],
|
||||
deprecated=True, # Marks entire router as deprecated in OpenAPI docs
|
||||
)
|
||||
|
||||
|
||||
async def resolve_relations_background(sync_service, entity_id: int, entity_permalink: str) -> None:
|
||||
|
||||
@@ -50,6 +50,7 @@ async def get_project(
|
||||
) # pragma: no cover
|
||||
|
||||
return ProjectItem(
|
||||
id=found_project.id,
|
||||
name=found_project.name,
|
||||
path=normalize_project_path(found_project.path),
|
||||
is_default=found_project.is_default or False,
|
||||
@@ -80,9 +81,17 @@ async def update_project(
|
||||
raise HTTPException(status_code=400, detail="Path must be absolute")
|
||||
|
||||
# Get original project info for the response
|
||||
old_project = await project_service.get_project(name)
|
||||
if not old_project:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Project '{name}' not found in configuration"
|
||||
)
|
||||
|
||||
old_project_info = ProjectItem(
|
||||
name=name,
|
||||
path=project_service.projects.get(name, ""),
|
||||
id=old_project.id,
|
||||
name=old_project.name,
|
||||
path=old_project.path,
|
||||
is_default=old_project.is_default or False,
|
||||
)
|
||||
|
||||
if path:
|
||||
@@ -91,14 +100,21 @@ async def update_project(
|
||||
await project_service.update_project(name, is_active=is_active)
|
||||
|
||||
# Get updated project info
|
||||
updated_path = path if path else project_service.projects.get(name, "")
|
||||
updated_project = await project_service.get_project(name)
|
||||
if not updated_project:
|
||||
raise HTTPException(status_code=404, detail=f"Project '{name}' not found after update")
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{name}' updated successfully",
|
||||
status="success",
|
||||
default=(name == project_service.default_project),
|
||||
old_project=old_project_info,
|
||||
new_project=ProjectItem(name=name, path=updated_path),
|
||||
new_project=ProjectItem(
|
||||
id=updated_project.id,
|
||||
name=updated_project.name,
|
||||
path=updated_project.path,
|
||||
is_default=updated_project.is_default or False,
|
||||
),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -186,6 +202,7 @@ async def list_projects(
|
||||
|
||||
project_items = [
|
||||
ProjectItem(
|
||||
id=project.id,
|
||||
name=project.name,
|
||||
path=normalize_project_path(project.path),
|
||||
is_default=project.is_default or False,
|
||||
@@ -232,6 +249,7 @@ async def add_project(
|
||||
status="success",
|
||||
default=existing_project.is_default or False,
|
||||
new_project=ProjectItem(
|
||||
id=existing_project.id,
|
||||
name=existing_project.name,
|
||||
path=existing_project.path,
|
||||
is_default=existing_project.is_default or False,
|
||||
@@ -250,12 +268,20 @@ async def add_project(
|
||||
project_data.name, project_data.path, set_default=project_data.set_default
|
||||
)
|
||||
|
||||
# Fetch the newly created project to get its ID
|
||||
new_project = await project_service.get_project(project_data.name)
|
||||
if not new_project:
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve newly created project")
|
||||
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message=f"Project '{project_data.name}' added successfully",
|
||||
status="success",
|
||||
default=project_data.set_default,
|
||||
new_project=ProjectItem(
|
||||
name=project_data.name, path=project_data.path, is_default=project_data.set_default
|
||||
id=new_project.id,
|
||||
name=new_project.name,
|
||||
path=new_project.path,
|
||||
is_default=new_project.is_default or False,
|
||||
),
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
@@ -306,7 +332,12 @@ async def remove_project(
|
||||
message=f"Project '{name}' removed successfully",
|
||||
status="success",
|
||||
default=False,
|
||||
old_project=ProjectItem(name=old_project.name, path=old_project.path),
|
||||
old_project=ProjectItem(
|
||||
id=old_project.id,
|
||||
name=old_project.name,
|
||||
path=old_project.path,
|
||||
is_default=old_project.is_default or False,
|
||||
),
|
||||
new_project=None,
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
@@ -349,8 +380,14 @@ async def set_default_project(
|
||||
message=f"Project '{name}' set as default successfully",
|
||||
status="success",
|
||||
default=True,
|
||||
old_project=ProjectItem(name=default_name, path=default_project.path),
|
||||
old_project=ProjectItem(
|
||||
id=default_project.id,
|
||||
name=default_name,
|
||||
path=default_project.path,
|
||||
is_default=False,
|
||||
),
|
||||
new_project=ProjectItem(
|
||||
id=new_default_project.id,
|
||||
name=name,
|
||||
path=new_default_project.path,
|
||||
is_default=True,
|
||||
@@ -378,7 +415,12 @@ async def get_default_project(
|
||||
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
|
||||
)
|
||||
|
||||
return ProjectItem(name=default_project.name, path=default_project.path, is_default=True)
|
||||
return ProjectItem(
|
||||
id=default_project.id,
|
||||
name=default_project.name,
|
||||
path=default_project.path,
|
||||
is_default=True,
|
||||
)
|
||||
|
||||
|
||||
# Synchronize projects between config and database
|
||||
|
||||
@@ -29,6 +29,7 @@ async def to_graph_context(
|
||||
match item.type:
|
||||
case SearchItemType.ENTITY:
|
||||
return EntitySummary(
|
||||
entity_id=item.id,
|
||||
title=item.title, # pyright: ignore
|
||||
permalink=item.permalink,
|
||||
content=item.content,
|
||||
@@ -37,6 +38,8 @@ async def to_graph_context(
|
||||
)
|
||||
case SearchItemType.OBSERVATION:
|
||||
return ObservationSummary(
|
||||
observation_id=item.id,
|
||||
entity_id=item.entity_id, # pyright: ignore
|
||||
title=item.title, # pyright: ignore
|
||||
file_path=item.file_path,
|
||||
category=item.category, # pyright: ignore
|
||||
@@ -48,12 +51,16 @@ async def to_graph_context(
|
||||
from_entity = await entity_repository.find_by_id(item.from_id) # pyright: ignore
|
||||
to_entity = await entity_repository.find_by_id(item.to_id) if item.to_id else None
|
||||
return RelationSummary(
|
||||
relation_id=item.id,
|
||||
entity_id=item.entity_id, # pyright: ignore
|
||||
title=item.title, # pyright: ignore
|
||||
file_path=item.file_path,
|
||||
permalink=item.permalink, # pyright: ignore
|
||||
relation_type=item.relation_type, # pyright: ignore
|
||||
from_entity=from_entity.title if from_entity else None,
|
||||
from_entity_id=item.from_id, # pyright: ignore
|
||||
to_entity=to_entity.title if to_entity else None,
|
||||
to_entity_id=item.to_id,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case _: # pragma: no cover
|
||||
@@ -111,6 +118,21 @@ async def to_search_results(entity_service: EntityService, results: List[SearchI
|
||||
search_results = []
|
||||
for r in results:
|
||||
entities = await entity_service.get_entities_by_id([r.entity_id, r.from_id, r.to_id]) # pyright: ignore
|
||||
|
||||
# Determine which IDs to set based on type
|
||||
entity_id = None
|
||||
observation_id = None
|
||||
relation_id = None
|
||||
|
||||
if r.type == SearchItemType.ENTITY:
|
||||
entity_id = r.id
|
||||
elif r.type == SearchItemType.OBSERVATION:
|
||||
observation_id = r.id
|
||||
entity_id = r.entity_id # Parent entity
|
||||
elif r.type == SearchItemType.RELATION:
|
||||
relation_id = r.id
|
||||
entity_id = r.entity_id # Parent entity
|
||||
|
||||
search_results.append(
|
||||
SearchResult(
|
||||
title=r.title, # pyright: ignore
|
||||
@@ -121,6 +143,9 @@ async def to_search_results(entity_service: EntityService, results: List[SearchI
|
||||
content=r.content,
|
||||
file_path=r.file_path,
|
||||
metadata=r.metadata,
|
||||
entity_id=entity_id,
|
||||
observation_id=observation_id,
|
||||
relation_id=relation_id,
|
||||
category=r.category,
|
||||
from_entity=entities[0].permalink if entities else None,
|
||||
to_entity=entities[1].permalink if len(entities) > 1 else None,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""API v2 module - ID-based entity references.
|
||||
|
||||
Version 2 of the Basic Memory API uses integer entity IDs as the primary
|
||||
identifier for improved performance and stability.
|
||||
|
||||
Key changes from v1:
|
||||
- Entity lookups use integer IDs instead of paths/permalinks
|
||||
- Direct database queries instead of cascading resolution
|
||||
- Stable references that don't change with file moves
|
||||
- Better caching support
|
||||
|
||||
All v2 routers are registered with the /v2 prefix.
|
||||
"""
|
||||
|
||||
from basic_memory.api.v2.routers import (
|
||||
knowledge_router,
|
||||
memory_router,
|
||||
project_router,
|
||||
resource_router,
|
||||
search_router,
|
||||
directory_router,
|
||||
prompt_router,
|
||||
importer_router,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"knowledge_router",
|
||||
"memory_router",
|
||||
"project_router",
|
||||
"resource_router",
|
||||
"search_router",
|
||||
"directory_router",
|
||||
"prompt_router",
|
||||
"importer_router",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""V2 API routers."""
|
||||
|
||||
from basic_memory.api.v2.routers.knowledge_router import router as knowledge_router
|
||||
from basic_memory.api.v2.routers.project_router import router as project_router
|
||||
from basic_memory.api.v2.routers.memory_router import router as memory_router
|
||||
from basic_memory.api.v2.routers.search_router import router as search_router
|
||||
from basic_memory.api.v2.routers.resource_router import router as resource_router
|
||||
from basic_memory.api.v2.routers.directory_router import router as directory_router
|
||||
from basic_memory.api.v2.routers.prompt_router import router as prompt_router
|
||||
from basic_memory.api.v2.routers.importer_router import router as importer_router
|
||||
|
||||
__all__ = [
|
||||
"knowledge_router",
|
||||
"project_router",
|
||||
"memory_router",
|
||||
"search_router",
|
||||
"resource_router",
|
||||
"directory_router",
|
||||
"prompt_router",
|
||||
"importer_router",
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""V2 Directory Router - ID-based directory tree operations.
|
||||
|
||||
This router provides directory structure browsing for projects using
|
||||
integer project IDs instead of name-based identifiers.
|
||||
|
||||
Key improvements:
|
||||
- Direct project lookup via integer primary keys
|
||||
- Consistent with other v2 endpoints
|
||||
- Better performance through indexed queries
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from basic_memory.deps import DirectoryServiceV2Dep, ProjectIdPathDep
|
||||
from basic_memory.schemas.directory import DirectoryNode
|
||||
|
||||
router = APIRouter(prefix="/directory", tags=["directory-v2"])
|
||||
|
||||
|
||||
@router.get("/tree", response_model=DirectoryNode, response_model_exclude_none=True)
|
||||
async def get_directory_tree(
|
||||
directory_service: DirectoryServiceV2Dep,
|
||||
project_id: ProjectIdPathDep,
|
||||
):
|
||||
"""Get hierarchical directory structure from the knowledge base.
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: Numeric project ID
|
||||
|
||||
Returns:
|
||||
DirectoryNode representing the root of the hierarchical tree structure
|
||||
"""
|
||||
# Get a hierarchical directory tree for the specific project
|
||||
tree = await directory_service.get_directory_tree()
|
||||
|
||||
# Return the hierarchical tree
|
||||
return tree
|
||||
|
||||
|
||||
@router.get("/structure", response_model=DirectoryNode, response_model_exclude_none=True)
|
||||
async def get_directory_structure(
|
||||
directory_service: DirectoryServiceV2Dep,
|
||||
project_id: ProjectIdPathDep,
|
||||
):
|
||||
"""Get folder structure for navigation (no files).
|
||||
|
||||
Optimized endpoint for folder tree navigation. Returns only directory nodes
|
||||
without file metadata. For full tree with files, use /directory/tree.
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: Numeric project ID
|
||||
|
||||
Returns:
|
||||
DirectoryNode tree containing only folders (type="directory")
|
||||
"""
|
||||
structure = await directory_service.get_directory_structure()
|
||||
return structure
|
||||
|
||||
|
||||
@router.get("/list", response_model=List[DirectoryNode], response_model_exclude_none=True)
|
||||
async def list_directory(
|
||||
directory_service: DirectoryServiceV2Dep,
|
||||
project_id: ProjectIdPathDep,
|
||||
dir_name: str = Query("/", description="Directory path to list"),
|
||||
depth: int = Query(1, ge=1, le=10, description="Recursion depth (1-10)"),
|
||||
file_name_glob: Optional[str] = Query(
|
||||
None, description="Glob pattern for filtering file names"
|
||||
),
|
||||
):
|
||||
"""List directory contents with filtering and depth control.
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: Numeric project ID
|
||||
dir_name: Directory path to list (default: root "/")
|
||||
depth: Recursion depth (1-10, default: 1 for immediate children only)
|
||||
file_name_glob: Optional glob pattern for filtering file names (e.g., "*.md", "*meeting*")
|
||||
|
||||
Returns:
|
||||
List of DirectoryNode objects matching the criteria
|
||||
"""
|
||||
# Get directory listing with filtering
|
||||
nodes = await directory_service.list_directory(
|
||||
dir_name=dir_name,
|
||||
depth=depth,
|
||||
file_name_glob=file_name_glob,
|
||||
)
|
||||
|
||||
return nodes
|
||||
@@ -0,0 +1,182 @@
|
||||
"""V2 Import Router - ID-based data import operations.
|
||||
|
||||
This router uses v2 dependencies for consistent project ID handling.
|
||||
Import endpoints use project_id in the path for consistency with other v2 endpoints.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, UploadFile, status
|
||||
|
||||
from basic_memory.deps import (
|
||||
ChatGPTImporterV2Dep,
|
||||
ClaudeConversationsImporterV2Dep,
|
||||
ClaudeProjectsImporterV2Dep,
|
||||
MemoryJsonImporterV2Dep,
|
||||
ProjectIdPathDep,
|
||||
)
|
||||
from basic_memory.importers import Importer
|
||||
from basic_memory.schemas.importer import (
|
||||
ChatImportResult,
|
||||
EntityImportResult,
|
||||
ProjectImportResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/import", tags=["import-v2"])
|
||||
|
||||
|
||||
@router.post("/chatgpt", response_model=ChatImportResult)
|
||||
async def import_chatgpt(
|
||||
project_id: ProjectIdPathDep,
|
||||
importer: ChatGPTImporterV2Dep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("conversations"),
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from ChatGPT JSON export.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
file: The ChatGPT conversations.json file.
|
||||
folder: The folder to place the files in.
|
||||
importer: ChatGPT importer instance.
|
||||
|
||||
Returns:
|
||||
ChatImportResult with import statistics.
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
logger.info(f"V2 Importing ChatGPT conversations for project {project_id}")
|
||||
return await import_file(importer, file, folder)
|
||||
|
||||
|
||||
@router.post("/claude/conversations", response_model=ChatImportResult)
|
||||
async def import_claude_conversations(
|
||||
project_id: ProjectIdPathDep,
|
||||
importer: ClaudeConversationsImporterV2Dep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("conversations"),
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from Claude conversations.json export.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
file: The Claude conversations.json file.
|
||||
folder: The folder to place the files in.
|
||||
importer: Claude conversations importer instance.
|
||||
|
||||
Returns:
|
||||
ChatImportResult with import statistics.
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
logger.info(f"V2 Importing Claude conversations for project {project_id}")
|
||||
return await import_file(importer, file, folder)
|
||||
|
||||
|
||||
@router.post("/claude/projects", response_model=ProjectImportResult)
|
||||
async def import_claude_projects(
|
||||
project_id: ProjectIdPathDep,
|
||||
importer: ClaudeProjectsImporterV2Dep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("projects"),
|
||||
) -> ProjectImportResult:
|
||||
"""Import projects from Claude projects.json export.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
file: The Claude projects.json file.
|
||||
folder: The base folder to place the files in.
|
||||
importer: Claude projects importer instance.
|
||||
|
||||
Returns:
|
||||
ProjectImportResult with import statistics.
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
logger.info(f"V2 Importing Claude projects for project {project_id}")
|
||||
return await import_file(importer, file, folder)
|
||||
|
||||
|
||||
@router.post("/memory-json", response_model=EntityImportResult)
|
||||
async def import_memory_json(
|
||||
project_id: ProjectIdPathDep,
|
||||
importer: MemoryJsonImporterV2Dep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("conversations"),
|
||||
) -> EntityImportResult:
|
||||
"""Import entities and relations from a memory.json file.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
file: The memory.json file.
|
||||
folder: Optional destination folder within the project.
|
||||
importer: Memory JSON importer instance.
|
||||
|
||||
Returns:
|
||||
EntityImportResult with import statistics.
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
logger.info(f"V2 Importing memory.json for project {project_id}")
|
||||
try:
|
||||
file_data = []
|
||||
file_bytes = await file.read()
|
||||
file_str = file_bytes.decode("utf-8")
|
||||
for line in file_str.splitlines():
|
||||
json_data = json.loads(line)
|
||||
file_data.append(json_data)
|
||||
|
||||
result = await importer.import_data(file_data, folder)
|
||||
if not result.success: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=result.error_message or "Import failed",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("V2 Import failed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Import failed: {str(e)}",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def import_file(importer: Importer, file: UploadFile, destination_folder: str):
|
||||
"""Helper function to import a file using an importer instance.
|
||||
|
||||
Args:
|
||||
importer: The importer instance to use
|
||||
file: The file to import
|
||||
destination_folder: Destination folder for imported content
|
||||
|
||||
Returns:
|
||||
Import result from the importer
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails
|
||||
"""
|
||||
try:
|
||||
# Process file
|
||||
json_data = json.load(file.file)
|
||||
result = await importer.import_data(json_data, destination_folder)
|
||||
if not result.success: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=result.error_message or "Import failed",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("V2 Import failed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Import failed: {str(e)}",
|
||||
)
|
||||
@@ -0,0 +1,415 @@
|
||||
"""V2 Knowledge Router - ID-based entity operations.
|
||||
|
||||
This router provides ID-based CRUD operations for entities, replacing the
|
||||
path-based identifiers used in v1 with direct integer ID lookups.
|
||||
|
||||
Key improvements:
|
||||
- Direct database lookups via integer primary keys
|
||||
- Stable references that don't change with file moves
|
||||
- Better performance through indexed queries
|
||||
- Simplified caching strategies
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Response
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import (
|
||||
EntityServiceV2Dep,
|
||||
SearchServiceV2Dep,
|
||||
LinkResolverV2Dep,
|
||||
ProjectConfigV2Dep,
|
||||
AppConfigDep,
|
||||
SyncServiceV2Dep,
|
||||
EntityRepositoryV2Dep,
|
||||
ProjectIdPathDep,
|
||||
)
|
||||
from basic_memory.schemas import DeleteEntitiesResponse
|
||||
from basic_memory.schemas.base import Entity
|
||||
from basic_memory.schemas.request import EditEntityRequest
|
||||
from basic_memory.schemas.v2 import (
|
||||
EntityResolveRequest,
|
||||
EntityResolveResponse,
|
||||
EntityResponseV2,
|
||||
MoveEntityRequestV2,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/knowledge", tags=["knowledge-v2"])
|
||||
|
||||
|
||||
async def resolve_relations_background(sync_service, entity_id: int, entity_permalink: str) -> None:
|
||||
"""Background task to resolve relations for a specific entity.
|
||||
|
||||
This runs asynchronously after the API response is sent, preventing
|
||||
long delays when creating entities with many relations.
|
||||
"""
|
||||
try:
|
||||
# Only resolve relations for the newly created entity
|
||||
await sync_service.resolve_relations(entity_id=entity_id)
|
||||
logger.debug(
|
||||
f"Background: Resolved relations for entity {entity_permalink} (id={entity_id})"
|
||||
)
|
||||
except Exception as e:
|
||||
# Log but don't fail - this is a background task
|
||||
logger.warning(
|
||||
f"Background: Failed to resolve relations for entity {entity_permalink}: {e}"
|
||||
)
|
||||
|
||||
|
||||
## Resolution endpoint
|
||||
|
||||
|
||||
@router.post("/resolve", response_model=EntityResolveResponse)
|
||||
async def resolve_identifier(
|
||||
project_id: ProjectIdPathDep,
|
||||
data: EntityResolveRequest,
|
||||
link_resolver: LinkResolverV2Dep,
|
||||
) -> EntityResolveResponse:
|
||||
"""Resolve a string identifier (permalink, title, or path) to an entity ID.
|
||||
|
||||
This endpoint provides a bridge between v1-style identifiers and v2 entity IDs.
|
||||
Use this to convert existing references to the new ID-based format.
|
||||
|
||||
Args:
|
||||
data: Request containing the identifier to resolve
|
||||
|
||||
Returns:
|
||||
Entity ID and metadata about how it was resolved
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if identifier cannot be resolved
|
||||
|
||||
Example:
|
||||
POST /v2/{project}/knowledge/resolve
|
||||
{"identifier": "specs/search"}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"entity_id": 123,
|
||||
"permalink": "specs/search",
|
||||
"file_path": "specs/search.md",
|
||||
"title": "Search Specification",
|
||||
"resolution_method": "permalink"
|
||||
}
|
||||
"""
|
||||
logger.info(f"API v2 request: resolve_identifier for '{data.identifier}'")
|
||||
|
||||
# Try to resolve the identifier
|
||||
entity = await link_resolver.resolve_link(data.identifier)
|
||||
if not entity:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Could not resolve identifier: '{data.identifier}'"
|
||||
)
|
||||
|
||||
# Determine resolution method
|
||||
resolution_method = "search" # default
|
||||
if data.identifier.isdigit():
|
||||
resolution_method = "id"
|
||||
elif entity.permalink == data.identifier:
|
||||
resolution_method = "permalink"
|
||||
elif entity.title == data.identifier:
|
||||
resolution_method = "title"
|
||||
elif entity.file_path == data.identifier:
|
||||
resolution_method = "path"
|
||||
|
||||
result = EntityResolveResponse(
|
||||
entity_id=entity.id,
|
||||
permalink=entity.permalink,
|
||||
file_path=entity.file_path,
|
||||
title=entity.title,
|
||||
resolution_method=resolution_method,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: resolved '{data.identifier}' to entity_id={result.entity_id} via {resolution_method}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
## Read endpoints
|
||||
|
||||
|
||||
@router.get("/entities/{entity_id}", response_model=EntityResponseV2)
|
||||
async def get_entity_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
) -> EntityResponseV2:
|
||||
"""Get an entity by its numeric ID.
|
||||
|
||||
This is the primary entity retrieval method in v2, using direct database
|
||||
lookups for maximum performance.
|
||||
|
||||
Args:
|
||||
entity_id: Numeric entity ID
|
||||
|
||||
Returns:
|
||||
Complete entity with observations and relations
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if entity not found
|
||||
"""
|
||||
logger.info(f"API v2 request: get_entity_by_id entity_id={entity_id}")
|
||||
|
||||
entity = await entity_repository.get_by_id(entity_id)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
||||
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
logger.info(f"API v2 response: entity_id={entity_id}, title='{result.title}'")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
## Create endpoints
|
||||
|
||||
|
||||
@router.post("/entities", response_model=EntityResponseV2)
|
||||
async def create_entity(
|
||||
project_id: ProjectIdPathDep,
|
||||
data: Entity,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
) -> EntityResponseV2:
|
||||
"""Create a new entity.
|
||||
|
||||
Args:
|
||||
data: Entity data to create
|
||||
|
||||
Returns:
|
||||
Created entity with generated ID
|
||||
"""
|
||||
logger.info(
|
||||
"API v2 request", endpoint="create_entity", entity_type=data.entity_type, title=data.title
|
||||
)
|
||||
|
||||
entity = await entity_service.create_entity(data)
|
||||
|
||||
# reindex
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: endpoint='create_entity' id={entity.id}, title={result.title}, permalink={result.permalink}, status_code=201"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
## Update endpoints
|
||||
|
||||
|
||||
@router.put("/entities/{entity_id}", response_model=EntityResponseV2)
|
||||
async def update_entity_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
data: Entity,
|
||||
response: Response,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
sync_service: SyncServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
) -> EntityResponseV2:
|
||||
"""Update an entity by ID.
|
||||
|
||||
If the entity doesn't exist, it will be created (upsert behavior).
|
||||
|
||||
Args:
|
||||
entity_id: Numeric entity ID
|
||||
data: Updated entity data
|
||||
|
||||
Returns:
|
||||
Updated entity
|
||||
"""
|
||||
logger.info(f"API v2 request: update_entity_by_id entity_id={entity_id}")
|
||||
|
||||
# Check if entity exists
|
||||
existing = await entity_repository.get_by_id(entity_id)
|
||||
created = existing is None
|
||||
|
||||
# Perform update or create
|
||||
entity, _ = await entity_service.create_or_update_entity(data)
|
||||
response.status_code = 201 if created else 200
|
||||
|
||||
# reindex
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
# Schedule relation resolution for new entities
|
||||
if created:
|
||||
background_tasks.add_task(
|
||||
resolve_relations_background, sync_service, entity.id, entity.permalink or ""
|
||||
)
|
||||
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: entity_id={entity_id}, created={created}, status_code={response.status_code}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.patch("/entities/{entity_id}", response_model=EntityResponseV2)
|
||||
async def edit_entity_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
data: EditEntityRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
) -> EntityResponseV2:
|
||||
"""Edit an existing entity by ID using operations like append, prepend, etc.
|
||||
|
||||
Args:
|
||||
entity_id: Numeric entity ID
|
||||
data: Edit operation details
|
||||
|
||||
Returns:
|
||||
Updated entity
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if entity not found, 400 if edit fails
|
||||
"""
|
||||
logger.info(
|
||||
f"API v2 request: edit_entity_by_id entity_id={entity_id}, operation='{data.operation}'"
|
||||
)
|
||||
|
||||
# Verify entity exists
|
||||
entity = await entity_repository.get_by_id(entity_id)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
||||
|
||||
try:
|
||||
# Edit using the entity's permalink or path
|
||||
identifier = entity.permalink or entity.file_path
|
||||
updated_entity = await entity_service.edit_entity(
|
||||
identifier=identifier,
|
||||
operation=data.operation,
|
||||
content=data.content,
|
||||
section=data.section,
|
||||
find_text=data.find_text,
|
||||
expected_replacements=data.expected_replacements,
|
||||
)
|
||||
|
||||
# Reindex
|
||||
await search_service.index_entity(updated_entity, background_tasks=background_tasks)
|
||||
|
||||
result = EntityResponseV2.model_validate(updated_entity)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: entity_id={entity_id}, operation='{data.operation}', status_code=200"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error editing entity {entity_id}: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
## Delete endpoints
|
||||
|
||||
|
||||
@router.delete("/entities/{entity_id}", response_model=DeleteEntitiesResponse)
|
||||
async def delete_entity_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
search_service=Depends(lambda: None), # Optional for now
|
||||
) -> DeleteEntitiesResponse:
|
||||
"""Delete an entity by ID.
|
||||
|
||||
Args:
|
||||
entity_id: Numeric entity ID
|
||||
|
||||
Returns:
|
||||
Deletion status
|
||||
|
||||
Note: Returns deleted=False if entity doesn't exist (idempotent)
|
||||
"""
|
||||
logger.info(f"API v2 request: delete_entity_by_id entity_id={entity_id}")
|
||||
|
||||
entity = await entity_repository.get_by_id(entity_id)
|
||||
if entity is None:
|
||||
logger.info(f"API v2 response: entity_id={entity_id} not found, deleted=False")
|
||||
return DeleteEntitiesResponse(deleted=False)
|
||||
|
||||
# Delete the entity
|
||||
deleted = await entity_service.delete_entity(entity_id)
|
||||
|
||||
# Remove from search index if search service available
|
||||
if search_service:
|
||||
background_tasks.add_task(search_service.handle_delete, entity)
|
||||
|
||||
logger.info(f"API v2 response: entity_id={entity_id}, deleted={deleted}")
|
||||
|
||||
return DeleteEntitiesResponse(deleted=deleted)
|
||||
|
||||
|
||||
## Move endpoint
|
||||
|
||||
|
||||
@router.put("/entities/{entity_id}/move", response_model=EntityResponseV2)
|
||||
async def move_entity(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
data: MoveEntityRequestV2,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
project_config: ProjectConfigV2Dep,
|
||||
app_config: AppConfigDep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
) -> EntityResponseV2:
|
||||
"""Move an entity to a new file location.
|
||||
|
||||
V2 API uses entity ID in the URL path for stable references.
|
||||
The entity ID will remain stable after the move.
|
||||
|
||||
Args:
|
||||
project_id: Project ID from URL path
|
||||
entity_id: Entity ID from URL path (primary identifier)
|
||||
data: Move request with destination path only
|
||||
|
||||
Returns:
|
||||
Updated entity with new file path
|
||||
"""
|
||||
logger.info(
|
||||
f"API v2 request: move_entity entity_id={entity_id}, destination='{data.destination_path}'"
|
||||
)
|
||||
|
||||
try:
|
||||
# First, get the entity by ID to verify it exists
|
||||
entity = await entity_repository.find_by_id(entity_id)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity not found: {entity_id}")
|
||||
|
||||
# Move the entity using its current file path as identifier
|
||||
moved_entity = await entity_service.move_entity(
|
||||
identifier=entity.file_path, # Use file path for resolution
|
||||
destination_path=data.destination_path,
|
||||
project_config=project_config,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
# Reindex at new location
|
||||
reindexed_entity = await entity_service.link_resolver.resolve_link(data.destination_path)
|
||||
if reindexed_entity:
|
||||
await search_service.index_entity(reindexed_entity, background_tasks=background_tasks)
|
||||
|
||||
result = EntityResponseV2.model_validate(moved_entity)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: moved entity_id={moved_entity.id} to '{data.destination_path}'"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving entity: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -0,0 +1,130 @@
|
||||
"""V2 routes for memory:// URI operations.
|
||||
|
||||
This router uses integer project IDs for stable, efficient routing.
|
||||
V1 uses string-based project names which are less efficient and less stable.
|
||||
"""
|
||||
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import ContextServiceV2Dep, EntityRepositoryV2Dep, ProjectIdPathDep
|
||||
from basic_memory.schemas.base import TimeFrame, parse_timeframe
|
||||
from basic_memory.schemas.memory import (
|
||||
GraphContext,
|
||||
normalize_memory_url,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
from basic_memory.api.routers.utils import to_graph_context
|
||||
|
||||
# Note: No prefix here - it's added during registration as /v2/{project_id}/memory
|
||||
router = APIRouter(tags=["memory"])
|
||||
|
||||
|
||||
@router.get("/memory/recent", response_model=GraphContext)
|
||||
async def recent(
|
||||
project_id: ProjectIdPathDep,
|
||||
context_service: ContextServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
type: Annotated[list[SearchItemType] | None, Query()] = None,
|
||||
depth: int = 1,
|
||||
timeframe: TimeFrame = "7d",
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get recent activity context for a project.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
context_service: Context service scoped to project
|
||||
entity_repository: Entity repository scoped to project
|
||||
type: Types of items to include (entities, relations, observations)
|
||||
depth: How many levels of related entities to include
|
||||
timeframe: Time window for recent activity (e.g., "7d", "1 week")
|
||||
page: Page number for pagination
|
||||
page_size: Number of items per page
|
||||
max_related: Maximum related entities to include per item
|
||||
|
||||
Returns:
|
||||
GraphContext with recent activity and related entities
|
||||
"""
|
||||
# return all types by default
|
||||
types = (
|
||||
[SearchItemType.ENTITY, SearchItemType.RELATION, SearchItemType.OBSERVATION]
|
||||
if not type
|
||||
else type
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"V2 Getting recent context for project {project_id}: `{types}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
||||
)
|
||||
# Parse timeframe
|
||||
since = parse_timeframe(timeframe)
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(
|
||||
types=types, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
|
||||
)
|
||||
recent_context = await to_graph_context(
|
||||
context, entity_repository=entity_repository, page=page, page_size=page_size
|
||||
)
|
||||
logger.debug(f"V2 Recent context: {recent_context.model_dump_json()}")
|
||||
return recent_context
|
||||
|
||||
|
||||
# get_memory_context needs to be declared last so other paths can match
|
||||
|
||||
|
||||
@router.get("/memory/{uri:path}", response_model=GraphContext)
|
||||
async def get_memory_context(
|
||||
project_id: ProjectIdPathDep,
|
||||
context_service: ContextServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
uri: str,
|
||||
depth: int = 1,
|
||||
timeframe: Optional[TimeFrame] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get rich context from memory:// URI.
|
||||
|
||||
V2 supports both legacy path-based URIs and new ID-based URIs:
|
||||
- Legacy: memory://path/to/note
|
||||
- ID-based: memory://id/123 or memory://123
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
context_service: Context service scoped to project
|
||||
entity_repository: Entity repository scoped to project
|
||||
uri: Memory URI path (e.g., "id/123", "123", or "path/to/note")
|
||||
depth: How many levels of related entities to include
|
||||
timeframe: Optional time window for filtering related content
|
||||
page: Page number for pagination
|
||||
page_size: Number of items per page
|
||||
max_related: Maximum related entities to include
|
||||
|
||||
Returns:
|
||||
GraphContext with the entity and its related context
|
||||
"""
|
||||
logger.debug(
|
||||
f"V2 Getting context for project {project_id}, URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
||||
)
|
||||
memory_url = normalize_memory_url(uri)
|
||||
|
||||
# Parse timeframe
|
||||
since = parse_timeframe(timeframe) if timeframe else None
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(
|
||||
memory_url, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
|
||||
)
|
||||
return await to_graph_context(
|
||||
context, entity_repository=entity_repository, page=page, page_size=page_size
|
||||
)
|
||||
@@ -0,0 +1,264 @@
|
||||
"""V2 Project Router - ID-based project management operations.
|
||||
|
||||
This router provides ID-based CRUD operations for projects, replacing the
|
||||
name-based identifiers used in v1 with direct integer ID lookups.
|
||||
|
||||
Key improvements:
|
||||
- Direct database lookups via integer primary keys
|
||||
- Stable references that don't change with project renames
|
||||
- Better performance through indexed queries
|
||||
- Consistent with v2 entity operations
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Body, Query
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import (
|
||||
ProjectServiceDep,
|
||||
ProjectRepositoryDep,
|
||||
ProjectIdPathDep,
|
||||
)
|
||||
from basic_memory.schemas.project_info import (
|
||||
ProjectItem,
|
||||
ProjectStatusResponse,
|
||||
)
|
||||
from basic_memory.utils import normalize_project_path
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["project_management-v2"])
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectItem)
|
||||
async def get_project_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> ProjectItem:
|
||||
"""Get project by its numeric ID.
|
||||
|
||||
This is the primary project retrieval method in v2, using direct database
|
||||
lookups for maximum performance.
|
||||
|
||||
Args:
|
||||
project_id: Numeric project ID
|
||||
|
||||
Returns:
|
||||
Project information
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if project not found
|
||||
|
||||
Example:
|
||||
GET /v2/projects/3
|
||||
"""
|
||||
logger.info(f"API v2 request: get_project_by_id for project_id={project_id}")
|
||||
|
||||
project = await project_repository.get_by_id(project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found")
|
||||
|
||||
return ProjectItem(
|
||||
id=project.id,
|
||||
name=project.name,
|
||||
path=normalize_project_path(project.path),
|
||||
is_default=project.is_default or False,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{project_id}", response_model=ProjectStatusResponse)
|
||||
async def update_project_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
project_service: ProjectServiceDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
path: Optional[str] = Body(None, description="New absolute path for the project"),
|
||||
is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Update a project's information by ID.
|
||||
|
||||
Args:
|
||||
project_id: Numeric project ID
|
||||
path: Optional new absolute path for the project
|
||||
is_active: Optional status update for the project
|
||||
|
||||
Returns:
|
||||
Response confirming the project was updated
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if validation fails, 404 if project not found
|
||||
|
||||
Example:
|
||||
PATCH /v2/projects/3
|
||||
{"path": "/new/path"}
|
||||
"""
|
||||
logger.info(f"API v2 request: update_project_by_id for project_id={project_id}")
|
||||
|
||||
try:
|
||||
# Validate that path is absolute if provided
|
||||
if path and not os.path.isabs(path):
|
||||
raise HTTPException(status_code=400, detail="Path must be absolute")
|
||||
|
||||
# Get original project info for the response
|
||||
old_project = await project_repository.get_by_id(project_id)
|
||||
if not old_project:
|
||||
raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found")
|
||||
|
||||
old_project_info = ProjectItem(
|
||||
id=old_project.id,
|
||||
name=old_project.name,
|
||||
path=old_project.path,
|
||||
is_default=old_project.is_default or False,
|
||||
)
|
||||
|
||||
# Update using project name (service layer still uses names internally)
|
||||
if path:
|
||||
await project_service.move_project(old_project.name, path)
|
||||
elif is_active is not None:
|
||||
await project_service.update_project(old_project.name, is_active=is_active)
|
||||
|
||||
# Get updated project info
|
||||
updated_project = await project_repository.get_by_id(project_id)
|
||||
if not updated_project:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Project with ID {project_id} not found after update"
|
||||
)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{updated_project.name}' updated successfully",
|
||||
status="success",
|
||||
default=(old_project.name == project_service.default_project),
|
||||
old_project=old_project_info,
|
||||
new_project=ProjectItem(
|
||||
id=updated_project.id,
|
||||
name=updated_project.name,
|
||||
path=updated_project.path,
|
||||
is_default=updated_project.is_default or False,
|
||||
),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{project_id}", response_model=ProjectStatusResponse)
|
||||
async def delete_project_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
project_service: ProjectServiceDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
delete_notes: bool = Query(
|
||||
False, description="If True, delete project directory from filesystem"
|
||||
),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Delete a project by ID.
|
||||
|
||||
Args:
|
||||
project_id: Numeric project ID
|
||||
delete_notes: If True, delete the project directory from the filesystem
|
||||
|
||||
Returns:
|
||||
Response confirming the project was deleted
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if trying to delete default project, 404 if not found
|
||||
|
||||
Example:
|
||||
DELETE /v2/projects/3?delete_notes=false
|
||||
"""
|
||||
logger.info(
|
||||
f"API v2 request: delete_project_by_id for project_id={project_id}, delete_notes={delete_notes}"
|
||||
)
|
||||
|
||||
try:
|
||||
old_project = await project_repository.get_by_id(project_id)
|
||||
if not old_project:
|
||||
raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found")
|
||||
|
||||
# Check if trying to delete the default project
|
||||
if old_project.name == project_service.default_project:
|
||||
available_projects = await project_service.list_projects()
|
||||
other_projects = [p.name for p in available_projects if p.id != project_id]
|
||||
detail = f"Cannot delete default project '{old_project.name}'. "
|
||||
if other_projects:
|
||||
detail += (
|
||||
f"Set another project as default first. Available: {', '.join(other_projects)}"
|
||||
)
|
||||
else:
|
||||
detail += "This is the only project in your configuration."
|
||||
raise HTTPException(status_code=400, detail=detail)
|
||||
|
||||
# Delete using project name (service layer still uses names internally)
|
||||
await project_service.remove_project(old_project.name, delete_notes=delete_notes)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{old_project.name}' removed successfully",
|
||||
status="success",
|
||||
default=False,
|
||||
old_project=ProjectItem(
|
||||
id=old_project.id,
|
||||
name=old_project.name,
|
||||
path=old_project.path,
|
||||
is_default=old_project.is_default or False,
|
||||
),
|
||||
new_project=None,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{project_id}/default", response_model=ProjectStatusResponse)
|
||||
async def set_default_project_by_id(
|
||||
project_id: ProjectIdPathDep,
|
||||
project_service: ProjectServiceDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> ProjectStatusResponse:
|
||||
"""Set a project as the default project by ID.
|
||||
|
||||
Args:
|
||||
project_id: Numeric project ID to set as default
|
||||
|
||||
Returns:
|
||||
Response confirming the project was set as default
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if project not found
|
||||
|
||||
Example:
|
||||
PUT /v2/projects/3/default
|
||||
"""
|
||||
logger.info(f"API v2 request: set_default_project_by_id for project_id={project_id}")
|
||||
|
||||
try:
|
||||
# Get the old default project
|
||||
default_name = project_service.default_project
|
||||
default_project = await project_service.get_project(default_name)
|
||||
if not default_project:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
|
||||
)
|
||||
|
||||
# Get the new default project
|
||||
new_default_project = await project_repository.get_by_id(project_id)
|
||||
if not new_default_project:
|
||||
raise HTTPException(status_code=404, detail=f"Project with ID {project_id} not found")
|
||||
|
||||
# Set as default using project name (service layer still uses names internally)
|
||||
await project_service.set_default_project(new_default_project.name)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{new_default_project.name}' set as default successfully",
|
||||
status="success",
|
||||
default=True,
|
||||
old_project=ProjectItem(
|
||||
id=default_project.id,
|
||||
name=default_name,
|
||||
path=default_project.path,
|
||||
is_default=False,
|
||||
),
|
||||
new_project=ProjectItem(
|
||||
id=new_default_project.id,
|
||||
name=new_default_project.name,
|
||||
path=new_default_project.path,
|
||||
is_default=True,
|
||||
),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -0,0 +1,270 @@
|
||||
"""V2 Prompt Router - ID-based prompt generation operations.
|
||||
|
||||
This router uses v2 dependencies for consistent project ID handling.
|
||||
Prompt endpoints are action-based (not resource-based), so they don't
|
||||
have entity IDs in URLs - they generate formatted prompts from queries.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.api.routers.utils import to_graph_context, to_search_results
|
||||
from basic_memory.api.template_loader import template_loader
|
||||
from basic_memory.schemas.base import parse_timeframe
|
||||
from basic_memory.deps import (
|
||||
ContextServiceV2Dep,
|
||||
EntityRepositoryV2Dep,
|
||||
SearchServiceV2Dep,
|
||||
EntityServiceV2Dep,
|
||||
ProjectIdPathDep,
|
||||
)
|
||||
from basic_memory.schemas.prompt import (
|
||||
ContinueConversationRequest,
|
||||
SearchPromptRequest,
|
||||
PromptResponse,
|
||||
PromptMetadata,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType, SearchQuery
|
||||
|
||||
router = APIRouter(prefix="/prompt", tags=["prompt-v2"])
|
||||
|
||||
|
||||
@router.post("/continue-conversation", response_model=PromptResponse)
|
||||
async def continue_conversation(
|
||||
project_id: ProjectIdPathDep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
context_service: ContextServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
request: ContinueConversationRequest,
|
||||
) -> PromptResponse:
|
||||
"""Generate a prompt for continuing a conversation.
|
||||
|
||||
This endpoint takes a topic and/or timeframe and generates a prompt with
|
||||
relevant context from the knowledge base.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
request: The request parameters
|
||||
|
||||
Returns:
|
||||
Formatted continuation prompt with context
|
||||
"""
|
||||
logger.info(
|
||||
f"V2 Generating continue conversation prompt for project {project_id}, "
|
||||
f"topic: {request.topic}, timeframe: {request.timeframe}"
|
||||
)
|
||||
|
||||
since = parse_timeframe(request.timeframe) if request.timeframe else None
|
||||
|
||||
# Initialize search results
|
||||
search_results = []
|
||||
|
||||
# Get data needed for template
|
||||
if request.topic:
|
||||
query = SearchQuery(text=request.topic, after_date=request.timeframe)
|
||||
results = await search_service.search(query, limit=request.search_items_limit)
|
||||
search_results = await to_search_results(entity_service, results)
|
||||
|
||||
# Build context from results
|
||||
all_hierarchical_results = []
|
||||
for result in search_results:
|
||||
if hasattr(result, "permalink") and result.permalink:
|
||||
# Get hierarchical context using the new dataclass-based approach
|
||||
context_result = await context_service.build_context(
|
||||
result.permalink,
|
||||
depth=request.depth,
|
||||
since=since,
|
||||
max_related=request.related_items_limit,
|
||||
include_observations=True, # Include observations for entities
|
||||
)
|
||||
|
||||
# Process results into the schema format
|
||||
graph_context = await to_graph_context(
|
||||
context_result, entity_repository=entity_repository
|
||||
)
|
||||
|
||||
# Add results to our collection (limit to top results for each permalink)
|
||||
if graph_context.results:
|
||||
all_hierarchical_results.extend(graph_context.results[:3])
|
||||
|
||||
# Limit to a reasonable number of total results
|
||||
all_hierarchical_results = all_hierarchical_results[:10]
|
||||
|
||||
template_context = {
|
||||
"topic": request.topic,
|
||||
"timeframe": request.timeframe,
|
||||
"hierarchical_results": all_hierarchical_results,
|
||||
"has_results": len(all_hierarchical_results) > 0,
|
||||
}
|
||||
else:
|
||||
# If no topic, get recent activity
|
||||
context_result = await context_service.build_context(
|
||||
types=[SearchItemType.ENTITY],
|
||||
depth=request.depth,
|
||||
since=since,
|
||||
max_related=request.related_items_limit,
|
||||
include_observations=True,
|
||||
)
|
||||
recent_context = await to_graph_context(context_result, entity_repository=entity_repository)
|
||||
|
||||
hierarchical_results = recent_context.results[:5] # Limit to top 5 recent items
|
||||
|
||||
template_context = {
|
||||
"topic": f"Recent Activity from ({request.timeframe})",
|
||||
"timeframe": request.timeframe,
|
||||
"hierarchical_results": hierarchical_results,
|
||||
"has_results": len(hierarchical_results) > 0,
|
||||
}
|
||||
|
||||
try:
|
||||
# Render template
|
||||
rendered_prompt = await template_loader.render(
|
||||
"prompts/continue_conversation.hbs", template_context
|
||||
)
|
||||
|
||||
# Calculate metadata
|
||||
# Count items of different types
|
||||
observation_count = 0
|
||||
relation_count = 0
|
||||
entity_count = 0
|
||||
|
||||
# Get the hierarchical results from the template context
|
||||
hierarchical_results_for_count = template_context.get("hierarchical_results", [])
|
||||
|
||||
# For topic-based search
|
||||
if request.topic:
|
||||
for item in hierarchical_results_for_count:
|
||||
if hasattr(item, "observations"):
|
||||
observation_count += len(item.observations) if item.observations else 0
|
||||
|
||||
if hasattr(item, "related_results"):
|
||||
for related in item.related_results or []:
|
||||
if hasattr(related, "type"):
|
||||
if related.type == "relation":
|
||||
relation_count += 1
|
||||
elif related.type == "entity": # pragma: no cover
|
||||
entity_count += 1 # pragma: no cover
|
||||
# For recent activity
|
||||
else:
|
||||
for item in hierarchical_results_for_count:
|
||||
if hasattr(item, "observations"):
|
||||
observation_count += len(item.observations) if item.observations else 0
|
||||
|
||||
if hasattr(item, "related_results"):
|
||||
for related in item.related_results or []:
|
||||
if hasattr(related, "type"):
|
||||
if related.type == "relation":
|
||||
relation_count += 1
|
||||
elif related.type == "entity": # pragma: no cover
|
||||
entity_count += 1 # pragma: no cover
|
||||
|
||||
# Build metadata
|
||||
metadata = {
|
||||
"query": request.topic,
|
||||
"timeframe": request.timeframe,
|
||||
"search_count": len(search_results)
|
||||
if request.topic
|
||||
else 0, # Original search results count
|
||||
"context_count": len(hierarchical_results_for_count),
|
||||
"observation_count": observation_count,
|
||||
"relation_count": relation_count,
|
||||
"total_items": (
|
||||
len(hierarchical_results_for_count)
|
||||
+ observation_count
|
||||
+ relation_count
|
||||
+ entity_count
|
||||
),
|
||||
"search_limit": request.search_items_limit,
|
||||
"context_depth": request.depth,
|
||||
"related_limit": request.related_items_limit,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
prompt_metadata = PromptMetadata(**metadata)
|
||||
|
||||
return PromptResponse(
|
||||
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error rendering continue conversation template: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error rendering prompt template: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/search", response_model=PromptResponse)
|
||||
async def search_prompt(
|
||||
project_id: ProjectIdPathDep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
request: SearchPromptRequest,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> PromptResponse:
|
||||
"""Generate a prompt for search results.
|
||||
|
||||
This endpoint takes a search query and formats the results into a helpful
|
||||
prompt with context and suggestions.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
request: The search parameters
|
||||
page: The page number for pagination
|
||||
page_size: The number of results per page, defaults to 10
|
||||
|
||||
Returns:
|
||||
Formatted search results prompt with context
|
||||
"""
|
||||
logger.info(
|
||||
f"V2 Generating search prompt for project {project_id}, "
|
||||
f"query: {request.query}, timeframe: {request.timeframe}"
|
||||
)
|
||||
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
query = SearchQuery(text=request.query, after_date=request.timeframe)
|
||||
results = await search_service.search(query, limit=limit, offset=offset)
|
||||
search_results = await to_search_results(entity_service, results)
|
||||
|
||||
template_context = {
|
||||
"query": request.query,
|
||||
"timeframe": request.timeframe,
|
||||
"results": search_results,
|
||||
"has_results": len(search_results) > 0,
|
||||
"result_count": len(search_results),
|
||||
}
|
||||
|
||||
try:
|
||||
# Render template
|
||||
rendered_prompt = await template_loader.render("prompts/search.hbs", template_context)
|
||||
|
||||
# Build metadata
|
||||
metadata = {
|
||||
"query": request.query,
|
||||
"timeframe": request.timeframe,
|
||||
"search_count": len(search_results),
|
||||
"context_count": len(search_results),
|
||||
"observation_count": 0, # Search results don't include observations
|
||||
"relation_count": 0, # Search results don't include relations
|
||||
"total_items": len(search_results),
|
||||
"search_limit": limit,
|
||||
"context_depth": 0, # No context depth for basic search
|
||||
"related_limit": 0, # No related items for basic search
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
prompt_metadata = PromptMetadata(**metadata)
|
||||
|
||||
return PromptResponse(
|
||||
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error rendering search template: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error rendering prompt template: {str(e)}",
|
||||
)
|
||||
@@ -0,0 +1,292 @@
|
||||
"""V2 Resource Router - ID-based resource content operations.
|
||||
|
||||
This router uses entity IDs for all operations, with file paths in request bodies
|
||||
when needed. This is consistent with v2's ID-first design.
|
||||
|
||||
Key differences from v1:
|
||||
- Uses integer entity IDs in URL paths instead of file paths
|
||||
- File paths are in request bodies for create/update operations
|
||||
- More RESTful: POST for create, PUT for update, GET for read
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import (
|
||||
ProjectConfigV2Dep,
|
||||
EntityServiceV2Dep,
|
||||
FileServiceV2Dep,
|
||||
EntityRepositoryV2Dep,
|
||||
SearchServiceV2Dep,
|
||||
ProjectIdPathDep,
|
||||
)
|
||||
from basic_memory.models.knowledge import Entity as EntityModel
|
||||
from basic_memory.schemas.v2.resource import (
|
||||
CreateResourceRequest,
|
||||
UpdateResourceRequest,
|
||||
ResourceResponse,
|
||||
)
|
||||
from basic_memory.utils import validate_project_path
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/resource", tags=["resources-v2"])
|
||||
|
||||
|
||||
@router.get("/{entity_id}")
|
||||
async def get_resource_content(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
config: ProjectConfigV2Dep,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> FileResponse:
|
||||
"""Get raw resource content by entity ID.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
entity_id: Numeric entity ID
|
||||
config: Project configuration
|
||||
entity_service: Entity service for fetching entity data
|
||||
file_service: File service for reading file content
|
||||
|
||||
Returns:
|
||||
FileResponse with entity content
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if entity or file not found
|
||||
"""
|
||||
logger.debug(f"V2 Getting content for project {project_id}, entity_id: {entity_id}")
|
||||
|
||||
# Get entity by ID
|
||||
entities = await entity_service.get_entities_by_id([entity_id])
|
||||
if not entities:
|
||||
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
||||
|
||||
entity = entities[0]
|
||||
|
||||
# Validate entity file path to prevent path traversal
|
||||
project_path = Path(config.home)
|
||||
if not validate_project_path(entity.file_path, project_path):
|
||||
logger.error(f"Invalid file path in entity {entity.id}: {entity.file_path}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Entity contains invalid file path",
|
||||
)
|
||||
|
||||
file_path = Path(f"{config.home}/{entity.file_path}")
|
||||
if not file_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"File not found: {file_path}",
|
||||
)
|
||||
|
||||
return FileResponse(path=file_path)
|
||||
|
||||
|
||||
@router.post("", response_model=ResourceResponse)
|
||||
async def create_resource(
|
||||
project_id: ProjectIdPathDep,
|
||||
data: CreateResourceRequest,
|
||||
config: ProjectConfigV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
) -> ResourceResponse:
|
||||
"""Create a new resource file.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
data: Create resource request with file_path and content
|
||||
config: Project configuration
|
||||
file_service: File service for writing files
|
||||
entity_repository: Entity repository for creating entities
|
||||
search_service: Search service for indexing
|
||||
|
||||
Returns:
|
||||
ResourceResponse with file information including entity_id
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 for invalid file paths, 409 if file already exists
|
||||
"""
|
||||
try:
|
||||
# Validate path to prevent path traversal attacks
|
||||
project_path = Path(config.home)
|
||||
if not validate_project_path(data.file_path, project_path):
|
||||
logger.warning(
|
||||
f"Invalid file path attempted: {data.file_path} in project {config.name}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file path: {data.file_path}. "
|
||||
"Path must be relative and stay within project boundaries.",
|
||||
)
|
||||
|
||||
# Check if entity already exists
|
||||
existing_entity = await entity_repository.get_by_file_path(data.file_path)
|
||||
if existing_entity:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Resource already exists at {data.file_path} with entity_id {existing_entity.id}. "
|
||||
f"Use PUT /resource/{existing_entity.id} to update it.",
|
||||
)
|
||||
|
||||
# Get full file path
|
||||
full_path = Path(f"{config.home}/{data.file_path}")
|
||||
|
||||
# Ensure parent directory exists
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write content to file
|
||||
checksum = await file_service.write_file(full_path, data.content)
|
||||
|
||||
# Get file info
|
||||
file_stats = file_service.file_stats(full_path)
|
||||
|
||||
# Determine file details
|
||||
file_name = Path(data.file_path).name
|
||||
content_type = file_service.content_type(full_path)
|
||||
entity_type = "canvas" if data.file_path.endswith(".canvas") else "file"
|
||||
|
||||
# Create a new entity model
|
||||
entity = EntityModel(
|
||||
title=file_name,
|
||||
entity_type=entity_type,
|
||||
content_type=content_type,
|
||||
file_path=data.file_path,
|
||||
checksum=checksum,
|
||||
created_at=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
|
||||
updated_at=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
|
||||
)
|
||||
entity = await entity_repository.add(entity)
|
||||
|
||||
# Index the file for search
|
||||
await search_service.index_entity(entity) # pyright: ignore
|
||||
|
||||
# Return success response
|
||||
return ResourceResponse(
|
||||
entity_id=entity.id,
|
||||
file_path=data.file_path,
|
||||
checksum=checksum,
|
||||
size=file_stats.st_size,
|
||||
created_at=file_stats.st_ctime,
|
||||
modified_at=file_stats.st_mtime,
|
||||
)
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions without wrapping
|
||||
raise
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error creating resource {data.file_path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create resource: {str(e)}")
|
||||
|
||||
|
||||
@router.put("/{entity_id}", response_model=ResourceResponse)
|
||||
async def update_resource(
|
||||
project_id: ProjectIdPathDep,
|
||||
entity_id: int,
|
||||
data: UpdateResourceRequest,
|
||||
config: ProjectConfigV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
) -> ResourceResponse:
|
||||
"""Update an existing resource by entity ID.
|
||||
|
||||
Can update content and optionally move the file to a new path.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
entity_id: Entity ID of the resource to update
|
||||
data: Update resource request with content and optional new file_path
|
||||
config: Project configuration
|
||||
file_service: File service for writing files
|
||||
entity_repository: Entity repository for updating entities
|
||||
search_service: Search service for indexing
|
||||
|
||||
Returns:
|
||||
ResourceResponse with updated file information
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if entity not found, 400 for invalid paths
|
||||
"""
|
||||
try:
|
||||
# Get existing entity
|
||||
entity = await entity_repository.get_by_id(entity_id)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
||||
|
||||
# Determine target file path
|
||||
target_file_path = data.file_path if data.file_path else entity.file_path
|
||||
|
||||
# Validate path to prevent path traversal attacks
|
||||
project_path = Path(config.home)
|
||||
if not validate_project_path(target_file_path, project_path):
|
||||
logger.warning(
|
||||
f"Invalid file path attempted: {target_file_path} in project {config.name}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file path: {target_file_path}. "
|
||||
"Path must be relative and stay within project boundaries.",
|
||||
)
|
||||
|
||||
# Get full paths
|
||||
old_full_path = Path(f"{config.home}/{entity.file_path}")
|
||||
new_full_path = Path(f"{config.home}/{target_file_path}")
|
||||
|
||||
# If moving file, handle the move
|
||||
if data.file_path and data.file_path != entity.file_path:
|
||||
# Ensure new parent directory exists
|
||||
new_full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# If old file exists, remove it
|
||||
if old_full_path.exists():
|
||||
old_full_path.unlink()
|
||||
else:
|
||||
# Ensure directory exists for in-place update
|
||||
new_full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write content to target file
|
||||
checksum = await file_service.write_file(new_full_path, data.content)
|
||||
|
||||
# Get file info
|
||||
file_stats = file_service.file_stats(new_full_path)
|
||||
|
||||
# Determine file details
|
||||
file_name = Path(target_file_path).name
|
||||
content_type = file_service.content_type(new_full_path)
|
||||
entity_type = "canvas" if target_file_path.endswith(".canvas") else "file"
|
||||
|
||||
# Update entity
|
||||
updated_entity = await entity_repository.update(
|
||||
entity_id,
|
||||
{
|
||||
"title": file_name,
|
||||
"entity_type": entity_type,
|
||||
"content_type": content_type,
|
||||
"file_path": target_file_path,
|
||||
"checksum": checksum,
|
||||
"updated_at": datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
|
||||
},
|
||||
)
|
||||
|
||||
# Index the updated file for search
|
||||
await search_service.index_entity(updated_entity) # pyright: ignore
|
||||
|
||||
# Return success response
|
||||
return ResourceResponse(
|
||||
entity_id=entity_id,
|
||||
file_path=target_file_path,
|
||||
checksum=checksum,
|
||||
size=file_stats.st_size,
|
||||
created_at=file_stats.st_ctime,
|
||||
modified_at=file_stats.st_mtime,
|
||||
)
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions without wrapping
|
||||
raise
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error updating resource {entity_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update resource: {str(e)}")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""V2 router for search operations.
|
||||
|
||||
This router uses integer project IDs for stable, efficient routing.
|
||||
V1 uses string-based project names which are less efficient and less stable.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
|
||||
from basic_memory.api.routers.utils import to_search_results
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResponse
|
||||
from basic_memory.deps import SearchServiceV2Dep, EntityServiceV2Dep, ProjectIdPathDep
|
||||
|
||||
# Note: No prefix here - it's added during registration as /v2/{project_id}/search
|
||||
router = APIRouter(tags=["search"])
|
||||
|
||||
|
||||
@router.post("/search/", response_model=SearchResponse)
|
||||
async def search(
|
||||
project_id: ProjectIdPathDep,
|
||||
query: SearchQuery,
|
||||
search_service: SearchServiceV2Dep,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
):
|
||||
"""Search across all knowledge and documents in a project.
|
||||
|
||||
V2 uses integer project IDs for improved performance and stability.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
query: Search query parameters (text, filters, etc.)
|
||||
search_service: Search service scoped to project
|
||||
entity_service: Entity service scoped to project
|
||||
page: Page number for pagination
|
||||
page_size: Number of results per page
|
||||
|
||||
Returns:
|
||||
SearchResponse with paginated search results
|
||||
"""
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
results = await search_service.search(query, limit=limit, offset=offset)
|
||||
search_results = await to_search_results(entity_service, results)
|
||||
return SearchResponse(
|
||||
results=search_results,
|
||||
current_page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/search/reindex")
|
||||
async def reindex(
|
||||
project_id: ProjectIdPathDep,
|
||||
background_tasks: BackgroundTasks,
|
||||
search_service: SearchServiceV2Dep,
|
||||
):
|
||||
"""Recreate and populate the search index for a project.
|
||||
|
||||
This is a background operation that rebuilds the search index
|
||||
from scratch. Useful after bulk updates or if the index becomes
|
||||
corrupted.
|
||||
|
||||
Args:
|
||||
project_id: Validated numeric project ID from URL path
|
||||
background_tasks: FastAPI background tasks handler
|
||||
search_service: Search service scoped to project
|
||||
|
||||
Returns:
|
||||
Status message indicating reindex has been initiated
|
||||
"""
|
||||
await search_service.reindex_all(background_tasks=background_tasks)
|
||||
return {"status": "ok", "message": "Reindex initiated"}
|
||||
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Literal, Optional, List, Tuple
|
||||
from enum import Enum
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
@@ -24,6 +25,13 @@ WATCH_STATUS_JSON = "watch-status.json"
|
||||
Environment = Literal["test", "dev", "user"]
|
||||
|
||||
|
||||
class DatabaseBackend(str, Enum):
|
||||
"""Supported database backends."""
|
||||
|
||||
SQLITE = "sqlite"
|
||||
POSTGRES = "postgres"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectConfig:
|
||||
"""Configuration for a specific basic-memory project."""
|
||||
@@ -81,6 +89,17 @@ class BasicMemoryConfig(BaseSettings):
|
||||
# overridden by ~/.basic-memory/config.json
|
||||
log_level: str = "INFO"
|
||||
|
||||
# Database configuration
|
||||
database_backend: DatabaseBackend = Field(
|
||||
default=DatabaseBackend.SQLITE,
|
||||
description="Database backend to use (sqlite or postgres)",
|
||||
)
|
||||
|
||||
database_url: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Database connection URL. For Postgres, use postgresql+asyncpg://user:pass@host:port/db. If not set, SQLite will use default path.",
|
||||
)
|
||||
|
||||
# Watch service configuration
|
||||
sync_delay: int = Field(
|
||||
default=1000, description="Milliseconds to wait after changes before syncing", gt=0
|
||||
|
||||
+123
-73
@@ -5,7 +5,7 @@ from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig, ConfigManager
|
||||
from basic_memory.config import BasicMemoryConfig, ConfigManager, DatabaseBackend
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
@@ -20,12 +20,12 @@ from sqlalchemy.ext.asyncio import (
|
||||
)
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from basic_memory.repository.search_repository import SearchRepository
|
||||
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
|
||||
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
|
||||
|
||||
# Module level state
|
||||
_engine: Optional[AsyncEngine] = None
|
||||
_session_maker: Optional[async_sessionmaker[AsyncSession]] = None
|
||||
_migrations_completed: bool = False
|
||||
|
||||
|
||||
class DatabaseType(Enum):
|
||||
@@ -33,10 +33,41 @@ class DatabaseType(Enum):
|
||||
|
||||
MEMORY = auto()
|
||||
FILESYSTEM = auto()
|
||||
POSTGRES = auto()
|
||||
|
||||
@classmethod
|
||||
def get_db_url(cls, db_path: Path, db_type: "DatabaseType") -> str:
|
||||
"""Get SQLAlchemy URL for database path."""
|
||||
def get_db_url(
|
||||
cls, db_path: Path, db_type: "DatabaseType", config: Optional[BasicMemoryConfig] = None
|
||||
) -> str:
|
||||
"""Get SQLAlchemy URL for database path.
|
||||
|
||||
Args:
|
||||
db_path: Path to SQLite database file (ignored for Postgres)
|
||||
db_type: Type of database (MEMORY, FILESYSTEM, or POSTGRES)
|
||||
config: Optional config to check for database backend and URL
|
||||
|
||||
Returns:
|
||||
SQLAlchemy connection URL
|
||||
"""
|
||||
# Load config if not provided
|
||||
if config is None:
|
||||
config = ConfigManager().config
|
||||
|
||||
# Handle explicit Postgres type
|
||||
if db_type == cls.POSTGRES:
|
||||
if not config.database_url:
|
||||
raise ValueError("DATABASE_URL must be set when using Postgres backend")
|
||||
logger.info(f"Using Postgres database: {config.database_url}")
|
||||
return config.database_url
|
||||
|
||||
# Check if Postgres backend is configured (for backward compatibility)
|
||||
if config.database_backend == DatabaseBackend.POSTGRES:
|
||||
if not config.database_url:
|
||||
raise ValueError("DATABASE_URL must be set when using Postgres backend")
|
||||
logger.info(f"Using Postgres database: {config.database_url}")
|
||||
return config.database_url
|
||||
|
||||
# SQLite databases
|
||||
if db_type == cls.MEMORY:
|
||||
logger.info("Using in-memory SQLite database")
|
||||
return "sqlite+aiosqlite://"
|
||||
@@ -64,7 +95,14 @@ async def scoped_session(
|
||||
factory = get_scoped_session_factory(session_maker)
|
||||
session = factory()
|
||||
try:
|
||||
await session.execute(text("PRAGMA foreign_keys=ON"))
|
||||
# Only enable foreign keys for SQLite (Postgres has them enabled by default)
|
||||
# Detect database type from session's bind (engine) dialect
|
||||
engine = session.get_bind()
|
||||
dialect_name = engine.dialect.name
|
||||
|
||||
if dialect_name == "sqlite":
|
||||
await session.execute(text("PRAGMA foreign_keys=ON"))
|
||||
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
@@ -103,13 +141,16 @@ def _configure_sqlite_connection(dbapi_conn, enable_wal: bool = True) -> None:
|
||||
cursor.close()
|
||||
|
||||
|
||||
def _create_engine_and_session(
|
||||
db_path: Path, db_type: DatabaseType = DatabaseType.FILESYSTEM
|
||||
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
|
||||
"""Internal helper to create engine and session maker."""
|
||||
db_url = DatabaseType.get_db_url(db_path, db_type)
|
||||
logger.debug(f"Creating engine for db_url: {db_url}")
|
||||
def _create_sqlite_engine(db_url: str, db_type: DatabaseType) -> AsyncEngine:
|
||||
"""Create SQLite async engine with appropriate configuration.
|
||||
|
||||
Args:
|
||||
db_url: SQLite connection URL
|
||||
db_type: Database type (MEMORY or FILESYSTEM)
|
||||
|
||||
Returns:
|
||||
Configured async engine for SQLite
|
||||
"""
|
||||
# Configure connection args with Windows-specific settings
|
||||
connect_args: dict[str, bool | float | None] = {"check_same_thread": False}
|
||||
|
||||
@@ -146,6 +187,51 @@ def _create_engine_and_session(
|
||||
"""Enable WAL mode on each connection."""
|
||||
_configure_sqlite_connection(dbapi_conn, enable_wal=enable_wal)
|
||||
|
||||
return engine
|
||||
|
||||
|
||||
def _create_postgres_engine(db_url: str) -> AsyncEngine:
|
||||
"""Create Postgres async engine with appropriate configuration.
|
||||
|
||||
Args:
|
||||
db_url: Postgres connection URL (postgresql+asyncpg://...)
|
||||
|
||||
Returns:
|
||||
Configured async engine for Postgres
|
||||
"""
|
||||
# Postgres with asyncpg - use standard async connection
|
||||
engine = create_async_engine(
|
||||
db_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True, # Verify connections before using them
|
||||
)
|
||||
|
||||
return engine
|
||||
|
||||
|
||||
def _create_engine_and_session(
|
||||
db_path: Path, db_type: DatabaseType = DatabaseType.FILESYSTEM
|
||||
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
|
||||
"""Internal helper to create engine and session maker.
|
||||
|
||||
Args:
|
||||
db_path: Path to database file (used for SQLite, ignored for Postgres)
|
||||
db_type: Type of database (MEMORY, FILESYSTEM, or POSTGRES)
|
||||
|
||||
Returns:
|
||||
Tuple of (engine, session_maker)
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
db_url = DatabaseType.get_db_url(db_path, db_type, config)
|
||||
logger.debug(f"Creating engine for db_url: {db_url}")
|
||||
|
||||
# Delegate to backend-specific engine creation
|
||||
# Check explicit POSTGRES type first, then config setting
|
||||
if db_type == DatabaseType.POSTGRES or config.database_backend == DatabaseBackend.POSTGRES:
|
||||
engine = _create_postgres_engine(db_url)
|
||||
else:
|
||||
engine = _create_sqlite_engine(db_url, db_type)
|
||||
|
||||
session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
return engine, session_maker
|
||||
|
||||
@@ -181,13 +267,12 @@ async def get_or_create_db(
|
||||
|
||||
async def shutdown_db() -> None: # pragma: no cover
|
||||
"""Clean up database connections."""
|
||||
global _engine, _session_maker, _migrations_completed
|
||||
global _engine, _session_maker
|
||||
|
||||
if _engine:
|
||||
await _engine.dispose()
|
||||
_engine = None
|
||||
_session_maker = None
|
||||
_migrations_completed = False
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -201,50 +286,12 @@ async def engine_session_factory(
|
||||
for each test. For production use, use get_or_create_db() instead.
|
||||
"""
|
||||
|
||||
global _engine, _session_maker, _migrations_completed
|
||||
global _engine, _session_maker
|
||||
|
||||
db_url = DatabaseType.get_db_url(db_path, db_type)
|
||||
logger.debug(f"Creating engine for db_url: {db_url}")
|
||||
|
||||
# Configure connection args with Windows-specific settings
|
||||
connect_args: dict[str, bool | float | None] = {"check_same_thread": False}
|
||||
|
||||
# Add Windows-specific parameters to improve reliability
|
||||
if os.name == "nt": # Windows
|
||||
connect_args.update(
|
||||
{
|
||||
"timeout": 30.0, # Increase timeout to 30 seconds for Windows
|
||||
"isolation_level": None, # Use autocommit mode
|
||||
}
|
||||
)
|
||||
# Use NullPool for Windows filesystem databases to avoid connection pooling issues
|
||||
# Important: Do NOT use NullPool for in-memory databases as it will destroy the database
|
||||
# between connections
|
||||
if db_type == DatabaseType.FILESYSTEM:
|
||||
_engine = create_async_engine(
|
||||
db_url,
|
||||
connect_args=connect_args,
|
||||
poolclass=NullPool, # Disable connection pooling on Windows
|
||||
echo=False,
|
||||
)
|
||||
else:
|
||||
# In-memory databases need connection pooling to maintain state
|
||||
_engine = create_async_engine(db_url, connect_args=connect_args)
|
||||
else:
|
||||
_engine = create_async_engine(db_url, connect_args=connect_args)
|
||||
|
||||
# Enable WAL mode for better concurrency and reliability
|
||||
# Note: WAL mode is not supported for in-memory databases
|
||||
enable_wal = db_type != DatabaseType.MEMORY
|
||||
|
||||
@event.listens_for(_engine.sync_engine, "connect")
|
||||
def enable_wal_mode(dbapi_conn, connection_record):
|
||||
"""Enable WAL mode on each connection."""
|
||||
_configure_sqlite_connection(dbapi_conn, enable_wal=enable_wal)
|
||||
# Use the same helper function as production code
|
||||
_engine, _session_maker = _create_engine_and_session(db_path, db_type)
|
||||
|
||||
try:
|
||||
_session_maker = async_sessionmaker(_engine, expire_on_commit=False)
|
||||
|
||||
# Verify that engine and session maker are initialized
|
||||
if _engine is None: # pragma: no cover
|
||||
logger.error("Database engine is None in engine_session_factory")
|
||||
@@ -260,20 +307,16 @@ async def engine_session_factory(
|
||||
await _engine.dispose()
|
||||
_engine = None
|
||||
_session_maker = None
|
||||
_migrations_completed = False
|
||||
|
||||
|
||||
async def run_migrations(
|
||||
app_config: BasicMemoryConfig, database_type=DatabaseType.FILESYSTEM, force: bool = False
|
||||
app_config: BasicMemoryConfig, database_type=DatabaseType.FILESYSTEM
|
||||
): # pragma: no cover
|
||||
"""Run any pending alembic migrations."""
|
||||
global _migrations_completed
|
||||
|
||||
# Skip if migrations already completed unless forced
|
||||
if _migrations_completed and not force:
|
||||
logger.debug("Migrations already completed in this session, skipping")
|
||||
return
|
||||
"""Run any pending alembic migrations.
|
||||
|
||||
Note: Alembic tracks which migrations have been applied via the alembic_version table,
|
||||
so it's safe to call this multiple times - it will only run pending migrations.
|
||||
"""
|
||||
logger.info("Running database migrations...")
|
||||
try:
|
||||
# Get the absolute path to the alembic directory relative to this file
|
||||
@@ -288,9 +331,11 @@ async def run_migrations(
|
||||
)
|
||||
config.set_main_option("timezone", "UTC")
|
||||
config.set_main_option("revision_environment", "false")
|
||||
config.set_main_option(
|
||||
"sqlalchemy.url", DatabaseType.get_db_url(app_config.database_path, database_type)
|
||||
)
|
||||
|
||||
# Get the correct database URL based on backend configuration
|
||||
# No URL conversion needed - env.py now handles both async and sync engines
|
||||
db_url = DatabaseType.get_db_url(app_config.database_path, database_type, app_config)
|
||||
config.set_main_option("sqlalchemy.url", db_url)
|
||||
|
||||
command.upgrade(config, "head")
|
||||
logger.info("Migrations completed successfully")
|
||||
@@ -301,12 +346,17 @@ async def run_migrations(
|
||||
else:
|
||||
session_maker = _session_maker
|
||||
|
||||
# initialize the search Index schema
|
||||
# the project_id is not used for init_search_index, so we pass a dummy value
|
||||
await SearchRepository(session_maker, 1).init_search_index()
|
||||
|
||||
# Mark migrations as completed
|
||||
_migrations_completed = True
|
||||
# Initialize the search index schema
|
||||
# For SQLite: Create FTS5 virtual table
|
||||
# For Postgres: No-op (tsvector column added by migrations)
|
||||
# The project_id is not used for init_search_index, so we pass a dummy value
|
||||
if (
|
||||
database_type == DatabaseType.POSTGRES
|
||||
or app_config.database_backend == DatabaseBackend.POSTGRES
|
||||
):
|
||||
await PostgresSearchRepository(session_maker, 1).init_search_index()
|
||||
else:
|
||||
await SQLiteSearchRepository(session_maker, 1).init_search_index()
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error running migrations: {e}")
|
||||
raise
|
||||
|
||||
+287
-3
@@ -25,7 +25,7 @@ from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.repository.observation_repository import ObservationRepository
|
||||
from basic_memory.repository.project_repository import ProjectRepository
|
||||
from basic_memory.repository.relation_repository import RelationRepository
|
||||
from basic_memory.repository.search_repository import SearchRepository
|
||||
from basic_memory.repository.search_repository import SearchRepository, create_search_repository
|
||||
from basic_memory.services import EntityService, ProjectService
|
||||
from basic_memory.services.context_service import ContextService
|
||||
from basic_memory.services.directory_service import DirectoryService
|
||||
@@ -76,6 +76,34 @@ async def get_project_config(
|
||||
|
||||
ProjectConfigDep = Annotated[ProjectConfig, Depends(get_project_config)] # pragma: no cover
|
||||
|
||||
|
||||
async def get_project_config_v2(
|
||||
project_id: "ProjectIdPathDep", project_repository: "ProjectRepositoryDep"
|
||||
) -> ProjectConfig: # pragma: no cover
|
||||
"""Get the project config for v2 API (uses integer project_id from path).
|
||||
|
||||
Args:
|
||||
project_id: The validated numeric project ID from the URL path
|
||||
project_repository: Repository for project operations
|
||||
|
||||
Returns:
|
||||
The resolved project config
|
||||
|
||||
Raises:
|
||||
HTTPException: If project is not found
|
||||
"""
|
||||
project_obj = await project_repository.get_by_id(project_id)
|
||||
if project_obj:
|
||||
return ProjectConfig(name=project_obj.name, home=pathlib.Path(project_obj.path))
|
||||
|
||||
# Not found (this should not happen since ProjectIdPathDep already validates existence)
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"Project with ID {project_id} not found."
|
||||
)
|
||||
|
||||
|
||||
ProjectConfigV2Dep = Annotated[ProjectConfig, Depends(get_project_config_v2)] # pragma: no cover
|
||||
|
||||
## sqlalchemy
|
||||
|
||||
|
||||
@@ -130,6 +158,38 @@ ProjectRepositoryDep = Annotated[ProjectRepository, Depends(get_project_reposito
|
||||
ProjectPathDep = Annotated[str, Path()] # Use Path dependency to extract from URL
|
||||
|
||||
|
||||
async def validate_project_id(
|
||||
project_id: int,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> int:
|
||||
"""Validate that a numeric project ID exists in the database.
|
||||
|
||||
This is used for v2 API endpoints that take project IDs as integers in the path.
|
||||
The project_id parameter will be automatically extracted from the URL path by FastAPI.
|
||||
|
||||
Args:
|
||||
project_id: The numeric project ID from the URL path
|
||||
project_repository: Repository for project operations
|
||||
|
||||
Returns:
|
||||
The validated project ID
|
||||
|
||||
Raises:
|
||||
HTTPException: If project with that ID is not found
|
||||
"""
|
||||
project_obj = await project_repository.get_by_id(project_id)
|
||||
if not project_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Project with ID {project_id} not found.",
|
||||
)
|
||||
return project_id
|
||||
|
||||
|
||||
# V2 API: Validated integer project ID from path
|
||||
ProjectIdPathDep = Annotated[int, Depends(validate_project_id)]
|
||||
|
||||
|
||||
async def get_project_id(
|
||||
project_repository: ProjectRepositoryDep,
|
||||
project: ProjectPathDep,
|
||||
@@ -188,6 +248,17 @@ async def get_entity_repository(
|
||||
EntityRepositoryDep = Annotated[EntityRepository, Depends(get_entity_repository)]
|
||||
|
||||
|
||||
async def get_entity_repository_v2(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdPathDep,
|
||||
) -> EntityRepository:
|
||||
"""Create an EntityRepository instance for v2 API (uses integer project_id from path)."""
|
||||
return EntityRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
EntityRepositoryV2Dep = Annotated[EntityRepository, Depends(get_entity_repository_v2)]
|
||||
|
||||
|
||||
async def get_observation_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
@@ -199,6 +270,19 @@ async def get_observation_repository(
|
||||
ObservationRepositoryDep = Annotated[ObservationRepository, Depends(get_observation_repository)]
|
||||
|
||||
|
||||
async def get_observation_repository_v2(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdPathDep,
|
||||
) -> ObservationRepository:
|
||||
"""Create an ObservationRepository instance for v2 API."""
|
||||
return ObservationRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
ObservationRepositoryV2Dep = Annotated[
|
||||
ObservationRepository, Depends(get_observation_repository_v2)
|
||||
]
|
||||
|
||||
|
||||
async def get_relation_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
@@ -210,17 +294,43 @@ async def get_relation_repository(
|
||||
RelationRepositoryDep = Annotated[RelationRepository, Depends(get_relation_repository)]
|
||||
|
||||
|
||||
async def get_relation_repository_v2(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdPathDep,
|
||||
) -> RelationRepository:
|
||||
"""Create a RelationRepository instance for v2 API."""
|
||||
return RelationRepository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
RelationRepositoryV2Dep = Annotated[RelationRepository, Depends(get_relation_repository_v2)]
|
||||
|
||||
|
||||
async def get_search_repository(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdDep,
|
||||
) -> SearchRepository:
|
||||
"""Create a SearchRepository instance for the current project."""
|
||||
return SearchRepository(session_maker, project_id=project_id)
|
||||
"""Create a backend-specific SearchRepository instance for the current project.
|
||||
|
||||
Uses factory function to return SQLiteSearchRepository or PostgresSearchRepository
|
||||
based on database backend configuration.
|
||||
"""
|
||||
return create_search_repository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
SearchRepositoryDep = Annotated[SearchRepository, Depends(get_search_repository)]
|
||||
|
||||
|
||||
async def get_search_repository_v2(
|
||||
session_maker: SessionMakerDep,
|
||||
project_id: ProjectIdPathDep,
|
||||
) -> SearchRepository:
|
||||
"""Create a SearchRepository instance for v2 API."""
|
||||
return create_search_repository(session_maker, project_id=project_id)
|
||||
|
||||
|
||||
SearchRepositoryV2Dep = Annotated[SearchRepository, Depends(get_search_repository_v2)]
|
||||
|
||||
|
||||
# ProjectInfoRepository is deprecated and will be removed in a future version.
|
||||
# Use ProjectRepository instead, which has the same functionality plus more project-specific operations.
|
||||
|
||||
@@ -234,6 +344,13 @@ async def get_entity_parser(project_config: ProjectConfigDep) -> EntityParser:
|
||||
EntityParserDep = Annotated["EntityParser", Depends(get_entity_parser)]
|
||||
|
||||
|
||||
async def get_entity_parser_v2(project_config: ProjectConfigV2Dep) -> EntityParser:
|
||||
return EntityParser(project_config.home)
|
||||
|
||||
|
||||
EntityParserV2Dep = Annotated["EntityParser", Depends(get_entity_parser_v2)]
|
||||
|
||||
|
||||
async def get_markdown_processor(entity_parser: EntityParserDep) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser)
|
||||
|
||||
@@ -241,6 +358,13 @@ async def get_markdown_processor(entity_parser: EntityParserDep) -> MarkdownProc
|
||||
MarkdownProcessorDep = Annotated[MarkdownProcessor, Depends(get_markdown_processor)]
|
||||
|
||||
|
||||
async def get_markdown_processor_v2(entity_parser: EntityParserV2Dep) -> MarkdownProcessor:
|
||||
return MarkdownProcessor(entity_parser)
|
||||
|
||||
|
||||
MarkdownProcessorV2Dep = Annotated[MarkdownProcessor, Depends(get_markdown_processor_v2)]
|
||||
|
||||
|
||||
async def get_file_service(
|
||||
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
|
||||
) -> FileService:
|
||||
@@ -255,6 +379,20 @@ async def get_file_service(
|
||||
FileServiceDep = Annotated[FileService, Depends(get_file_service)]
|
||||
|
||||
|
||||
async def get_file_service_v2(
|
||||
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
|
||||
) -> FileService:
|
||||
logger.debug(
|
||||
f"Creating FileService for project: {project_config.name}, base_path: {project_config.home}"
|
||||
)
|
||||
file_service = FileService(project_config.home, markdown_processor)
|
||||
logger.debug(f"Created FileService for project: {file_service} ")
|
||||
return file_service
|
||||
|
||||
|
||||
FileServiceV2Dep = Annotated[FileService, Depends(get_file_service_v2)]
|
||||
|
||||
|
||||
async def get_entity_service(
|
||||
entity_repository: EntityRepositoryDep,
|
||||
observation_repository: ObservationRepositoryDep,
|
||||
@@ -279,6 +417,30 @@ async def get_entity_service(
|
||||
EntityServiceDep = Annotated[EntityService, Depends(get_entity_service)]
|
||||
|
||||
|
||||
async def get_entity_service_v2(
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
observation_repository: ObservationRepositoryV2Dep,
|
||||
relation_repository: RelationRepositoryV2Dep,
|
||||
entity_parser: EntityParserV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
link_resolver: "LinkResolverV2Dep",
|
||||
app_config: AppConfigDep,
|
||||
) -> EntityService:
|
||||
"""Create EntityService for v2 API."""
|
||||
return EntityService(
|
||||
entity_repository=entity_repository,
|
||||
observation_repository=observation_repository,
|
||||
relation_repository=relation_repository,
|
||||
entity_parser=entity_parser,
|
||||
file_service=file_service,
|
||||
link_resolver=link_resolver,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
|
||||
EntityServiceV2Dep = Annotated[EntityService, Depends(get_entity_service_v2)]
|
||||
|
||||
|
||||
async def get_search_service(
|
||||
search_repository: SearchRepositoryDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
@@ -291,6 +453,18 @@ async def get_search_service(
|
||||
SearchServiceDep = Annotated[SearchService, Depends(get_search_service)]
|
||||
|
||||
|
||||
async def get_search_service_v2(
|
||||
search_repository: SearchRepositoryV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> SearchService:
|
||||
"""Create SearchService for v2 API."""
|
||||
return SearchService(search_repository, entity_repository, file_service)
|
||||
|
||||
|
||||
SearchServiceV2Dep = Annotated[SearchService, Depends(get_search_service_v2)]
|
||||
|
||||
|
||||
async def get_link_resolver(
|
||||
entity_repository: EntityRepositoryDep, search_service: SearchServiceDep
|
||||
) -> LinkResolver:
|
||||
@@ -300,6 +474,15 @@ async def get_link_resolver(
|
||||
LinkResolverDep = Annotated[LinkResolver, Depends(get_link_resolver)]
|
||||
|
||||
|
||||
async def get_link_resolver_v2(
|
||||
entity_repository: EntityRepositoryV2Dep, search_service: SearchServiceV2Dep
|
||||
) -> LinkResolver:
|
||||
return LinkResolver(entity_repository=entity_repository, search_service=search_service)
|
||||
|
||||
|
||||
LinkResolverV2Dep = Annotated[LinkResolver, Depends(get_link_resolver_v2)]
|
||||
|
||||
|
||||
async def get_context_service(
|
||||
search_repository: SearchRepositoryDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
@@ -315,6 +498,22 @@ async def get_context_service(
|
||||
ContextServiceDep = Annotated[ContextService, Depends(get_context_service)]
|
||||
|
||||
|
||||
async def get_context_service_v2(
|
||||
search_repository: SearchRepositoryV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
observation_repository: ObservationRepositoryV2Dep,
|
||||
) -> ContextService:
|
||||
"""Create ContextService for v2 API."""
|
||||
return ContextService(
|
||||
search_repository=search_repository,
|
||||
entity_repository=entity_repository,
|
||||
observation_repository=observation_repository,
|
||||
)
|
||||
|
||||
|
||||
ContextServiceV2Dep = Annotated[ContextService, Depends(get_context_service_v2)]
|
||||
|
||||
|
||||
async def get_sync_service(
|
||||
app_config: AppConfigDep,
|
||||
entity_service: EntityServiceDep,
|
||||
@@ -344,6 +543,32 @@ async def get_sync_service(
|
||||
SyncServiceDep = Annotated[SyncService, Depends(get_sync_service)]
|
||||
|
||||
|
||||
async def get_sync_service_v2(
|
||||
app_config: AppConfigDep,
|
||||
entity_service: EntityServiceV2Dep,
|
||||
entity_parser: EntityParserV2Dep,
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
relation_repository: RelationRepositoryV2Dep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
search_service: SearchServiceV2Dep,
|
||||
file_service: FileServiceV2Dep,
|
||||
) -> SyncService: # pragma: no cover
|
||||
"""Create SyncService for v2 API."""
|
||||
return SyncService(
|
||||
app_config=app_config,
|
||||
entity_service=entity_service,
|
||||
entity_parser=entity_parser,
|
||||
entity_repository=entity_repository,
|
||||
relation_repository=relation_repository,
|
||||
project_repository=project_repository,
|
||||
search_service=search_service,
|
||||
file_service=file_service,
|
||||
)
|
||||
|
||||
|
||||
SyncServiceV2Dep = Annotated[SyncService, Depends(get_sync_service_v2)]
|
||||
|
||||
|
||||
async def get_project_service(
|
||||
project_repository: ProjectRepositoryDep,
|
||||
) -> ProjectService:
|
||||
@@ -366,6 +591,18 @@ async def get_directory_service(
|
||||
DirectoryServiceDep = Annotated[DirectoryService, Depends(get_directory_service)]
|
||||
|
||||
|
||||
async def get_directory_service_v2(
|
||||
entity_repository: EntityRepositoryV2Dep,
|
||||
) -> DirectoryService:
|
||||
"""Create DirectoryService for v2 API (uses integer project_id from path)."""
|
||||
return DirectoryService(
|
||||
entity_repository=entity_repository,
|
||||
)
|
||||
|
||||
|
||||
DirectoryServiceV2Dep = Annotated[DirectoryService, Depends(get_directory_service_v2)]
|
||||
|
||||
|
||||
# Import
|
||||
|
||||
|
||||
@@ -409,3 +646,50 @@ async def get_memory_json_importer(
|
||||
|
||||
|
||||
MemoryJsonImporterDep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer)]
|
||||
|
||||
|
||||
# V2 Import dependencies
|
||||
|
||||
|
||||
async def get_chatgpt_importer_v2(
|
||||
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
|
||||
) -> ChatGPTImporter:
|
||||
"""Create ChatGPTImporter with v2 dependencies."""
|
||||
return ChatGPTImporter(project_config.home, markdown_processor)
|
||||
|
||||
|
||||
ChatGPTImporterV2Dep = Annotated[ChatGPTImporter, Depends(get_chatgpt_importer_v2)]
|
||||
|
||||
|
||||
async def get_claude_conversations_importer_v2(
|
||||
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
|
||||
) -> ClaudeConversationsImporter:
|
||||
"""Create ClaudeConversationsImporter with v2 dependencies."""
|
||||
return ClaudeConversationsImporter(project_config.home, markdown_processor)
|
||||
|
||||
|
||||
ClaudeConversationsImporterV2Dep = Annotated[
|
||||
ClaudeConversationsImporter, Depends(get_claude_conversations_importer_v2)
|
||||
]
|
||||
|
||||
|
||||
async def get_claude_projects_importer_v2(
|
||||
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
|
||||
) -> ClaudeProjectsImporter:
|
||||
"""Create ClaudeProjectsImporter with v2 dependencies."""
|
||||
return ClaudeProjectsImporter(project_config.home, markdown_processor)
|
||||
|
||||
|
||||
ClaudeProjectsImporterV2Dep = Annotated[
|
||||
ClaudeProjectsImporter, Depends(get_claude_projects_importer_v2)
|
||||
]
|
||||
|
||||
|
||||
async def get_memory_json_importer_v2(
|
||||
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
|
||||
) -> MemoryJsonImporter:
|
||||
"""Create MemoryJsonImporter with v2 dependencies."""
|
||||
return MemoryJsonImporter(project_config.home, markdown_processor)
|
||||
|
||||
|
||||
MemoryJsonImporterV2Dep = Annotated[MemoryJsonImporter, Depends(get_memory_json_importer_v2)]
|
||||
|
||||
@@ -40,10 +40,13 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
chats_imported = 0
|
||||
|
||||
for chat in conversations:
|
||||
# Get name, providing default for unnamed conversations
|
||||
chat_name = chat.get("name") or f"Conversation {chat.get('uuid', 'untitled')}"
|
||||
|
||||
# Convert to entity
|
||||
entity = self._format_chat_content(
|
||||
base_path=folder_path,
|
||||
name=chat["name"],
|
||||
name=chat_name,
|
||||
messages=chat["chat_messages"],
|
||||
created_at=chat["created_at"],
|
||||
modified_at=chat["updated_at"],
|
||||
|
||||
@@ -189,35 +189,63 @@ class EntityParser:
|
||||
return self.base_path / path
|
||||
|
||||
async def parse_file_content(self, absolute_path, file_content):
|
||||
# Parse frontmatter with proper error handling for malformed YAML (issue #185)
|
||||
try:
|
||||
post = frontmatter.loads(file_content)
|
||||
except yaml.YAMLError as e:
|
||||
# Log the YAML parsing error with file context
|
||||
logger.warning(
|
||||
f"Failed to parse YAML frontmatter in {absolute_path}: {e}. "
|
||||
f"Treating file as plain markdown without frontmatter."
|
||||
)
|
||||
# Create a post with no frontmatter - treat entire content as markdown
|
||||
post = frontmatter.Post(file_content, metadata={})
|
||||
"""Parse markdown content from file stats.
|
||||
|
||||
# Extract file stat info
|
||||
Delegates to parse_markdown_content() for actual parsing logic.
|
||||
Exists for backwards compatibility with code that passes file paths.
|
||||
"""
|
||||
# Extract file stat info for timestamps
|
||||
file_stats = absolute_path.stat()
|
||||
|
||||
# Normalize frontmatter values to prevent AttributeError on date objects (issue #236)
|
||||
# PyYAML automatically converts date strings like "2025-10-24" to datetime.date objects
|
||||
# This normalization converts them back to ISO format strings to ensure compatibility
|
||||
# with code that expects string values
|
||||
# Delegate to parse_markdown_content with timestamps from file stats
|
||||
return await self.parse_markdown_content(
|
||||
file_path=absolute_path,
|
||||
content=file_content,
|
||||
mtime=file_stats.st_mtime,
|
||||
ctime=file_stats.st_ctime,
|
||||
)
|
||||
|
||||
async def parse_markdown_content(
|
||||
self,
|
||||
file_path: Path,
|
||||
content: str,
|
||||
mtime: Optional[float] = None,
|
||||
ctime: Optional[float] = None,
|
||||
) -> EntityMarkdown:
|
||||
"""Parse markdown content without requiring file to exist on disk.
|
||||
|
||||
Useful for parsing content from S3 or other remote sources where the file
|
||||
is not available locally.
|
||||
|
||||
Args:
|
||||
file_path: Path for metadata (doesn't need to exist on disk)
|
||||
content: Markdown content as string
|
||||
mtime: Optional modification time (Unix timestamp)
|
||||
ctime: Optional creation time (Unix timestamp)
|
||||
|
||||
Returns:
|
||||
EntityMarkdown with parsed content
|
||||
"""
|
||||
# Parse frontmatter with proper error handling for malformed YAML
|
||||
try:
|
||||
post = frontmatter.loads(content)
|
||||
except yaml.YAMLError as e:
|
||||
logger.warning(
|
||||
f"Failed to parse YAML frontmatter in {file_path}: {e}. "
|
||||
f"Treating file as plain markdown without frontmatter."
|
||||
)
|
||||
post = frontmatter.Post(content, metadata={})
|
||||
|
||||
# Normalize frontmatter values
|
||||
metadata = normalize_frontmatter_metadata(post.metadata)
|
||||
|
||||
# Ensure required fields have defaults (issue #184, #387)
|
||||
# Handle title - use default if missing, None/null, empty, or string "None"
|
||||
# Ensure required fields have defaults
|
||||
title = metadata.get("title")
|
||||
if not title or title == "None":
|
||||
metadata["title"] = absolute_path.stem
|
||||
metadata["title"] = file_path.stem
|
||||
else:
|
||||
metadata["title"] = title
|
||||
# Handle type - use default if missing OR explicitly set to None/null
|
||||
|
||||
entity_type = metadata.get("type")
|
||||
metadata["type"] = entity_type if entity_type is not None else "note"
|
||||
|
||||
@@ -225,16 +253,20 @@ class EntityParser:
|
||||
if tags:
|
||||
metadata["tags"] = tags
|
||||
|
||||
# frontmatter - use metadata with defaults applied
|
||||
entity_frontmatter = EntityFrontmatter(
|
||||
metadata=metadata,
|
||||
)
|
||||
# Parse content for observations and relations
|
||||
entity_frontmatter = EntityFrontmatter(metadata=metadata)
|
||||
entity_content = parse(post.content)
|
||||
|
||||
# Use provided timestamps or current time as fallback
|
||||
now = datetime.now().astimezone()
|
||||
created = datetime.fromtimestamp(ctime).astimezone() if ctime else now
|
||||
modified = datetime.fromtimestamp(mtime).astimezone() if mtime else now
|
||||
|
||||
return EntityMarkdown(
|
||||
frontmatter=entity_frontmatter,
|
||||
content=post.content,
|
||||
observations=entity_content.observations,
|
||||
relations=entity_content.relations,
|
||||
created=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
|
||||
modified=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
|
||||
created=created,
|
||||
modified=modified,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import basic_memory
|
||||
from basic_memory.models.base import Base
|
||||
from basic_memory.models.knowledge import Entity, Observation, Relation
|
||||
from basic_memory.models.project import Project
|
||||
from basic_memory.models.search import SearchIndex
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -11,5 +12,6 @@ __all__ = [
|
||||
"Observation",
|
||||
"Relation",
|
||||
"Project",
|
||||
"SearchIndex",
|
||||
"basic_memory",
|
||||
]
|
||||
|
||||
@@ -129,7 +129,7 @@ class Entity(Base):
|
||||
return value
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}'"
|
||||
return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}', checksum='{self.checksum}')"
|
||||
|
||||
|
||||
class Observation(Base):
|
||||
|
||||
@@ -1,8 +1,56 @@
|
||||
"""Search models and tables."""
|
||||
|
||||
from sqlalchemy import DDL
|
||||
from sqlalchemy import DDL, Column, Integer, String, DateTime, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
# Define FTS5 virtual table creation
|
||||
from basic_memory.models.base import Base
|
||||
|
||||
|
||||
class SearchIndex(Base):
|
||||
"""Search index table for Postgres only.
|
||||
|
||||
For SQLite: This model is skipped; FTS5 virtual table is created via DDL instead.
|
||||
For Postgres: This is the actual table structure with tsvector support.
|
||||
"""
|
||||
|
||||
__tablename__ = "search_index"
|
||||
|
||||
# Primary key (rowid in SQLite FTS5, explicit id in Postgres)
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Core searchable fields
|
||||
title = Column(Text, nullable=True)
|
||||
content_stems = Column(Text, nullable=True)
|
||||
content_snippet = Column(Text, nullable=True)
|
||||
permalink = Column(String(255), nullable=True, index=True)
|
||||
file_path = Column(Text, nullable=True)
|
||||
type = Column(String(50), nullable=True)
|
||||
|
||||
# Project context
|
||||
project_id = Column(Integer, nullable=True, index=True)
|
||||
|
||||
# Relation fields
|
||||
from_id = Column(Integer, nullable=True)
|
||||
to_id = Column(Integer, nullable=True)
|
||||
relation_type = Column(String(100), nullable=True)
|
||||
|
||||
# Observation fields
|
||||
entity_id = Column(Integer, nullable=True)
|
||||
category = Column(String(100), nullable=True)
|
||||
|
||||
# Common fields
|
||||
# Use JSONB for Postgres, JSON for SQLite
|
||||
# Note: 'metadata' is a reserved name in SQLAlchemy, so we use 'metadata_' and map to 'metadata'
|
||||
metadata_ = Column("metadata", JSON().with_variant(JSONB(), "postgresql"), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=True)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Note: textsearchable_index_col (tsvector) will be added by migration for Postgres only
|
||||
|
||||
|
||||
# Define FTS5 virtual table creation for SQLite only
|
||||
# This DDL is executed separately for SQLite databases
|
||||
CREATE_SEARCH_INDEX = DDL("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
|
||||
-- Core entity fields
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Repository for managing entities in the knowledge graph."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Sequence, Union
|
||||
from typing import List, Optional, Sequence, Union, Any
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import select
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm.interfaces import LoaderOption
|
||||
from sqlalchemy.engine import Row
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.models.knowledge import Entity, Observation, Relation
|
||||
@@ -31,6 +32,18 @@ class EntityRepository(Repository[Entity]):
|
||||
"""
|
||||
super().__init__(session_maker, Entity, project_id=project_id)
|
||||
|
||||
async def get_by_id(self, entity_id: int) -> Optional[Entity]:
|
||||
"""Get entity by numeric ID.
|
||||
|
||||
Args:
|
||||
entity_id: Numeric entity ID
|
||||
|
||||
Returns:
|
||||
Entity if found, None otherwise
|
||||
"""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
return await self.select_by_id(session, entity_id)
|
||||
|
||||
async def get_by_permalink(self, permalink: str) -> Optional[Entity]:
|
||||
"""Get entity by permalink.
|
||||
|
||||
@@ -63,6 +76,34 @@ class EntityRepository(Repository[Entity]):
|
||||
)
|
||||
return await self.find_one(query)
|
||||
|
||||
async def get_by_file_paths(
|
||||
self, session: AsyncSession, file_paths: Sequence[Union[Path, str]]
|
||||
) -> List[Row[Any]]:
|
||||
"""Get file paths and checksums for multiple entities (optimized for change detection).
|
||||
|
||||
Only queries file_path and checksum columns, skips loading full entities and relationships.
|
||||
This is much faster than loading complete Entity objects when you only need checksums.
|
||||
|
||||
Args:
|
||||
session: Database session to use for the query
|
||||
file_paths: List of file paths to query
|
||||
|
||||
Returns:
|
||||
List of (file_path, checksum) tuples for matching entities
|
||||
"""
|
||||
if not file_paths:
|
||||
return []
|
||||
|
||||
# Convert all paths to POSIX strings for consistent comparison
|
||||
posix_paths = [Path(fp).as_posix() for fp in file_paths]
|
||||
|
||||
# Query ONLY file_path and checksum columns (not full Entity objects)
|
||||
query = select(Entity.file_path, Entity.checksum).where(Entity.file_path.in_(posix_paths))
|
||||
query = self._add_project_filter(query)
|
||||
|
||||
result = await session.execute(query)
|
||||
return list(result.all())
|
||||
|
||||
async def find_by_checksum(self, checksum: str) -> Sequence[Entity]:
|
||||
"""Find entities with the given checksum.
|
||||
|
||||
@@ -80,6 +121,34 @@ class EntityRepository(Repository[Entity]):
|
||||
result = await self.execute_query(query, use_query_options=False)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def find_by_checksums(self, checksums: Sequence[str]) -> Sequence[Entity]:
|
||||
"""Find entities with any of the given checksums (batch query for move detection).
|
||||
|
||||
This is a batch-optimized version of find_by_checksum() that queries multiple checksums
|
||||
in a single database query. Used for efficient move detection in cloud indexing.
|
||||
|
||||
Performance: For 1000 new files, this makes 1 query vs 1000 individual queries (~100x faster).
|
||||
|
||||
Example:
|
||||
When processing new files, we check if any are actually moved files by finding
|
||||
entities with matching checksums at different paths.
|
||||
|
||||
Args:
|
||||
checksums: List of file content checksums to search for
|
||||
|
||||
Returns:
|
||||
Sequence of entities with matching checksums (may be empty).
|
||||
Multiple entities may have the same checksum if files were copied.
|
||||
"""
|
||||
if not checksums:
|
||||
return []
|
||||
|
||||
# Query: SELECT * FROM entities WHERE checksum IN (checksum1, checksum2, ...)
|
||||
query = self.select().where(Entity.checksum.in_(checksums))
|
||||
# Don't load relationships for move detection - we only need file_path and checksum
|
||||
result = await self.execute_query(query, use_query_options=False)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def delete_by_file_path(self, file_path: Union[Path, str]) -> bool:
|
||||
"""Delete entity with the provided file_path.
|
||||
|
||||
@@ -155,8 +224,13 @@ class EntityRepository(Repository[Entity]):
|
||||
|
||||
except IntegrityError as e:
|
||||
# Check if this is a FOREIGN KEY constraint failure
|
||||
# SQLite: "FOREIGN KEY constraint failed"
|
||||
# Postgres: "violates foreign key constraint"
|
||||
error_str = str(e)
|
||||
if "FOREIGN KEY constraint failed" in error_str:
|
||||
if (
|
||||
"FOREIGN KEY constraint failed" in error_str
|
||||
or "violates foreign key constraint" in error_str
|
||||
):
|
||||
# Import locally to avoid circular dependency (repository -> services -> repository)
|
||||
from basic_memory.services.exceptions import SyncFatalError
|
||||
|
||||
@@ -310,5 +384,26 @@ class EntityRepository(Repository[Entity]):
|
||||
|
||||
# Insert with unique permalink
|
||||
session.add(entity)
|
||||
await session.flush()
|
||||
try:
|
||||
await session.flush()
|
||||
except IntegrityError as e:
|
||||
# Check if this is a FOREIGN KEY constraint failure
|
||||
# SQLite: "FOREIGN KEY constraint failed"
|
||||
# Postgres: "violates foreign key constraint"
|
||||
error_str = str(e)
|
||||
if (
|
||||
"FOREIGN KEY constraint failed" in error_str
|
||||
or "violates foreign key constraint" in error_str
|
||||
):
|
||||
# Import locally to avoid circular dependency (repository -> services -> repository)
|
||||
from basic_memory.services.exceptions import SyncFatalError
|
||||
|
||||
# Project doesn't exist in database - this is a fatal sync error
|
||||
raise SyncFatalError(
|
||||
f"Cannot sync file '{entity.file_path}': "
|
||||
f"project_id={entity.project_id} does not exist in database. "
|
||||
f"The project may have been deleted. This sync will be terminated."
|
||||
) from e
|
||||
# Re-raise if not a foreign key error
|
||||
raise
|
||||
return entity
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"""PostgreSQL tsvector-based search repository implementation."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import text
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.repository.search_index_row import SearchIndexRow
|
||||
from basic_memory.repository.search_repository_base import SearchRepositoryBase
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
|
||||
class PostgresSearchRepository(SearchRepositoryBase):
|
||||
"""PostgreSQL tsvector implementation of search repository.
|
||||
|
||||
Uses PostgreSQL's full-text search capabilities with:
|
||||
- tsvector for document representation
|
||||
- tsquery for query representation
|
||||
- GIN indexes for performance
|
||||
- ts_rank() function for relevance scoring
|
||||
- JSONB containment operators for metadata search
|
||||
"""
|
||||
|
||||
async def init_search_index(self):
|
||||
"""Create Postgres table with tsvector column and GIN indexes.
|
||||
|
||||
Note: This is handled by Alembic migrations. This method is a no-op
|
||||
for Postgres as the schema is created via migrations.
|
||||
"""
|
||||
logger.info("PostgreSQL search index initialization handled by migrations")
|
||||
# Table creation is done via Alembic migrations
|
||||
# This includes:
|
||||
# - CREATE TABLE search_index (...)
|
||||
# - ADD COLUMN textsearchable_index_col tsvector GENERATED ALWAYS AS (...)
|
||||
# - CREATE INDEX USING GIN on textsearchable_index_col
|
||||
# - CREATE INDEX USING GIN on metadata jsonb_path_ops
|
||||
pass
|
||||
|
||||
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a search term for tsquery format.
|
||||
|
||||
Args:
|
||||
term: The search term to prepare
|
||||
is_prefix: Whether to add prefix search capability (:* operator)
|
||||
|
||||
Returns:
|
||||
Formatted search term for tsquery
|
||||
|
||||
For Postgres:
|
||||
- Boolean operators are converted to tsquery format (&, |, !)
|
||||
- Prefix matching uses the :* operator
|
||||
- Terms are sanitized to prevent tsquery syntax errors
|
||||
"""
|
||||
# Check for explicit boolean operators
|
||||
boolean_operators = [" AND ", " OR ", " NOT "]
|
||||
if any(op in f" {term} " for op in boolean_operators):
|
||||
return self._prepare_boolean_query(term)
|
||||
|
||||
# For non-Boolean queries, prepare single term
|
||||
return self._prepare_single_term(term, is_prefix)
|
||||
|
||||
def _prepare_boolean_query(self, query: str) -> str:
|
||||
"""Convert Boolean query to tsquery format.
|
||||
|
||||
Args:
|
||||
query: A Boolean query like "coffee AND brewing" or "(pour OR french) AND press"
|
||||
|
||||
Returns:
|
||||
tsquery-formatted string with & (AND), | (OR), ! (NOT) operators
|
||||
|
||||
Examples:
|
||||
"coffee AND brewing" -> "coffee & brewing"
|
||||
"(pour OR french) AND press" -> "(pour | french) & press"
|
||||
"coffee NOT decaf" -> "coffee & !decaf"
|
||||
"""
|
||||
# Replace Boolean operators with tsquery operators
|
||||
# Keep parentheses for grouping
|
||||
result = query
|
||||
result = re.sub(r"\bAND\b", "&", result)
|
||||
result = re.sub(r"\bOR\b", "|", result)
|
||||
# NOT must be converted to "& !" and the ! must be attached to the following term
|
||||
# "Python NOT Django" -> "Python & !Django"
|
||||
result = re.sub(r"\bNOT\s+", "& !", result)
|
||||
|
||||
return result
|
||||
|
||||
def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a single search term for tsquery.
|
||||
|
||||
Args:
|
||||
term: A single search term
|
||||
is_prefix: Whether to add prefix search capability (:* suffix)
|
||||
|
||||
Returns:
|
||||
A properly formatted single term for tsquery
|
||||
|
||||
For Postgres tsquery:
|
||||
- Multi-word queries become "word1 & word2"
|
||||
- Prefix matching uses ":*" suffix (e.g., "coff:*")
|
||||
- Special characters that need escaping: & | ! ( ) :
|
||||
"""
|
||||
if not term or not term.strip():
|
||||
return term
|
||||
|
||||
term = term.strip()
|
||||
|
||||
# Check if term is already a wildcard pattern
|
||||
if "*" in term:
|
||||
# Replace * with :* for Postgres prefix matching
|
||||
return term.replace("*", ":*")
|
||||
|
||||
# Remove tsquery special characters from the search term
|
||||
# These characters have special meaning in tsquery and cause syntax errors
|
||||
# if not used as operators
|
||||
special_chars = ["&", "|", "!", "(", ")", ":"]
|
||||
cleaned_term = term
|
||||
for char in special_chars:
|
||||
cleaned_term = cleaned_term.replace(char, " ")
|
||||
|
||||
# Handle multi-word queries
|
||||
if " " in cleaned_term:
|
||||
words = [w for w in cleaned_term.split() if w.strip()]
|
||||
if not words:
|
||||
# All characters were special chars, search won't match anything
|
||||
# Return a safe search term that won't cause syntax errors
|
||||
return "NOSPECIALCHARS:*"
|
||||
if is_prefix:
|
||||
# Add prefix matching to each word
|
||||
prepared_words = [f"{word}:*" for word in words]
|
||||
else:
|
||||
prepared_words = words
|
||||
# Join with AND operator
|
||||
return " & ".join(prepared_words)
|
||||
|
||||
# Single word
|
||||
cleaned_term = cleaned_term.strip()
|
||||
if not cleaned_term:
|
||||
return "NOSPECIALCHARS:*"
|
||||
if is_prefix:
|
||||
return f"{cleaned_term}:*"
|
||||
else:
|
||||
return cleaned_term
|
||||
|
||||
async def search(
|
||||
self,
|
||||
search_text: Optional[str] = None,
|
||||
permalink: Optional[str] = None,
|
||||
permalink_match: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content using PostgreSQL tsvector."""
|
||||
conditions = []
|
||||
params = {}
|
||||
order_by_clause = ""
|
||||
|
||||
# Handle text search for title and content using tsvector
|
||||
if search_text:
|
||||
if search_text.strip() == "*" or search_text.strip() == "":
|
||||
# For wildcard searches, don't add any text conditions
|
||||
pass
|
||||
else:
|
||||
# Prepare search term for tsquery
|
||||
processed_text = self._prepare_search_term(search_text.strip())
|
||||
params["text"] = processed_text
|
||||
# Use @@ operator for tsvector matching
|
||||
conditions.append("textsearchable_index_col @@ to_tsquery('english', :text)")
|
||||
|
||||
# Handle title search
|
||||
if title:
|
||||
title_text = self._prepare_search_term(title.strip(), is_prefix=False)
|
||||
params["title_text"] = title_text
|
||||
conditions.append("to_tsvector('english', title) @@ to_tsquery('english', :title_text)")
|
||||
|
||||
# Handle permalink exact search
|
||||
if permalink:
|
||||
params["permalink"] = permalink
|
||||
conditions.append("permalink = :permalink")
|
||||
|
||||
# Handle permalink pattern match
|
||||
if permalink_match:
|
||||
permalink_text = permalink_match.lower().strip()
|
||||
params["permalink"] = permalink_text
|
||||
if "*" in permalink_match:
|
||||
# Use LIKE for pattern matching in Postgres
|
||||
# Convert * to % for SQL LIKE
|
||||
permalink_pattern = permalink_text.replace("*", "%")
|
||||
params["permalink"] = permalink_pattern
|
||||
conditions.append("permalink LIKE :permalink")
|
||||
else:
|
||||
conditions.append("permalink = :permalink")
|
||||
|
||||
# Handle search item type filter
|
||||
if search_item_types:
|
||||
type_list = ", ".join(f"'{t.value}'" for t in search_item_types)
|
||||
conditions.append(f"type IN ({type_list})")
|
||||
|
||||
# Handle entity type filter using JSONB containment
|
||||
if types:
|
||||
# Use JSONB @> operator for efficient containment queries
|
||||
type_conditions = []
|
||||
for entity_type in types:
|
||||
# Create JSONB containment condition for each type
|
||||
type_conditions.append(f'metadata @> \'{{"entity_type": "{entity_type}"}}\'')
|
||||
conditions.append(f"({' OR '.join(type_conditions)})")
|
||||
|
||||
# Handle date filter
|
||||
if after_date:
|
||||
params["after_date"] = after_date
|
||||
conditions.append("created_at > :after_date")
|
||||
# order by most recent first
|
||||
order_by_clause = ", updated_at DESC"
|
||||
|
||||
# Always filter by project_id
|
||||
params["project_id"] = self.project_id
|
||||
conditions.append("project_id = :project_id")
|
||||
|
||||
# set limit and offset
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
|
||||
# Build SQL with ts_rank() for scoring
|
||||
# Note: If no text search, score will be NULL, so we use COALESCE to default to 0
|
||||
if search_text and search_text.strip() and search_text.strip() != "*":
|
||||
score_expr = "ts_rank(textsearchable_index_col, to_tsquery('english', :text))"
|
||||
else:
|
||||
score_expr = "0"
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
project_id,
|
||||
id,
|
||||
title,
|
||||
permalink,
|
||||
file_path,
|
||||
type,
|
||||
metadata,
|
||||
from_id,
|
||||
to_id,
|
||||
relation_type,
|
||||
entity_id,
|
||||
content_snippet,
|
||||
category,
|
||||
created_at,
|
||||
updated_at,
|
||||
{score_expr} as score
|
||||
FROM search_index
|
||||
WHERE {where_clause}
|
||||
ORDER BY score DESC {order_by_clause}
|
||||
LIMIT :limit
|
||||
OFFSET :offset
|
||||
"""
|
||||
|
||||
logger.trace(f"Search {sql} params: {params}")
|
||||
try:
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
result = await session.execute(text(sql), params)
|
||||
rows = result.fetchall()
|
||||
except Exception as e:
|
||||
# Handle tsquery syntax errors
|
||||
if "tsquery" in str(e).lower() or "syntax error" in str(e).lower(): # pragma: no cover
|
||||
logger.warning(f"tsquery syntax error for search term: {search_text}, error: {e}")
|
||||
# Return empty results rather than crashing
|
||||
return []
|
||||
else:
|
||||
# Re-raise other database errors
|
||||
logger.error(f"Database error during search: {e}")
|
||||
raise
|
||||
|
||||
results = [
|
||||
SearchIndexRow(
|
||||
project_id=self.project_id,
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
permalink=row.permalink,
|
||||
file_path=row.file_path,
|
||||
type=row.type,
|
||||
score=float(row.score) if row.score else 0.0,
|
||||
metadata=(
|
||||
row.metadata
|
||||
if isinstance(row.metadata, dict)
|
||||
else (json.loads(row.metadata) if row.metadata else {})
|
||||
),
|
||||
from_id=row.from_id,
|
||||
to_id=row.to_id,
|
||||
relation_type=row.relation_type,
|
||||
entity_id=row.entity_id,
|
||||
content_snippet=row.content_snippet,
|
||||
category=row.category,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
logger.trace(f"Found {len(results)} search results")
|
||||
for r in results:
|
||||
logger.trace(
|
||||
f"Search result: project_id: {r.project_id} type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]) -> None:
|
||||
"""Index multiple items in a single batch operation using UPSERT.
|
||||
|
||||
Uses INSERT ... ON CONFLICT DO UPDATE to handle re-indexing of existing
|
||||
entities (e.g., during forward reference resolution) without requiring
|
||||
a separate delete operation. This eliminates race conditions between
|
||||
delete and insert operations in separate transactions.
|
||||
|
||||
Args:
|
||||
search_index_rows: List of SearchIndexRow objects to index
|
||||
"""
|
||||
|
||||
if not search_index_rows:
|
||||
return
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
# When using text() raw SQL, always serialize JSON to string
|
||||
# Both SQLite (TEXT) and Postgres (JSONB) accept JSON strings in raw SQL
|
||||
# The database driver/column type will handle conversion
|
||||
insert_data_list = []
|
||||
for row in search_index_rows:
|
||||
insert_data = row.to_insert(serialize_json=True)
|
||||
insert_data["project_id"] = self.project_id
|
||||
insert_data_list.append(insert_data)
|
||||
|
||||
# Use UPSERT (INSERT ... ON CONFLICT) to handle re-indexing
|
||||
# Primary key is (id, type, project_id)
|
||||
# This handles race conditions during forward reference resolution
|
||||
# where an entity might be re-indexed before the delete commits
|
||||
# Syntax works for both SQLite 3.24+ and PostgreSQL
|
||||
await session.execute(
|
||||
text("""
|
||||
INSERT INTO search_index (
|
||||
id, title, content_stems, content_snippet, permalink, file_path, type, metadata,
|
||||
from_id, to_id, relation_type,
|
||||
entity_id, category,
|
||||
created_at, updated_at,
|
||||
project_id
|
||||
) VALUES (
|
||||
:id, :title, :content_stems, :content_snippet, :permalink, :file_path, :type, :metadata,
|
||||
:from_id, :to_id, :relation_type,
|
||||
:entity_id, :category,
|
||||
:created_at, :updated_at,
|
||||
:project_id
|
||||
)
|
||||
ON CONFLICT (id, type, project_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
content_stems = EXCLUDED.content_stems,
|
||||
content_snippet = EXCLUDED.content_snippet,
|
||||
permalink = EXCLUDED.permalink,
|
||||
file_path = EXCLUDED.file_path,
|
||||
metadata = EXCLUDED.metadata,
|
||||
from_id = EXCLUDED.from_id,
|
||||
to_id = EXCLUDED.to_id,
|
||||
relation_type = EXCLUDED.relation_type,
|
||||
entity_id = EXCLUDED.entity_id,
|
||||
category = EXCLUDED.category,
|
||||
created_at = EXCLUDED.created_at,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
"""),
|
||||
insert_data_list,
|
||||
)
|
||||
logger.debug(f"Bulk indexed {len(search_index_rows)} rows")
|
||||
await session.commit()
|
||||
@@ -49,6 +49,18 @@ class ProjectRepository(Repository[Project]):
|
||||
query = self.select().where(Project.path == Path(path).as_posix())
|
||||
return await self.find_one(query)
|
||||
|
||||
async def get_by_id(self, project_id: int) -> Optional[Project]:
|
||||
"""Get project by numeric ID.
|
||||
|
||||
Args:
|
||||
project_id: Numeric project ID
|
||||
|
||||
Returns:
|
||||
Project if found, None otherwise
|
||||
"""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
return await self.select_by_id(session, project_id)
|
||||
|
||||
async def get_default_project(self) -> Optional[Project]:
|
||||
"""Get the default project (the one marked as is_default=True)."""
|
||||
query = self.select().where(Project.is_default.is_not(None))
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Search index data structures."""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchIndexRow:
|
||||
"""Search result with score and metadata."""
|
||||
|
||||
project_id: int
|
||||
id: int
|
||||
type: str
|
||||
file_path: str
|
||||
|
||||
# date values
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
permalink: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
# assigned in result
|
||||
score: Optional[float] = None
|
||||
|
||||
# Type-specific fields
|
||||
title: Optional[str] = None # entity
|
||||
content_stems: Optional[str] = None # entity, observation
|
||||
content_snippet: Optional[str] = None # entity, observation
|
||||
entity_id: Optional[int] = None # observations
|
||||
category: Optional[str] = None # observations
|
||||
from_id: Optional[int] = None # relations
|
||||
to_id: Optional[int] = None # relations
|
||||
relation_type: Optional[str] = None # relations
|
||||
|
||||
@property
|
||||
def content(self):
|
||||
return self.content_snippet
|
||||
|
||||
@property
|
||||
def directory(self) -> str:
|
||||
"""Extract directory part from file_path.
|
||||
|
||||
For a file at "projects/notes/ideas.md", returns "/projects/notes"
|
||||
For a file at root level "README.md", returns "/"
|
||||
"""
|
||||
if not self.type == SearchItemType.ENTITY.value and not self.file_path:
|
||||
return ""
|
||||
|
||||
# Normalize path separators to handle both Windows (\) and Unix (/) paths
|
||||
normalized_path = Path(self.file_path).as_posix()
|
||||
|
||||
# Split the path by slashes
|
||||
parts = normalized_path.split("/")
|
||||
|
||||
# If there's only one part (e.g., "README.md"), it's at the root
|
||||
if len(parts) <= 1:
|
||||
return "/"
|
||||
|
||||
# Join all parts except the last one (filename)
|
||||
directory_path = "/".join(parts[:-1])
|
||||
return f"/{directory_path}"
|
||||
|
||||
def to_insert(self, serialize_json: bool = True):
|
||||
"""Convert to dict for database insertion.
|
||||
|
||||
Args:
|
||||
serialize_json: If True, converts metadata dict to JSON string (for SQLite).
|
||||
If False, keeps metadata as dict (for Postgres JSONB).
|
||||
"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"content_stems": self.content_stems,
|
||||
"content_snippet": self.content_snippet,
|
||||
"permalink": self.permalink,
|
||||
"file_path": self.file_path,
|
||||
"type": self.type,
|
||||
"metadata": json.dumps(self.metadata)
|
||||
if serialize_json and self.metadata
|
||||
else self.metadata,
|
||||
"from_id": self.from_id,
|
||||
"to_id": self.to_id,
|
||||
"relation_type": self.relation_type,
|
||||
"entity_id": self.entity_id,
|
||||
"category": self.category,
|
||||
"created_at": self.created_at if self.created_at else None,
|
||||
"updated_at": self.updated_at if self.updated_at else None,
|
||||
"project_id": self.project_id,
|
||||
}
|
||||
@@ -1,365 +1,35 @@
|
||||
"""Repository for search operations."""
|
||||
"""Repository for search operations.
|
||||
|
||||
This module provides the search repository interface.
|
||||
The actual repository implementations are backend-specific:
|
||||
- SQLiteSearchRepository: Uses FTS5 virtual tables
|
||||
- PostgresSearchRepository: Uses tsvector/tsquery with GIN indexes
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Protocol
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import Executable, Result, text
|
||||
from sqlalchemy import Result
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.models.search import CREATE_SEARCH_INDEX
|
||||
from basic_memory.config import ConfigManager, DatabaseBackend
|
||||
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
|
||||
from basic_memory.repository.search_index_row import SearchIndexRow
|
||||
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchIndexRow:
|
||||
"""Search result with score and metadata."""
|
||||
class SearchRepository(Protocol):
|
||||
"""Protocol defining the search repository interface.
|
||||
|
||||
Both SQLite and Postgres implementations must satisfy this protocol.
|
||||
"""
|
||||
|
||||
project_id: int
|
||||
id: int
|
||||
type: str
|
||||
file_path: str
|
||||
|
||||
# date values
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
permalink: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
# assigned in result
|
||||
score: Optional[float] = None
|
||||
|
||||
# Type-specific fields
|
||||
title: Optional[str] = None # entity
|
||||
content_stems: Optional[str] = None # entity, observation
|
||||
content_snippet: Optional[str] = None # entity, observation
|
||||
entity_id: Optional[int] = None # observations
|
||||
category: Optional[str] = None # observations
|
||||
from_id: Optional[int] = None # relations
|
||||
to_id: Optional[int] = None # relations
|
||||
relation_type: Optional[str] = None # relations
|
||||
|
||||
@property
|
||||
def content(self):
|
||||
return self.content_snippet
|
||||
|
||||
@property
|
||||
def directory(self) -> str:
|
||||
"""Extract directory part from file_path.
|
||||
|
||||
For a file at "projects/notes/ideas.md", returns "/projects/notes"
|
||||
For a file at root level "README.md", returns "/"
|
||||
"""
|
||||
if not self.type == SearchItemType.ENTITY.value and not self.file_path:
|
||||
return ""
|
||||
|
||||
# Normalize path separators to handle both Windows (\) and Unix (/) paths
|
||||
normalized_path = Path(self.file_path).as_posix()
|
||||
|
||||
# Split the path by slashes
|
||||
parts = normalized_path.split("/")
|
||||
|
||||
# If there's only one part (e.g., "README.md"), it's at the root
|
||||
if len(parts) <= 1:
|
||||
return "/"
|
||||
|
||||
# Join all parts except the last one (filename)
|
||||
directory_path = "/".join(parts[:-1])
|
||||
return f"/{directory_path}"
|
||||
|
||||
def to_insert(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"content_stems": self.content_stems,
|
||||
"content_snippet": self.content_snippet,
|
||||
"permalink": self.permalink,
|
||||
"file_path": self.file_path,
|
||||
"type": self.type,
|
||||
"metadata": json.dumps(self.metadata),
|
||||
"from_id": self.from_id,
|
||||
"to_id": self.to_id,
|
||||
"relation_type": self.relation_type,
|
||||
"entity_id": self.entity_id,
|
||||
"category": self.category,
|
||||
"created_at": self.created_at if self.created_at else None,
|
||||
"updated_at": self.updated_at if self.updated_at else None,
|
||||
"project_id": self.project_id,
|
||||
}
|
||||
|
||||
|
||||
class SearchRepository:
|
||||
"""Repository for search index operations."""
|
||||
|
||||
def __init__(self, session_maker: async_sessionmaker[AsyncSession], project_id: int):
|
||||
"""Initialize with session maker and project_id filter.
|
||||
|
||||
Args:
|
||||
session_maker: SQLAlchemy session maker
|
||||
project_id: Project ID to filter all operations by
|
||||
|
||||
Raises:
|
||||
ValueError: If project_id is None or invalid
|
||||
"""
|
||||
if project_id is None or project_id <= 0: # pragma: no cover
|
||||
raise ValueError("A valid project_id is required for SearchRepository")
|
||||
|
||||
self.session_maker = session_maker
|
||||
self.project_id = project_id
|
||||
|
||||
async def init_search_index(self):
|
||||
"""Create or recreate the search index."""
|
||||
logger.info("Initializing search index")
|
||||
try:
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await session.execute(CREATE_SEARCH_INDEX)
|
||||
await session.commit()
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error initializing search index: {e}")
|
||||
raise e
|
||||
|
||||
def _prepare_boolean_query(self, query: str) -> str:
|
||||
"""Prepare a Boolean query by quoting individual terms while preserving operators.
|
||||
|
||||
Args:
|
||||
query: A Boolean query like "tier1-test AND unicode" or "(hello OR world) NOT test"
|
||||
|
||||
Returns:
|
||||
A properly formatted Boolean query with quoted terms that need quoting
|
||||
"""
|
||||
# Define Boolean operators and their boundaries
|
||||
boolean_pattern = r"(\bAND\b|\bOR\b|\bNOT\b)"
|
||||
|
||||
# Split the query by Boolean operators, keeping the operators
|
||||
parts = re.split(boolean_pattern, query)
|
||||
|
||||
processed_parts = []
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
# If it's a Boolean operator, keep it as is
|
||||
if part in ["AND", "OR", "NOT"]:
|
||||
processed_parts.append(part)
|
||||
else:
|
||||
# Handle parentheses specially - they should be preserved for grouping
|
||||
if "(" in part or ")" in part:
|
||||
# Parse parenthetical expressions carefully
|
||||
processed_part = self._prepare_parenthetical_term(part)
|
||||
processed_parts.append(processed_part)
|
||||
else:
|
||||
# This is a search term - for Boolean queries, don't add prefix wildcards
|
||||
prepared_term = self._prepare_single_term(part, is_prefix=False)
|
||||
processed_parts.append(prepared_term)
|
||||
|
||||
return " ".join(processed_parts)
|
||||
|
||||
def _prepare_parenthetical_term(self, term: str) -> str:
|
||||
"""Prepare a term that contains parentheses, preserving the parentheses for grouping.
|
||||
|
||||
Args:
|
||||
term: A term that may contain parentheses like "(hello" or "world)" or "(hello OR world)"
|
||||
|
||||
Returns:
|
||||
A properly formatted term with parentheses preserved
|
||||
"""
|
||||
# Handle terms that start/end with parentheses but may contain quotable content
|
||||
result = ""
|
||||
i = 0
|
||||
while i < len(term):
|
||||
if term[i] in "()":
|
||||
# Preserve parentheses as-is
|
||||
result += term[i]
|
||||
i += 1
|
||||
else:
|
||||
# Find the next parenthesis or end of string
|
||||
start = i
|
||||
while i < len(term) and term[i] not in "()":
|
||||
i += 1
|
||||
|
||||
# Extract the content between parentheses
|
||||
content = term[start:i].strip()
|
||||
if content:
|
||||
# Only quote if it actually needs quoting (has hyphens, special chars, etc)
|
||||
# but don't quote if it's just simple words
|
||||
if self._needs_quoting(content):
|
||||
escaped_content = content.replace('"', '""')
|
||||
result += f'"{escaped_content}"'
|
||||
else:
|
||||
result += content
|
||||
|
||||
return result
|
||||
|
||||
def _needs_quoting(self, term: str) -> bool:
|
||||
"""Check if a term needs to be quoted for FTS5 safety.
|
||||
|
||||
Args:
|
||||
term: The term to check
|
||||
|
||||
Returns:
|
||||
True if the term should be quoted
|
||||
"""
|
||||
if not term or not term.strip():
|
||||
return False
|
||||
|
||||
# Characters that indicate we should quote (excluding parentheses which are valid syntax)
|
||||
needs_quoting_chars = [
|
||||
" ",
|
||||
".",
|
||||
":",
|
||||
";",
|
||||
",",
|
||||
"<",
|
||||
">",
|
||||
"?",
|
||||
"/",
|
||||
"-",
|
||||
"'",
|
||||
'"',
|
||||
"[",
|
||||
"]",
|
||||
"{",
|
||||
"}",
|
||||
"+",
|
||||
"!",
|
||||
"@",
|
||||
"#",
|
||||
"$",
|
||||
"%",
|
||||
"^",
|
||||
"&",
|
||||
"=",
|
||||
"|",
|
||||
"\\",
|
||||
"~",
|
||||
"`",
|
||||
]
|
||||
|
||||
return any(c in term for c in needs_quoting_chars)
|
||||
|
||||
def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a single search term (no Boolean operators).
|
||||
|
||||
Args:
|
||||
term: A single search term
|
||||
is_prefix: Whether to add prefix search capability (* suffix)
|
||||
|
||||
Returns:
|
||||
A properly formatted single term
|
||||
"""
|
||||
if not term or not term.strip():
|
||||
return term
|
||||
|
||||
term = term.strip()
|
||||
|
||||
# Check if term is already a proper wildcard pattern (alphanumeric + *)
|
||||
# e.g., "hello*", "test*world" - these should be left alone
|
||||
if "*" in term and all(c.isalnum() or c in "*_-" for c in term):
|
||||
return term
|
||||
|
||||
# Characters that can cause FTS5 syntax errors when used as operators
|
||||
# We're more conservative here - only quote when we detect problematic patterns
|
||||
problematic_chars = [
|
||||
'"',
|
||||
"'",
|
||||
"(",
|
||||
")",
|
||||
"[",
|
||||
"]",
|
||||
"{",
|
||||
"}",
|
||||
"+",
|
||||
"!",
|
||||
"@",
|
||||
"#",
|
||||
"$",
|
||||
"%",
|
||||
"^",
|
||||
"&",
|
||||
"=",
|
||||
"|",
|
||||
"\\",
|
||||
"~",
|
||||
"`",
|
||||
]
|
||||
|
||||
# Characters that indicate we should quote (spaces, dots, colons, etc.)
|
||||
# Adding hyphens here because FTS5 can have issues with hyphens followed by wildcards
|
||||
needs_quoting_chars = [" ", ".", ":", ";", ",", "<", ">", "?", "/", "-"]
|
||||
|
||||
# Check if term needs quoting
|
||||
has_problematic = any(c in term for c in problematic_chars)
|
||||
has_spaces_or_special = any(c in term for c in needs_quoting_chars)
|
||||
|
||||
if has_problematic or has_spaces_or_special:
|
||||
# Handle multi-word queries differently from special character queries
|
||||
if " " in term and not any(c in term for c in problematic_chars):
|
||||
# Check if any individual word contains special characters that need quoting
|
||||
words = term.strip().split()
|
||||
has_special_in_words = any(
|
||||
any(c in word for c in needs_quoting_chars if c != " ") for word in words
|
||||
)
|
||||
|
||||
if not has_special_in_words:
|
||||
# For multi-word queries with simple words (like "emoji unicode"),
|
||||
# use boolean AND to handle word order variations
|
||||
if is_prefix:
|
||||
# Add prefix wildcard to each word for better matching
|
||||
prepared_words = [f"{word}*" for word in words if word]
|
||||
else:
|
||||
prepared_words = words
|
||||
term = " AND ".join(prepared_words)
|
||||
else:
|
||||
# If any word has special characters, quote the entire phrase
|
||||
escaped_term = term.replace('"', '""')
|
||||
if is_prefix and not ("/" in term and term.endswith(".md")):
|
||||
term = f'"{escaped_term}"*'
|
||||
else:
|
||||
term = f'"{escaped_term}"'
|
||||
else:
|
||||
# For terms with problematic characters or file paths, use exact phrase matching
|
||||
# Escape any existing quotes by doubling them
|
||||
escaped_term = term.replace('"', '""')
|
||||
# Quote the entire term to handle special characters safely
|
||||
if is_prefix and not ("/" in term and term.endswith(".md")):
|
||||
# For search terms (not file paths), add prefix matching
|
||||
term = f'"{escaped_term}"*'
|
||||
else:
|
||||
# For file paths, use exact matching
|
||||
term = f'"{escaped_term}"'
|
||||
elif is_prefix:
|
||||
# Only add wildcard for simple terms without special characters
|
||||
term = f"{term}*"
|
||||
|
||||
return term
|
||||
|
||||
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a search term for FTS5 query.
|
||||
|
||||
Args:
|
||||
term: The search term to prepare
|
||||
is_prefix: Whether to add prefix search capability (* suffix)
|
||||
|
||||
For FTS5:
|
||||
- Boolean operators (AND, OR, NOT) are preserved for complex queries
|
||||
- Terms with FTS5 special characters are quoted to prevent syntax errors
|
||||
- Simple terms get prefix wildcards for better matching
|
||||
"""
|
||||
# Check for explicit boolean operators - if present, process as Boolean query
|
||||
boolean_operators = [" AND ", " OR ", " NOT "]
|
||||
if any(op in f" {term} " for op in boolean_operators):
|
||||
return self._prepare_boolean_query(term)
|
||||
|
||||
# For non-Boolean queries, use the single term preparation logic
|
||||
return self._prepare_single_term(term, is_prefix)
|
||||
async def init_search_index(self) -> None:
|
||||
"""Initialize the search index schema."""
|
||||
...
|
||||
|
||||
async def search(
|
||||
self,
|
||||
@@ -373,267 +43,52 @@ class SearchRepository:
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content with fuzzy matching."""
|
||||
conditions = []
|
||||
params = {}
|
||||
order_by_clause = ""
|
||||
"""Search across indexed content."""
|
||||
...
|
||||
|
||||
# Handle text search for title and content
|
||||
if search_text:
|
||||
# Skip FTS for wildcard-only queries that would cause "unknown special query" errors
|
||||
if search_text.strip() == "*" or search_text.strip() == "":
|
||||
# For wildcard searches, don't add any text conditions - return all results
|
||||
pass
|
||||
else:
|
||||
# Use _prepare_search_term to handle both Boolean and non-Boolean queries
|
||||
processed_text = self._prepare_search_term(search_text.strip())
|
||||
params["text"] = processed_text
|
||||
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
|
||||
async def index_item(self, search_index_row: SearchIndexRow) -> None:
|
||||
"""Index a single item."""
|
||||
...
|
||||
|
||||
# Handle title match search
|
||||
if title:
|
||||
title_text = self._prepare_search_term(title.strip(), is_prefix=False)
|
||||
params["title_text"] = title_text
|
||||
conditions.append("title MATCH :title_text")
|
||||
async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]) -> None:
|
||||
"""Index multiple items in a batch."""
|
||||
...
|
||||
|
||||
# Handle permalink exact search
|
||||
if permalink:
|
||||
params["permalink"] = permalink
|
||||
conditions.append("permalink = :permalink")
|
||||
async def delete_by_permalink(self, permalink: str) -> None:
|
||||
"""Delete item by permalink."""
|
||||
...
|
||||
|
||||
# Handle permalink match search, supports *
|
||||
if permalink_match:
|
||||
# For GLOB patterns, don't use _prepare_search_term as it will quote slashes
|
||||
# GLOB patterns need to preserve their syntax
|
||||
permalink_text = permalink_match.lower().strip()
|
||||
params["permalink"] = permalink_text
|
||||
if "*" in permalink_match:
|
||||
conditions.append("permalink GLOB :permalink")
|
||||
else:
|
||||
# For exact matches without *, we can use FTS5 MATCH
|
||||
# but only prepare the term if it doesn't look like a path
|
||||
if "/" in permalink_text:
|
||||
conditions.append("permalink = :permalink")
|
||||
else:
|
||||
permalink_text = self._prepare_search_term(permalink_text, is_prefix=False)
|
||||
params["permalink"] = permalink_text
|
||||
conditions.append("permalink MATCH :permalink")
|
||||
async def delete_by_entity_id(self, entity_id: int) -> None:
|
||||
"""Delete items by entity ID."""
|
||||
...
|
||||
|
||||
# Handle entity type filter
|
||||
if search_item_types:
|
||||
type_list = ", ".join(f"'{t.value}'" for t in search_item_types)
|
||||
conditions.append(f"type IN ({type_list})")
|
||||
async def execute_query(self, query, params: dict) -> Result:
|
||||
"""Execute a raw SQL query."""
|
||||
...
|
||||
|
||||
# Handle type filter
|
||||
if types:
|
||||
type_list = ", ".join(f"'{t}'" for t in types)
|
||||
conditions.append(f"json_extract(metadata, '$.entity_type') IN ({type_list})")
|
||||
|
||||
# Handle date filter using datetime() for proper comparison
|
||||
if after_date:
|
||||
params["after_date"] = after_date
|
||||
conditions.append("datetime(created_at) > datetime(:after_date)")
|
||||
def create_search_repository(
|
||||
session_maker: async_sessionmaker[AsyncSession], project_id: int
|
||||
) -> SearchRepository:
|
||||
"""Factory function to create the appropriate search repository based on database backend.
|
||||
|
||||
# order by most recent first
|
||||
order_by_clause = ", updated_at DESC"
|
||||
Args:
|
||||
session_maker: SQLAlchemy async session maker
|
||||
project_id: Project ID for the repository
|
||||
|
||||
# Always filter by project_id
|
||||
params["project_id"] = self.project_id
|
||||
conditions.append("project_id = :project_id")
|
||||
Returns:
|
||||
SearchRepository: Backend-appropriate search repository instance
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
|
||||
# set limit on search query
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
if config.database_backend == DatabaseBackend.POSTGRES:
|
||||
return PostgresSearchRepository(session_maker, project_id=project_id)
|
||||
else:
|
||||
return SQLiteSearchRepository(session_maker, project_id=project_id)
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
project_id,
|
||||
id,
|
||||
title,
|
||||
permalink,
|
||||
file_path,
|
||||
type,
|
||||
metadata,
|
||||
from_id,
|
||||
to_id,
|
||||
relation_type,
|
||||
entity_id,
|
||||
content_snippet,
|
||||
category,
|
||||
created_at,
|
||||
updated_at,
|
||||
bm25(search_index) as score
|
||||
FROM search_index
|
||||
WHERE {where_clause}
|
||||
ORDER BY score ASC {order_by_clause}
|
||||
LIMIT :limit
|
||||
OFFSET :offset
|
||||
"""
|
||||
|
||||
logger.trace(f"Search {sql} params: {params}")
|
||||
try:
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
result = await session.execute(text(sql), params)
|
||||
rows = result.fetchall()
|
||||
except Exception as e:
|
||||
# Handle FTS5 syntax errors and provide user-friendly feedback
|
||||
if "fts5: syntax error" in str(e).lower(): # pragma: no cover
|
||||
logger.warning(f"FTS5 syntax error for search term: {search_text}, error: {e}")
|
||||
# Return empty results rather than crashing
|
||||
return []
|
||||
else:
|
||||
# Re-raise other database errors
|
||||
logger.error(f"Database error during search: {e}")
|
||||
raise
|
||||
|
||||
results = [
|
||||
SearchIndexRow(
|
||||
project_id=self.project_id,
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
permalink=row.permalink,
|
||||
file_path=row.file_path,
|
||||
type=row.type,
|
||||
score=row.score,
|
||||
metadata=json.loads(row.metadata),
|
||||
from_id=row.from_id,
|
||||
to_id=row.to_id,
|
||||
relation_type=row.relation_type,
|
||||
entity_id=row.entity_id,
|
||||
content_snippet=row.content_snippet,
|
||||
category=row.category,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
logger.trace(f"Found {len(results)} search results")
|
||||
for r in results:
|
||||
logger.trace(
|
||||
f"Search result: project_id: {r.project_id} type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
async def index_item(
|
||||
self,
|
||||
search_index_row: SearchIndexRow,
|
||||
):
|
||||
"""Index or update a single item."""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
# Delete existing record if any
|
||||
await session.execute(
|
||||
text(
|
||||
"DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"
|
||||
),
|
||||
{"permalink": search_index_row.permalink, "project_id": self.project_id},
|
||||
)
|
||||
|
||||
# Prepare data for insert with project_id
|
||||
insert_data = search_index_row.to_insert()
|
||||
insert_data["project_id"] = self.project_id
|
||||
|
||||
# Insert new record
|
||||
await session.execute(
|
||||
text("""
|
||||
INSERT INTO search_index (
|
||||
id, title, content_stems, content_snippet, permalink, file_path, type, metadata,
|
||||
from_id, to_id, relation_type,
|
||||
entity_id, category,
|
||||
created_at, updated_at,
|
||||
project_id
|
||||
) VALUES (
|
||||
:id, :title, :content_stems, :content_snippet, :permalink, :file_path, :type, :metadata,
|
||||
:from_id, :to_id, :relation_type,
|
||||
:entity_id, :category,
|
||||
:created_at, :updated_at,
|
||||
:project_id
|
||||
)
|
||||
"""),
|
||||
insert_data,
|
||||
)
|
||||
logger.debug(f"indexed row {search_index_row}")
|
||||
await session.commit()
|
||||
|
||||
async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]):
|
||||
"""Index multiple items in a single batch operation.
|
||||
|
||||
Note: This method assumes that any existing records for the entity_id
|
||||
have already been deleted (typically via delete_by_entity_id).
|
||||
|
||||
Args:
|
||||
search_index_rows: List of SearchIndexRow objects to index
|
||||
"""
|
||||
if not search_index_rows:
|
||||
return
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
# Prepare all insert data with project_id
|
||||
insert_data_list = []
|
||||
for row in search_index_rows:
|
||||
insert_data = row.to_insert()
|
||||
insert_data["project_id"] = self.project_id
|
||||
insert_data_list.append(insert_data)
|
||||
|
||||
# Batch insert all records using executemany
|
||||
await session.execute(
|
||||
text("""
|
||||
INSERT INTO search_index (
|
||||
id, title, content_stems, content_snippet, permalink, file_path, type, metadata,
|
||||
from_id, to_id, relation_type,
|
||||
entity_id, category,
|
||||
created_at, updated_at,
|
||||
project_id
|
||||
) VALUES (
|
||||
:id, :title, :content_stems, :content_snippet, :permalink, :file_path, :type, :metadata,
|
||||
:from_id, :to_id, :relation_type,
|
||||
:entity_id, :category,
|
||||
:created_at, :updated_at,
|
||||
:project_id
|
||||
)
|
||||
"""),
|
||||
insert_data_list,
|
||||
)
|
||||
logger.debug(f"Bulk indexed {len(search_index_rows)} rows")
|
||||
await session.commit()
|
||||
|
||||
async def delete_by_entity_id(self, entity_id: int):
|
||||
"""Delete an item from the search index by entity_id."""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await session.execute(
|
||||
text(
|
||||
"DELETE FROM search_index WHERE entity_id = :entity_id AND project_id = :project_id"
|
||||
),
|
||||
{"entity_id": entity_id, "project_id": self.project_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def delete_by_permalink(self, permalink: str):
|
||||
"""Delete an item from the search index."""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await session.execute(
|
||||
text(
|
||||
"DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"
|
||||
),
|
||||
{"permalink": permalink, "project_id": self.project_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def execute_query(
|
||||
self,
|
||||
query: Executable,
|
||||
params: Dict[str, Any],
|
||||
) -> Result[Any]:
|
||||
"""Execute a query asynchronously."""
|
||||
# logger.debug(f"Executing query: {query}, params: {params}")
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
start_time = time.perf_counter()
|
||||
result = await session.execute(query, params)
|
||||
end_time = time.perf_counter()
|
||||
elapsed_time = end_time - start_time
|
||||
logger.debug(f"Query executed successfully in {elapsed_time:.2f}s.")
|
||||
return result
|
||||
__all__ = [
|
||||
"SearchRepository",
|
||||
"SearchIndexRow",
|
||||
"create_search_repository",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Abstract base class for search repository implementations."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import Executable, Result, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
from basic_memory.repository.search_index_row import SearchIndexRow
|
||||
|
||||
|
||||
class SearchRepositoryBase(ABC):
|
||||
"""Abstract base class for backend-specific search repository implementations.
|
||||
|
||||
This class defines the common interface that all search repositories must implement,
|
||||
regardless of whether they use SQLite FTS5 or Postgres tsvector for full-text search.
|
||||
|
||||
Concrete implementations:
|
||||
- SQLiteSearchRepository: Uses FTS5 virtual tables with MATCH queries
|
||||
- PostgresSearchRepository: Uses tsvector/tsquery with GIN indexes
|
||||
"""
|
||||
|
||||
def __init__(self, session_maker: async_sessionmaker[AsyncSession], project_id: int):
|
||||
"""Initialize with session maker and project_id filter.
|
||||
|
||||
Args:
|
||||
session_maker: SQLAlchemy session maker
|
||||
project_id: Project ID to filter all operations by
|
||||
|
||||
Raises:
|
||||
ValueError: If project_id is None or invalid
|
||||
"""
|
||||
if project_id is None or project_id <= 0: # pragma: no cover
|
||||
raise ValueError("A valid project_id is required for SearchRepository")
|
||||
|
||||
self.session_maker = session_maker
|
||||
self.project_id = project_id
|
||||
|
||||
@abstractmethod
|
||||
async def init_search_index(self) -> None:
|
||||
"""Create or recreate the search index.
|
||||
|
||||
Backend-specific implementations:
|
||||
- SQLite: CREATE VIRTUAL TABLE using FTS5
|
||||
- Postgres: CREATE TABLE with tsvector column and GIN indexes
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a search term for backend-specific query syntax.
|
||||
|
||||
Args:
|
||||
term: The search term to prepare
|
||||
is_prefix: Whether to add prefix search capability
|
||||
|
||||
Returns:
|
||||
Formatted search term for the backend
|
||||
|
||||
Backend-specific implementations:
|
||||
- SQLite: Quotes FTS5 special characters, adds * wildcards
|
||||
- Postgres: Converts to tsquery syntax with :* prefix operator
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def search(
|
||||
self,
|
||||
search_text: Optional[str] = None,
|
||||
permalink: Optional[str] = None,
|
||||
permalink_match: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content.
|
||||
|
||||
Args:
|
||||
search_text: Full-text search across title and content
|
||||
permalink: Exact permalink match
|
||||
permalink_match: Permalink pattern match (supports *)
|
||||
title: Title search
|
||||
types: Filter by entity types (from metadata.entity_type)
|
||||
after_date: Filter by created_at > after_date
|
||||
search_item_types: Filter by SearchItemType (ENTITY, OBSERVATION, RELATION)
|
||||
limit: Maximum results to return
|
||||
offset: Number of results to skip
|
||||
|
||||
Returns:
|
||||
List of SearchIndexRow results with relevance scores
|
||||
|
||||
Backend-specific implementations:
|
||||
- SQLite: Uses MATCH operator and bm25() for scoring
|
||||
- Postgres: Uses @@ operator and ts_rank() for scoring
|
||||
"""
|
||||
pass
|
||||
|
||||
async def index_item(self, search_index_row: SearchIndexRow) -> None:
|
||||
"""Index or update a single item.
|
||||
|
||||
This implementation is shared across backends as it uses standard SQL INSERT.
|
||||
"""
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
# Delete existing record if any
|
||||
await session.execute(
|
||||
text(
|
||||
"DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"
|
||||
),
|
||||
{"permalink": search_index_row.permalink, "project_id": self.project_id},
|
||||
)
|
||||
|
||||
# When using text() raw SQL, always serialize JSON to string
|
||||
# Both SQLite (TEXT) and Postgres (JSONB) accept JSON strings in raw SQL
|
||||
# The database driver/column type will handle conversion
|
||||
insert_data = search_index_row.to_insert(serialize_json=True)
|
||||
insert_data["project_id"] = self.project_id
|
||||
|
||||
# Insert new record
|
||||
await session.execute(
|
||||
text("""
|
||||
INSERT INTO search_index (
|
||||
id, title, content_stems, content_snippet, permalink, file_path, type, metadata,
|
||||
from_id, to_id, relation_type,
|
||||
entity_id, category,
|
||||
created_at, updated_at,
|
||||
project_id
|
||||
) VALUES (
|
||||
:id, :title, :content_stems, :content_snippet, :permalink, :file_path, :type, :metadata,
|
||||
:from_id, :to_id, :relation_type,
|
||||
:entity_id, :category,
|
||||
:created_at, :updated_at,
|
||||
:project_id
|
||||
)
|
||||
"""),
|
||||
insert_data,
|
||||
)
|
||||
logger.debug(f"indexed row {search_index_row}")
|
||||
await session.commit()
|
||||
|
||||
async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]) -> None:
|
||||
"""Index multiple items in a single batch operation.
|
||||
|
||||
This implementation is shared across backends as it uses standard SQL INSERT.
|
||||
|
||||
Note: This method assumes that any existing records for the entity_id
|
||||
have already been deleted (typically via delete_by_entity_id).
|
||||
|
||||
Args:
|
||||
search_index_rows: List of SearchIndexRow objects to index
|
||||
"""
|
||||
|
||||
if not search_index_rows:
|
||||
return
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
# When using text() raw SQL, always serialize JSON to string
|
||||
# Both SQLite (TEXT) and Postgres (JSONB) accept JSON strings in raw SQL
|
||||
# The database driver/column type will handle conversion
|
||||
insert_data_list = []
|
||||
for row in search_index_rows:
|
||||
insert_data = row.to_insert(serialize_json=True)
|
||||
insert_data["project_id"] = self.project_id
|
||||
insert_data_list.append(insert_data)
|
||||
|
||||
# Batch insert all records using executemany
|
||||
await session.execute(
|
||||
text("""
|
||||
INSERT INTO search_index (
|
||||
id, title, content_stems, content_snippet, permalink, file_path, type, metadata,
|
||||
from_id, to_id, relation_type,
|
||||
entity_id, category,
|
||||
created_at, updated_at,
|
||||
project_id
|
||||
) VALUES (
|
||||
:id, :title, :content_stems, :content_snippet, :permalink, :file_path, :type, :metadata,
|
||||
:from_id, :to_id, :relation_type,
|
||||
:entity_id, :category,
|
||||
:created_at, :updated_at,
|
||||
:project_id
|
||||
)
|
||||
"""),
|
||||
insert_data_list,
|
||||
)
|
||||
logger.debug(f"Bulk indexed {len(search_index_rows)} rows")
|
||||
await session.commit()
|
||||
|
||||
async def delete_by_entity_id(self, entity_id: int) -> None:
|
||||
"""Delete all search index entries for an entity.
|
||||
|
||||
This implementation is shared across backends as it uses standard SQL DELETE.
|
||||
"""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await session.execute(
|
||||
text(
|
||||
"DELETE FROM search_index WHERE entity_id = :entity_id AND project_id = :project_id"
|
||||
),
|
||||
{"entity_id": entity_id, "project_id": self.project_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def delete_by_permalink(self, permalink: str) -> None:
|
||||
"""Delete a search index entry by permalink.
|
||||
|
||||
This implementation is shared across backends as it uses standard SQL DELETE.
|
||||
"""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await session.execute(
|
||||
text(
|
||||
"DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"
|
||||
),
|
||||
{"permalink": permalink, "project_id": self.project_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def execute_query(
|
||||
self,
|
||||
query: Executable,
|
||||
params: Dict[str, Any],
|
||||
) -> Result[Any]:
|
||||
"""Execute a query asynchronously.
|
||||
|
||||
This implementation is shared across backends for utility query execution.
|
||||
"""
|
||||
import time
|
||||
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
start_time = time.perf_counter()
|
||||
result = await session.execute(query, params)
|
||||
end_time = time.perf_counter()
|
||||
elapsed_time = end_time - start_time
|
||||
logger.debug(f"Query executed successfully in {elapsed_time:.2f}s.")
|
||||
return result
|
||||
@@ -0,0 +1,438 @@
|
||||
"""SQLite FTS5-based search repository implementation."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import text
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.models.search import CREATE_SEARCH_INDEX
|
||||
from basic_memory.repository.search_index_row import SearchIndexRow
|
||||
from basic_memory.repository.search_repository_base import SearchRepositoryBase
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
|
||||
class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
"""SQLite FTS5 implementation of search repository.
|
||||
|
||||
Uses SQLite's FTS5 virtual tables for full-text search with:
|
||||
- MATCH operator for queries
|
||||
- bm25() function for relevance scoring
|
||||
- Special character quoting for syntax safety
|
||||
- Prefix wildcard matching with *
|
||||
"""
|
||||
|
||||
async def init_search_index(self):
|
||||
"""Create FTS5 virtual table for search.
|
||||
|
||||
Note: Drops any existing search_index table first to ensure FTS5 virtual table creation.
|
||||
This is necessary because Base.metadata.create_all() might create a regular table.
|
||||
"""
|
||||
logger.info("Initializing SQLite FTS5 search index")
|
||||
try:
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
# Drop any existing regular or virtual table first
|
||||
await session.execute(text("DROP TABLE IF EXISTS search_index"))
|
||||
# Create FTS5 virtual table
|
||||
await session.execute(CREATE_SEARCH_INDEX)
|
||||
await session.commit()
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error initializing search index: {e}")
|
||||
raise e
|
||||
|
||||
def _prepare_boolean_query(self, query: str) -> str:
|
||||
"""Prepare a Boolean query by quoting individual terms while preserving operators.
|
||||
|
||||
Args:
|
||||
query: A Boolean query like "tier1-test AND unicode" or "(hello OR world) NOT test"
|
||||
|
||||
Returns:
|
||||
A properly formatted Boolean query with quoted terms that need quoting
|
||||
"""
|
||||
# Define Boolean operators and their boundaries
|
||||
boolean_pattern = r"(\bAND\b|\bOR\b|\bNOT\b)"
|
||||
|
||||
# Split the query by Boolean operators, keeping the operators
|
||||
parts = re.split(boolean_pattern, query)
|
||||
|
||||
processed_parts = []
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
# If it's a Boolean operator, keep it as is
|
||||
if part in ["AND", "OR", "NOT"]:
|
||||
processed_parts.append(part)
|
||||
else:
|
||||
# Handle parentheses specially - they should be preserved for grouping
|
||||
if "(" in part or ")" in part:
|
||||
# Parse parenthetical expressions carefully
|
||||
processed_part = self._prepare_parenthetical_term(part)
|
||||
processed_parts.append(processed_part)
|
||||
else:
|
||||
# This is a search term - for Boolean queries, don't add prefix wildcards
|
||||
prepared_term = self._prepare_single_term(part, is_prefix=False)
|
||||
processed_parts.append(prepared_term)
|
||||
|
||||
return " ".join(processed_parts)
|
||||
|
||||
def _prepare_parenthetical_term(self, term: str) -> str:
|
||||
"""Prepare a term that contains parentheses, preserving the parentheses for grouping.
|
||||
|
||||
Args:
|
||||
term: A term that may contain parentheses like "(hello" or "world)" or "(hello OR world)"
|
||||
|
||||
Returns:
|
||||
A properly formatted term with parentheses preserved
|
||||
"""
|
||||
# Handle terms that start/end with parentheses but may contain quotable content
|
||||
result = ""
|
||||
i = 0
|
||||
while i < len(term):
|
||||
if term[i] in "()":
|
||||
# Preserve parentheses as-is
|
||||
result += term[i]
|
||||
i += 1
|
||||
else:
|
||||
# Find the next parenthesis or end of string
|
||||
start = i
|
||||
while i < len(term) and term[i] not in "()":
|
||||
i += 1
|
||||
|
||||
# Extract the content between parentheses
|
||||
content = term[start:i].strip()
|
||||
if content:
|
||||
# Only quote if it actually needs quoting (has hyphens, special chars, etc)
|
||||
# but don't quote if it's just simple words
|
||||
if self._needs_quoting(content):
|
||||
escaped_content = content.replace('"', '""')
|
||||
result += f'"{escaped_content}"'
|
||||
else:
|
||||
result += content
|
||||
|
||||
return result
|
||||
|
||||
def _needs_quoting(self, term: str) -> bool:
|
||||
"""Check if a term needs to be quoted for FTS5 safety.
|
||||
|
||||
Args:
|
||||
term: The term to check
|
||||
|
||||
Returns:
|
||||
True if the term should be quoted
|
||||
"""
|
||||
if not term or not term.strip():
|
||||
return False
|
||||
|
||||
# Characters that indicate we should quote (excluding parentheses which are valid syntax)
|
||||
needs_quoting_chars = [
|
||||
" ",
|
||||
".",
|
||||
":",
|
||||
";",
|
||||
",",
|
||||
"<",
|
||||
">",
|
||||
"?",
|
||||
"/",
|
||||
"-",
|
||||
"'",
|
||||
'"',
|
||||
"[",
|
||||
"]",
|
||||
"{",
|
||||
"}",
|
||||
"+",
|
||||
"!",
|
||||
"@",
|
||||
"#",
|
||||
"$",
|
||||
"%",
|
||||
"^",
|
||||
"&",
|
||||
"=",
|
||||
"|",
|
||||
"\\",
|
||||
"~",
|
||||
"`",
|
||||
]
|
||||
|
||||
return any(c in term for c in needs_quoting_chars)
|
||||
|
||||
def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a single search term (no Boolean operators).
|
||||
|
||||
Args:
|
||||
term: A single search term
|
||||
is_prefix: Whether to add prefix search capability (* suffix)
|
||||
|
||||
Returns:
|
||||
A properly formatted single term
|
||||
"""
|
||||
if not term or not term.strip():
|
||||
return term
|
||||
|
||||
term = term.strip()
|
||||
|
||||
# Check if term is already a proper wildcard pattern (alphanumeric + *)
|
||||
# e.g., "hello*", "test*world" - these should be left alone
|
||||
if "*" in term and all(c.isalnum() or c in "*_-" for c in term):
|
||||
return term
|
||||
|
||||
# Characters that can cause FTS5 syntax errors when used as operators
|
||||
# We're more conservative here - only quote when we detect problematic patterns
|
||||
problematic_chars = [
|
||||
'"',
|
||||
"'",
|
||||
"(",
|
||||
")",
|
||||
"[",
|
||||
"]",
|
||||
"{",
|
||||
"}",
|
||||
"+",
|
||||
"!",
|
||||
"@",
|
||||
"#",
|
||||
"$",
|
||||
"%",
|
||||
"^",
|
||||
"&",
|
||||
"=",
|
||||
"|",
|
||||
"\\",
|
||||
"~",
|
||||
"`",
|
||||
]
|
||||
|
||||
# Characters that indicate we should quote (spaces, dots, colons, etc.)
|
||||
# Adding hyphens here because FTS5 can have issues with hyphens followed by wildcards
|
||||
needs_quoting_chars = [" ", ".", ":", ";", ",", "<", ">", "?", "/", "-"]
|
||||
|
||||
# Check if term needs quoting
|
||||
has_problematic = any(c in term for c in problematic_chars)
|
||||
has_spaces_or_special = any(c in term for c in needs_quoting_chars)
|
||||
|
||||
if has_problematic or has_spaces_or_special:
|
||||
# Handle multi-word queries differently from special character queries
|
||||
if " " in term and not any(c in term for c in problematic_chars):
|
||||
# Check if any individual word contains special characters that need quoting
|
||||
words = term.strip().split()
|
||||
has_special_in_words = any(
|
||||
any(c in word for c in needs_quoting_chars if c != " ") for word in words
|
||||
)
|
||||
|
||||
if not has_special_in_words:
|
||||
# For multi-word queries with simple words (like "emoji unicode"),
|
||||
# use boolean AND to handle word order variations
|
||||
if is_prefix:
|
||||
# Add prefix wildcard to each word for better matching
|
||||
prepared_words = [f"{word}*" for word in words if word]
|
||||
else:
|
||||
prepared_words = words
|
||||
term = " AND ".join(prepared_words)
|
||||
else:
|
||||
# If any word has special characters, quote the entire phrase
|
||||
escaped_term = term.replace('"', '""')
|
||||
if is_prefix and not ("/" in term and term.endswith(".md")):
|
||||
term = f'"{escaped_term}"*'
|
||||
else:
|
||||
term = f'"{escaped_term}"'
|
||||
else:
|
||||
# For terms with problematic characters or file paths, use exact phrase matching
|
||||
# Escape any existing quotes by doubling them
|
||||
escaped_term = term.replace('"', '""')
|
||||
# Quote the entire term to handle special characters safely
|
||||
if is_prefix and not ("/" in term and term.endswith(".md")):
|
||||
# For search terms (not file paths), add prefix matching
|
||||
term = f'"{escaped_term}"*'
|
||||
else:
|
||||
# For file paths, use exact matching
|
||||
term = f'"{escaped_term}"'
|
||||
elif is_prefix:
|
||||
# Only add wildcard for simple terms without special characters
|
||||
term = f"{term}*"
|
||||
|
||||
return term
|
||||
|
||||
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a search term for FTS5 query.
|
||||
|
||||
Args:
|
||||
term: The search term to prepare
|
||||
is_prefix: Whether to add prefix search capability (* suffix)
|
||||
|
||||
For FTS5:
|
||||
- Boolean operators (AND, OR, NOT) are preserved for complex queries
|
||||
- Terms with FTS5 special characters are quoted to prevent syntax errors
|
||||
- Simple terms get prefix wildcards for better matching
|
||||
"""
|
||||
# Check for explicit boolean operators - if present, process as Boolean query
|
||||
boolean_operators = [" AND ", " OR ", " NOT "]
|
||||
if any(op in f" {term} " for op in boolean_operators):
|
||||
return self._prepare_boolean_query(term)
|
||||
|
||||
# For non-Boolean queries, use the single term preparation logic
|
||||
return self._prepare_single_term(term, is_prefix)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
search_text: Optional[str] = None,
|
||||
permalink: Optional[str] = None,
|
||||
permalink_match: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content using SQLite FTS5."""
|
||||
conditions = []
|
||||
params = {}
|
||||
order_by_clause = ""
|
||||
|
||||
# Handle text search for title and content
|
||||
if search_text:
|
||||
# Skip FTS for wildcard-only queries that would cause "unknown special query" errors
|
||||
if search_text.strip() == "*" or search_text.strip() == "":
|
||||
# For wildcard searches, don't add any text conditions - return all results
|
||||
pass
|
||||
else:
|
||||
# Use _prepare_search_term to handle both Boolean and non-Boolean queries
|
||||
processed_text = self._prepare_search_term(search_text.strip())
|
||||
params["text"] = processed_text
|
||||
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
|
||||
|
||||
# Handle title match search
|
||||
if title:
|
||||
title_text = self._prepare_search_term(title.strip(), is_prefix=False)
|
||||
params["title_text"] = title_text
|
||||
conditions.append("title MATCH :title_text")
|
||||
|
||||
# Handle permalink exact search
|
||||
if permalink:
|
||||
params["permalink"] = permalink
|
||||
conditions.append("permalink = :permalink")
|
||||
|
||||
# Handle permalink match search, supports *
|
||||
if permalink_match:
|
||||
# For GLOB patterns, don't use _prepare_search_term as it will quote slashes
|
||||
# GLOB patterns need to preserve their syntax
|
||||
permalink_text = permalink_match.lower().strip()
|
||||
params["permalink"] = permalink_text
|
||||
if "*" in permalink_match:
|
||||
conditions.append("permalink GLOB :permalink")
|
||||
else:
|
||||
# For exact matches without *, we can use FTS5 MATCH
|
||||
# but only prepare the term if it doesn't look like a path
|
||||
if "/" in permalink_text:
|
||||
conditions.append("permalink = :permalink")
|
||||
else:
|
||||
permalink_text = self._prepare_search_term(permalink_text, is_prefix=False)
|
||||
params["permalink"] = permalink_text
|
||||
conditions.append("permalink MATCH :permalink")
|
||||
|
||||
# Handle entity type filter
|
||||
if search_item_types:
|
||||
type_list = ", ".join(f"'{t.value}'" for t in search_item_types)
|
||||
conditions.append(f"type IN ({type_list})")
|
||||
|
||||
# Handle type filter
|
||||
if types:
|
||||
type_list = ", ".join(f"'{t}'" for t in types)
|
||||
conditions.append(f"json_extract(metadata, '$.entity_type') IN ({type_list})")
|
||||
|
||||
# Handle date filter using datetime() for proper comparison
|
||||
if after_date:
|
||||
params["after_date"] = after_date
|
||||
conditions.append("datetime(created_at) > datetime(:after_date)")
|
||||
|
||||
# order by most recent first
|
||||
order_by_clause = ", updated_at DESC"
|
||||
|
||||
# Always filter by project_id
|
||||
params["project_id"] = self.project_id
|
||||
conditions.append("project_id = :project_id")
|
||||
|
||||
# set limit on search query
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
project_id,
|
||||
id,
|
||||
title,
|
||||
permalink,
|
||||
file_path,
|
||||
type,
|
||||
metadata,
|
||||
from_id,
|
||||
to_id,
|
||||
relation_type,
|
||||
entity_id,
|
||||
content_snippet,
|
||||
category,
|
||||
created_at,
|
||||
updated_at,
|
||||
bm25(search_index) as score
|
||||
FROM search_index
|
||||
WHERE {where_clause}
|
||||
ORDER BY score ASC {order_by_clause}
|
||||
LIMIT :limit
|
||||
OFFSET :offset
|
||||
"""
|
||||
|
||||
logger.trace(f"Search {sql} params: {params}")
|
||||
try:
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
result = await session.execute(text(sql), params)
|
||||
rows = result.fetchall()
|
||||
except Exception as e:
|
||||
# Handle FTS5 syntax errors and provide user-friendly feedback
|
||||
if "fts5: syntax error" in str(e).lower(): # pragma: no cover
|
||||
logger.warning(f"FTS5 syntax error for search term: {search_text}, error: {e}")
|
||||
# Return empty results rather than crashing
|
||||
return []
|
||||
else:
|
||||
# Re-raise other database errors
|
||||
logger.error(f"Database error during search: {e}")
|
||||
raise
|
||||
|
||||
results = [
|
||||
SearchIndexRow(
|
||||
project_id=self.project_id,
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
permalink=row.permalink,
|
||||
file_path=row.file_path,
|
||||
type=row.type,
|
||||
score=row.score,
|
||||
metadata=json.loads(row.metadata) if row.metadata else {},
|
||||
from_id=row.from_id,
|
||||
to_id=row.to_id,
|
||||
relation_type=row.relation_type,
|
||||
entity_id=row.entity_id,
|
||||
content_snippet=row.content_snippet,
|
||||
category=row.category,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
logger.trace(f"Found {len(results)} search results")
|
||||
for r in results:
|
||||
logger.trace(
|
||||
f"Search result: project_id: {r.project_id} type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -21,13 +21,38 @@ from typing import List, Optional, Annotated, Dict
|
||||
from annotated_types import MinLen, MaxLen
|
||||
from dateparser import parse
|
||||
|
||||
from pydantic import BaseModel, BeforeValidator, Field, model_validator
|
||||
from pydantic import BaseModel, BeforeValidator, Field, model_validator, computed_field
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.file_utils import sanitize_for_filename, sanitize_for_folder
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
def has_valid_file_extension(filename: str) -> bool:
|
||||
"""Check if a filename has a valid file extension recognized by mimetypes.
|
||||
|
||||
This is used to determine whether to split the extension when processing
|
||||
titles in kebab_filenames mode. Prevents treating periods in version numbers
|
||||
or decimals as file extensions.
|
||||
|
||||
Args:
|
||||
filename: The filename to check
|
||||
|
||||
Returns:
|
||||
True if the filename has a recognized file extension, False otherwise
|
||||
|
||||
Examples:
|
||||
>>> has_valid_file_extension("document.md")
|
||||
True
|
||||
>>> has_valid_file_extension("Version 2.0.0")
|
||||
False
|
||||
>>> has_valid_file_extension("image.png")
|
||||
True
|
||||
"""
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
return mime_type is not None
|
||||
|
||||
|
||||
def to_snake_case(name: str) -> str:
|
||||
"""Convert a string to snake_case.
|
||||
|
||||
@@ -232,12 +257,17 @@ class Entity(BaseModel):
|
||||
use_kebab_case = app_config.kebab_filenames
|
||||
|
||||
if use_kebab_case:
|
||||
fixed_title = generate_permalink(file_path=fixed_title, split_extension=False)
|
||||
# Convert to kebab-case: lowercase with hyphens, preserving periods in version numbers
|
||||
# generate_permalink() uses mimetypes to detect real file extensions and only splits
|
||||
# them off, avoiding misinterpreting periods in version numbers as extensions
|
||||
has_extension = has_valid_file_extension(fixed_title)
|
||||
fixed_title = generate_permalink(file_path=fixed_title, split_extension=has_extension)
|
||||
|
||||
return fixed_title
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def file_path(self):
|
||||
def file_path(self) -> str:
|
||||
"""Get the file path for this entity based on its permalink."""
|
||||
safe_title = self.safe_title
|
||||
if self.content_type == "text/markdown":
|
||||
|
||||
@@ -124,6 +124,7 @@ class EntitySummary(BaseModel):
|
||||
"""Simplified entity representation."""
|
||||
|
||||
type: Literal["entity"] = "entity"
|
||||
entity_id: int # Database ID for v2 API consistency
|
||||
permalink: Optional[str]
|
||||
title: str
|
||||
content: Optional[str] = None
|
||||
@@ -141,12 +142,16 @@ class RelationSummary(BaseModel):
|
||||
"""Simplified relation representation."""
|
||||
|
||||
type: Literal["relation"] = "relation"
|
||||
relation_id: int # Database ID for v2 API consistency
|
||||
entity_id: Optional[int] = None # ID of the entity this relation belongs to
|
||||
title: str
|
||||
file_path: str
|
||||
permalink: str
|
||||
relation_type: str
|
||||
from_entity: Optional[str] = None
|
||||
from_entity_id: Optional[int] = None # ID of source entity
|
||||
to_entity: Optional[str] = None
|
||||
to_entity_id: Optional[int] = None # ID of target entity
|
||||
created_at: Annotated[
|
||||
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
|
||||
]
|
||||
@@ -160,6 +165,8 @@ class ObservationSummary(BaseModel):
|
||||
"""Simplified observation representation."""
|
||||
|
||||
type: Literal["observation"] = "observation"
|
||||
observation_id: int # Database ID for v2 API consistency
|
||||
entity_id: Optional[int] = None # ID of the entity this observation belongs to
|
||||
title: str
|
||||
file_path: str
|
||||
permalink: str
|
||||
|
||||
@@ -173,6 +173,7 @@ class ProjectWatchStatus(BaseModel):
|
||||
class ProjectItem(BaseModel):
|
||||
"""Simple representation of a project."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
path: str
|
||||
is_default: bool = False
|
||||
|
||||
@@ -97,6 +97,11 @@ class SearchResult(BaseModel):
|
||||
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
# IDs for v2 API consistency
|
||||
entity_id: Optional[int] = None # Entity ID (always present for entities)
|
||||
observation_id: Optional[int] = None # Observation ID (for observation results)
|
||||
relation_id: Optional[int] = None # Relation ID (for relation results)
|
||||
|
||||
# Type-specific fields
|
||||
category: Optional[str] = None # For observations
|
||||
from_entity: Optional[Permalink] = None # For relations
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""V2 API schemas - ID-based entity references."""
|
||||
|
||||
from basic_memory.schemas.v2.entity import (
|
||||
EntityResolveRequest,
|
||||
EntityResolveResponse,
|
||||
EntityResponseV2,
|
||||
MoveEntityRequestV2,
|
||||
)
|
||||
from basic_memory.schemas.v2.resource import (
|
||||
CreateResourceRequest,
|
||||
UpdateResourceRequest,
|
||||
ResourceResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EntityResolveRequest",
|
||||
"EntityResolveResponse",
|
||||
"EntityResponseV2",
|
||||
"MoveEntityRequestV2",
|
||||
"CreateResourceRequest",
|
||||
"UpdateResourceRequest",
|
||||
"ResourceResponse",
|
||||
]
|
||||
@@ -0,0 +1,96 @@
|
||||
"""V2 entity schemas with ID-first design."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
from basic_memory.schemas.response import ObservationResponse, RelationResponse
|
||||
|
||||
|
||||
class EntityResolveRequest(BaseModel):
|
||||
"""Request to resolve a string identifier to an entity ID.
|
||||
|
||||
Supports resolution of:
|
||||
- Permalinks (e.g., "specs/search")
|
||||
- Titles (e.g., "Search Specification")
|
||||
- File paths (e.g., "specs/search.md")
|
||||
"""
|
||||
|
||||
identifier: str = Field(
|
||||
...,
|
||||
description="Entity identifier to resolve (permalink, title, or file path)",
|
||||
min_length=1,
|
||||
max_length=500,
|
||||
)
|
||||
|
||||
|
||||
class EntityResolveResponse(BaseModel):
|
||||
"""Response from identifier resolution.
|
||||
|
||||
Returns the entity ID and associated metadata for the resolved entity.
|
||||
"""
|
||||
|
||||
entity_id: int = Field(..., description="Numeric entity ID (primary identifier)")
|
||||
permalink: Optional[str] = Field(None, description="Entity permalink")
|
||||
file_path: str = Field(..., description="Relative file path")
|
||||
title: str = Field(..., description="Entity title")
|
||||
resolution_method: Literal["id", "permalink", "title", "path", "search"] = Field(
|
||||
..., description="How the identifier was resolved"
|
||||
)
|
||||
|
||||
|
||||
class MoveEntityRequestV2(BaseModel):
|
||||
"""V2 request schema for moving an entity to a new file location.
|
||||
|
||||
In V2 API, the entity ID is provided in the URL path, so this request
|
||||
only needs the destination path.
|
||||
"""
|
||||
|
||||
destination_path: str = Field(
|
||||
...,
|
||||
description="New file path for the entity (relative to project root)",
|
||||
min_length=1,
|
||||
max_length=500,
|
||||
)
|
||||
|
||||
|
||||
class EntityResponseV2(BaseModel):
|
||||
"""V2 entity response with ID as the primary field.
|
||||
|
||||
This response format emphasizes the entity ID as the primary identifier,
|
||||
with all other fields (permalink, file_path) as secondary metadata.
|
||||
"""
|
||||
|
||||
# ID first - this is the primary identifier in v2
|
||||
id: int = Field(..., description="Numeric entity ID (primary identifier)")
|
||||
|
||||
# Core entity fields
|
||||
title: str = Field(..., description="Entity title")
|
||||
entity_type: str = Field(..., description="Entity type")
|
||||
content_type: str = Field(default="text/markdown", description="Content MIME type")
|
||||
|
||||
# Secondary identifiers (for compatibility and convenience)
|
||||
permalink: Optional[str] = Field(None, description="Entity permalink (may change)")
|
||||
file_path: str = Field(..., description="Relative file path (may change)")
|
||||
|
||||
# Content and metadata
|
||||
content: Optional[str] = Field(None, description="Entity content")
|
||||
entity_metadata: Optional[Dict] = Field(None, description="Entity metadata")
|
||||
|
||||
# Relationships
|
||||
observations: List[ObservationResponse] = Field(
|
||||
default_factory=list, description="Entity observations"
|
||||
)
|
||||
relations: List[RelationResponse] = Field(default_factory=list, description="Entity relations")
|
||||
|
||||
# Timestamps
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Last update timestamp")
|
||||
|
||||
# V2-specific metadata
|
||||
api_version: Literal["v2"] = Field(
|
||||
default="v2", description="API version (always 'v2' for this response)"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""V2 resource schemas for file content operations."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateResourceRequest(BaseModel):
|
||||
"""Request to create a new resource file.
|
||||
|
||||
File path is required for new resources since we need to know where
|
||||
to create the file.
|
||||
"""
|
||||
|
||||
file_path: str = Field(
|
||||
...,
|
||||
description="Path to create the file, relative to project root",
|
||||
min_length=1,
|
||||
max_length=500,
|
||||
)
|
||||
content: str = Field(..., description="File content to write")
|
||||
|
||||
|
||||
class UpdateResourceRequest(BaseModel):
|
||||
"""Request to update an existing resource by entity ID.
|
||||
|
||||
Only content is required - the file path is already known from the entity.
|
||||
Optionally can update the file_path to move the file.
|
||||
"""
|
||||
|
||||
content: str = Field(..., description="File content to write")
|
||||
file_path: str | None = Field(
|
||||
None,
|
||||
description="Optional new file path to move the resource",
|
||||
min_length=1,
|
||||
max_length=500,
|
||||
)
|
||||
|
||||
|
||||
class ResourceResponse(BaseModel):
|
||||
"""Response from resource operations."""
|
||||
|
||||
entity_id: int = Field(..., description="Entity ID of the resource")
|
||||
file_path: str = Field(..., description="File path of the resource")
|
||||
checksum: str = Field(..., description="File content checksum")
|
||||
size: int = Field(..., description="File size in bytes")
|
||||
created_at: float = Field(..., description="Creation timestamp")
|
||||
modified_at: float = Field(..., description="Modification timestamp")
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import text
|
||||
|
||||
from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.repository.observation_repository import ObservationRepository
|
||||
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
|
||||
from basic_memory.repository.search_repository import SearchRepository, SearchIndexRow
|
||||
from basic_memory.schemas.memory import MemoryUrl, memory_url_path
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
@@ -252,9 +253,6 @@ class ContextService:
|
||||
# Build the VALUES clause for entity IDs
|
||||
entity_id_values = ", ".join([str(i) for i in entity_ids])
|
||||
|
||||
# For compatibility with the old query, we still need this for filtering
|
||||
values = ", ".join([f"('{t}', {i})" for t, i in type_id_pairs])
|
||||
|
||||
# Parameters for bindings - include project_id for security filtering
|
||||
params = {
|
||||
"max_depth": max_depth,
|
||||
@@ -264,7 +262,14 @@ class ContextService:
|
||||
|
||||
# Build date and timeframe filters conditionally based on since parameter
|
||||
if since:
|
||||
params["since_date"] = since.isoformat() # pyright: ignore
|
||||
# SQLite accepts ISO strings, but Postgres/asyncpg requires datetime objects
|
||||
if isinstance(self.search_repository, PostgresSearchRepository):
|
||||
# asyncpg expects timezone-NAIVE datetime in UTC for DateTime(timezone=True) columns
|
||||
# even though the column stores timezone-aware values
|
||||
since_utc = since.astimezone(timezone.utc) if since.tzinfo else since
|
||||
params["since_date"] = since_utc.replace(tzinfo=None) # pyright: ignore
|
||||
else:
|
||||
params["since_date"] = since.isoformat() # pyright: ignore
|
||||
date_filter = "AND e.created_at >= :since_date"
|
||||
relation_date_filter = "AND e_from.created_at >= :since_date"
|
||||
timeframe_condition = "AND eg.relation_date >= :since_date"
|
||||
@@ -279,13 +284,210 @@ class ContextService:
|
||||
|
||||
# Use a CTE that operates directly on entity and relation tables
|
||||
# This avoids the overhead of the search_index virtual table
|
||||
query = text(f"""
|
||||
# Note: Postgres and SQLite have different CTE limitations:
|
||||
# - Postgres: doesn't allow multiple UNION ALL branches referencing the CTE
|
||||
# - SQLite: doesn't support LATERAL joins
|
||||
# So we need different queries for each database backend
|
||||
|
||||
# Detect database backend
|
||||
is_postgres = isinstance(self.search_repository, PostgresSearchRepository)
|
||||
|
||||
if is_postgres:
|
||||
query = self._build_postgres_query(
|
||||
entity_id_values,
|
||||
date_filter,
|
||||
project_filter,
|
||||
relation_date_filter,
|
||||
relation_project_filter,
|
||||
timeframe_condition,
|
||||
)
|
||||
else:
|
||||
# SQLite needs VALUES clause for exclusion (not needed for Postgres)
|
||||
values = ", ".join([f"('{t}', {i})" for t, i in type_id_pairs])
|
||||
query = self._build_sqlite_query(
|
||||
entity_id_values,
|
||||
date_filter,
|
||||
project_filter,
|
||||
relation_date_filter,
|
||||
relation_project_filter,
|
||||
timeframe_condition,
|
||||
values,
|
||||
)
|
||||
|
||||
result = await self.search_repository.execute_query(query, params=params)
|
||||
rows = result.all()
|
||||
|
||||
context_rows = [
|
||||
ContextResultRow(
|
||||
type=row.type,
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
permalink=row.permalink,
|
||||
file_path=row.file_path,
|
||||
from_id=row.from_id,
|
||||
to_id=row.to_id,
|
||||
relation_type=row.relation_type,
|
||||
content=row.content,
|
||||
category=row.category,
|
||||
entity_id=row.entity_id,
|
||||
depth=row.depth,
|
||||
root_id=row.root_id,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
return context_rows
|
||||
|
||||
def _build_postgres_query(
|
||||
self,
|
||||
entity_id_values: str,
|
||||
date_filter: str,
|
||||
project_filter: str,
|
||||
relation_date_filter: str,
|
||||
relation_project_filter: str,
|
||||
timeframe_condition: str,
|
||||
):
|
||||
"""Build Postgres-specific CTE query using LATERAL joins."""
|
||||
return text(f"""
|
||||
WITH RECURSIVE entity_graph AS (
|
||||
-- Base case: seed entities
|
||||
SELECT
|
||||
SELECT
|
||||
e.id,
|
||||
'entity' as type,
|
||||
e.title,
|
||||
e.title,
|
||||
e.permalink,
|
||||
e.file_path,
|
||||
CAST(NULL AS INTEGER) as from_id,
|
||||
CAST(NULL AS INTEGER) as to_id,
|
||||
CAST(NULL AS TEXT) as relation_type,
|
||||
CAST(NULL AS TEXT) as content,
|
||||
CAST(NULL AS TEXT) as category,
|
||||
CAST(NULL AS INTEGER) as entity_id,
|
||||
0 as depth,
|
||||
e.id as root_id,
|
||||
e.created_at,
|
||||
e.created_at as relation_date
|
||||
FROM entity e
|
||||
WHERE e.id IN ({entity_id_values})
|
||||
{date_filter}
|
||||
{project_filter}
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Fetch BOTH relations AND connected entities in a single recursive step
|
||||
-- Postgres only allows ONE reference to the recursive CTE in the recursive term
|
||||
-- We use CROSS JOIN LATERAL to generate two rows (relation + entity) from each traversal
|
||||
SELECT
|
||||
CASE
|
||||
WHEN step_type = 1 THEN r.id
|
||||
ELSE e.id
|
||||
END as id,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN 'relation'
|
||||
ELSE 'entity'
|
||||
END as type,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN r.relation_type || ': ' || r.to_name
|
||||
ELSE e.title
|
||||
END as title,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN ''
|
||||
ELSE COALESCE(e.permalink, '')
|
||||
END as permalink,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN e_from.file_path
|
||||
ELSE e.file_path
|
||||
END as file_path,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN r.from_id
|
||||
ELSE NULL
|
||||
END as from_id,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN r.to_id
|
||||
ELSE NULL
|
||||
END as to_id,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN r.relation_type
|
||||
ELSE NULL
|
||||
END as relation_type,
|
||||
CAST(NULL AS TEXT) as content,
|
||||
CAST(NULL AS TEXT) as category,
|
||||
CAST(NULL AS INTEGER) as entity_id,
|
||||
eg.depth + step_type as depth,
|
||||
eg.root_id,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN e_from.created_at
|
||||
ELSE e.created_at
|
||||
END as created_at,
|
||||
CASE
|
||||
WHEN step_type = 1 THEN e_from.created_at
|
||||
ELSE eg.relation_date
|
||||
END as relation_date
|
||||
FROM entity_graph eg
|
||||
CROSS JOIN LATERAL (VALUES (1), (2)) AS steps(step_type)
|
||||
JOIN relation r ON (
|
||||
eg.type = 'entity' AND
|
||||
(r.from_id = eg.id OR r.to_id = eg.id)
|
||||
)
|
||||
JOIN entity e_from ON (
|
||||
r.from_id = e_from.id
|
||||
{relation_project_filter}
|
||||
)
|
||||
LEFT JOIN entity e ON (
|
||||
step_type = 2 AND
|
||||
e.id = CASE
|
||||
WHEN r.from_id = eg.id THEN r.to_id
|
||||
ELSE r.from_id
|
||||
END
|
||||
{date_filter}
|
||||
{project_filter}
|
||||
)
|
||||
WHERE eg.depth < :max_depth
|
||||
AND (step_type = 1 OR (step_type = 2 AND e.id IS NOT NULL AND e.id != eg.id))
|
||||
{timeframe_condition}
|
||||
)
|
||||
-- Materialize and filter
|
||||
SELECT DISTINCT
|
||||
type,
|
||||
id,
|
||||
title,
|
||||
permalink,
|
||||
file_path,
|
||||
from_id,
|
||||
to_id,
|
||||
relation_type,
|
||||
content,
|
||||
category,
|
||||
entity_id,
|
||||
MIN(depth) as depth,
|
||||
root_id,
|
||||
created_at
|
||||
FROM entity_graph
|
||||
WHERE depth > 0
|
||||
GROUP BY type, id, title, permalink, file_path, from_id, to_id,
|
||||
relation_type, content, category, entity_id, root_id, created_at
|
||||
ORDER BY depth, type, id
|
||||
LIMIT :max_results
|
||||
""")
|
||||
|
||||
def _build_sqlite_query(
|
||||
self,
|
||||
entity_id_values: str,
|
||||
date_filter: str,
|
||||
project_filter: str,
|
||||
relation_date_filter: str,
|
||||
relation_project_filter: str,
|
||||
timeframe_condition: str,
|
||||
values: str,
|
||||
):
|
||||
"""Build SQLite-specific CTE query using multiple UNION ALL branches."""
|
||||
return text(f"""
|
||||
WITH RECURSIVE entity_graph AS (
|
||||
-- Base case: seed entities
|
||||
SELECT
|
||||
e.id,
|
||||
'entity' as type,
|
||||
e.title,
|
||||
e.permalink,
|
||||
e.file_path,
|
||||
NULL as from_id,
|
||||
@@ -311,7 +513,6 @@ class ContextService:
|
||||
r.id,
|
||||
'relation' as type,
|
||||
r.relation_type || ': ' || r.to_name as title,
|
||||
-- Relation model doesn't have permalink column - we'll generate it at runtime
|
||||
'' as permalink,
|
||||
e_from.file_path,
|
||||
r.from_id,
|
||||
@@ -322,7 +523,7 @@ class ContextService:
|
||||
NULL as entity_id,
|
||||
eg.depth + 1,
|
||||
eg.root_id,
|
||||
e_from.created_at, -- Use the from_entity's created_at since relation has no timestamp
|
||||
e_from.created_at,
|
||||
e_from.created_at as relation_date,
|
||||
CASE WHEN r.from_id = eg.id THEN 0 ELSE 1 END as is_incoming
|
||||
FROM entity_graph eg
|
||||
@@ -337,7 +538,6 @@ class ContextService:
|
||||
)
|
||||
LEFT JOIN entity e_to ON (r.to_id = e_to.id)
|
||||
WHERE eg.depth < :max_depth
|
||||
-- Ensure to_entity (if exists) also belongs to same project
|
||||
AND (r.to_id IS NULL OR e_to.project_id = :project_id)
|
||||
|
||||
UNION ALL
|
||||
@@ -347,9 +547,9 @@ class ContextService:
|
||||
e.id,
|
||||
'entity' as type,
|
||||
e.title,
|
||||
CASE
|
||||
WHEN e.permalink IS NULL THEN ''
|
||||
ELSE e.permalink
|
||||
CASE
|
||||
WHEN e.permalink IS NULL THEN ''
|
||||
ELSE e.permalink
|
||||
END as permalink,
|
||||
e.file_path,
|
||||
NULL as from_id,
|
||||
@@ -366,7 +566,7 @@ class ContextService:
|
||||
FROM entity_graph eg
|
||||
JOIN entity e ON (
|
||||
eg.type = 'relation' AND
|
||||
e.id = CASE
|
||||
e.id = CASE
|
||||
WHEN eg.is_incoming = 0 THEN eg.to_id
|
||||
ELSE eg.from_id
|
||||
END
|
||||
@@ -374,10 +574,9 @@ class ContextService:
|
||||
{project_filter}
|
||||
)
|
||||
WHERE eg.depth < :max_depth
|
||||
-- Only include entities connected by relations within timeframe if specified
|
||||
{timeframe_condition}
|
||||
)
|
||||
SELECT DISTINCT
|
||||
SELECT DISTINCT
|
||||
type,
|
||||
id,
|
||||
title,
|
||||
@@ -393,33 +592,9 @@ class ContextService:
|
||||
root_id,
|
||||
created_at
|
||||
FROM entity_graph
|
||||
WHERE (type, id) NOT IN ({values})
|
||||
GROUP BY
|
||||
type, id
|
||||
WHERE depth > 0
|
||||
GROUP BY type, id, title, permalink, file_path, from_id, to_id,
|
||||
relation_type, content, category, entity_id, root_id, created_at
|
||||
ORDER BY depth, type, id
|
||||
LIMIT :max_results
|
||||
""")
|
||||
|
||||
result = await self.search_repository.execute_query(query, params=params)
|
||||
rows = result.all()
|
||||
|
||||
context_rows = [
|
||||
ContextResultRow(
|
||||
type=row.type,
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
permalink=row.permalink,
|
||||
file_path=row.file_path,
|
||||
from_id=row.from_id,
|
||||
to_id=row.to_id,
|
||||
relation_type=row.relation_type,
|
||||
content=row.content,
|
||||
category=row.category,
|
||||
entity_id=row.entity_id,
|
||||
depth=row.depth,
|
||||
root_id=row.root_id,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
return context_rows
|
||||
|
||||
@@ -448,8 +448,11 @@ class EntityService(BaseService[EntityModel]):
|
||||
import asyncio
|
||||
|
||||
# Create tasks for all relation lookups
|
||||
# Use strict=True to disable fuzzy search - only exact matches should create resolved relations
|
||||
# This ensures forward references (links to non-existent entities) remain unresolved (to_id=NULL)
|
||||
lookup_tasks = [
|
||||
self.link_resolver.resolve_link(rel.target) for rel in markdown.relations
|
||||
self.link_resolver.resolve_link(rel.target, strict=True)
|
||||
for rel in markdown.relations
|
||||
]
|
||||
|
||||
# Execute all lookups in parallel
|
||||
|
||||
@@ -766,25 +766,42 @@ class ProjectService:
|
||||
)
|
||||
|
||||
# Query for monthly entity creation (project filtered)
|
||||
# Use different date formatting for SQLite vs Postgres
|
||||
from basic_memory.config import DatabaseBackend
|
||||
|
||||
is_postgres = self.config_manager.config.database_backend == DatabaseBackend.POSTGRES
|
||||
date_format = (
|
||||
"to_char(created_at, 'YYYY-MM')" if is_postgres else "strftime('%Y-%m', created_at)"
|
||||
)
|
||||
|
||||
# Postgres needs datetime objects, SQLite needs ISO strings
|
||||
six_months_param = six_months_ago if is_postgres else six_months_ago.isoformat()
|
||||
|
||||
entity_growth_result = await self.repository.execute_query(
|
||||
text("""
|
||||
SELECT
|
||||
strftime('%Y-%m', created_at) AS month,
|
||||
text(f"""
|
||||
SELECT
|
||||
{date_format} AS month,
|
||||
COUNT(*) AS count
|
||||
FROM entity
|
||||
WHERE created_at >= :six_months_ago AND project_id = :project_id
|
||||
GROUP BY month
|
||||
ORDER BY month
|
||||
"""),
|
||||
{"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
|
||||
{"six_months_ago": six_months_param, "project_id": project_id},
|
||||
)
|
||||
entity_growth = {row[0]: row[1] for row in entity_growth_result.fetchall()}
|
||||
|
||||
# Query for monthly observation creation (project filtered)
|
||||
date_format_entity = (
|
||||
"to_char(entity.created_at, 'YYYY-MM')"
|
||||
if is_postgres
|
||||
else "strftime('%Y-%m', entity.created_at)"
|
||||
)
|
||||
|
||||
observation_growth_result = await self.repository.execute_query(
|
||||
text("""
|
||||
SELECT
|
||||
strftime('%Y-%m', entity.created_at) AS month,
|
||||
text(f"""
|
||||
SELECT
|
||||
{date_format_entity} AS month,
|
||||
COUNT(*) AS count
|
||||
FROM observation
|
||||
INNER JOIN entity ON observation.entity_id = entity.id
|
||||
@@ -792,15 +809,15 @@ class ProjectService:
|
||||
GROUP BY month
|
||||
ORDER BY month
|
||||
"""),
|
||||
{"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
|
||||
{"six_months_ago": six_months_param, "project_id": project_id},
|
||||
)
|
||||
observation_growth = {row[0]: row[1] for row in observation_growth_result.fetchall()}
|
||||
|
||||
# Query for monthly relation creation (project filtered)
|
||||
relation_growth_result = await self.repository.execute_query(
|
||||
text("""
|
||||
SELECT
|
||||
strftime('%Y-%m', entity.created_at) AS month,
|
||||
text(f"""
|
||||
SELECT
|
||||
{date_format_entity} AS month,
|
||||
COUNT(*) AS count
|
||||
FROM relation
|
||||
INNER JOIN entity ON relation.from_id = entity.id
|
||||
@@ -808,7 +825,7 @@ class ProjectService:
|
||||
GROUP BY month
|
||||
ORDER BY month
|
||||
"""),
|
||||
{"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
|
||||
{"six_months_ago": six_months_param, "project_id": project_id},
|
||||
)
|
||||
relation_growth = {row[0]: row[1] for row in relation_growth_result.fetchall()}
|
||||
|
||||
|
||||
@@ -156,22 +156,24 @@ class SearchService:
|
||||
self,
|
||||
entity: Entity,
|
||||
background_tasks: Optional[BackgroundTasks] = None,
|
||||
content: str | None = None,
|
||||
) -> None:
|
||||
if background_tasks:
|
||||
background_tasks.add_task(self.index_entity_data, entity)
|
||||
background_tasks.add_task(self.index_entity_data, entity, content)
|
||||
else:
|
||||
await self.index_entity_data(entity)
|
||||
await self.index_entity_data(entity, content)
|
||||
|
||||
async def index_entity_data(
|
||||
self,
|
||||
entity: Entity,
|
||||
content: str | None = None,
|
||||
) -> None:
|
||||
# delete all search index data associated with entity
|
||||
await self.repository.delete_by_entity_id(entity_id=entity.id)
|
||||
|
||||
# reindex
|
||||
await self.index_entity_markdown(
|
||||
entity
|
||||
entity, content
|
||||
) if entity.is_markdown else await self.index_entity_file(entity)
|
||||
|
||||
async def index_entity_file(
|
||||
@@ -185,6 +187,7 @@ class SearchService:
|
||||
entity_id=entity.id,
|
||||
type=SearchItemType.ENTITY.value,
|
||||
title=entity.title,
|
||||
permalink=entity.permalink, # Required for Postgres NOT NULL constraint
|
||||
file_path=entity.file_path,
|
||||
metadata={
|
||||
"entity_type": entity.entity_type,
|
||||
@@ -198,9 +201,14 @@ class SearchService:
|
||||
async def index_entity_markdown(
|
||||
self,
|
||||
entity: Entity,
|
||||
content: str | None = None,
|
||||
) -> None:
|
||||
"""Index an entity and all its observations and relations.
|
||||
|
||||
Args:
|
||||
entity: The entity to index
|
||||
content: Optional pre-loaded content (avoids file read). If None, will read from file.
|
||||
|
||||
Indexing structure:
|
||||
1. Entities
|
||||
- permalink: direct from entity (e.g., "specs/search")
|
||||
@@ -229,7 +237,9 @@ class SearchService:
|
||||
title_variants = self._generate_variants(entity.title)
|
||||
content_stems.extend(title_variants)
|
||||
|
||||
content = await self.file_service.read_entity_content(entity)
|
||||
# Use provided content or read from file
|
||||
if content is None:
|
||||
content = await self.file_service.read_entity_content(entity)
|
||||
if content:
|
||||
content_stems.append(content)
|
||||
content_snippet = f"{content[:250]}"
|
||||
|
||||
@@ -26,7 +26,7 @@ from basic_memory.repository import (
|
||||
ObservationRepository,
|
||||
ProjectRepository,
|
||||
)
|
||||
from basic_memory.repository.search_repository import SearchRepository
|
||||
from basic_memory.repository.search_repository import create_search_repository
|
||||
from basic_memory.services import EntityService, FileService
|
||||
from basic_memory.services.exceptions import SyncFatalError
|
||||
from basic_memory.services.link_resolver import LinkResolver
|
||||
@@ -1213,7 +1213,7 @@ async def get_sync_service(project: Project) -> SyncService: # pragma: no cover
|
||||
entity_repository = EntityRepository(session_maker, project_id=project.id)
|
||||
observation_repository = ObservationRepository(session_maker, project_id=project.id)
|
||||
relation_repository = RelationRepository(session_maker, project_id=project.id)
|
||||
search_repository = SearchRepository(session_maker, project_id=project.id)
|
||||
search_repository = create_search_repository(session_maker, project_id=project.id)
|
||||
project_repository = ProjectRepository(session_maker)
|
||||
|
||||
# Initialize services
|
||||
|
||||
@@ -76,10 +76,14 @@ def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: b
|
||||
|
||||
Args:
|
||||
file_path: Original file path (str, Path, or PathLike)
|
||||
split_extension: Whether to split off and discard file extensions.
|
||||
When True, uses mimetypes to detect real extensions.
|
||||
When False, preserves all content including periods.
|
||||
|
||||
Returns:
|
||||
Normalized permalink that matches validation rules. Converts spaces and underscores
|
||||
to hyphens for consistency. Preserves non-ASCII characters like Chinese.
|
||||
Preserves periods in version numbers (e.g., "2.0.0") when they're not real file extensions.
|
||||
|
||||
Examples:
|
||||
>>> generate_permalink("docs/My Feature.md")
|
||||
@@ -90,12 +94,26 @@ def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: b
|
||||
'design/unified-model-refactor'
|
||||
>>> generate_permalink("中文/测试文档.md")
|
||||
'中文/测试文档'
|
||||
>>> generate_permalink("Version 2.0.0")
|
||||
'version-2.0.0'
|
||||
"""
|
||||
# Convert Path to string if needed
|
||||
path_str = Path(str(file_path)).as_posix()
|
||||
|
||||
# Remove extension (for now, possibly)
|
||||
(base, extension) = os.path.splitext(path_str)
|
||||
# Only split extension if there's a real file extension
|
||||
# Use mimetypes to detect real extensions, avoiding misinterpreting periods in version numbers
|
||||
import mimetypes
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(path_str)
|
||||
has_real_extension = mime_type is not None
|
||||
|
||||
if has_real_extension and split_extension:
|
||||
# Real file extension detected - split it off
|
||||
(base, extension) = os.path.splitext(path_str)
|
||||
else:
|
||||
# No real extension or split_extension=False - process the whole string
|
||||
base = path_str
|
||||
extension = ""
|
||||
|
||||
# Check if we have CJK characters that should be preserved
|
||||
# CJK ranges: \u4e00-\u9fff (CJK Unified Ideographs), \u3000-\u303f (CJK symbols),
|
||||
@@ -147,9 +165,9 @@ def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: b
|
||||
# Remove apostrophes entirely (don't replace with hyphens)
|
||||
text_no_apostrophes = text_with_hyphens.replace("'", "")
|
||||
|
||||
# Replace unsafe chars with hyphens, but preserve CJK characters
|
||||
# Replace unsafe chars with hyphens, but preserve CJK characters and periods
|
||||
clean_text = re.sub(
|
||||
r"[^a-z0-9\u4e00-\u9fff\u3000-\u303f\u3400-\u4dbf/\-]", "-", text_no_apostrophes
|
||||
r"[^a-z0-9\u4e00-\u9fff\u3000-\u303f\u3400-\u4dbf/\-\.]", "-", text_no_apostrophes
|
||||
)
|
||||
else:
|
||||
# Original ASCII-only processing for backward compatibility
|
||||
@@ -168,8 +186,8 @@ def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: b
|
||||
# Remove apostrophes entirely (don't replace with hyphens)
|
||||
text_no_apostrophes = text_with_hyphens.replace("'", "")
|
||||
|
||||
# Replace remaining invalid chars with hyphens
|
||||
clean_text = re.sub(r"[^a-z0-9/\-]", "-", text_no_apostrophes)
|
||||
# Replace remaining invalid chars with hyphens, preserving periods
|
||||
clean_text = re.sub(r"[^a-z0-9/\-\.]", "-", text_no_apostrophes)
|
||||
|
||||
# Collapse multiple hyphens
|
||||
clean_text = re.sub(r"-+", "-", clean_text)
|
||||
|
||||
@@ -5,13 +5,13 @@ from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.main import app
|
||||
from basic_memory.cli.main import app as cli_app
|
||||
|
||||
|
||||
def test_project_list(app_config, test_project, config_manager):
|
||||
def test_project_list(app, app_config, test_project, config_manager):
|
||||
"""Test 'bm project list' command shows projects."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["project", "list"])
|
||||
result = runner.invoke(cli_app, ["project", "list"])
|
||||
|
||||
if result.exit_code != 0:
|
||||
print(f"STDOUT: {result.stdout}")
|
||||
@@ -22,10 +22,10 @@ def test_project_list(app_config, test_project, config_manager):
|
||||
assert "[X]" in result.stdout # default marker
|
||||
|
||||
|
||||
def test_project_info(app_config, test_project, config_manager):
|
||||
def test_project_info(app, app_config, test_project, config_manager):
|
||||
"""Test 'bm project info' command shows project details."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["project", "info", "test-project"])
|
||||
result = runner.invoke(cli_app, ["project", "info", "test-project"])
|
||||
|
||||
if result.exit_code != 0:
|
||||
print(f"STDOUT: {result.stdout}")
|
||||
@@ -36,12 +36,12 @@ def test_project_info(app_config, test_project, config_manager):
|
||||
assert "Statistics" in result.stdout
|
||||
|
||||
|
||||
def test_project_info_json(app_config, test_project, config_manager):
|
||||
def test_project_info_json(app, app_config, test_project, config_manager):
|
||||
"""Test 'bm project info --json' command outputs valid JSON."""
|
||||
import json
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["project", "info", "test-project", "--json"])
|
||||
result = runner.invoke(cli_app, ["project", "info", "test-project", "--json"])
|
||||
|
||||
if result.exit_code != 0:
|
||||
print(f"STDOUT: {result.stdout}")
|
||||
@@ -55,7 +55,7 @@ def test_project_info_json(app_config, test_project, config_manager):
|
||||
assert "system" in data
|
||||
|
||||
|
||||
def test_project_add_and_remove(app_config, config_manager):
|
||||
def test_project_add_and_remove(app, app_config, config_manager):
|
||||
"""Test adding and removing a project."""
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -65,7 +65,7 @@ def test_project_add_and_remove(app_config, config_manager):
|
||||
new_project_path.mkdir()
|
||||
|
||||
# Add project
|
||||
result = runner.invoke(app, ["project", "add", "new-project", str(new_project_path)])
|
||||
result = runner.invoke(cli_app, ["project", "add", "new-project", str(new_project_path)])
|
||||
|
||||
if result.exit_code != 0:
|
||||
print(f"STDOUT: {result.stdout}")
|
||||
@@ -77,17 +77,17 @@ def test_project_add_and_remove(app_config, config_manager):
|
||||
)
|
||||
|
||||
# Verify it shows up in list
|
||||
result = runner.invoke(app, ["project", "list"])
|
||||
result = runner.invoke(cli_app, ["project", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "new-project" in result.stdout
|
||||
|
||||
# Remove project
|
||||
result = runner.invoke(app, ["project", "remove", "new-project"])
|
||||
result = runner.invoke(cli_app, ["project", "remove", "new-project"])
|
||||
assert result.exit_code == 0
|
||||
assert "removed" in result.stdout.lower() or "deleted" in result.stdout.lower()
|
||||
|
||||
|
||||
def test_project_set_default(app_config, config_manager):
|
||||
def test_project_set_default(app, app_config, config_manager):
|
||||
"""Test setting default project."""
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -97,14 +97,16 @@ def test_project_set_default(app_config, config_manager):
|
||||
new_project_path.mkdir()
|
||||
|
||||
# Add a second project
|
||||
result = runner.invoke(app, ["project", "add", "another-project", str(new_project_path)])
|
||||
result = runner.invoke(
|
||||
cli_app, ["project", "add", "another-project", str(new_project_path)]
|
||||
)
|
||||
if result.exit_code != 0:
|
||||
print(f"STDOUT: {result.stdout}")
|
||||
print(f"STDERR: {result.stderr}")
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Set as default
|
||||
result = runner.invoke(app, ["project", "default", "another-project"])
|
||||
result = runner.invoke(cli_app, ["project", "default", "another-project"])
|
||||
if result.exit_code != 0:
|
||||
print(f"STDOUT: {result.stdout}")
|
||||
print(f"STDERR: {result.stderr}")
|
||||
@@ -112,7 +114,7 @@ def test_project_set_default(app_config, config_manager):
|
||||
assert "default" in result.stdout.lower()
|
||||
|
||||
# Verify in list
|
||||
result = runner.invoke(app, ["project", "list"])
|
||||
result = runner.invoke(cli_app, ["project", "list"])
|
||||
assert result.exit_code == 0
|
||||
# The new project should have the [X] marker now
|
||||
lines = result.stdout.split("\n")
|
||||
@@ -121,7 +123,7 @@ def test_project_set_default(app_config, config_manager):
|
||||
assert "[X]" in line
|
||||
|
||||
|
||||
def test_remove_main_project(app_config, config_manager):
|
||||
def test_remove_main_project(app, app_config, config_manager):
|
||||
"""Test that removing main project then listing projects prevents main from reappearing (issue #397)."""
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -134,30 +136,30 @@ def test_remove_main_project(app_config, config_manager):
|
||||
new_default_path = Path(new_default_dir)
|
||||
|
||||
# Ensure main exists
|
||||
result = runner.invoke(app, ["project", "list"])
|
||||
result = runner.invoke(cli_app, ["project", "list"])
|
||||
if "main" not in result.stdout:
|
||||
result = runner.invoke(app, ["project", "add", "main", str(main_path)])
|
||||
result = runner.invoke(cli_app, ["project", "add", "main", str(main_path)])
|
||||
print(result.stdout)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Confirm main is present
|
||||
result = runner.invoke(app, ["project", "list"])
|
||||
result = runner.invoke(cli_app, ["project", "list"])
|
||||
assert "main" in result.stdout
|
||||
|
||||
# Add a second project
|
||||
result = runner.invoke(app, ["project", "add", "new_default", str(new_default_path)])
|
||||
result = runner.invoke(cli_app, ["project", "add", "new_default", str(new_default_path)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Set new_default as default (if needed)
|
||||
result = runner.invoke(app, ["project", "default", "new_default"])
|
||||
result = runner.invoke(cli_app, ["project", "default", "new_default"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Remove main
|
||||
result = runner.invoke(app, ["project", "remove", "main"])
|
||||
result = runner.invoke(cli_app, ["project", "remove", "main"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Confirm only new_default exists and main does not
|
||||
result = runner.invoke(app, ["project", "list"])
|
||||
result = runner.invoke(cli_app, ["project", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "main" not in result.stdout
|
||||
assert "new_default" in result.stdout
|
||||
|
||||
+120
-29
@@ -50,15 +50,16 @@ The `app` fixture ensures FastAPI dependency overrides are active, and
|
||||
`mcp_server` provides the MCP server with proper project session initialization.
|
||||
"""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
from typing import AsyncGenerator, Literal
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from pathlib import Path
|
||||
from sqlalchemy import text
|
||||
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig, ProjectConfig, ConfigManager
|
||||
from basic_memory.config import BasicMemoryConfig, ProjectConfig, ConfigManager, DatabaseBackend
|
||||
from basic_memory.db import engine_session_factory, DatabaseType
|
||||
from basic_memory.models import Project
|
||||
from basic_memory.repository.project_repository import ProjectRepository
|
||||
@@ -71,24 +72,89 @@ from basic_memory.deps import get_project_config, get_engine_factory, get_app_co
|
||||
from basic_memory.mcp import tools # noqa: F401
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def engine_factory(tmp_path):
|
||||
"""Create a SQLite file engine factory for integration testing."""
|
||||
db_path = tmp_path / "test.db"
|
||||
async with engine_session_factory(db_path, DatabaseType.FILESYSTEM) as (
|
||||
engine,
|
||||
session_maker,
|
||||
):
|
||||
# Initialize database schema
|
||||
from basic_memory.models.base import Base
|
||||
@pytest.fixture(
|
||||
params=[
|
||||
pytest.param("sqlite", id="sqlite"),
|
||||
pytest.param("postgres", id="postgres", marks=pytest.mark.postgres),
|
||||
]
|
||||
)
|
||||
def db_backend(request) -> Literal["sqlite", "postgres"]:
|
||||
"""Parametrize tests to run against both SQLite and Postgres.
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
Usage:
|
||||
pytest # Runs tests against SQLite only (default)
|
||||
pytest -m postgres # Runs tests against Postgres only
|
||||
pytest -m "not postgres" # Runs tests against SQLite only
|
||||
pytest --run-all-backends # Runs tests against both backends
|
||||
|
||||
yield engine, session_maker
|
||||
Note: Only tests that use database fixtures (engine_factory, session_maker, etc.)
|
||||
will be parametrized. Tests that don't use the database won't be affected.
|
||||
"""
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
@pytest_asyncio.fixture
|
||||
async def engine_factory(
|
||||
app_config,
|
||||
config_manager,
|
||||
db_backend: Literal["sqlite", "postgres"],
|
||||
tmp_path,
|
||||
) -> AsyncGenerator[tuple, None]:
|
||||
"""Create engine and session factory for the configured database backend."""
|
||||
from basic_memory.models.search import CREATE_SEARCH_INDEX
|
||||
from basic_memory import db
|
||||
|
||||
# Determine database type based on backend
|
||||
if db_backend == "postgres":
|
||||
db_type = DatabaseType.FILESYSTEM
|
||||
else:
|
||||
db_type = DatabaseType.FILESYSTEM # Integration tests use file-based SQLite
|
||||
|
||||
# Use tmp_path for SQLite, use config database_path for Postgres
|
||||
if db_backend == "sqlite":
|
||||
db_path = tmp_path / "test.db"
|
||||
else:
|
||||
db_path = app_config.database_path
|
||||
|
||||
if db_backend == "postgres":
|
||||
# Postgres: Create fresh engine for each test with full schema reset
|
||||
config_manager._config = app_config
|
||||
|
||||
# Use context manager to handle engine disposal properly
|
||||
async with engine_session_factory(db_path, db_type) as (engine, session_maker):
|
||||
# Drop and recreate schema for complete isolation
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE"))
|
||||
await conn.execute(text("CREATE SCHEMA public"))
|
||||
await conn.execute(text("GRANT ALL ON SCHEMA public TO basic_memory_user"))
|
||||
await conn.execute(text("GRANT ALL ON SCHEMA public TO public"))
|
||||
|
||||
# Run migrations to create production tables
|
||||
from basic_memory.db import run_migrations
|
||||
|
||||
await run_migrations(app_config, db_type)
|
||||
|
||||
yield engine, session_maker
|
||||
|
||||
else:
|
||||
# SQLite: Create fresh database (fast with tmp files)
|
||||
async with engine_session_factory(db_path, db_type) as (engine, session_maker):
|
||||
# Create all tables via ORM
|
||||
from basic_memory.models.base import Base
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Drop any SearchIndex ORM table, then create FTS5 virtual table
|
||||
async with db.scoped_session(session_maker) as session:
|
||||
await session.execute(text("DROP TABLE IF EXISTS search_index"))
|
||||
await session.execute(CREATE_SEARCH_INDEX)
|
||||
await session.commit()
|
||||
|
||||
yield engine, session_maker
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_project(config_home, engine_factory) -> Project:
|
||||
"""Create a test project."""
|
||||
project_data = {
|
||||
@@ -113,14 +179,27 @@ def config_home(tmp_path, monkeypatch) -> Path:
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
def app_config(config_home, tmp_path, monkeypatch) -> BasicMemoryConfig:
|
||||
@pytest.fixture
|
||||
def app_config(
|
||||
config_home, db_backend: Literal["sqlite", "postgres"], tmp_path, monkeypatch
|
||||
) -> BasicMemoryConfig:
|
||||
"""Create test app configuration."""
|
||||
# Disable cloud mode for CLI tests
|
||||
monkeypatch.setenv("BASIC_MEMORY_CLOUD_MODE", "false")
|
||||
|
||||
# Create a basic config with test-project like unit tests do
|
||||
projects = {"test-project": str(config_home)}
|
||||
|
||||
# Configure database backend based on test parameter
|
||||
if db_backend == "postgres":
|
||||
database_backend = DatabaseBackend.POSTGRES
|
||||
database_url = (
|
||||
"postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test"
|
||||
)
|
||||
else:
|
||||
database_backend = DatabaseBackend.SQLITE
|
||||
database_url = None
|
||||
|
||||
app_config = BasicMemoryConfig(
|
||||
env="test",
|
||||
projects=projects,
|
||||
@@ -128,12 +207,19 @@ def app_config(config_home, tmp_path, monkeypatch) -> BasicMemoryConfig:
|
||||
default_project_mode=False, # Match real-world usage - tools must pass explicit project
|
||||
update_permalinks_on_move=True,
|
||||
cloud_mode=False, # Explicitly disable cloud mode
|
||||
database_backend=database_backend,
|
||||
database_url=database_url,
|
||||
)
|
||||
return app_config
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
@pytest.fixture
|
||||
def config_manager(app_config: BasicMemoryConfig, config_home) -> ConfigManager:
|
||||
# Invalidate config cache to ensure clean state for each test
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
|
||||
config_manager = ConfigManager()
|
||||
# Update its paths to use the test directory
|
||||
config_manager.config_dir = config_home / ".basic-memory"
|
||||
@@ -145,7 +231,7 @@ def config_manager(app_config: BasicMemoryConfig, config_home) -> ConfigManager:
|
||||
return config_manager
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
@pytest.fixture
|
||||
def project_config(test_project):
|
||||
"""Create test project configuration."""
|
||||
|
||||
@@ -157,7 +243,7 @@ def project_config(test_project):
|
||||
return project_config
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
@pytest.fixture
|
||||
def app(app_config, project_config, engine_factory, test_project, config_manager) -> FastAPI:
|
||||
"""Create test FastAPI application with single project."""
|
||||
|
||||
@@ -172,20 +258,25 @@ def app(app_config, project_config, engine_factory, test_project, config_manager
|
||||
return app
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def search_service(engine_factory, test_project):
|
||||
"""Create and initialize search service for integration tests."""
|
||||
from basic_memory.repository.search_repository import SearchRepository
|
||||
@pytest_asyncio.fixture
|
||||
async def search_service(engine_factory, test_project, app_config):
|
||||
"""Create and initialize search service for integration tests.
|
||||
|
||||
Uses app_config fixture to determine database backend - no patching needed.
|
||||
"""
|
||||
from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.services.file_service import FileService
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
||||
from basic_memory.markdown import EntityParser
|
||||
|
||||
from basic_memory.repository.search_repository import create_search_repository
|
||||
|
||||
engine, session_maker = engine_factory
|
||||
|
||||
# Create repositories
|
||||
search_repository = SearchRepository(session_maker, project_id=test_project.id)
|
||||
# Use factory function to create appropriate search repository
|
||||
search_repository = create_search_repository(session_maker, project_id=test_project.id)
|
||||
|
||||
entity_repository = EntityRepository(session_maker, project_id=test_project.id)
|
||||
|
||||
# Create file service
|
||||
@@ -199,7 +290,7 @@ async def search_service(engine_factory, test_project):
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
@pytest.fixture
|
||||
def mcp_server(config_manager, search_service):
|
||||
# Import mcp instance
|
||||
from basic_memory.mcp.server import mcp as server
|
||||
@@ -213,7 +304,7 @@ def mcp_server(config_manager, search_service):
|
||||
return server
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
@pytest_asyncio.fixture
|
||||
async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create test client that both MCP and tests will use."""
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
|
||||
@@ -77,7 +77,8 @@ async def test_create_project_basic_operation(mcp_server, app, test_project):
|
||||
assert "test-new-project" in create_text
|
||||
assert "Project Details:" in create_text
|
||||
assert "Name: test-new-project" in create_text
|
||||
assert "Path: /tmp/test-new-project" in create_text
|
||||
# Check path contains project name (platform-independent)
|
||||
assert "Path:" in create_text and "test-new-project" in create_text
|
||||
assert "Project is now available for use" in create_text
|
||||
|
||||
# Verify project appears in project list
|
||||
|
||||
@@ -9,9 +9,10 @@ from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
from unittest.mock import patch
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.schemas.project_info import ProjectItem
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -313,79 +314,68 @@ async def test_write_note_preserve_frontmatter(mcp_server, app, test_project):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_kebab_filenames_basic(mcp_server, test_project):
|
||||
async def test_write_note_kebab_filenames_basic(mcp_server, app, test_project, app_config):
|
||||
"""Test note creation with kebab_filenames=True and invalid filename characters."""
|
||||
|
||||
config = ConfigManager().config
|
||||
curr_config_val = config.kebab_filenames
|
||||
config.kebab_filenames = True
|
||||
app_config.kebab_filenames = True
|
||||
ConfigManager().save_config(app_config)
|
||||
|
||||
with patch.object(ConfigManager, "config", config):
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "My Note: With/Invalid|Chars?",
|
||||
"folder": "my-folder",
|
||||
"content": "Testing kebab-case and invalid characters.",
|
||||
"tags": "kebab,invalid,filename",
|
||||
},
|
||||
)
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "My Note: With/Invalid|Chars?",
|
||||
"folder": "my-folder",
|
||||
"content": "Testing kebab-case and invalid characters.",
|
||||
"tags": "kebab,invalid,filename",
|
||||
},
|
||||
)
|
||||
|
||||
assert len(result.content) == 1
|
||||
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
assert len(result.content) == 1
|
||||
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
|
||||
# File path and permalink should be kebab-case and sanitized
|
||||
assert f"project: {test_project.name}" in response_text
|
||||
assert "file_path: my-folder/my-note-with-invalid-chars.md" in response_text
|
||||
assert "permalink: my-folder/my-note-with-invalid-chars" in response_text
|
||||
assert f"[Session: Using project '{test_project.name}']" in response_text
|
||||
|
||||
# Restore original config value
|
||||
config.kebab_filenames = curr_config_val
|
||||
# File path and permalink should be kebab-case and sanitized
|
||||
assert f"project: {test_project.name}" in response_text
|
||||
assert "file_path: my-folder/my-note-with-invalid-chars.md" in response_text
|
||||
assert "permalink: my-folder/my-note-with-invalid-chars" in response_text
|
||||
assert f"[Session: Using project '{test_project.name}']" in response_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_kebab_filenames_repeat_invalid(mcp_server, test_project):
|
||||
async def test_write_note_kebab_filenames_repeat_invalid(mcp_server, app, test_project, app_config):
|
||||
"""Test note creation with multiple invalid and repeated characters."""
|
||||
|
||||
config = ConfigManager().config
|
||||
curr_config_val = config.kebab_filenames
|
||||
config.kebab_filenames = True
|
||||
app_config.kebab_filenames = True
|
||||
ConfigManager().save_config(app_config)
|
||||
|
||||
with patch.object(ConfigManager, "config", config):
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": 'Crazy<>:"|?*Note/Name',
|
||||
"folder": "my-folder",
|
||||
"content": "Should be fully kebab-case and safe.",
|
||||
"tags": "crazy,filename,test",
|
||||
},
|
||||
)
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": 'Crazy<>:"|?*Note/Name',
|
||||
"folder": "my-folder",
|
||||
"content": "Should be fully kebab-case and safe.",
|
||||
"tags": "crazy,filename,test",
|
||||
},
|
||||
)
|
||||
|
||||
assert len(result.content) == 1
|
||||
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
assert len(result.content) == 1
|
||||
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
|
||||
assert f"project: {test_project.name}" in response_text
|
||||
assert "file_path: my-folder/crazy-note-name.md" in response_text
|
||||
assert "permalink: my-folder/crazy-note-name" in response_text
|
||||
assert f"[Session: Using project '{test_project.name}']" in response_text
|
||||
|
||||
# Restore original config value
|
||||
config.kebab_filenames = curr_config_val
|
||||
assert f"project: {test_project.name}" in response_text
|
||||
assert "file_path: my-folder/crazy-note-name.md" in response_text
|
||||
assert "permalink: my-folder/crazy-note-name" in response_text
|
||||
assert f"[Session: Using project '{test_project.name}']" in response_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_file_path_os_path_join(mcp_server, test_project):
|
||||
async def test_write_note_file_path_os_path_join(mcp_server, app, test_project, app_config):
|
||||
"""Test that os.path.join logic in Entity.file_path works for various folder/title combinations."""
|
||||
|
||||
config = ConfigManager().config
|
||||
curr_config_val = config.kebab_filenames
|
||||
config.kebab_filenames = True
|
||||
app_config.kebab_filenames = True
|
||||
ConfigManager().save_config(app_config)
|
||||
|
||||
test_cases = [
|
||||
# (folder, title, expected file_path, expected permalink)
|
||||
@@ -407,35 +397,31 @@ async def test_write_note_file_path_os_path_join(mcp_server, test_project):
|
||||
("folder//subfolder", "Note", "folder/subfolder/note.md", "folder/subfolder/note"),
|
||||
]
|
||||
|
||||
with patch.object(ConfigManager, "config", config):
|
||||
async with Client(mcp_server) as client:
|
||||
for folder, title, expected_path, expected_permalink in test_cases:
|
||||
result = await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": title,
|
||||
"folder": folder,
|
||||
"content": "Testing os.path.join logic.",
|
||||
"tags": "integration,ospath",
|
||||
},
|
||||
)
|
||||
async with Client(mcp_server) as client:
|
||||
for folder, title, expected_path, expected_permalink in test_cases:
|
||||
result = await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": title,
|
||||
"folder": folder,
|
||||
"content": "Testing os.path.join logic.",
|
||||
"tags": "integration,ospath",
|
||||
},
|
||||
)
|
||||
|
||||
assert len(result.content) == 1
|
||||
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
print(response_text)
|
||||
assert len(result.content) == 1
|
||||
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
|
||||
print(response_text)
|
||||
|
||||
assert f"project: {test_project.name}" in response_text
|
||||
assert f"file_path: {expected_path}" in response_text
|
||||
assert f"permalink: {expected_permalink}" in response_text
|
||||
assert f"[Session: Using project '{test_project.name}']" in response_text
|
||||
|
||||
# Restore original config value
|
||||
config.kebab_filenames = curr_config_val
|
||||
assert f"project: {test_project.name}" in response_text
|
||||
assert f"file_path: {expected_path}" in response_text
|
||||
assert f"permalink: {expected_permalink}" in response_text
|
||||
assert f"[Session: Using project '{test_project.name}']" in response_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_project_path_validation(mcp_server, test_project):
|
||||
async def test_write_note_project_path_validation(mcp_server, app, test_project):
|
||||
"""Test that ProjectItem.home uses expanded path, not name (Issue #340).
|
||||
|
||||
Regression test verifying that:
|
||||
@@ -446,16 +432,12 @@ async def test_write_note_project_path_validation(mcp_server, test_project):
|
||||
the project name and path happen to be the same. The fix in src/basic_memory/schemas/project_info.py:186
|
||||
ensures .expanduser() is called, which is critical for paths with ~ like "~/Documents/Test BiSync".
|
||||
"""
|
||||
from basic_memory.schemas.project_info import ProjectItem
|
||||
from pathlib import Path
|
||||
|
||||
# Test the fix directly: ProjectItem.home should expand tilde paths
|
||||
project_with_tilde = ProjectItem(
|
||||
id=1,
|
||||
name="Test BiSync", # Name differs from path structure
|
||||
description="Test",
|
||||
path="~/Documents/Test BiSync", # Path with tilde
|
||||
is_active=True,
|
||||
is_default=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,8 +10,11 @@ from sqlalchemy import text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wal_mode_enabled(engine_factory):
|
||||
async def test_wal_mode_enabled(engine_factory, db_backend):
|
||||
"""Test that WAL mode is enabled on filesystem database connections."""
|
||||
if db_backend == "postgres":
|
||||
pytest.skip("SQLite-specific test - PRAGMA commands not supported in Postgres")
|
||||
|
||||
engine, _ = engine_factory
|
||||
|
||||
# Execute a query to verify WAL mode is enabled
|
||||
@@ -24,8 +27,11 @@ async def test_wal_mode_enabled(engine_factory):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_busy_timeout_configured(engine_factory):
|
||||
async def test_busy_timeout_configured(engine_factory, db_backend):
|
||||
"""Test that busy timeout is configured for database connections."""
|
||||
if db_backend == "postgres":
|
||||
pytest.skip("SQLite-specific test - PRAGMA commands not supported in Postgres")
|
||||
|
||||
engine, _ = engine_factory
|
||||
|
||||
async with engine.connect() as conn:
|
||||
@@ -37,8 +43,11 @@ async def test_busy_timeout_configured(engine_factory):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_synchronous_mode_configured(engine_factory):
|
||||
async def test_synchronous_mode_configured(engine_factory, db_backend):
|
||||
"""Test that synchronous mode is set to NORMAL for performance."""
|
||||
if db_backend == "postgres":
|
||||
pytest.skip("SQLite-specific test - PRAGMA commands not supported in Postgres")
|
||||
|
||||
engine, _ = engine_factory
|
||||
|
||||
async with engine.connect() as conn:
|
||||
@@ -50,8 +59,11 @@ async def test_synchronous_mode_configured(engine_factory):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_size_configured(engine_factory):
|
||||
async def test_cache_size_configured(engine_factory, db_backend):
|
||||
"""Test that cache size is configured for performance."""
|
||||
if db_backend == "postgres":
|
||||
pytest.skip("SQLite-specific test - PRAGMA commands not supported in Postgres")
|
||||
|
||||
engine, _ = engine_factory
|
||||
|
||||
async with engine.connect() as conn:
|
||||
@@ -63,8 +75,11 @@ async def test_cache_size_configured(engine_factory):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temp_store_configured(engine_factory):
|
||||
async def test_temp_store_configured(engine_factory, db_backend):
|
||||
"""Test that temp_store is set to MEMORY."""
|
||||
if db_backend == "postgres":
|
||||
pytest.skip("SQLite-specific test - PRAGMA commands not supported in Postgres")
|
||||
|
||||
engine, _ = engine_factory
|
||||
|
||||
async with engine.connect() as conn:
|
||||
@@ -76,42 +91,61 @@ async def test_temp_store_configured(engine_factory):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_locking_mode_when_on_windows(tmp_path):
|
||||
@pytest.mark.windows
|
||||
@pytest.mark.skipif(
|
||||
__import__("os").name != "nt", reason="Windows-specific test - only runs on Windows platform"
|
||||
)
|
||||
async def test_windows_locking_mode_when_on_windows(tmp_path, monkeypatch, config_manager):
|
||||
"""Test that Windows-specific locking mode is set when running on Windows."""
|
||||
from basic_memory.db import engine_session_factory, DatabaseType
|
||||
from basic_memory.config import DatabaseBackend
|
||||
|
||||
# Force SQLite backend for this SQLite-specific test
|
||||
config_manager.config.database_backend = DatabaseBackend.SQLITE
|
||||
|
||||
# Set HOME environment variable
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("BASIC_MEMORY_HOME", str(tmp_path / "basic-memory"))
|
||||
|
||||
db_path = tmp_path / "test_windows.db"
|
||||
|
||||
with patch("os.name", "nt"):
|
||||
# Need to patch at module level where it's imported
|
||||
with patch("basic_memory.db.os.name", "nt"):
|
||||
async with engine_session_factory(db_path, DatabaseType.FILESYSTEM) as (
|
||||
engine,
|
||||
_,
|
||||
):
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(text("PRAGMA locking_mode"))
|
||||
locking_mode = result.fetchone()[0]
|
||||
async with engine_session_factory(db_path, DatabaseType.FILESYSTEM) as (
|
||||
engine,
|
||||
_,
|
||||
):
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(text("PRAGMA locking_mode"))
|
||||
locking_mode = result.fetchone()[0]
|
||||
|
||||
# Locking mode should be NORMAL on Windows
|
||||
assert locking_mode.upper() == "NORMAL"
|
||||
# Locking mode should be NORMAL on Windows
|
||||
assert locking_mode.upper() == "NORMAL"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_pool_on_windows(tmp_path):
|
||||
@pytest.mark.windows
|
||||
@pytest.mark.skipif(
|
||||
__import__("os").name != "nt", reason="Windows-specific test - only runs on Windows platform"
|
||||
)
|
||||
async def test_null_pool_on_windows(tmp_path, monkeypatch):
|
||||
"""Test that NullPool is used on Windows to avoid connection pooling issues."""
|
||||
from basic_memory.db import engine_session_factory, DatabaseType
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
# Set HOME environment variable
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("BASIC_MEMORY_HOME", str(tmp_path / "basic-memory"))
|
||||
|
||||
db_path = tmp_path / "test_windows_pool.db"
|
||||
|
||||
with patch("basic_memory.db.os.name", "nt"):
|
||||
async with engine_session_factory(db_path, DatabaseType.FILESYSTEM) as (engine, _):
|
||||
# Engine should be using NullPool on Windows
|
||||
assert isinstance(engine.pool, NullPool)
|
||||
async with engine_session_factory(db_path, DatabaseType.FILESYSTEM) as (engine, _):
|
||||
# Engine should be using NullPool on Windows
|
||||
assert isinstance(engine.pool, NullPool)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif(
|
||||
__import__("os").name == "nt", reason="Non-Windows test - cannot mock POSIX paths on Windows"
|
||||
)
|
||||
async def test_regular_pool_on_non_windows(tmp_path):
|
||||
"""Test that regular pooling is used on non-Windows platforms."""
|
||||
from basic_memory.db import engine_session_factory, DatabaseType
|
||||
@@ -126,7 +160,11 @@ async def test_regular_pool_on_non_windows(tmp_path):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_database_no_null_pool_on_windows(tmp_path):
|
||||
@pytest.mark.windows
|
||||
@pytest.mark.skipif(
|
||||
__import__("os").name != "nt", reason="Windows-specific test - only runs on Windows platform"
|
||||
)
|
||||
async def test_memory_database_no_null_pool_on_windows(tmp_path, monkeypatch):
|
||||
"""Test that in-memory databases do NOT use NullPool even on Windows.
|
||||
|
||||
NullPool closes connections immediately, which destroys in-memory databases.
|
||||
@@ -135,9 +173,12 @@ async def test_memory_database_no_null_pool_on_windows(tmp_path):
|
||||
from basic_memory.db import engine_session_factory, DatabaseType
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
# Set HOME environment variable
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("BASIC_MEMORY_HOME", str(tmp_path / "basic-memory"))
|
||||
|
||||
db_path = tmp_path / "test_memory.db"
|
||||
|
||||
with patch("basic_memory.db.os.name", "nt"):
|
||||
async with engine_session_factory(db_path, DatabaseType.MEMORY) as (engine, _):
|
||||
# In-memory databases should NOT use NullPool on Windows
|
||||
assert not isinstance(engine.pool, NullPool)
|
||||
async with engine_session_factory(db_path, DatabaseType.MEMORY) as (engine, _):
|
||||
# In-memory databases should NOT use NullPool on Windows
|
||||
assert not isinstance(engine.pool, NullPool)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
from basic_memory.markdown import EntityParser, MarkdownProcessor
|
||||
from basic_memory.repository import (
|
||||
EntityRepository,
|
||||
@@ -10,7 +9,8 @@ from basic_memory.repository import (
|
||||
RelationRepository,
|
||||
ProjectRepository,
|
||||
)
|
||||
from basic_memory.repository.search_repository import SearchRepository
|
||||
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
|
||||
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
|
||||
from basic_memory.schemas import Entity as EntitySchema
|
||||
from basic_memory.services import FileService
|
||||
from basic_memory.services.entity_service import EntityService
|
||||
@@ -20,18 +20,25 @@ from basic_memory.sync.sync_service import SyncService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disable_permalinks_create_entity(tmp_path, engine_factory):
|
||||
async def test_disable_permalinks_create_entity(tmp_path, engine_factory, app_config, test_project):
|
||||
"""Test that entities created with disable_permalinks=True don't have permalinks."""
|
||||
from basic_memory.config import DatabaseBackend
|
||||
|
||||
engine, session_maker = engine_factory
|
||||
|
||||
# Create app config with disable_permalinks=True
|
||||
app_config = BasicMemoryConfig(disable_permalinks=True)
|
||||
# Override app config to enable disable_permalinks
|
||||
app_config.disable_permalinks = True
|
||||
|
||||
# Setup repositories
|
||||
entity_repository = EntityRepository(session_maker, project_id=1)
|
||||
observation_repository = ObservationRepository(session_maker, project_id=1)
|
||||
relation_repository = RelationRepository(session_maker, project_id=1)
|
||||
search_repository = SearchRepository(session_maker, project_id=1)
|
||||
entity_repository = EntityRepository(session_maker, project_id=test_project.id)
|
||||
observation_repository = ObservationRepository(session_maker, project_id=test_project.id)
|
||||
relation_repository = RelationRepository(session_maker, project_id=test_project.id)
|
||||
|
||||
# Use database-specific search repository
|
||||
if app_config.database_backend == DatabaseBackend.POSTGRES:
|
||||
search_repository = PostgresSearchRepository(session_maker, project_id=test_project.id)
|
||||
else:
|
||||
search_repository = SQLiteSearchRepository(session_maker, project_id=test_project.id)
|
||||
|
||||
# Setup services
|
||||
entity_parser = EntityParser(tmp_path)
|
||||
@@ -73,22 +80,30 @@ async def test_disable_permalinks_create_entity(tmp_path, engine_factory):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disable_permalinks_sync_workflow(tmp_path, engine_factory):
|
||||
async def test_disable_permalinks_sync_workflow(tmp_path, engine_factory, app_config, test_project):
|
||||
"""Test full sync workflow with disable_permalinks enabled."""
|
||||
from basic_memory.config import DatabaseBackend
|
||||
|
||||
engine, session_maker = engine_factory
|
||||
|
||||
# Create app config with disable_permalinks=True
|
||||
app_config = BasicMemoryConfig(disable_permalinks=True)
|
||||
# Override app config to enable disable_permalinks
|
||||
app_config.disable_permalinks = True
|
||||
|
||||
# Create a test markdown file without frontmatter
|
||||
test_file = tmp_path / "test_note.md"
|
||||
test_file.write_text("# Test Note\nThis is test content.")
|
||||
|
||||
# Setup repositories
|
||||
entity_repository = EntityRepository(session_maker, project_id=1)
|
||||
observation_repository = ObservationRepository(session_maker, project_id=1)
|
||||
relation_repository = RelationRepository(session_maker, project_id=1)
|
||||
search_repository = SearchRepository(session_maker, project_id=1)
|
||||
entity_repository = EntityRepository(session_maker, project_id=test_project.id)
|
||||
observation_repository = ObservationRepository(session_maker, project_id=test_project.id)
|
||||
relation_repository = RelationRepository(session_maker, project_id=test_project.id)
|
||||
|
||||
# Use database-specific search repository
|
||||
if app_config.database_backend == DatabaseBackend.POSTGRES:
|
||||
search_repository = PostgresSearchRepository(session_maker, project_id=test_project.id)
|
||||
else:
|
||||
search_repository = SQLiteSearchRepository(session_maker, project_id=test_project.id)
|
||||
|
||||
project_repository = ProjectRepository(session_maker)
|
||||
|
||||
# Setup services
|
||||
|
||||
@@ -252,6 +252,7 @@ async def test_benchmark_sync_100_files(app_config, project_config, config_manag
|
||||
|
||||
@pytest.mark.benchmark
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skip
|
||||
async def test_benchmark_sync_500_files(app_config, project_config, config_manager):
|
||||
"""Benchmark: Sync 500 files (medium repository)."""
|
||||
results = await run_sync_benchmark(
|
||||
@@ -268,6 +269,7 @@ async def test_benchmark_sync_500_files(app_config, project_config, config_manag
|
||||
@pytest.mark.benchmark
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.skip
|
||||
async def test_benchmark_sync_1000_files(app_config, project_config, config_manager):
|
||||
"""Benchmark: Sync 1000 files (large repository).
|
||||
|
||||
@@ -287,6 +289,7 @@ async def test_benchmark_sync_1000_files(app_config, project_config, config_mana
|
||||
|
||||
@pytest.mark.benchmark
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skip
|
||||
async def test_benchmark_resync_no_changes(app_config, project_config, config_manager):
|
||||
"""Benchmark: Re-sync with no changes (should be fast).
|
||||
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
# Dual-Backend Testing
|
||||
|
||||
Basic Memory tests run against both SQLite and Postgres backends to ensure compatibility.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Run tests against SQLite only (default, no setup needed)
|
||||
pytest
|
||||
|
||||
# Run tests against Postgres only (requires docker-compose)
|
||||
docker-compose -f docker-compose-postgres.yml up -d
|
||||
pytest -m postgres
|
||||
|
||||
# Run tests against BOTH backends
|
||||
docker-compose -f docker-compose-postgres.yml up -d
|
||||
pytest --run-all-backends # Not yet implemented - run both commands above
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Parametrized Backend Fixture
|
||||
|
||||
The `db_backend` fixture is parametrized to run tests against both `sqlite` and `postgres`:
|
||||
|
||||
```python
|
||||
@pytest.fixture(
|
||||
params=[
|
||||
pytest.param("sqlite", id="sqlite"),
|
||||
pytest.param("postgres", id="postgres", marks=pytest.mark.postgres),
|
||||
]
|
||||
)
|
||||
def db_backend(request) -> Literal["sqlite", "postgres"]:
|
||||
return request.param
|
||||
```
|
||||
|
||||
### Backend-Specific Engine Factories
|
||||
|
||||
Each backend has its own engine factory implementation:
|
||||
|
||||
- **`sqlite_engine_factory`** - Uses in-memory SQLite (fast, isolated)
|
||||
- **`postgres_engine_factory`** - Uses Postgres test database (realistic, requires Docker)
|
||||
|
||||
The main `engine_factory` fixture delegates to the appropriate implementation based on `db_backend`.
|
||||
|
||||
### Configuration
|
||||
|
||||
The `app_config` fixture automatically configures the correct backend:
|
||||
|
||||
```python
|
||||
# SQLite config
|
||||
database_backend = DatabaseBackend.SQLITE
|
||||
database_url = None # Uses default SQLite path
|
||||
|
||||
# Postgres config
|
||||
database_backend = DatabaseBackend.POSTGRES
|
||||
database_url = "postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test"
|
||||
```
|
||||
|
||||
## Running Postgres Tests
|
||||
|
||||
### 1. Start Postgres Docker Container
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose-postgres.yml up -d
|
||||
```
|
||||
|
||||
This starts:
|
||||
- Postgres 17 on port **5433** (not 5432 to avoid conflicts)
|
||||
- Test database: `basic_memory_test`
|
||||
- Credentials: `basic_memory_user` / `dev_password`
|
||||
|
||||
### 2. Run Postgres Tests
|
||||
|
||||
```bash
|
||||
# Run only Postgres tests
|
||||
pytest -m postgres
|
||||
|
||||
# Run specific test with Postgres
|
||||
pytest tests/test_entity_repository.py::test_create -m postgres
|
||||
|
||||
# Skip Postgres tests (default behavior)
|
||||
pytest -m "not postgres"
|
||||
```
|
||||
|
||||
### 3. Stop Docker Container
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose-postgres.yml down
|
||||
```
|
||||
|
||||
## Test Isolation
|
||||
|
||||
### SQLite Tests
|
||||
- Each test gets a fresh in-memory database
|
||||
- Automatic cleanup (database destroyed after test)
|
||||
- No setup required
|
||||
|
||||
### Postgres Tests
|
||||
- Database is **cleaned before each test** (drop all tables, recreate)
|
||||
- Tests share the same Postgres instance but get isolated schemas
|
||||
- Requires Docker Compose to be running
|
||||
|
||||
## Markers
|
||||
|
||||
- `postgres` - Marks tests that run against Postgres backend
|
||||
- Use `-m postgres` to run only Postgres tests
|
||||
- Use `-m "not postgres"` to skip Postgres tests (default)
|
||||
|
||||
## CI Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
Use service containers for Postgres (no Docker Compose needed):
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Postgres service container
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
env:
|
||||
POSTGRES_DB: basic_memory_test
|
||||
POSTGRES_USER: basic_memory_user
|
||||
POSTGRES_PASSWORD: dev_password
|
||||
ports:
|
||||
- 5433:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Run SQLite tests
|
||||
run: pytest -m "not postgres"
|
||||
|
||||
- name: Run Postgres tests
|
||||
run: pytest -m postgres
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Postgres tests fail with "connection refused"
|
||||
|
||||
Make sure Docker Compose is running:
|
||||
```bash
|
||||
docker-compose -f docker-compose-postgres.yml ps
|
||||
docker-compose -f docker-compose-postgres.yml logs postgres
|
||||
```
|
||||
|
||||
### Port 5433 already in use
|
||||
|
||||
Either:
|
||||
- Stop the conflicting service
|
||||
- Change the port in `docker-compose-postgres.yml` and `tests/conftest.py`
|
||||
|
||||
### Tests hang or timeout
|
||||
|
||||
Check Postgres health:
|
||||
```bash
|
||||
docker-compose -f docker-compose-postgres.yml exec postgres pg_isready -U basic_memory_user
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Add `--run-all-backends` CLI flag to run both backends in sequence
|
||||
- [ ] Implement test fixtures for backend-specific features (e.g., Postgres full-text search vs SQLite FTS5)
|
||||
- [ ] Add performance comparison benchmarks between backends
|
||||
@@ -18,6 +18,7 @@ def template_loader():
|
||||
def entity_summary():
|
||||
"""Create a sample EntitySummary for testing."""
|
||||
return EntitySummary(
|
||||
entity_id=1,
|
||||
title="Test Entity",
|
||||
permalink="test/entity",
|
||||
type=SearchItemType.ENTITY,
|
||||
@@ -34,6 +35,8 @@ def context_with_results(entity_summary):
|
||||
|
||||
# Create an observation for the entity
|
||||
observation = ObservationSummary(
|
||||
observation_id=1,
|
||||
entity_id=1,
|
||||
title="Test Observation",
|
||||
permalink="test/entity/observations/1",
|
||||
category="test",
|
||||
|
||||
@@ -12,7 +12,7 @@ from basic_memory.schemas.search import SearchItemType, SearchResponse
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def indexed_entity(init_search_index, full_entity, search_service):
|
||||
async def indexed_entity(full_entity, search_service):
|
||||
"""Create an entity and index it."""
|
||||
await search_service.index_entity(full_entity)
|
||||
return full_entity
|
||||
@@ -118,8 +118,16 @@ async def test_search_empty(search_service, client, project_url):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reindex(client, search_service, entity_service, session_maker, project_url):
|
||||
async def test_reindex(
|
||||
client, search_service, entity_service, session_maker, project_url, app_config
|
||||
):
|
||||
"""Test reindex endpoint."""
|
||||
# Skip for Postgres - needs investigation of database connection isolation
|
||||
from basic_memory.config import DatabaseBackend
|
||||
|
||||
if app_config.database_backend == DatabaseBackend.POSTGRES:
|
||||
pytest.skip("Not yet supported for Postgres - database connection isolation issue")
|
||||
|
||||
# Create test entity and document
|
||||
await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""V2 API tests."""
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Fixtures for V2 API tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.models import Project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def v2_project_url(test_project: Project) -> str:
|
||||
"""Create a URL prefix for v2 project-scoped routes using project ID.
|
||||
|
||||
This helps tests generate the correct URL for v2 project-scoped routes
|
||||
which use integer project IDs instead of permalinks.
|
||||
"""
|
||||
return f"/v2/projects/{test_project.id}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def v2_projects_url() -> str:
|
||||
"""Base URL for v2 project management endpoints."""
|
||||
return "/v2/projects"
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Tests for V2 directory API routes (ID-based endpoints)."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.models import Project
|
||||
from basic_memory.schemas.directory import DirectoryNode
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_directory_tree(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test getting directory tree via v2 endpoint."""
|
||||
response = await client.get(f"{v2_project_url}/directory/tree")
|
||||
|
||||
assert response.status_code == 200
|
||||
tree = DirectoryNode.model_validate(response.json())
|
||||
assert tree.type == "directory"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_directory_structure(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test getting directory structure (folders only) via v2 endpoint."""
|
||||
response = await client.get(f"{v2_project_url}/directory/structure")
|
||||
|
||||
assert response.status_code == 200
|
||||
structure = DirectoryNode.model_validate(response.json())
|
||||
assert structure.type == "directory"
|
||||
# Structure should only contain directories, not files
|
||||
if structure.children:
|
||||
for child in structure.children:
|
||||
assert child.type == "directory"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_default(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test listing directory contents with default parameters via v2 endpoint."""
|
||||
response = await client.get(f"{v2_project_url}/directory/list")
|
||||
|
||||
assert response.status_code == 200
|
||||
nodes = response.json()
|
||||
assert isinstance(nodes, list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_with_depth(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test listing directory with custom depth via v2 endpoint."""
|
||||
response = await client.get(f"{v2_project_url}/directory/list?depth=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
nodes = response.json()
|
||||
assert isinstance(nodes, list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_with_glob(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test listing directory with file name glob filter via v2 endpoint."""
|
||||
response = await client.get(f"{v2_project_url}/directory/list?file_name_glob=*.md")
|
||||
|
||||
assert response.status_code == 200
|
||||
nodes = response.json()
|
||||
assert isinstance(nodes, list)
|
||||
# All file nodes should have .md extension
|
||||
for node in nodes:
|
||||
if node.get("type") == "file":
|
||||
assert node.get("path", "").endswith(".md")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_with_custom_path(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test listing a specific directory path via v2 endpoint."""
|
||||
response = await client.get(f"{v2_project_url}/directory/list?dir_name=/")
|
||||
|
||||
assert response.status_code == 200
|
||||
nodes = response.json()
|
||||
assert isinstance(nodes, list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_directory_invalid_project_id(
|
||||
client: AsyncClient,
|
||||
):
|
||||
"""Test directory endpoints with invalid project ID return 404."""
|
||||
# Test tree endpoint
|
||||
response = await client.get("/v2/projects/999999/directory/tree")
|
||||
assert response.status_code == 404
|
||||
|
||||
# Test structure endpoint
|
||||
response = await client.get("/v2/projects/999999/directory/structure")
|
||||
assert response.status_code == 404
|
||||
|
||||
# Test list endpoint
|
||||
response = await client.get("/v2/projects/999999/directory/list")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_directory_endpoints_use_project_id_not_name(
|
||||
client: AsyncClient, test_project: Project
|
||||
):
|
||||
"""Verify v2 directory endpoints require project ID, not name."""
|
||||
# Try using project name instead of ID - should fail
|
||||
response = await client.get(f"/v2/projects/{test_project.name}/directory/tree")
|
||||
|
||||
# Should get validation error or 404 because name is not a valid integer
|
||||
assert response.status_code in [404, 422]
|
||||
@@ -0,0 +1,530 @@
|
||||
"""Tests for V2 importer API routes (ID-based endpoints)."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.models import Project
|
||||
from basic_memory.schemas.importer import (
|
||||
ChatImportResult,
|
||||
EntityImportResult,
|
||||
ProjectImportResult,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chatgpt_json_content():
|
||||
"""Sample ChatGPT conversation data for testing."""
|
||||
return [
|
||||
{
|
||||
"title": "Test Conversation",
|
||||
"create_time": 1736616594.24054,
|
||||
"update_time": 1736616603.164995,
|
||||
"mapping": {
|
||||
"root": {"id": "root", "message": None, "parent": None, "children": ["msg1"]},
|
||||
"msg1": {
|
||||
"id": "msg1",
|
||||
"message": {
|
||||
"id": "msg1",
|
||||
"author": {"role": "user", "name": None, "metadata": {}},
|
||||
"create_time": 1736616594.24054,
|
||||
"content": {
|
||||
"content_type": "text",
|
||||
"parts": ["Hello, this is a test message"],
|
||||
},
|
||||
"status": "finished_successfully",
|
||||
"metadata": {},
|
||||
},
|
||||
"parent": "root",
|
||||
"children": ["msg2"],
|
||||
},
|
||||
"msg2": {
|
||||
"id": "msg2",
|
||||
"message": {
|
||||
"id": "msg2",
|
||||
"author": {"role": "assistant", "name": None, "metadata": {}},
|
||||
"create_time": 1736616603.164995,
|
||||
"content": {"content_type": "text", "parts": ["This is a test response"]},
|
||||
"status": "finished_successfully",
|
||||
"metadata": {},
|
||||
},
|
||||
"parent": "msg1",
|
||||
"children": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def claude_conversations_json_content():
|
||||
"""Sample Claude conversations data for testing."""
|
||||
return [
|
||||
{
|
||||
"uuid": "test-uuid",
|
||||
"name": "Test Conversation",
|
||||
"created_at": "2025-01-05T20:55:32.499880+00:00",
|
||||
"updated_at": "2025-01-05T20:56:39.477600+00:00",
|
||||
"chat_messages": [
|
||||
{
|
||||
"uuid": "msg-1",
|
||||
"text": "Hello, this is a test",
|
||||
"sender": "human",
|
||||
"created_at": "2025-01-05T20:55:32.499880+00:00",
|
||||
"content": [{"type": "text", "text": "Hello, this is a test"}],
|
||||
},
|
||||
{
|
||||
"uuid": "msg-2",
|
||||
"text": "Response to test",
|
||||
"sender": "assistant",
|
||||
"created_at": "2025-01-05T20:55:40.123456+00:00",
|
||||
"content": [{"type": "text", "text": "Response to test"}],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def claude_projects_json_content():
|
||||
"""Sample Claude projects data for testing."""
|
||||
return [
|
||||
{
|
||||
"uuid": "test-uuid",
|
||||
"name": "Test Project",
|
||||
"created_at": "2025-01-05T20:55:32.499880+00:00",
|
||||
"updated_at": "2025-01-05T20:56:39.477600+00:00",
|
||||
"prompt_template": "# Test Prompt\n\nThis is a test prompt.",
|
||||
"docs": [
|
||||
{
|
||||
"uuid": "doc-uuid-1",
|
||||
"filename": "Test Document",
|
||||
"content": "# Test Document\n\nThis is test content.",
|
||||
"created_at": "2025-01-05T20:56:39.477600+00:00",
|
||||
},
|
||||
{
|
||||
"uuid": "doc-uuid-2",
|
||||
"filename": "Another Document",
|
||||
"content": "# Another Document\n\nMore test content.",
|
||||
"created_at": "2025-01-05T20:56:39.477600+00:00",
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_json_content():
|
||||
"""Sample memory.json data for testing."""
|
||||
return [
|
||||
{
|
||||
"type": "entity",
|
||||
"name": "test_entity",
|
||||
"entityType": "test",
|
||||
"observations": ["Test observation 1", "Test observation 2"],
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"from": "test_entity",
|
||||
"to": "related_entity",
|
||||
"relationType": "test_relation",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def create_test_upload_file(tmp_path, content):
|
||||
"""Create a test file for upload."""
|
||||
file_path = tmp_path / "test_import.json"
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(content, f)
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_chatgpt(
|
||||
project_config,
|
||||
client: AsyncClient,
|
||||
tmp_path,
|
||||
chatgpt_json_content,
|
||||
file_service,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test importing ChatGPT conversations via v2 endpoint."""
|
||||
# Create a test file
|
||||
file_path = await create_test_upload_file(tmp_path, chatgpt_json_content)
|
||||
|
||||
# Create a multipart form with the file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("conversations.json", f, "application/json")}
|
||||
data = {"folder": "test_chatgpt"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(f"{v2_project_url}/import/chatgpt", files=files, data=data)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
result = ChatImportResult.model_validate(response.json())
|
||||
assert result.success is True
|
||||
assert result.conversations == 1
|
||||
assert result.messages == 2
|
||||
|
||||
# Verify files were created
|
||||
conv_path = Path("test_chatgpt") / "20250111-Test_Conversation.md"
|
||||
assert await file_service.exists(conv_path)
|
||||
|
||||
content, _ = await file_service.read_file(conv_path)
|
||||
assert "# Test Conversation" in content
|
||||
assert "Hello, this is a test message" in content
|
||||
assert "This is a test response" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_chatgpt_invalid_file(client: AsyncClient, tmp_path, v2_project_url: str):
|
||||
"""Test importing invalid ChatGPT file via v2 endpoint."""
|
||||
# Create invalid file
|
||||
file_path = tmp_path / "invalid.json"
|
||||
with open(file_path, "w") as f:
|
||||
f.write("This is not JSON")
|
||||
|
||||
# Create multipart form with invalid file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("invalid.json", f, "application/json")}
|
||||
data = {"folder": "test_chatgpt"}
|
||||
|
||||
# Send request - this should return an error
|
||||
response = await client.post(f"{v2_project_url}/import/chatgpt", files=files, data=data)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 500
|
||||
assert "Import failed" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_claude_conversations(
|
||||
client: AsyncClient,
|
||||
tmp_path,
|
||||
claude_conversations_json_content,
|
||||
file_service,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test importing Claude conversations via v2 endpoint."""
|
||||
# Create a test file
|
||||
file_path = await create_test_upload_file(tmp_path, claude_conversations_json_content)
|
||||
|
||||
# Create a multipart form with the file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("conversations.json", f, "application/json")}
|
||||
data = {"folder": "test_claude_conversations"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/import/claude/conversations", files=files, data=data
|
||||
)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
result = ChatImportResult.model_validate(response.json())
|
||||
assert result.success is True
|
||||
assert result.conversations == 1
|
||||
assert result.messages == 2
|
||||
|
||||
# Verify files were created
|
||||
conv_path = Path("test_claude_conversations") / "20250105-Test_Conversation.md"
|
||||
assert await file_service.exists(conv_path)
|
||||
|
||||
content, _ = await file_service.read_file(conv_path)
|
||||
assert "# Test Conversation" in content
|
||||
assert "Hello, this is a test" in content
|
||||
assert "Response to test" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_claude_conversations_invalid_file(
|
||||
client: AsyncClient, tmp_path, v2_project_url: str
|
||||
):
|
||||
"""Test importing invalid Claude conversations file via v2 endpoint."""
|
||||
# Create invalid file
|
||||
file_path = tmp_path / "invalid.json"
|
||||
with open(file_path, "w") as f:
|
||||
f.write("This is not JSON")
|
||||
|
||||
# Create multipart form with invalid file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("invalid.json", f, "application/json")}
|
||||
data = {"folder": "test_claude_conversations"}
|
||||
|
||||
# Send request - this should return an error
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/import/claude/conversations", files=files, data=data
|
||||
)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 500
|
||||
assert "Import failed" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_claude_projects(
|
||||
client: AsyncClient, tmp_path, claude_projects_json_content, file_service, v2_project_url: str
|
||||
):
|
||||
"""Test importing Claude projects via v2 endpoint."""
|
||||
# Create a test file
|
||||
file_path = await create_test_upload_file(tmp_path, claude_projects_json_content)
|
||||
|
||||
# Create a multipart form with the file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("projects.json", f, "application/json")}
|
||||
data = {"folder": "test_claude_projects"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/import/claude/projects", files=files, data=data
|
||||
)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
result = ProjectImportResult.model_validate(response.json())
|
||||
assert result.success is True
|
||||
assert result.documents == 2
|
||||
assert result.prompts == 1
|
||||
|
||||
# Verify files were created
|
||||
project_dir = Path("test_claude_projects") / "Test_Project"
|
||||
assert await file_service.exists(project_dir / "prompt-template.md")
|
||||
assert await file_service.exists(project_dir / "docs" / "Test_Document.md")
|
||||
assert await file_service.exists(project_dir / "docs" / "Another_Document.md")
|
||||
|
||||
# Check content
|
||||
prompt_content, _ = await file_service.read_file(project_dir / "prompt-template.md")
|
||||
assert "# Test Prompt" in prompt_content
|
||||
|
||||
doc_content, _ = await file_service.read_file(project_dir / "docs" / "Test_Document.md")
|
||||
assert "# Test Document" in doc_content
|
||||
assert "This is test content" in doc_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_claude_projects_invalid_file(
|
||||
client: AsyncClient, tmp_path, v2_project_url: str
|
||||
):
|
||||
"""Test importing invalid Claude projects file via v2 endpoint."""
|
||||
# Create invalid file
|
||||
file_path = tmp_path / "invalid.json"
|
||||
with open(file_path, "w") as f:
|
||||
f.write("This is not JSON")
|
||||
|
||||
# Create multipart form with invalid file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("invalid.json", f, "application/json")}
|
||||
data = {"folder": "test_claude_projects"}
|
||||
|
||||
# Send request - this should return an error
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/import/claude/projects", files=files, data=data
|
||||
)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 500
|
||||
assert "Import failed" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_memory_json(
|
||||
client: AsyncClient, tmp_path, memory_json_content, file_service, v2_project_url: str
|
||||
):
|
||||
"""Test importing memory.json file via v2 endpoint."""
|
||||
# Create a test file
|
||||
json_file = tmp_path / "memory.json"
|
||||
with open(json_file, "w", encoding="utf-8") as f:
|
||||
for entity in memory_json_content:
|
||||
f.write(json.dumps(entity) + "\n")
|
||||
|
||||
# Create a multipart form with the file
|
||||
with open(json_file, "rb") as f:
|
||||
files = {"file": ("memory.json", f, "application/json")}
|
||||
data = {"folder": "test_memory_json"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(f"{v2_project_url}/import/memory-json", files=files, data=data)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
result = EntityImportResult.model_validate(response.json())
|
||||
assert result.success is True
|
||||
assert result.entities == 1
|
||||
assert result.relations == 1
|
||||
|
||||
# Verify files were created
|
||||
entity_path = Path("test_memory_json") / "test" / "test_entity.md"
|
||||
assert await file_service.exists(entity_path)
|
||||
|
||||
# Check content
|
||||
content, _ = await file_service.read_file(entity_path)
|
||||
assert "Test observation 1" in content
|
||||
assert "Test observation 2" in content
|
||||
assert "test_relation [[related_entity]]" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_memory_json_without_folder(
|
||||
client: AsyncClient, tmp_path, memory_json_content, file_service, v2_project_url: str
|
||||
):
|
||||
"""Test importing memory.json file without specifying a destination folder."""
|
||||
# Create a test file
|
||||
json_file = tmp_path / "memory.json"
|
||||
with open(json_file, "w", encoding="utf-8") as f:
|
||||
for entity in memory_json_content:
|
||||
f.write(json.dumps(entity) + "\n")
|
||||
|
||||
# Create a multipart form with the file
|
||||
with open(json_file, "rb") as f:
|
||||
files = {"file": ("memory.json", f, "application/json")}
|
||||
|
||||
# Send request without destination_folder
|
||||
response = await client.post(f"{v2_project_url}/import/memory-json", files=files)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
result = EntityImportResult.model_validate(response.json())
|
||||
assert result.success is True
|
||||
assert result.entities == 1
|
||||
assert result.relations == 1
|
||||
|
||||
# Verify files were created in the default directory
|
||||
entity_path = Path("conversations") / "test" / "test_entity.md"
|
||||
assert await file_service.exists(entity_path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_memory_json_invalid_file(client: AsyncClient, tmp_path, v2_project_url: str):
|
||||
"""Test importing invalid memory.json file via v2 endpoint."""
|
||||
# Create invalid file
|
||||
file_path = tmp_path / "invalid.json"
|
||||
with open(file_path, "w") as f:
|
||||
f.write("This is not JSON")
|
||||
|
||||
# Create multipart form with invalid file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("invalid.json", f, "application/json")}
|
||||
data = {"folder": "test_memory_json"}
|
||||
|
||||
# Send request - this should return an error
|
||||
response = await client.post(f"{v2_project_url}/import/memory-json", files=files, data=data)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 500
|
||||
assert "Import failed" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_import_endpoints_use_project_id_not_name(
|
||||
client: AsyncClient, tmp_path, test_project: Project, chatgpt_json_content
|
||||
):
|
||||
"""Verify v2 import endpoints require project ID, not name."""
|
||||
# Create a test file
|
||||
file_path = await create_test_upload_file(tmp_path, chatgpt_json_content)
|
||||
|
||||
# Try using project name instead of ID - should fail
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("conversations.json", f, "application/json")}
|
||||
data = {"folder": "test"}
|
||||
|
||||
response = await client.post(
|
||||
f"/v2/projects/{test_project.name}/import/chatgpt",
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
|
||||
# Should get validation error or 404 because name is not a valid integer
|
||||
assert response.status_code in [404, 422]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_invalid_project_id(client: AsyncClient, tmp_path, chatgpt_json_content):
|
||||
"""Test import endpoints with invalid project ID return 404."""
|
||||
# Create a test file
|
||||
file_path = await create_test_upload_file(tmp_path, chatgpt_json_content)
|
||||
|
||||
# Test all import endpoints
|
||||
endpoints = [
|
||||
"/import/chatgpt",
|
||||
"/import/claude/conversations",
|
||||
"/import/claude/projects",
|
||||
"/import/memory-json",
|
||||
]
|
||||
|
||||
for endpoint in endpoints:
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("test.json", f, "application/json")}
|
||||
data = {"folder": "test"}
|
||||
|
||||
response = await client.post(
|
||||
f"/v2/projects/999999{endpoint}",
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_missing_file(client: AsyncClient, v2_project_url: str):
|
||||
"""Test importing with missing file via v2 endpoint."""
|
||||
# Send a request without a file
|
||||
response = await client.post(f"{v2_project_url}/import/chatgpt", data={"folder": "test_folder"})
|
||||
|
||||
# Check that the request was rejected
|
||||
assert response.status_code in [400, 422] # Either bad request or unprocessable entity
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_empty_file(client: AsyncClient, tmp_path, v2_project_url: str):
|
||||
"""Test importing an empty file via v2 endpoint."""
|
||||
# Create an empty file
|
||||
file_path = tmp_path / "empty.json"
|
||||
with open(file_path, "w") as f:
|
||||
f.write("")
|
||||
|
||||
# Create multipart form with empty file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("empty.json", f, "application/json")}
|
||||
data = {"folder": "test_chatgpt"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(f"{v2_project_url}/import/chatgpt", files=files, data=data)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 500
|
||||
assert "Import failed" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_malformed_json(client: AsyncClient, tmp_path, v2_project_url: str):
|
||||
"""Test importing malformed JSON for all v2 import endpoints."""
|
||||
# Create malformed JSON file
|
||||
file_path = tmp_path / "malformed.json"
|
||||
with open(file_path, "w") as f:
|
||||
f.write('{"incomplete": "json"') # Missing closing brace
|
||||
|
||||
# Test all import endpoints
|
||||
endpoints = [
|
||||
(f"{v2_project_url}/import/chatgpt", {"folder": "test"}),
|
||||
(f"{v2_project_url}/import/claude/conversations", {"folder": "test"}),
|
||||
(f"{v2_project_url}/import/claude/projects", {"folder": "test"}),
|
||||
(f"{v2_project_url}/import/memory-json", {"folder": "test"}),
|
||||
]
|
||||
|
||||
for endpoint, data in endpoints:
|
||||
# Create multipart form with malformed JSON
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("malformed.json", f, "application/json")}
|
||||
|
||||
# Send request
|
||||
response = await client.post(endpoint, files=files, data=data)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 500
|
||||
assert "Import failed" in response.json()["detail"]
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Tests for V2 knowledge graph API routes (ID-based endpoints)."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.models import Project
|
||||
from basic_memory.schemas import DeleteEntitiesResponse
|
||||
from basic_memory.schemas.v2 import EntityResponseV2, EntityResolveResponse
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_identifier_by_permalink(
|
||||
client: AsyncClient, test_graph, v2_project_url, test_project: Project, entity_repository
|
||||
):
|
||||
"""Test resolving an identifier by permalink returns correct entity ID."""
|
||||
# test_graph fixture creates some test entities
|
||||
# We'll use one of them to test resolution
|
||||
|
||||
# Create an entity first
|
||||
entity_data = {
|
||||
"title": "TestResolve",
|
||||
"folder": "test",
|
||||
"content": "Test content for resolve",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
|
||||
assert response.status_code == 200
|
||||
created_entity = EntityResponseV2.model_validate(response.json())
|
||||
|
||||
# V2 create must return id
|
||||
assert created_entity.id is not None
|
||||
entity_id = created_entity.id
|
||||
|
||||
# Now resolve it by permalink
|
||||
resolve_data = {"identifier": created_entity.permalink}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
resolved = EntityResolveResponse.model_validate(response.json())
|
||||
assert resolved.entity_id == entity_id
|
||||
assert resolved.permalink == created_entity.permalink
|
||||
assert resolved.resolution_method == "permalink"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_identifier_not_found(client: AsyncClient, v2_project_url):
|
||||
"""Test resolving a non-existent identifier returns 404."""
|
||||
resolve_data = {"identifier": "nonexistent/entity"}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "Could not resolve identifier" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_entity_by_id(client: AsyncClient, test_graph, v2_project_url, entity_repository):
|
||||
"""Test getting an entity by its numeric ID."""
|
||||
# Create an entity first
|
||||
entity_data = {
|
||||
"title": "TestGetById",
|
||||
"folder": "test",
|
||||
"content": "Test content for get by ID",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
|
||||
assert response.status_code == 200
|
||||
created_entity = EntityResponseV2.model_validate(response.json())
|
||||
|
||||
# V2 create must return id
|
||||
assert created_entity.id is not None
|
||||
entity_id = created_entity.id
|
||||
|
||||
# Get it by ID using v2 endpoint
|
||||
response = await client.get(f"{v2_project_url}/knowledge/entities/{entity_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
entity = EntityResponseV2.model_validate(response.json())
|
||||
assert entity.id == entity_id
|
||||
assert entity.title == "TestGetById"
|
||||
assert entity.api_version == "v2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_entity_by_id_not_found(client: AsyncClient, v2_project_url):
|
||||
"""Test getting a non-existent entity by ID returns 404."""
|
||||
response = await client.get(f"{v2_project_url}/knowledge/entities/999999")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entity(client: AsyncClient, file_service, v2_project_url):
|
||||
"""Test creating an entity via v2 endpoint."""
|
||||
data = {
|
||||
"title": "TestV2Entity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content_type": "text/markdown",
|
||||
"content": "TestContent for V2",
|
||||
}
|
||||
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=data)
|
||||
|
||||
assert response.status_code == 200
|
||||
entity = EntityResponseV2.model_validate(response.json())
|
||||
|
||||
# V2 endpoints must return id field
|
||||
assert entity.id is not None
|
||||
assert isinstance(entity.id, int)
|
||||
assert entity.api_version == "v2"
|
||||
|
||||
assert entity.permalink == "test/test-v2-entity"
|
||||
assert entity.file_path == "test/TestV2Entity.md"
|
||||
assert entity.entity_type == data["entity_type"]
|
||||
|
||||
# Verify file was created
|
||||
file_path = file_service.get_entity_path(entity)
|
||||
file_content, _ = await file_service.read_file(file_path)
|
||||
assert data["content"] in file_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entity_with_observations_and_relations(
|
||||
client: AsyncClient, file_service, v2_project_url
|
||||
):
|
||||
"""Test creating an entity with observations and relations via v2."""
|
||||
data = {
|
||||
"title": "TestV2Complex",
|
||||
"folder": "test",
|
||||
"content": """
|
||||
# TestV2Complex
|
||||
|
||||
## Observations
|
||||
- [note] This is a test observation #tag1 (context)
|
||||
- related to [[OtherEntity]]
|
||||
""",
|
||||
}
|
||||
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=data)
|
||||
|
||||
assert response.status_code == 200
|
||||
entity = EntityResponseV2.model_validate(response.json())
|
||||
|
||||
# V2 endpoints must return id field
|
||||
assert entity.id is not None
|
||||
assert isinstance(entity.id, int)
|
||||
assert entity.api_version == "v2"
|
||||
|
||||
assert len(entity.observations) == 1
|
||||
assert entity.observations[0].category == "note"
|
||||
assert entity.observations[0].content == "This is a test observation #tag1"
|
||||
assert entity.observations[0].tags == ["tag1"]
|
||||
|
||||
assert len(entity.relations) == 1
|
||||
assert entity.relations[0].relation_type == "related to"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_entity_by_id(
|
||||
client: AsyncClient, file_service, v2_project_url, entity_repository
|
||||
):
|
||||
"""Test updating an entity by ID using PUT (replace)."""
|
||||
# Create an entity first
|
||||
create_data = {
|
||||
"title": "TestUpdate",
|
||||
"folder": "test",
|
||||
"content": "Original content",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
|
||||
assert response.status_code == 200
|
||||
created_entity = EntityResponseV2.model_validate(response.json())
|
||||
|
||||
# V2 create must return id
|
||||
assert created_entity.id is not None
|
||||
original_id = created_entity.id
|
||||
|
||||
# Update it by ID
|
||||
update_data = {
|
||||
"title": "TestUpdate",
|
||||
"folder": "test",
|
||||
"content": "Updated content via V2",
|
||||
}
|
||||
response = await client.put(
|
||||
f"{v2_project_url}/knowledge/entities/{original_id}",
|
||||
json=update_data,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
updated_entity = EntityResponseV2.model_validate(response.json())
|
||||
|
||||
# V2 update must return id field
|
||||
assert updated_entity.id is not None
|
||||
assert isinstance(updated_entity.id, int)
|
||||
assert updated_entity.api_version == "v2"
|
||||
|
||||
# Verify file was updated
|
||||
file_path = file_service.get_entity_path(updated_entity)
|
||||
file_content, _ = await file_service.read_file(file_path)
|
||||
assert "Updated content via V2" in file_content
|
||||
assert "Original content" not in file_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_entity_by_id_append(
|
||||
client: AsyncClient, file_service, v2_project_url, entity_repository
|
||||
):
|
||||
"""Test editing an entity by ID using PATCH (append operation)."""
|
||||
# Create an entity first
|
||||
create_data = {
|
||||
"title": "TestEdit",
|
||||
"folder": "test",
|
||||
"content": "# TestEdit\n\nOriginal content",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
|
||||
assert response.status_code == 200
|
||||
created_entity = EntityResponseV2.model_validate(response.json())
|
||||
|
||||
# V2 create must return id
|
||||
assert created_entity.id is not None
|
||||
original_id = created_entity.id
|
||||
|
||||
# Edit it by appending
|
||||
edit_data = {
|
||||
"operation": "append",
|
||||
"content": "\n\n## New Section\n\nAppended content",
|
||||
}
|
||||
response = await client.patch(
|
||||
f"{v2_project_url}/knowledge/entities/{original_id}",
|
||||
json=edit_data,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
edited_entity = EntityResponseV2.model_validate(response.json())
|
||||
|
||||
# V2 patch must return id field
|
||||
assert edited_entity.id is not None
|
||||
assert isinstance(edited_entity.id, int)
|
||||
assert edited_entity.api_version == "v2"
|
||||
|
||||
# Verify file has both original and appended content
|
||||
file_path = file_service.get_entity_path(edited_entity)
|
||||
file_content, _ = await file_service.read_file(file_path)
|
||||
assert "Original content" in file_content
|
||||
assert "Appended content" in file_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_entity_by_id_find_replace(
|
||||
client: AsyncClient, file_service, v2_project_url, entity_repository
|
||||
):
|
||||
"""Test editing an entity by ID using PATCH (find/replace operation)."""
|
||||
# Create an entity first
|
||||
create_data = {
|
||||
"title": "TestFindReplace",
|
||||
"folder": "test",
|
||||
"content": "# TestFindReplace\n\nOld text that will be replaced",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
|
||||
assert response.status_code == 200
|
||||
created_entity = EntityResponseV2.model_validate(response.json())
|
||||
|
||||
# V2 create must return id
|
||||
assert created_entity.id is not None
|
||||
original_id = created_entity.id
|
||||
|
||||
# Edit using find/replace
|
||||
edit_data = {
|
||||
"operation": "find_replace",
|
||||
"find_text": "Old text",
|
||||
"content": "New text",
|
||||
}
|
||||
response = await client.patch(
|
||||
f"{v2_project_url}/knowledge/entities/{original_id}",
|
||||
json=edit_data,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
edited_entity = EntityResponseV2.model_validate(response.json())
|
||||
|
||||
# V2 patch must return id field
|
||||
assert edited_entity.id is not None
|
||||
assert isinstance(edited_entity.id, int)
|
||||
assert edited_entity.api_version == "v2"
|
||||
|
||||
# Verify replacement
|
||||
file_path = file_service.get_entity_path(created_entity)
|
||||
file_content, _ = await file_service.read_file(file_path)
|
||||
assert "New text" in file_content
|
||||
assert "Old text" not in file_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_entity_by_id(
|
||||
client: AsyncClient, file_service, v2_project_url, entity_repository
|
||||
):
|
||||
"""Test deleting an entity by ID."""
|
||||
# Create an entity first
|
||||
create_data = {
|
||||
"title": "TestDelete",
|
||||
"folder": "test",
|
||||
"content": "Content to be deleted",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
|
||||
assert response.status_code == 200
|
||||
created_entity = EntityResponseV2.model_validate(response.json())
|
||||
|
||||
# V2 create must return id
|
||||
assert created_entity.id is not None
|
||||
entity_id = created_entity.id
|
||||
|
||||
# Delete it by ID
|
||||
response = await client.delete(f"{v2_project_url}/knowledge/entities/{entity_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
delete_response = DeleteEntitiesResponse.model_validate(response.json())
|
||||
assert delete_response.deleted is True
|
||||
|
||||
# Verify it's gone - trying to get it should return 404
|
||||
response = await client.get(f"{v2_project_url}/knowledge/entities/{entity_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_entity_by_id_not_found(client: AsyncClient, v2_project_url):
|
||||
"""Test deleting a non-existent entity returns deleted=False (idempotent)."""
|
||||
response = await client.delete(f"{v2_project_url}/knowledge/entities/999999")
|
||||
|
||||
# Delete is idempotent - returns 200 with deleted=False
|
||||
assert response.status_code == 200
|
||||
delete_response = DeleteEntitiesResponse.model_validate(response.json())
|
||||
assert delete_response.deleted is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_entity(client: AsyncClient, file_service, v2_project_url, entity_repository):
|
||||
"""Test moving an entity to a new location."""
|
||||
# Create an entity first
|
||||
create_data = {
|
||||
"title": "TestMove",
|
||||
"folder": "test",
|
||||
"content": "Content to be moved",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
|
||||
assert response.status_code == 200
|
||||
created_entity = EntityResponseV2.model_validate(response.json())
|
||||
|
||||
# V2 create must return id
|
||||
assert created_entity.id is not None
|
||||
original_id = created_entity.id
|
||||
|
||||
# Move it to a new folder (V2 uses entity ID in path)
|
||||
move_data = {
|
||||
"destination_path": "moved/MovedEntity.md",
|
||||
}
|
||||
response = await client.put(
|
||||
f"{v2_project_url}/knowledge/entities/{created_entity.id}/move", json=move_data
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
moved_entity = EntityResponseV2.model_validate(response.json())
|
||||
|
||||
# V2 move must return id field
|
||||
assert moved_entity.id is not None
|
||||
assert isinstance(moved_entity.id, int)
|
||||
assert moved_entity.api_version == "v2"
|
||||
|
||||
# ID should remain the same (stable reference)
|
||||
assert moved_entity.id == original_id
|
||||
assert moved_entity.file_path == "moved/MovedEntity.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_endpoints_use_project_id_not_name(client: AsyncClient, test_project: Project):
|
||||
"""Verify v2 endpoints require project ID, not name."""
|
||||
# Try using project name instead of ID - should fail
|
||||
response = await client.get(f"/v2/{test_project.name}/knowledge/entities/1")
|
||||
|
||||
# Should get validation error or 404 because name is not a valid integer
|
||||
assert response.status_code in [404, 422]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entity_response_v2_has_api_version(
|
||||
client: AsyncClient, v2_project_url, entity_repository
|
||||
):
|
||||
"""Test that EntityResponseV2 includes api_version field."""
|
||||
# Create an entity
|
||||
entity_data = {
|
||||
"title": "TestApiVersion",
|
||||
"folder": "test",
|
||||
"content": "Test content",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
|
||||
assert response.status_code == 200
|
||||
created_entity = EntityResponseV2.model_validate(response.json())
|
||||
|
||||
# V2 create must return id and api_version
|
||||
assert created_entity.id is not None
|
||||
assert created_entity.api_version == "v2"
|
||||
entity_id = created_entity.id
|
||||
|
||||
# Get it via v2 endpoint
|
||||
response = await client.get(f"{v2_project_url}/knowledge/entities/{entity_id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
entity_v2 = EntityResponseV2.model_validate(response.json())
|
||||
assert entity_v2.api_version == "v2"
|
||||
assert entity_v2.id == entity_id
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Tests for v2 memory router endpoints."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from pathlib import Path
|
||||
|
||||
from basic_memory.models import Project
|
||||
|
||||
|
||||
async def create_test_entity(
|
||||
test_project, entity_data, entity_repository, search_service, file_service
|
||||
):
|
||||
"""Helper to create an entity with file and index it."""
|
||||
# Create file
|
||||
test_content = f"# {entity_data['title']}\n\nTest content"
|
||||
file_path = Path(test_project.path) / entity_data["file_path"]
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
await file_service.write_file(file_path, test_content)
|
||||
|
||||
# Create entity
|
||||
entity = await entity_repository.create(entity_data)
|
||||
|
||||
# Index for search
|
||||
await search_service.index_entity(entity)
|
||||
|
||||
return entity
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recent_context(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
entity_repository,
|
||||
search_service,
|
||||
file_service,
|
||||
):
|
||||
"""Test getting recent activity context."""
|
||||
entity_data = {
|
||||
"title": "Recent Test Entity",
|
||||
"entity_type": "note",
|
||||
"content_type": "text/markdown",
|
||||
"file_path": "recent_test.md",
|
||||
"checksum": "abc123",
|
||||
}
|
||||
await create_test_entity(
|
||||
test_project, entity_data, entity_repository, search_service, file_service
|
||||
)
|
||||
|
||||
# Get recent context
|
||||
response = await client.get(f"{v2_project_url}/memory/recent")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify response structure (GraphContext uses 'results' not 'entities')
|
||||
assert "results" in data
|
||||
assert "metadata" in data
|
||||
assert "page" in data
|
||||
assert "page_size" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recent_context_with_pagination(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
entity_repository,
|
||||
search_service,
|
||||
file_service,
|
||||
):
|
||||
"""Test recent context with pagination parameters."""
|
||||
# Create multiple test entities
|
||||
for i in range(5):
|
||||
entity_data = {
|
||||
"title": f"Entity {i}",
|
||||
"entity_type": "note",
|
||||
"content_type": "text/markdown",
|
||||
"file_path": f"entity_{i}.md",
|
||||
"checksum": f"checksum{i}",
|
||||
}
|
||||
await create_test_entity(
|
||||
test_project, entity_data, entity_repository, search_service, file_service
|
||||
)
|
||||
|
||||
# Get recent context with pagination
|
||||
response = await client.get(
|
||||
f"{v2_project_url}/memory/recent", params={"page": 1, "page_size": 3}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "results" in data
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recent_context_with_type_filter(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
entity_repository,
|
||||
search_service,
|
||||
file_service,
|
||||
):
|
||||
"""Test filtering recent context by type."""
|
||||
# Create a test entity
|
||||
entity_data = {
|
||||
"title": "Filtered Entity",
|
||||
"entity_type": "note",
|
||||
"content_type": "text/markdown",
|
||||
"file_path": "filtered.md",
|
||||
"checksum": "xyz789",
|
||||
}
|
||||
await create_test_entity(
|
||||
test_project, entity_data, entity_repository, search_service, file_service
|
||||
)
|
||||
|
||||
# Get recent context filtered by type
|
||||
response = await client.get(f"{v2_project_url}/memory/recent", params={"type": ["entity"]})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "results" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recent_context_with_timeframe(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test recent context with custom timeframe."""
|
||||
response = await client.get(f"{v2_project_url}/memory/recent", params={"timeframe": "1d"})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "results" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recent_context_invalid_project_id(
|
||||
client: AsyncClient,
|
||||
):
|
||||
"""Test getting recent context with invalid project ID returns 404."""
|
||||
response = await client.get("/v2/projects/999999/memory/recent")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_context_by_permalink(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
entity_repository,
|
||||
search_service,
|
||||
file_service,
|
||||
):
|
||||
"""Test getting context for a specific memory URI (permalink)."""
|
||||
# Create a test entity
|
||||
entity_data = {
|
||||
"title": "Context Test",
|
||||
"entity_type": "note",
|
||||
"content_type": "text/markdown",
|
||||
"file_path": "context_test.md",
|
||||
"checksum": "def456",
|
||||
"permalink": "context-test",
|
||||
}
|
||||
await create_test_entity(
|
||||
test_project, entity_data, entity_repository, search_service, file_service
|
||||
)
|
||||
|
||||
# Get context for this entity
|
||||
response = await client.get(f"{v2_project_url}/memory/context-test")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "results" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_context_by_id(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
entity_repository,
|
||||
search_service,
|
||||
file_service,
|
||||
):
|
||||
"""Test getting context using ID-based memory URI."""
|
||||
# Create a test entity
|
||||
entity_data = {
|
||||
"title": "ID Context Test",
|
||||
"entity_type": "note",
|
||||
"content_type": "text/markdown",
|
||||
"file_path": "id_context_test.md",
|
||||
"checksum": "ghi789",
|
||||
}
|
||||
created_entity = await create_test_entity(
|
||||
test_project, entity_data, entity_repository, search_service, file_service
|
||||
)
|
||||
|
||||
# Get context using ID format (memory://id/123 or memory://123)
|
||||
response = await client.get(f"{v2_project_url}/memory/id/{created_entity.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "results" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_context_with_depth(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
entity_repository,
|
||||
search_service,
|
||||
file_service,
|
||||
):
|
||||
"""Test getting context with depth parameter."""
|
||||
# Create a test entity
|
||||
entity_data = {
|
||||
"title": "Depth Test",
|
||||
"entity_type": "note",
|
||||
"content_type": "text/markdown",
|
||||
"file_path": "depth_test.md",
|
||||
"checksum": "jkl012",
|
||||
"permalink": "depth-test",
|
||||
}
|
||||
await create_test_entity(
|
||||
test_project, entity_data, entity_repository, search_service, file_service
|
||||
)
|
||||
|
||||
# Get context with depth
|
||||
response = await client.get(f"{v2_project_url}/memory/depth-test", params={"depth": 2})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "results" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_context_not_found(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test getting context for non-existent memory URI returns 404."""
|
||||
response = await client.get(f"{v2_project_url}/memory/nonexistent-uri")
|
||||
|
||||
# Note: This might return 200 with empty results depending on implementation
|
||||
# Adjust assertion based on actual behavior
|
||||
assert response.status_code in [200, 404]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_context_with_timeframe(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
entity_repository,
|
||||
search_service,
|
||||
file_service,
|
||||
):
|
||||
"""Test getting context with timeframe filter."""
|
||||
# Create a test entity
|
||||
entity_data = {
|
||||
"title": "Timeframe Test",
|
||||
"entity_type": "note",
|
||||
"content_type": "text/markdown",
|
||||
"file_path": "timeframe_test.md",
|
||||
"checksum": "mno345",
|
||||
"permalink": "timeframe-test",
|
||||
}
|
||||
await create_test_entity(
|
||||
test_project, entity_data, entity_repository, search_service, file_service
|
||||
)
|
||||
|
||||
# Get context with timeframe
|
||||
response = await client.get(
|
||||
f"{v2_project_url}/memory/timeframe-test", params={"timeframe": "7d"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "results" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_memory_endpoints_use_project_id_not_name(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
):
|
||||
"""Test that v2 memory endpoints reject string project names."""
|
||||
# Try to use project name instead of ID - should fail
|
||||
response = await client.get(f"/v2/{test_project.name}/memory/recent")
|
||||
|
||||
# FastAPI path validation should reject non-integer project_id
|
||||
assert response.status_code in [404, 422]
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Tests for V2 project management API routes (ID-based endpoints)."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.models import Project
|
||||
from basic_memory.schemas.project_info import ProjectItem, ProjectStatusResponse
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_by_id(client: AsyncClient, test_project: Project, v2_projects_url):
|
||||
"""Test getting a project by its numeric ID."""
|
||||
response = await client.get(f"{v2_projects_url}/{test_project.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
project = ProjectItem.model_validate(response.json())
|
||||
assert project.id == test_project.id
|
||||
assert project.name == test_project.name
|
||||
assert project.path == test_project.path
|
||||
assert project.is_default == (test_project.is_default or False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_by_id_not_found(client: AsyncClient, v2_projects_url):
|
||||
"""Test getting a non-existent project by ID returns 404."""
|
||||
response = await client.get(f"{v2_projects_url}/999999")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_path_by_id(
|
||||
client: AsyncClient, test_project: Project, v2_projects_url
|
||||
):
|
||||
"""Test updating a project's path by ID."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
new_path = str(Path(tmpdir) / "new-project-location")
|
||||
Path(new_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
update_data = {"path": new_path}
|
||||
response = await client.patch(
|
||||
f"{v2_projects_url}/{test_project.id}",
|
||||
json=update_data,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
status_response = ProjectStatusResponse.model_validate(response.json())
|
||||
assert status_response.status == "success"
|
||||
assert status_response.new_project.id == test_project.id
|
||||
# Normalize paths for cross-platform comparison (Windows uses backslashes, API returns forward slashes)
|
||||
assert Path(status_response.new_project.path) == Path(new_path)
|
||||
assert status_response.old_project.id == test_project.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_invalid_path(
|
||||
client: AsyncClient, test_project: Project, v2_projects_url
|
||||
):
|
||||
"""Test updating with a relative path returns 400."""
|
||||
update_data = {"path": "relative/path"}
|
||||
response = await client.patch(
|
||||
f"{v2_projects_url}/{test_project.id}",
|
||||
json=update_data,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "absolute" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_not_found(client: AsyncClient, v2_projects_url):
|
||||
"""Test updating a non-existent project returns 404."""
|
||||
update_data = {"path": "/tmp/new-path"}
|
||||
response = await client.patch(
|
||||
f"{v2_projects_url}/999999",
|
||||
json=update_data,
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_default_project_by_id(
|
||||
client: AsyncClient, test_project: Project, v2_projects_url, project_repository, project_service
|
||||
):
|
||||
"""Test setting a project as default by ID."""
|
||||
# Create a second project to test setting default
|
||||
await project_service.add_project("second-project", "/tmp/second-project")
|
||||
|
||||
# Get the created project from the repository to get its ID
|
||||
created_project = await project_repository.get_by_name("second-project")
|
||||
assert created_project is not None
|
||||
|
||||
# Set the second project as default
|
||||
response = await client.put(f"{v2_projects_url}/{created_project.id}/default")
|
||||
|
||||
assert response.status_code == 200
|
||||
status_response = ProjectStatusResponse.model_validate(response.json())
|
||||
assert status_response.status == "success"
|
||||
assert status_response.default is True
|
||||
assert status_response.new_project.id == created_project.id
|
||||
assert status_response.new_project.is_default is True
|
||||
assert status_response.old_project.id == test_project.id
|
||||
assert status_response.old_project.is_default is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_default_project_not_found(client: AsyncClient, v2_projects_url):
|
||||
"""Test setting a non-existent project as default returns 404."""
|
||||
response = await client.put(f"{v2_projects_url}/999999/default")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_project_by_id(
|
||||
client: AsyncClient, test_project: Project, v2_projects_url, project_repository, project_service
|
||||
):
|
||||
"""Test deleting a project by ID."""
|
||||
# Create a second project since we can't delete the default
|
||||
await project_service.add_project("to-delete", "/tmp/to-delete")
|
||||
|
||||
# Get the created project from the repository to get its ID
|
||||
created_project = await project_repository.get_by_name("to-delete")
|
||||
assert created_project is not None
|
||||
|
||||
# Delete it
|
||||
response = await client.delete(f"{v2_projects_url}/{created_project.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
status_response = ProjectStatusResponse.model_validate(response.json())
|
||||
assert status_response.status == "success"
|
||||
assert status_response.old_project.id == created_project.id
|
||||
assert status_response.new_project is None
|
||||
|
||||
# Verify it's deleted - trying to get it should return 404
|
||||
response = await client.get(f"{v2_projects_url}/{created_project.id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_project_with_delete_notes_param(
|
||||
client: AsyncClient, test_project: Project, v2_projects_url, project_repository, project_service
|
||||
):
|
||||
"""Test deleting a project with delete_notes parameter."""
|
||||
# Create a project in a temp directory
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
project_path = Path(tmpdir) / "test-delete-notes"
|
||||
project_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create a test file in the project
|
||||
test_file = project_path / "test.md"
|
||||
test_file.write_text("Test content")
|
||||
|
||||
await project_service.add_project("delete-with-notes", str(project_path))
|
||||
|
||||
# Get the created project from the repository to get its ID
|
||||
created_project = await project_repository.get_by_name("delete-with-notes")
|
||||
assert created_project is not None
|
||||
|
||||
# Delete with delete_notes=true
|
||||
response = await client.delete(f"{v2_projects_url}/{created_project.id}?delete_notes=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify directory was deleted
|
||||
assert not project_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_default_project_fails(
|
||||
client: AsyncClient, test_project: Project, v2_projects_url
|
||||
):
|
||||
"""Test that deleting the default project returns 400."""
|
||||
# test_project is the default project
|
||||
response = await client.delete(f"{v2_projects_url}/{test_project.id}")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "default project" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_project_not_found(client: AsyncClient, v2_projects_url):
|
||||
"""Test deleting a non-existent project returns 404."""
|
||||
response = await client.delete(f"{v2_projects_url}/999999")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_project_endpoints_use_id_not_name(
|
||||
client: AsyncClient, test_project: Project, v2_projects_url
|
||||
):
|
||||
"""Verify v2 project endpoints require project ID, not name."""
|
||||
# Try using project name instead of ID - should fail
|
||||
response = await client.get(f"{v2_projects_url}/{test_project.name}")
|
||||
|
||||
# Should get 404 or 422 because name is not a valid integer
|
||||
assert response.status_code in [404, 422]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_id_stability_after_rename(
|
||||
client: AsyncClient, test_project: Project, v2_projects_url, project_repository
|
||||
):
|
||||
"""Test that project ID remains stable even after renaming."""
|
||||
original_id = test_project.id
|
||||
original_name = test_project.name
|
||||
|
||||
# Get project by ID
|
||||
response = await client.get(f"{v2_projects_url}/{original_id}")
|
||||
assert response.status_code == 200
|
||||
project_before = ProjectItem.model_validate(response.json())
|
||||
assert project_before.id == original_id
|
||||
assert project_before.name == original_name
|
||||
|
||||
# Even if we renamed the project (not testing rename here, just the concept),
|
||||
# the ID would stay the same. This test demonstrates the stability.
|
||||
# Re-fetch by same ID
|
||||
response = await client.get(f"{v2_projects_url}/{original_id}")
|
||||
assert response.status_code == 200
|
||||
project_after = ProjectItem.model_validate(response.json())
|
||||
assert project_after.id == original_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_active_status(
|
||||
client: AsyncClient, test_project: Project, v2_projects_url, project_repository, project_service
|
||||
):
|
||||
"""Test updating a project's active status by ID."""
|
||||
# Create a non-default project
|
||||
await project_service.add_project("test-active", "/tmp/test-active")
|
||||
|
||||
# Get the created project from the repository to get its ID
|
||||
created_project = await project_repository.get_by_name("test-active")
|
||||
assert created_project is not None
|
||||
|
||||
# Update active status
|
||||
update_data = {"is_active": False}
|
||||
response = await client.patch(
|
||||
f"{v2_projects_url}/{created_project.id}",
|
||||
json=update_data,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
status_response = ProjectStatusResponse.model_validate(response.json())
|
||||
assert status_response.status == "success"
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Tests for V2 prompt router endpoints (ID-based)."""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.models import Project
|
||||
from basic_memory.services.context_service import ContextService
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def context_service(entity_repository, search_service, observation_repository):
|
||||
"""Create a real context service for testing."""
|
||||
return ContextService(entity_repository, search_service, observation_repository)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_continue_conversation_endpoint(
|
||||
client: AsyncClient,
|
||||
entity_service,
|
||||
search_service,
|
||||
context_service,
|
||||
entity_repository,
|
||||
test_graph,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test the v2 continue_conversation endpoint with real services."""
|
||||
# Create request data
|
||||
request_data = {
|
||||
"topic": "Root", # This should match our test entity in test_graph
|
||||
"timeframe": "7d",
|
||||
"depth": 1,
|
||||
"related_items_limit": 2,
|
||||
}
|
||||
|
||||
# Call the endpoint
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/prompt/continue-conversation", json=request_data
|
||||
)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert "prompt" in result
|
||||
assert "context" in result
|
||||
|
||||
# Check content of context
|
||||
context = result["context"]
|
||||
assert context["topic"] == "Root"
|
||||
assert context["timeframe"] == "7d"
|
||||
assert context["has_results"] is True
|
||||
assert len(context["hierarchical_results"]) > 0
|
||||
|
||||
# Check content of prompt
|
||||
prompt = result["prompt"]
|
||||
assert "Continuing conversation on: Root" in prompt
|
||||
assert "memory retrieval session" in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_continue_conversation_without_topic(
|
||||
client: AsyncClient,
|
||||
entity_service,
|
||||
search_service,
|
||||
context_service,
|
||||
entity_repository,
|
||||
test_graph,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test v2 continue_conversation without topic - should use recent activity."""
|
||||
request_data = {"timeframe": "1d", "depth": 1, "related_items_limit": 2}
|
||||
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/prompt/continue-conversation", json=request_data
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert "Recent Activity" in result["context"]["topic"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_prompt_endpoint(
|
||||
client: AsyncClient, entity_service, search_service, test_graph, v2_project_url: str
|
||||
):
|
||||
"""Test the v2 search_prompt endpoint with real services."""
|
||||
# Create request data
|
||||
request_data = {
|
||||
"query": "Root", # This should match our test entity
|
||||
"timeframe": "7d",
|
||||
}
|
||||
|
||||
# Call the endpoint
|
||||
response = await client.post(f"{v2_project_url}/prompt/search", json=request_data)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert "prompt" in result
|
||||
assert "context" in result
|
||||
|
||||
# Check content of context
|
||||
context = result["context"]
|
||||
assert context["query"] == "Root"
|
||||
assert context["timeframe"] == "7d"
|
||||
assert context["has_results"] is True
|
||||
assert len(context["results"]) > 0
|
||||
|
||||
# Check content of prompt
|
||||
prompt = result["prompt"]
|
||||
assert 'Search Results for: "Root"' in prompt
|
||||
assert "This is a memory search session" in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_prompt_no_results(
|
||||
client: AsyncClient, entity_service, search_service, v2_project_url: str
|
||||
):
|
||||
"""Test the v2 search_prompt endpoint with a query that returns no results."""
|
||||
# Create request data with a query that shouldn't match anything
|
||||
request_data = {"query": "NonExistentQuery12345", "timeframe": "7d"}
|
||||
|
||||
# Call the endpoint
|
||||
response = await client.post(f"{v2_project_url}/prompt/search", json=request_data)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
|
||||
# Check content of context
|
||||
context = result["context"]
|
||||
assert context["query"] == "NonExistentQuery12345"
|
||||
assert context["has_results"] is False
|
||||
assert len(context["results"]) == 0
|
||||
|
||||
# Check content of prompt
|
||||
prompt = result["prompt"]
|
||||
assert 'Search Results for: "NonExistentQuery12345"' in prompt
|
||||
assert "I couldn't find any results for this query" in prompt
|
||||
assert "Opportunity to Capture Knowledge" in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_handling(client: AsyncClient, monkeypatch, v2_project_url: str):
|
||||
"""Test error handling in v2 endpoints by breaking the template loader."""
|
||||
|
||||
# Patch the template loader to raise an exception
|
||||
def mock_render(*args, **kwargs):
|
||||
raise Exception("Template error")
|
||||
|
||||
# Apply the patch
|
||||
monkeypatch.setattr("basic_memory.api.template_loader.TemplateLoader.render", mock_render)
|
||||
|
||||
# Test continue_conversation error handling
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/prompt/continue-conversation",
|
||||
json={"topic": "test error", "timeframe": "7d"},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert "detail" in response.json()
|
||||
assert "Template error" in response.json()["detail"]
|
||||
|
||||
# Test search_prompt error handling
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/prompt/search", json={"query": "test error", "timeframe": "7d"}
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert "detail" in response.json()
|
||||
assert "Template error" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_prompt_endpoints_use_project_id_not_name(
|
||||
client: AsyncClient, test_project: Project
|
||||
):
|
||||
"""Verify v2 prompt endpoints require project ID, not name."""
|
||||
# Try using project name instead of ID - should fail
|
||||
response = await client.post(
|
||||
f"/v2/projects/{test_project.name}/prompt/continue-conversation",
|
||||
json={"topic": "test", "timeframe": "7d"},
|
||||
)
|
||||
|
||||
# Should get validation error or 404 because name is not a valid integer
|
||||
assert response.status_code in [404, 422]
|
||||
|
||||
# Also test search endpoint
|
||||
response = await client.post(
|
||||
f"/v2/projects/{test_project.name}/prompt/search",
|
||||
json={"query": "test", "timeframe": "7d"},
|
||||
)
|
||||
|
||||
assert response.status_code in [404, 422]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_invalid_project_id(client: AsyncClient):
|
||||
"""Test prompt endpoints with invalid project ID return 404."""
|
||||
# Test continue-conversation
|
||||
response = await client.post(
|
||||
"/v2/projects/999999/prompt/continue-conversation",
|
||||
json={"topic": "test", "timeframe": "7d"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
# Test search
|
||||
response = await client.post(
|
||||
"/v2/projects/999999/prompt/search",
|
||||
json={"query": "test", "timeframe": "7d"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Tests for V2 resource API routes (ID-based endpoints)."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.models import Project
|
||||
from basic_memory.schemas.v2.resource import ResourceResponse
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_resource(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test creating a new resource via v2 POST endpoint."""
|
||||
create_data = {
|
||||
"file_path": "test-resources/test-file.md",
|
||||
"content": "# Test Resource\n\nThis is test content.",
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/resource",
|
||||
json=create_data,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = ResourceResponse.model_validate(response.json())
|
||||
|
||||
# V2 must return entity_id
|
||||
assert result.entity_id is not None
|
||||
assert isinstance(result.entity_id, int)
|
||||
assert result.file_path == "test-resources/test-file.md"
|
||||
assert result.checksum is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_resource_duplicate_fails(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test that creating a resource at an existing path returns 409."""
|
||||
create_data = {
|
||||
"file_path": "duplicate-test.md",
|
||||
"content": "First version",
|
||||
}
|
||||
|
||||
# Create first time - should succeed
|
||||
response = await client.post(f"{v2_project_url}/resource", json=create_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Try to create again - should fail with 409
|
||||
response = await client.post(f"{v2_project_url}/resource", json=create_data)
|
||||
assert response.status_code == 409
|
||||
assert "already exists" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_by_id(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test getting resource content by entity ID."""
|
||||
# First create a resource
|
||||
test_content = "# Test Resource\n\nThis is test content."
|
||||
create_data = {
|
||||
"file_path": "test-get.md",
|
||||
"content": test_content,
|
||||
}
|
||||
|
||||
create_response = await client.post(f"{v2_project_url}/resource", json=create_data)
|
||||
assert create_response.status_code == 200
|
||||
created = ResourceResponse.model_validate(create_response.json())
|
||||
|
||||
# Now get it by entity ID
|
||||
response = await client.get(f"{v2_project_url}/resource/{created.entity_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
# Normalize line endings for cross-platform compatibility
|
||||
assert test_content.replace("\n", "") in response.text.replace("\r\n", "").replace("\n", "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_not_found(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test getting a non-existent resource returns 404."""
|
||||
response = await client.get(f"{v2_project_url}/resource/999999")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_resource(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test updating resource content by entity ID."""
|
||||
# Create a resource
|
||||
create_data = {
|
||||
"file_path": "test-update.md",
|
||||
"content": "Original content",
|
||||
}
|
||||
create_response = await client.post(f"{v2_project_url}/resource", json=create_data)
|
||||
assert create_response.status_code == 200
|
||||
created = ResourceResponse.model_validate(create_response.json())
|
||||
|
||||
# Update it
|
||||
update_data = {
|
||||
"content": "Updated content",
|
||||
}
|
||||
response = await client.put(
|
||||
f"{v2_project_url}/resource/{created.entity_id}",
|
||||
json=update_data,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = ResourceResponse.model_validate(response.json())
|
||||
assert result.entity_id == created.entity_id
|
||||
assert result.file_path == "test-update.md"
|
||||
|
||||
# Verify content was updated
|
||||
get_response = await client.get(f"{v2_project_url}/resource/{created.entity_id}")
|
||||
assert "Updated content" in get_response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_resource_and_move(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test updating resource content and moving it to a new path."""
|
||||
# Create a resource
|
||||
create_data = {
|
||||
"file_path": "original-location.md",
|
||||
"content": "Original content",
|
||||
}
|
||||
create_response = await client.post(f"{v2_project_url}/resource", json=create_data)
|
||||
assert create_response.status_code == 200
|
||||
created = ResourceResponse.model_validate(create_response.json())
|
||||
|
||||
# Update content and move file
|
||||
update_data = {
|
||||
"content": "Updated content in new location",
|
||||
"file_path": "moved/new-location.md",
|
||||
}
|
||||
response = await client.put(
|
||||
f"{v2_project_url}/resource/{created.entity_id}",
|
||||
json=update_data,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = ResourceResponse.model_validate(response.json())
|
||||
assert result.entity_id == created.entity_id
|
||||
assert result.file_path == "moved/new-location.md"
|
||||
|
||||
# Verify content at new location
|
||||
get_response = await client.get(f"{v2_project_url}/resource/{created.entity_id}")
|
||||
assert "Updated content in new location" in get_response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_resource_not_found(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test updating a non-existent resource returns 404."""
|
||||
update_data = {
|
||||
"content": "New content",
|
||||
}
|
||||
response = await client.put(
|
||||
f"{v2_project_url}/resource/999999",
|
||||
json=update_data,
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_resource_invalid_path(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test creating a resource with path traversal attempt fails."""
|
||||
create_data = {
|
||||
"file_path": "../../../etc/passwd",
|
||||
"content": "malicious content",
|
||||
}
|
||||
|
||||
response = await client.post(f"{v2_project_url}/resource", json=create_data)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Invalid file path" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_resource_invalid_path(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test updating a resource with path traversal attempt fails."""
|
||||
# Create a valid resource first
|
||||
create_data = {
|
||||
"file_path": "valid.md",
|
||||
"content": "Valid content",
|
||||
}
|
||||
create_response = await client.post(f"{v2_project_url}/resource", json=create_data)
|
||||
assert create_response.status_code == 200
|
||||
created = ResourceResponse.model_validate(create_response.json())
|
||||
|
||||
# Try to move it to an invalid path
|
||||
update_data = {
|
||||
"content": "Updated content",
|
||||
"file_path": "../../../etc/passwd",
|
||||
}
|
||||
response = await client.put(
|
||||
f"{v2_project_url}/resource/{created.entity_id}",
|
||||
json=update_data,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Invalid file path" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_invalid_project_id(
|
||||
client: AsyncClient,
|
||||
):
|
||||
"""Test resource endpoints with invalid project ID return 404."""
|
||||
# Test create
|
||||
response = await client.post(
|
||||
"/v2/projects/999999/resource",
|
||||
json={"file_path": "test.md", "content": "test"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
# Test get
|
||||
response = await client.get("/v2/projects/999999/resource/1")
|
||||
assert response.status_code == 404
|
||||
|
||||
# Test update
|
||||
response = await client.put(
|
||||
"/v2/projects/999999/resource/1",
|
||||
json={"content": "test"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_resource_endpoints_use_project_id_not_name(
|
||||
client: AsyncClient, test_project: Project
|
||||
):
|
||||
"""Verify v2 resource endpoints require project ID, not name."""
|
||||
# Try using project name instead of ID - should fail
|
||||
response = await client.get(f"/v2/projects/{test_project.name}/resource/1")
|
||||
|
||||
# Should get validation error or 404 because name is not a valid integer
|
||||
assert response.status_code in [404, 422]
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Tests for v2 search router endpoints."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from pathlib import Path
|
||||
|
||||
from basic_memory.models import Project
|
||||
|
||||
|
||||
async def create_test_entity(
|
||||
test_project, entity_data, entity_repository, search_service, file_service
|
||||
):
|
||||
"""Helper to create an entity with file and index it."""
|
||||
# Create file
|
||||
test_content = f"# {entity_data['title']}\n\nTest content"
|
||||
file_path = Path(test_project.path) / entity_data["file_path"]
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
await file_service.write_file(file_path, test_content)
|
||||
|
||||
# Create entity
|
||||
entity = await entity_repository.create(entity_data)
|
||||
|
||||
# Index for search
|
||||
await search_service.index_entity(entity)
|
||||
|
||||
return entity
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_entities(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
entity_repository,
|
||||
search_service,
|
||||
file_service,
|
||||
):
|
||||
"""Test searching for entities."""
|
||||
# Create a test entity
|
||||
entity_data = {
|
||||
"title": "Searchable Entity",
|
||||
"entity_type": "note",
|
||||
"content_type": "text/markdown",
|
||||
"file_path": "searchable.md",
|
||||
"checksum": "search123",
|
||||
}
|
||||
await create_test_entity(
|
||||
test_project, entity_data, entity_repository, search_service, file_service
|
||||
)
|
||||
|
||||
# Search for the entity
|
||||
response = await client.post(f"{v2_project_url}/search/", json={"search_text": "Searchable"})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify response structure
|
||||
assert "results" in data
|
||||
assert "current_page" in data
|
||||
assert "page_size" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_pagination(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
entity_repository,
|
||||
search_service,
|
||||
file_service,
|
||||
):
|
||||
"""Test search with pagination parameters."""
|
||||
# Create multiple test entities
|
||||
for i in range(5):
|
||||
entity_data = {
|
||||
"title": f"Search Entity {i}",
|
||||
"entity_type": "note",
|
||||
"content_type": "text/markdown",
|
||||
"file_path": f"search_{i}.md",
|
||||
"checksum": f"searchsum{i}",
|
||||
}
|
||||
await create_test_entity(
|
||||
test_project, entity_data, entity_repository, search_service, file_service
|
||||
)
|
||||
|
||||
# Search with pagination
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/search/",
|
||||
json={"search_text": "Search Entity"},
|
||||
params={"page": 1, "page_size": 3},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["current_page"] == 1
|
||||
assert data["page_size"] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_by_permalink(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
entity_repository,
|
||||
search_service,
|
||||
file_service,
|
||||
):
|
||||
"""Test searching by permalink."""
|
||||
# Create a test entity with permalink
|
||||
entity_data = {
|
||||
"title": "Permalink Search",
|
||||
"entity_type": "note",
|
||||
"content_type": "text/markdown",
|
||||
"file_path": "permalink_search.md",
|
||||
"checksum": "perm123",
|
||||
"permalink": "permalink-search",
|
||||
}
|
||||
await create_test_entity(
|
||||
test_project, entity_data, entity_repository, search_service, file_service
|
||||
)
|
||||
|
||||
# Search by permalink
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/search/", json={"permalink": "permalink-search"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "results" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_by_title(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
entity_repository,
|
||||
search_service,
|
||||
file_service,
|
||||
):
|
||||
"""Test searching by title."""
|
||||
# Create a test entity
|
||||
entity_data = {
|
||||
"title": "Unique Title For Search",
|
||||
"entity_type": "note",
|
||||
"content_type": "text/markdown",
|
||||
"file_path": "unique_title.md",
|
||||
"checksum": "title123",
|
||||
}
|
||||
await create_test_entity(
|
||||
test_project, entity_data, entity_repository, search_service, file_service
|
||||
)
|
||||
|
||||
# Search by title
|
||||
response = await client.post(f"{v2_project_url}/search/", json={"title": "Unique Title"})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "results" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_type_filter(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
entity_repository,
|
||||
search_service,
|
||||
file_service,
|
||||
):
|
||||
"""Test searching with entity type filter."""
|
||||
# Create test entities of different types
|
||||
for entity_type in ["note", "document"]:
|
||||
entity_data = {
|
||||
"title": f"Type {entity_type}",
|
||||
"entity_type": entity_type,
|
||||
"content_type": "text/markdown",
|
||||
"file_path": f"type_{entity_type}.md",
|
||||
"checksum": f"type{entity_type}",
|
||||
}
|
||||
await create_test_entity(
|
||||
test_project, entity_data, entity_repository, search_service, file_service
|
||||
)
|
||||
|
||||
# Search with type filter
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/search/", json={"search_text": "Type", "types": ["note"]}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "results" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_date_filter(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
entity_repository,
|
||||
search_service,
|
||||
file_service,
|
||||
):
|
||||
"""Test searching with date filter."""
|
||||
# Create a test entity
|
||||
entity_data = {
|
||||
"title": "Date Filtered",
|
||||
"entity_type": "note",
|
||||
"content_type": "text/markdown",
|
||||
"file_path": "date_filtered.md",
|
||||
"checksum": "date123",
|
||||
}
|
||||
await create_test_entity(
|
||||
test_project, entity_data, entity_repository, search_service, file_service
|
||||
)
|
||||
|
||||
# Search with date filter
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/search/",
|
||||
json={"search_text": "Date Filtered", "after_date": "2024-01-01T00:00:00Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "results" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_empty_query(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test search with empty query."""
|
||||
response = await client.post(f"{v2_project_url}/search/", json={})
|
||||
|
||||
# Empty query should still be valid (returns all)
|
||||
assert response.status_code in [200, 422]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_invalid_project_id(
|
||||
client: AsyncClient,
|
||||
):
|
||||
"""Test searching with invalid project ID returns 404."""
|
||||
response = await client.post("/v2/projects/999999/search/", json={"search_text": "test"})
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reindex(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
v2_project_url: str,
|
||||
):
|
||||
"""Test reindexing search index."""
|
||||
response = await client.post(f"{v2_project_url}/search/reindex")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify response structure
|
||||
assert "status" in data
|
||||
assert data["status"] == "ok"
|
||||
assert "message" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reindex_invalid_project_id(
|
||||
client: AsyncClient,
|
||||
):
|
||||
"""Test reindexing with invalid project ID returns 404."""
|
||||
response = await client.post("/v2/projects/999999/search/reindex")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_search_endpoints_use_project_id_not_name(
|
||||
client: AsyncClient,
|
||||
test_project: Project,
|
||||
):
|
||||
"""Test that v2 search endpoints reject string project names."""
|
||||
# Try to use project name instead of ID - should fail
|
||||
response = await client.post(f"/v2/{test_project.name}/search/", json={"search_text": "test"})
|
||||
|
||||
# FastAPI path validation should reject non-integer project_id
|
||||
assert response.status_code in [404, 422]
|
||||
@@ -1,6 +1,5 @@
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
@@ -26,7 +25,7 @@ async def client(app: FastAPI, aiolib) -> AsyncGenerator[AsyncClient, None]:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_env(project_config, client, test_config):
|
||||
@pytest_asyncio.fixture
|
||||
async def cli_env(project_config, client, test_config):
|
||||
"""Set up CLI environment with correct project session."""
|
||||
return {"project_config": project_config, "client": client}
|
||||
|
||||
@@ -12,12 +12,16 @@ from textwrap import dedent
|
||||
from typing import AsyncGenerator
|
||||
from unittest.mock import patch
|
||||
|
||||
import nest_asyncio
|
||||
import pytest_asyncio
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.commands.tool import tool_app
|
||||
from basic_memory.schemas.base import Entity as EntitySchema
|
||||
|
||||
# Allow nested asyncio.run() calls - needed for CLI tests with async fixtures
|
||||
nest_asyncio.apply()
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@@ -72,6 +76,7 @@ def test_write_note(cli_env, project_config, test_project):
|
||||
test_project.name,
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Check for expected success message
|
||||
|
||||
@@ -18,6 +18,11 @@ def runner():
|
||||
@pytest.fixture
|
||||
def mock_config(tmp_path, monkeypatch):
|
||||
"""Create a mock config in cloud mode using environment variables."""
|
||||
# Invalidate config cache to ensure clean state for each test
|
||||
from basic_memory import config as config_module
|
||||
|
||||
config_module._CONFIG_CACHE = None
|
||||
|
||||
config_dir = tmp_path / ".basic-memory"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_file = config_dir / "config.json"
|
||||
@@ -50,6 +55,7 @@ def mock_api_client():
|
||||
"default": False,
|
||||
"old_project": None,
|
||||
"new_project": {
|
||||
"id": 1,
|
||||
"name": "test-project",
|
||||
"path": "/test-project",
|
||||
"is_default": False,
|
||||
|
||||
+128
-26
@@ -4,15 +4,16 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from typing import AsyncGenerator
|
||||
from typing import AsyncGenerator, Literal
|
||||
|
||||
import os
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.config import ProjectConfig, BasicMemoryConfig, ConfigManager
|
||||
from basic_memory.config import ProjectConfig, BasicMemoryConfig, ConfigManager, DatabaseBackend
|
||||
from basic_memory.db import DatabaseType
|
||||
from basic_memory.markdown import EntityParser
|
||||
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
||||
@@ -23,7 +24,6 @@ from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.repository.observation_repository import ObservationRepository
|
||||
from basic_memory.repository.project_repository import ProjectRepository
|
||||
from basic_memory.repository.relation_repository import RelationRepository
|
||||
from basic_memory.repository.search_repository import SearchRepository
|
||||
from basic_memory.schemas.base import Entity as EntitySchema
|
||||
from basic_memory.services import (
|
||||
EntityService,
|
||||
@@ -42,6 +42,27 @@ def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
params=[
|
||||
pytest.param("sqlite", id="sqlite"),
|
||||
pytest.param("postgres", id="postgres", marks=pytest.mark.postgres),
|
||||
]
|
||||
)
|
||||
def db_backend(request) -> Literal["sqlite", "postgres"]:
|
||||
"""Parametrize tests to run against both SQLite and Postgres.
|
||||
|
||||
Usage:
|
||||
pytest # Runs tests against SQLite only (default)
|
||||
pytest -m postgres # Runs tests against Postgres only
|
||||
pytest -m "not postgres" # Runs tests against SQLite only
|
||||
pytest --run-all-backends # Runs tests against both backends
|
||||
|
||||
Note: Only tests that use database fixtures (engine_factory, session_maker, etc.)
|
||||
will be parametrized. Tests that don't use the database won't be affected.
|
||||
"""
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_root() -> Path:
|
||||
return Path(__file__).parent.parent
|
||||
@@ -59,25 +80,41 @@ def config_home(tmp_path, monkeypatch) -> Path:
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
def app_config(config_home, tmp_path, monkeypatch) -> BasicMemoryConfig:
|
||||
@pytest.fixture(scope="function")
|
||||
def app_config(
|
||||
config_home, db_backend: Literal["sqlite", "postgres"], monkeypatch
|
||||
) -> BasicMemoryConfig:
|
||||
"""Create test app configuration."""
|
||||
# Create a basic config without depending on test_project to avoid circular dependency
|
||||
projects = {"test-project": str(config_home)}
|
||||
|
||||
# Configure database backend based on test parameter
|
||||
if db_backend == "postgres":
|
||||
database_backend = DatabaseBackend.POSTGRES
|
||||
# Use env var if set, otherwise use default matching docker-compose-postgres.yml
|
||||
# These are local test credentials only - NOT for production
|
||||
database_url = os.getenv(
|
||||
"POSTGRES_TEST_URL",
|
||||
"postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test",
|
||||
)
|
||||
else:
|
||||
database_backend = DatabaseBackend.SQLITE
|
||||
database_url = None
|
||||
|
||||
app_config = BasicMemoryConfig(
|
||||
env="test",
|
||||
projects=projects,
|
||||
default_project="test-project",
|
||||
update_permalinks_on_move=True,
|
||||
database_backend=database_backend,
|
||||
database_url=database_url,
|
||||
)
|
||||
|
||||
return app_config
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def config_manager(
|
||||
app_config: BasicMemoryConfig, project_config: ProjectConfig, config_home: Path, monkeypatch
|
||||
) -> ConfigManager:
|
||||
@pytest.fixture
|
||||
def config_manager(app_config: BasicMemoryConfig, config_home: Path, monkeypatch) -> ConfigManager:
|
||||
# Invalidate config cache to ensure clean state for each test
|
||||
from basic_memory import config as config_module
|
||||
|
||||
@@ -95,7 +132,7 @@ def config_manager(
|
||||
return config_manager
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
@pytest.fixture(scope="function")
|
||||
def project_config(test_project):
|
||||
"""Create test project configuration."""
|
||||
|
||||
@@ -124,16 +161,80 @@ def test_config(config_home, project_config, app_config, config_manager) -> Test
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def engine_factory(
|
||||
app_config,
|
||||
config_manager,
|
||||
db_backend: Literal["sqlite", "postgres"],
|
||||
) -> AsyncGenerator[tuple[AsyncEngine, async_sessionmaker[AsyncSession]], None]:
|
||||
"""Create an engine and session factory using an in-memory SQLite database."""
|
||||
async with db.engine_session_factory(
|
||||
db_path=app_config.database_path, db_type=DatabaseType.MEMORY
|
||||
) as (engine, session_maker):
|
||||
# Create all tables for the DB the engine is connected to
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
"""Create engine and session factory for the configured database backend."""
|
||||
from basic_memory.models.search import CREATE_SEARCH_INDEX
|
||||
|
||||
yield engine, session_maker
|
||||
if db_backend == "postgres":
|
||||
# Postgres: Create fresh engine for each test with full schema reset
|
||||
config_manager._config = app_config
|
||||
db_type = DatabaseType.FILESYSTEM
|
||||
|
||||
# Use context manager to handle engine disposal properly
|
||||
async with db.engine_session_factory(db_path=app_config.database_path, db_type=db_type) as (
|
||||
engine,
|
||||
session_maker,
|
||||
):
|
||||
# Drop and recreate schema for complete isolation
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE"))
|
||||
await conn.execute(text("CREATE SCHEMA public"))
|
||||
await conn.execute(text("GRANT ALL ON SCHEMA public TO basic_memory_user"))
|
||||
await conn.execute(text("GRANT ALL ON SCHEMA public TO public"))
|
||||
|
||||
# Run migrations to create production tables (including search_index with correct schema)
|
||||
# Alembic handles duplicate migration checks, so it's safe to call this for each test
|
||||
from basic_memory.db import run_migrations
|
||||
|
||||
await run_migrations(app_config, db_type)
|
||||
|
||||
# For Postgres, migrations create all production tables with correct schemas
|
||||
# We only need to create test-specific tables (like ModelTest) that aren't in migrations
|
||||
# Don't create search_index via ORM - it's already created by migration with composite PK
|
||||
async with engine.begin() as conn:
|
||||
# List of tables created by migrations - don't recreate them via ORM
|
||||
production_tables = {
|
||||
"entity",
|
||||
"observation",
|
||||
"relation",
|
||||
"project",
|
||||
"search_index",
|
||||
"alembic_version",
|
||||
}
|
||||
|
||||
# Get test-specific tables that aren't created by migrations
|
||||
test_tables = [
|
||||
table
|
||||
for table in Base.metadata.sorted_tables
|
||||
if table.name not in production_tables
|
||||
]
|
||||
if test_tables:
|
||||
await conn.run_sync(
|
||||
lambda sync_conn: Base.metadata.create_all(sync_conn, tables=test_tables)
|
||||
)
|
||||
|
||||
yield engine, session_maker
|
||||
else:
|
||||
# SQLite: Create fresh in-memory database for each test
|
||||
db_type = DatabaseType.MEMORY
|
||||
async with db.engine_session_factory(db_path=app_config.database_path, db_type=db_type) as (
|
||||
engine,
|
||||
session_maker,
|
||||
):
|
||||
# Create all tables via ORM
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Drop any SearchIndex ORM table, then create FTS5 virtual table
|
||||
async with db.scoped_session(session_maker) as session:
|
||||
await session.execute(text("DROP TABLE IF EXISTS search_index"))
|
||||
await session.execute(CREATE_SEARCH_INDEX)
|
||||
await session.commit()
|
||||
|
||||
# Yield after setup is complete
|
||||
yield engine, session_maker
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@@ -278,19 +379,20 @@ async def directory_service(entity_repository, project_config) -> DirectoryServi
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def search_repository(session_maker, test_project: Project):
|
||||
"""Create SearchRepository instance with project context"""
|
||||
return SearchRepository(session_maker, project_id=test_project.id)
|
||||
async def search_repository(session_maker, test_project: Project, app_config: BasicMemoryConfig):
|
||||
"""Create backend-appropriate SearchRepository instance with project context"""
|
||||
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
|
||||
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def init_search_index(search_service):
|
||||
await search_service.init_search_index()
|
||||
if app_config.database_backend == DatabaseBackend.POSTGRES:
|
||||
return PostgresSearchRepository(session_maker, project_id=test_project.id)
|
||||
else:
|
||||
return SQLiteSearchRepository(session_maker, project_id=test_project.id)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def search_service(
|
||||
search_repository: SearchRepository,
|
||||
search_repository,
|
||||
entity_repository: EntityRepository,
|
||||
file_service: FileService,
|
||||
) -> SearchService:
|
||||
|
||||
@@ -116,6 +116,7 @@ def test_prompt_context_with_file_path_no_permalink():
|
||||
|
||||
# Create a mock context with a file that has no permalink (like a binary file)
|
||||
test_entity = EntitySummary(
|
||||
entity_id=1,
|
||||
type="entity",
|
||||
title="Test File",
|
||||
permalink=None, # No permalink
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
"""Comprehensive test suite for kebab_filenames configuration.
|
||||
|
||||
Tests the BASIC_MEMORY_KEBAB_FILENAMES configuration option which controls
|
||||
whether note filenames are converted to kebab-case (lowercase with hyphens).
|
||||
|
||||
Feature added in PR #260 to handle forward slashes in filenames.
|
||||
This test suite was expanded to comprehensively test all kebab-case transformations.
|
||||
|
||||
Key behaviors tested:
|
||||
1. When kebab_filenames=true: All special characters, spaces, periods, underscores,
|
||||
and mixed case are converted to lowercase kebab-case
|
||||
2. When kebab_filenames=false: Original formatting is preserved (backward compatibility)
|
||||
3. Folder paths are not affected by kebab_filenames setting
|
||||
4. Permalinks are always kebab-case regardless of kebab_filenames setting
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from basic_memory.mcp.tools import write_note
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Basic Transformations (kebab_filenames=true)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_spaces_to_hyphens(app, test_project, app_config):
|
||||
"""Test that spaces are converted to hyphens when kebab_filenames=true."""
|
||||
ConfigManager().config.kebab_filenames = True
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="My Awesome Note",
|
||||
folder="test",
|
||||
content="Testing space conversion",
|
||||
)
|
||||
|
||||
assert "file_path: test/my-awesome-note.md" in result
|
||||
assert "permalink: test/my-awesome-note" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_underscores_to_hyphens(app, test_project, app_config):
|
||||
"""Test that underscores are converted to hyphens when kebab_filenames=true."""
|
||||
ConfigManager().config.kebab_filenames = True
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="my_note_with_underscores",
|
||||
folder="test",
|
||||
content="Testing underscore conversion",
|
||||
)
|
||||
|
||||
assert "file_path: test/my-note-with-underscores.md" in result
|
||||
assert "permalink: test/my-note-with-underscores" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_camelcase_to_kebab(app, test_project, app_config):
|
||||
"""Test that CamelCase is converted to kebab-case when kebab_filenames=true."""
|
||||
ConfigManager().config.kebab_filenames = True
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="MyAwesomeFeature",
|
||||
folder="test",
|
||||
content="Testing CamelCase conversion",
|
||||
)
|
||||
|
||||
assert "file_path: test/my-awesome-feature.md" in result
|
||||
assert "permalink: test/my-awesome-feature" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_mixed_case_to_lowercase(app, test_project, app_config):
|
||||
"""Test that mixed case is converted to lowercase when kebab_filenames=true."""
|
||||
ConfigManager().config.kebab_filenames = True
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="MIXED_Case_Example",
|
||||
folder="test",
|
||||
content="Testing case conversion",
|
||||
)
|
||||
|
||||
assert "file_path: test/mixed-case-example.md" in result
|
||||
assert "permalink: test/mixed-case-example" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Period Handling (kebab_filenames=true)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_single_period_preserved(app, test_project, app_config):
|
||||
"""Test that periods in version numbers are preserved when kebab_filenames=true.
|
||||
|
||||
This preserves semantic meaning of version numbers like "3.0" while still
|
||||
converting spaces to hyphens. Only actual file extensions are split off.
|
||||
"""
|
||||
ConfigManager().config.kebab_filenames = True
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test 3.0 Version",
|
||||
folder="test",
|
||||
content="Testing period preservation",
|
||||
)
|
||||
|
||||
assert "file_path: test/test-3.0-version.md" in result
|
||||
assert "permalink: test/test-3.0-version" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_multiple_periods_preserved(app, test_project, app_config):
|
||||
"""Test that multiple periods in version numbers are preserved when kebab_filenames=true."""
|
||||
ConfigManager().config.kebab_filenames = True
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Version 1.2.3 Release",
|
||||
folder="test",
|
||||
content="Testing multiple period preservation",
|
||||
)
|
||||
|
||||
assert "file_path: test/version-1.2.3-release.md" in result
|
||||
assert "permalink: test/version-1.2.3-release" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Special Characters (kebab_filenames=true)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_special_chars_to_hyphens(app, test_project, app_config):
|
||||
"""Test that special characters are converted while preserving periods in version numbers."""
|
||||
ConfigManager().config.kebab_filenames = True
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test 2.0: New Feature",
|
||||
folder="test",
|
||||
content="Testing special character conversion",
|
||||
)
|
||||
|
||||
assert "file_path: test/test-2.0-new-feature.md" in result
|
||||
assert "permalink: test/test-2.0-new-feature" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_parentheses_removed(app, test_project, app_config):
|
||||
"""Test that parentheses are handled while preserving periods in version numbers."""
|
||||
ConfigManager().config.kebab_filenames = True
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Feature (v2.0) Update",
|
||||
folder="test",
|
||||
content="Testing parentheses handling",
|
||||
)
|
||||
|
||||
assert "file_path: test/feature-v2.0-update.md" in result
|
||||
assert "permalink: test/feature-v2.0-update" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_apostrophes_removed(app, test_project, app_config):
|
||||
"""Test that apostrophes are removed when kebab_filenames=true."""
|
||||
ConfigManager().config.kebab_filenames = True
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="User's Guide",
|
||||
folder="test",
|
||||
content="Testing apostrophe handling",
|
||||
)
|
||||
|
||||
assert "file_path: test/users-guide.md" in result
|
||||
assert "permalink: test/users-guide" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Combined Transformations (kebab_filenames=true)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_all_transformations_combined(app, test_project, app_config):
|
||||
"""Test multiple transformation types combined while preserving periods in version numbers."""
|
||||
ConfigManager().config.kebab_filenames = True
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="MyProject_v3.0: Feature Update (DRAFT)",
|
||||
folder="test",
|
||||
content="Testing combined transformations",
|
||||
)
|
||||
|
||||
assert "file_path: test/my-project-v3.0-feature-update-draft.md" in result
|
||||
assert "permalink: test/my-project-v3.0-feature-update-draft" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_consecutive_special_chars_collapsed(app, test_project, app_config):
|
||||
"""Test that consecutive special characters collapse to single hyphen."""
|
||||
ConfigManager().config.kebab_filenames = True
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test___Multiple---Separators",
|
||||
folder="test",
|
||||
content="Testing consecutive special character collapse",
|
||||
)
|
||||
|
||||
# Multiple underscores/hyphens should collapse to single hyphen
|
||||
assert "file_path: test/test-multiple-separators.md" in result
|
||||
assert "permalink: test/test-multiple-separators" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Edge Cases (kebab_filenames=true)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_leading_trailing_hyphens_trimmed(app, test_project, app_config):
|
||||
"""Test that leading/trailing hyphens are trimmed when kebab_filenames=true."""
|
||||
ConfigManager().config.kebab_filenames = True
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="---Test Note---",
|
||||
folder="test",
|
||||
content="Testing leading/trailing hyphen trimming",
|
||||
)
|
||||
|
||||
assert "file_path: test/test-note.md" in result
|
||||
assert "permalink: test/test-note" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_all_special_chars_becomes_valid_filename(app, test_project, app_config):
|
||||
"""Test that a title with mostly special characters becomes valid."""
|
||||
ConfigManager().config.kebab_filenames = True
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="!!!Test!!!",
|
||||
folder="test",
|
||||
content="Testing all special characters",
|
||||
)
|
||||
|
||||
assert "file_path: test/test.md" in result
|
||||
assert "permalink: test/test" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Folder Path Handling (kebab_filenames=true)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_folder_path_unaffected(app, test_project, app_config):
|
||||
"""Test that folder paths are NOT affected by kebab_filenames setting."""
|
||||
ConfigManager().config.kebab_filenames = True
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="My_Folder/Sub Folder", # Folder should remain as-is
|
||||
content="Testing folder path preservation",
|
||||
)
|
||||
|
||||
# Folder paths should be preserved (sanitized but not kebab-cased)
|
||||
assert "file_path: My_Folder/Sub Folder/test-note.md" in result
|
||||
assert "permalink: my-folder/sub-folder/test-note" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_root_folder_with_kebab(app, test_project, app_config):
|
||||
"""Test kebab_filenames preserves periods in version numbers with root folder."""
|
||||
ConfigManager().config.kebab_filenames = True
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test 3.0 Note",
|
||||
folder="", # Root folder
|
||||
content="Testing root folder",
|
||||
)
|
||||
|
||||
assert "file_path: test-3.0-note.md" in result
|
||||
assert "permalink: test-3.0-note" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Backward Compatibility (kebab_filenames=false)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_kebab_disabled_preserves_original(app, test_project, app_config):
|
||||
"""Test that original formatting is preserved when kebab_filenames=false."""
|
||||
ConfigManager().config.kebab_filenames = False
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test 3.0 Version",
|
||||
folder="test",
|
||||
content="Testing backward compatibility",
|
||||
)
|
||||
|
||||
# Periods and spaces should be preserved
|
||||
assert "file_path: test/Test 3.0 Version.md" in result
|
||||
# Permalinks are ALWAYS kebab-case regardless of setting, and preserve periods
|
||||
assert "permalink: test/test-3.0-version" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_kebab_disabled_preserves_underscores(app, test_project, app_config):
|
||||
"""Test that underscores are preserved when kebab_filenames=false."""
|
||||
ConfigManager().config.kebab_filenames = False
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="my_note_example",
|
||||
folder="test",
|
||||
content="Testing underscore preservation",
|
||||
)
|
||||
|
||||
assert "file_path: test/my_note_example.md" in result
|
||||
assert "permalink: test/my-note-example" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_kebab_disabled_preserves_case(app, test_project, app_config):
|
||||
"""Test that case is preserved when kebab_filenames=false."""
|
||||
ConfigManager().config.kebab_filenames = False
|
||||
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="MyAwesomeNote",
|
||||
folder="test",
|
||||
content="Testing case preservation",
|
||||
)
|
||||
|
||||
assert "file_path: test/MyAwesomeNote.md" in result
|
||||
assert "permalink: test/my-awesome-note" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Permalink Consistency (both modes)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permalinks_always_kebab_case(app, test_project, app_config):
|
||||
"""Test that permalinks are ALWAYS kebab-case regardless of kebab_filenames setting.
|
||||
|
||||
This is important: even when kebab_filenames=false (preserving filename formatting),
|
||||
permalinks should still be kebab-case for URL consistency. Both modes preserve periods
|
||||
in version numbers.
|
||||
"""
|
||||
# Test with kebab disabled
|
||||
ConfigManager().config.kebab_filenames = False
|
||||
|
||||
result1 = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note 1",
|
||||
folder="test",
|
||||
content="Testing permalink consistency",
|
||||
)
|
||||
|
||||
# Filename preserves original, permalink is kebab-case
|
||||
assert "file_path: test/Test Note 1.md" in result1
|
||||
assert "permalink: test/test-note-1" in result1
|
||||
|
||||
# Test with kebab enabled
|
||||
ConfigManager().config.kebab_filenames = True
|
||||
|
||||
result2 = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note 2",
|
||||
folder="test",
|
||||
content="Testing permalink consistency",
|
||||
)
|
||||
|
||||
# Both filename and permalink are kebab-case
|
||||
assert "file_path: test/test-note-2.md" in result2
|
||||
assert "permalink: test/test-note-2" in result2
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user