Skill: gianni-dalerta/memory-tools (version 2.0.4)

--- name: memory-tools description: Agent-controlled memory plugin for OpenClaw with confidence scoring, decay, and semantic search. The agent decides WHEN to store/retrieve memories — no auto-capture noise. v2 uses file-based storage with optional QMD search (no external APIs required). homepage: https://github.com/Purple-Horizons/memory-tools metadata: openclaw: emoji: 🧠 kind: plugin --- # Memory Tools v2 Agent-controlled persistent memory for OpenClaw. ## What's New in v2 - **File-based storage** — Memories stored as readable markdown files - **No external APIs** — No OpenAI dependency, everything runs locally - **QMD search (optional)** — BM25 + vector + reranking via local GGUF models - **Auto-migration** — Seamlessly upgrades from v1 SQLite/LanceDB storage ## Why Memory-as-Tools? Traditional memory systems auto-capture everything, flooding context with irrelevant information. Memory Tools follows the [AgeMem](https://arxiv.org/abs/2409.02634) approach: **the agent decides** when to store and retrieve memories. ## Features - **6 Memory Tools**: `memory_store`, `memory_update`, `memory_forget`, `memory_search`, `memory_summarize`, `memory_list` - **Confidence Scoring**: Track how certain you are (1.0 = explicit, 0.5 = inferred) - **Importance Scoring**: Prioritize critical instructions over nice-to-know facts - **Decay/Expiration**: Temporal memories automatically become stale - **Human-readable storage**: Markdown files with YAML frontmatter - **Conflict Resolution**: New info auto-supersedes old (no contradictions) ## Installation ### Step 1: Install from ClawHub ```bash clawhub install memory-tools ``` ### Step 2: Build the plugin ```bash cd skills/memory-tools npm install npm run build ``` ### Step 3: Activate the plugin ```bash openclaw plugins install --link . openclaw plugins enable memory-tools ``` ### Step 4: Restart the gateway ```bash openclaw gateway restart ``` ### Optional: Install QMD for better search ```bash npm install -g @tobilu/qmd ``` Without QMD, basic filtering works. With QMD, you get semantic search (BM25 + vector + reranking). ### Node Compatibility (QMD) - QMD may not be compatible with the newest Node ABI immediately. - If QMD fails (for example `NODE_MODULE_VERSION` mismatch), memory-tools falls back to basic mode. - To force basic mode: `MEMORY_TOOLS_DISABLE_QMD=true`. - For best QMD stability, use a Node LTS version supported by your installed QMD build. ## Security Model - No API keys or external credentials are required. - Data is stored locally in `~/.openclaw/memories` (or configured `memoriesPath`). - The plugin can prepend standing-instruction context at `before_agent_start` when `autoInjectInstructions=true`. - The plugin can auto-migrate legacy v1 data from `~/.openclaw/memory/tools/memory.db` when `autoMigrateLegacy=true`. - Defaults are conservative: both `autoInjectInstructions` and `autoMigrateLegacy` are `false`. ## Storage Format Memories are stored as markdown files in `~/.openclaw/memories/`: ``` ~/.openclaw/memories/ ├── facts/ │ └── abc123-def4-5678-90ab-cdef12345678.md ├── preferences/ │ └── def456-7890-abcd-ef12-345678901234.md ├── instructions/ │ └── ghi789-0abc-def1-2345-678901234567.md └── .deleted/ └── old-memory.md ``` Each memory file: ```markdown --- id: abc123-def4-5678-90ab-cdef12345678 category: preference confidence: 0.9 importance: 0.7 created_at: 2024-01-15T10:30:00Z tags: [ui, settings] --- User prefers dark mode in all applications. ``` ## Memory Categories | Category | Use For | Example | |----------|---------|---------| | fact | Static information | "User's dog is named Rex" | | preference | Likes/dislikes | "User prefers dark mode" | | event | Temporal things | "Dentist Tuesday 3pm" | | relationship | People connections | "Sarah is user's wife" | | instruction | Standing orders | "Always respond in Spanish" | | decision | Choices made | "We decided to use PostgreSQL" | | context | Situational info | "User is job hunting" | | entity | Named things | "Project Apollo is their startup" | ## Tool Reference ### memory_store ``` memory_store({ content: "User prefers bullet points", category: "preference", confidence: 0.9, importance: 0.7, tags: ["formatting", "communication"] }) ``` ### memory_search ``` memory_search({ query: "formatting preferences", category: "preference", limit: 10 }) ``` ### memory_update ``` memory_update({ id: "abc123", content: "User now prefers numbered lists", confidence: 1.0 }) ``` ### memory_forget ``` memory_forget({ query: "bullet points", reason: "User corrected preference" }) ``` ### memory_summarize ``` memory_summarize({ topic: "user's work projects", maxMemories: 20 }) ``` ### memory_list ``` memory_list({ category: "instruction", sortBy: "importance", limit: 20 }) ``` ## CLI Commands ```bash # Show memory statistics openclaw memory-tools stats # List memories openclaw memory-tools list openclaw memory-tools list --category fact # Search memories (requires QMD) openclaw memory-tools search "dark mode" # Export all memories as JSON openclaw memory-tools export # Force re-index with QMD openclaw memory-tools reindex # Show storage path openclaw memory-tools path ``` ## Debugging Memories are plain markdown files — just read them: ```bash cat ~/.openclaw/memories/facts/*.md ls ~/.openclaw/memories/ ``` Export all memories: ```bash openclaw memory-tools export > memories.json ``` ## Migration from v1 v2 automatically detects v1 databases and migrates them: 1. On startup, v2 checks for `~/.openclaw/memory/tools/memory.db` 2. If found, exports all memories to markdown files 3. Original database preserved as backup 4. No manual action required To enable auto-migration, set `autoMigrateLegacy: true` in plugin config. ## License MIT — [Purple Horizons](https://github.com/Purple-Horizons)

path: CLAUDE.md
content: # CLAUDE.md

Project context for Claude Code.

## Project

**memory-tools v2** - Agent-controlled memory plugin for OpenClaw/ClawHub.

- **Repo**: `Purple-Horizons/memory-tools`
- **ClawHub slug**: `memory-tools`
- **Owner**: gianni-dalerta

## Architecture (v2)

- **File-based storage** - Memories stored as markdown files with YAML frontmatter
- **QMD search** - Optional BM25 + vector + reranking via local GGUF models
- **No external APIs** - No OpenAI dependency, everything runs locally
- **Auto-migration** - Seamlessly upgrades from v1 SQLite/LanceDB storage

### Storage Structure
```
~/.openclaw/memories/
├── facts/
│   └── abc123.md
├── preferences/
│   └── def456.md
├── instructions/
│   └── ghi789.md
└── .deleted/
    └── old-memory.md
```

### Memory File Format
```markdown
---
id: abc123-def4-5678-90ab-cdef12345678
category: preference
confidence: 0.9
importance: 0.7
created_at: 2024-01-15T10:30:00Z
tags: [ui, settings]
---

User prefers dark mode in all applications.
```

## Key Decisions

- **v2 rewrote storage layer**: Switched from SQLite + LanceDB + OpenAI to file-based + QMD
- **QMD is optional**: Works without QMD (basic file listing), better search with QMD installed
- **Auto-migration**: v2 detects v1 databases and automatically exports to markdown files
- **Backward compatible API**: Tools interface unchanged, existing integrations work

## QMD Integration

QMD provides hybrid search (BM25 + vector + reranking) using local GGUF models:
- `npm install -g @tobilu/qmd` to install
- Models download on first use (~2GB total)
- Without QMD: basic category/tag filtering only
- With QMD: semantic search across all memories

## Publishing to ClawHub

```bash
clawhub publish . --slug memory-tools --version X.Y.Z --changelog "description"
```

Always use `--slug memory-tools` to update the correct skill.

## Build & Test

```bash
npm run build    # TypeScript compile
npm test         # Run vitest (uses MEMORY_TOOLS_DISABLE_QMD=true in CI)
```

## Environment Variables

- `MEMORY_TOOLS_DISABLE_QMD=true` - Disable QMD for testing


path: README.md
content: <p align="center">
  <img src="header.png" alt="memory-tools" width="100%">
</p>

# OpenClaw Memory Tools v2

Agent-controlled memory plugin for OpenClaw with confidence scoring, decay, and semantic search.

## What's New in v2

- **File-based storage** - Memories stored as readable markdown files
- **No external APIs** - No OpenAI dependency, everything runs locally
- **QMD search (optional)** - BM25 + vector + reranking via local GGUF models
- **Auto-migration** - Seamlessly upgrades from v1 SQLite/LanceDB storage

## Why Memory-as-Tools?

Traditional AI memory systems auto-capture everything, flooding context with irrelevant information. **Memory-as-Tools** follows the [AgeMem](https://arxiv.org/abs/2409.02634) approach: the agent decides **when** to store and retrieve memories.

```
Traditional: Agent -> always retrieves -> context flooded
Memory-as-Tools: Agent -> decides IF/WHAT to remember -> uses tools explicitly
```

## Features

- **6 Memory Tools**: `memory_store`, `memory_update`, `memory_forget`, `memory_search`, `memory_summarize`, `memory_list`
- **Confidence Scoring**: Track how certain you are about each memory (1.0 = explicit, 0.5 = inferred)
- **Importance Scoring**: Prioritize critical instructions over nice-to-know facts
- **Decay/Expiration**: Temporal memories (events) automatically become stale
- **Human-readable storage**: Markdown files with YAML frontmatter
- **Semantic Search**: Optional QMD-powered hybrid search (BM25 + vector + reranking)
- **Zero External Dependencies**: Everything runs locally, no API keys needed
- **Standing Instructions**: Auto-inject category="instruction" memories at conversation start

## Installation

### Step 1: Install from ClawHub

```bash
clawhub install memory-tools
```

### Step 2: Build the plugin

```bash
cd skills/memory-tools
npm install
npm run build
```

### Step 3: Activate the plugin

```bash
openclaw plugins install --link .
openclaw plugins enable memory-tools
```

### Step 4: Restart the gateway

```bash
openclaw gateway restart
```

### Optional: Install QMD for better search

```bash
npm install -g @tobilu/qmd
```

Without QMD, basic category/tag filtering works. With QMD, you get semantic search (BM25 + vector + reranking using local GGUF models).

### Node Compatibility (QMD)

- `memory-tools` works without QMD on modern Node versions.
- QMD dependencies (notably `better-sqlite3`) may lag behind the latest Node ABI.
- If QMD fails on startup (for example `NODE_MODULE_VERSION` mismatch on Node v25), the plugin now falls back to basic mode.
- To force basic mode explicitly, set `MEMORY_TOOLS_DISABLE_QMD=true`.
- Recommended for full QMD stability: use a Node LTS version supported by your installed QMD build.

## Configuration

Add to `~/.openclaw/openclaw.json`:

```json
{
  "plugins": {
    "slots": {
      "memory": "memory-tools"
    },
    "entries": {
      "memory-tools": {
        "enabled": true,
        "config": {
          "memoriesPath": "~/.openclaw/memories",
          "autoInjectInstructions": false,
          "autoMigrateLegacy": false
        }
      }
    }
  },
  "tools": {
    "alsoAllow": ["group:plugins"]
  }
}
```

### Security Model

- No API keys or external credentials are required by this plugin.
- Data is stored locally in `~/.openclaw/memories` (or your configured `memoriesPath`).
- The plugin can prepend standing-instruction context at `before_agent_start` only when `autoInjectInstructions=true`.
- The plugin can auto-migrate legacy v1 data from `~/.openclaw/memory/tools/memory.db` only when `autoMigrateLegacy=true`.
- Defaults are conservative: both `autoInjectInstructions` and `autoMigrateLegacy` are `false`.

### Verify Installation

```bash
# Restart gateway
openclaw gateway stop && openclaw gateway run

# Check plugin loaded
openclaw plugins list

# Test CLI
openclaw memory-tools stats
```

## Storage Format

Memories are stored as markdown files in `~/.openclaw/memories/`:

```
~/.openclaw/memories/
├── facts/
│   └── abc123-def4-5678-90ab-cdef12345678.md
├── preferences/
│   └── def456-7890-abcd-ef12-345678901234.md
├── instructions/
│   └── ghi789-0abc-def1-2345-678901234567.md
└── .deleted/
    └── old-memory.md
```

Each memory file contains YAML frontmatter with metadata:

```markdown
---
id: abc123-def4-5678-90ab-cdef12345678
category: preference
confidence: 0.9
importance: 0.7
created_at: 2024-01-15T10:30:00Z
tags: [ui, settings]
---

User prefers dark mode in all applications.
```

## Memory Categories

| Category | Use For | Example |
|----------|---------|---------|
| `fact` | Static information | "User's dog is named Rex" |
| `preference` | Likes/dislikes | "User prefers dark mode" |
| `event` | Temporal things | "Dentist appointment Tuesday 3pm" |
| `relationship` | People connections | "User's sister is Sarah" |
| `context` | Current work | "Working on React project" |
| `instruction` | Standing orders | "Always respond in Spanish" |
| `decision` | Choices made | "We decided to use PostgreSQL" |
| `entity` | Contact info | "User's email is [REDACTED_SECRET]" |

## Tool Reference

### memory_store

Store a new memory.

```typescript
memory_store({
  content: "User prefers bullet points",
  category: "preference",
  confidence: 0.9,      // How sure (0-1)
  importance: 0.7,      // How critical (0-1)
  decayDays: null,      // null = permanent
  tags: ["formatting"]
})
```

### memory_update

Update an existing memory.

```typescript
memory_update({
  id: "abc-123",
  content: "User prefers numbered lists",  // Optional
  confidence: 0.95                          // Optional
})
```

### memory_forget

Delete a memory.

```typescript
memory_forget({
  id: "abc-123",           // If known
  query: "bullet points",  // Or search
  reason: "User corrected"
})
```

### memory_search

Semantic search (uses QMD if installed, otherwise basic filtering).

```typescript
memory_search({
  query: "formatting preferences",
  category: "preference",      // Optional filter
  minConfidence: 0.7,          // Optional filter
  limit: 10
})
```

### memory_summarize

Get topic summary.

```typescript
memory_summarize({
  topic: "user's work",
  maxMemories: 20
})
```

### memory_list

Browse all memories.

```typescript
memory_list({
  category: "instruction",
  sortBy: "importance",
  limit: 20
})
```

## CLI Commands

```bash
# Show statistics
openclaw memory-tools stats

# List memories
openclaw memory-tools list --category preference

# Search memories (uses QMD if installed)
openclaw memory-tools search "dark mode"

# Export all memories as JSON
openclaw memory-tools export

# Force re-index with QMD
openclaw memory-tools reindex

# Show storage path
openclaw memory-tools path
```

## Debugging

Memories are plain markdown files - just read them:

```bash
cat ~/.openclaw/memories/facts/*.md
ls ~/.openclaw/memories/
```

Export all memories:

```bash
openclaw memory-tools export > memories.json
```

## Architecture

```
┌─────────────────────────────────────────────────────────┐
│                    OpenClaw Agent                       │
│                                                         │
│  Agent decides: "This is worth remembering"             │
│         ↓                                               │
│  Calls: memory_store(...)                               │
└─────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────┐
│                   Memory Tools                          │
├─────────────────────────────────────────────────────────┤
│  store │ update │ forget │ search │ summarize │ list   │
└─────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────┐
│                 Storage Layer                           │
│  ┌──────────────────────────────────────────────────┐  │
│  │           Markdown Files (YAML frontmatter)       │  │
│  │           ~/.openclaw/memories/{category}/*.md    │  │
│  └──────────────────────────────────────────────────┘  │
│                          ↓                              │
│  ┌──────────────────────────────────────────────────┐  │
│  │        QMD Search (optional)                      │  │
│  │        BM25 + Vector + Reranking                  │  │
│  └──────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────┘
```

## Migration from v1

v2 automatically detects v1 databases and migrates them:

1. On startup, v2 checks for `~/.openclaw/memory/tools/memory.db`
2. If found, exports all memories to markdown files
3. Original database preserved as backup
4. No manual action required

To enable auto-migration, set `"autoMigrateLegacy": true` in plugin config.

## Development

```bash
# Install dependencies
npm install

# Run tests
npm test

# Run tests with coverage
npm test:coverage

# Type check
npm run typecheck

# Build
npm run build
```

### Environment Variables

- `MEMORY_TOOLS_DISABLE_QMD=true` - Disable QMD for testing

No environment variables are required for credentials.

## Comparison with Other Memory Systems

| Feature | [memU](https://github.com/NevaMind-AI/memU) | [claude-mem](https://github.com/thedotmack/claude-mem) | **memory-tools** |
|---------|------|------------|--------------|
| **Architecture** | 3-tier hierarchical (Resource -> Item -> Category) | Hook-based observer with lifecycle events | Tool-based agent control |
| **Storage Trigger** | Automatic extraction during background processing | Lifecycle hooks (SessionStart, PostToolUse, etc.) | Agent explicitly decides when to store |
| **Storage Backend** | Custom hierarchical | Custom | Markdown files + optional QMD |
| **External APIs** | Varies | Varies | None required |
| **Conflict Handling** | None - relies on proactive pattern detection | None - auto-capture model | Auto-supersede + explicit forget |
| **Context Injection** | Proactive - predicts and pre-loads context | Progressive disclosure (3-layer filtering) | On-demand via memory_search |
| **Token Efficiency** | Compression via fact extraction | ~10x savings via progressive disclosure | Semantic search with configurable limits |
| **Auditability** | Background processing | Hook-based capture | Human-readable markdown files |
| **User Corrections** | Accumulates conflicting facts | Accumulates conflicting facts | Replaces old with new automatically |
| **Best For** | 24/7 agents with predictable patterns | Automatic session continuity | Personal assistants with ongoing relationships |

### Design Philosophy

Different memory systems optimize for different things:

- **Automatic systems** (memU, claude-mem) minimize agent cognitive load by extracting memories in the background. Trade-off: less control over what's captured, conflicts accumulate.

- **Agent-controlled systems** (memory-tools) put the agent in charge of what matters. Trade-off: requires active management, but memories are deliberate choices.

For agents that maintain ongoing relationships with users - where someone might say "no, my favorite color is purple, not blue" - explicit conflict handling prevents contradictory memories from accumulating. Every memory has a clear provenance: the agent decided it was worth remembering, and corrections replace rather than compete with old information.

The file-based storage means you can always `ls ~/.openclaw/memories/` and read exactly what your agent knows. Each memory is a plain markdown file you can edit, delete, or backup.

## References

- [AgeMem Paper](https://arxiv.org/abs/2409.02634) - Memory operations as first-class tools
- [QMD](https://github.com/tobilu/qmd) - Local hybrid search with GGUF models
- [memU](https://github.com/NevaMind-AI/memU) - Hierarchical memory with proactive context
- [claude-mem](https://github.com/thedotmack/claude-mem) - Hook-based automatic memory
- [Mem0](https://github.com/mem0ai/mem0) - AI memory layer
- [OpenClaw](https://github.com/openclaw/openclaw) - Personal AI assistant

## License

MIT - [Purple Horizons](https://github.com/Purple-Horizons)


path: clawdbot.plugin.json
content: {
  "id": "memory-tools",
  "kind": "memory",
  "name": "Memory Tools",
  "description": "Agent-controlled memory with file-based storage and optional QMD search. No external APIs required.",
  "version": "2.0.4",
  "uiHints": {
    "memoriesPath": {
      "label": "Memories Path",
      "placeholder": "~/.clawdbot/memories",
      "advanced": true,
      "help": "Directory for storing memory files"
    },
    "autoInjectInstructions": {
      "label": "Auto-Inject Instructions",
      "help": "Inject standing instructions (category='instruction') at conversation start"
    },
    "autoMigrateLegacy": {
      "label": "Auto-Migrate Legacy DB",
      "advanced": true,
      "help": "Automatically migrate v1 database to markdown files on startup"
    },
    "qmdCollection": {
      "label": "QMD Collection",
      "advanced": true,
      "help": "QMD collection name for semantic search index"
    }
  },
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "memoriesPath": {
        "type": "string",
        "description": "Directory for storing memory markdown files"
      },
      "autoInjectInstructions": {
        "type": "boolean",
        "default": false
      },
      "autoMigrateLegacy": {
        "type": "boolean",
        "default": false
      },
      "qmdCollection": {
        "type": "string",
        "default": "memories"
      }
    }
  }
}


path: openclaw.plugin.json
content: {
  "id": "memory-tools",
  "kind": "memory",
  "name": "Memory Tools",
  "description": "Agent-controlled memory with file-based storage and optional QMD search. No external APIs required.",
  "version": "2.0.4",
  "uiHints": {
    "memoriesPath": {
      "label": "Memories Path",
      "placeholder": "~/.openclaw/memories",
      "advanced": true,
      "help": "Directory for storing memory files"
    },
    "autoInjectInstructions": {
      "label": "Auto-Inject Instructions",
      "help": "Inject standing instructions (category='instruction') at conversation start"
    },
    "autoMigrateLegacy": {
      "label": "Auto-Migrate Legacy DB",
      "advanced": true,
      "help": "Automatically migrate v1 database to markdown files on startup"
    },
    "qmdCollection": {
      "label": "QMD Collection",
      "advanced": true,
      "help": "QMD collection name for semantic search index"
    }
  },
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "memoriesPath": {
        "type": "string",
        "description": "Directory for storing memory markdown files"
      },
      "autoInjectInstructions": {
        "type": "boolean",
        "default": false
      },
      "autoMigrateLegacy": {
        "type": "boolean",
        "default": false
      },
      "qmdCollection": {
        "type": "string",
        "default": "memories"
      }
    }
  }
}


path: package-lock.json
content: {
  "name": "memory-tools",
  "version": "2.0.4",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "memory-tools",
      "version": "2.0.4",
      "license": "MIT",
      "dependencies": {
        "@sinclair/typebox": "0.34.48",
        "sql.js": "^1.13.0"
      },
      "devDependencies": {
        "@types/node": "^22.0.0",
        "@types/sql.js": "^1.4.9",
        "@vitest/coverage-v8": "^2.1.0",
        "typescript": "^5.7.0",
        "vitest": "^2.1.0"
      },
      "peerDependencies": {
        "openclaw": ">=2026.0.0"
      },
      "peerDependenciesMeta": {
        "openclaw": {
          "optional": true
        }
      }
    },
    "node_modules/@ampproject/remapping": {
      "version": "2.3.0",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@jridgewell/gen-mapping": "^0.3.5",
        "@jridgewell/trace-mapping": "^0.3.24"
      },
      "engines": {
        "node": ">=6.0.0"
      }
    },
    "node_modules/@babel/helper-string-parser": {
      "version": "7.27.1",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helper-validator-identifier": {
      "version": "7.28.5",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/parser": {
      "version": "7.29.0",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/types": "^7.29.0"
      },
      "bin": {
        "parser": "bin/babel-parser.js"
      },
      "engines": {
        "node": ">=6.0.0"
      }
    },
    "node_modules/@babel/types": {
      "version": "7.29.0",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/helper-string-parser": "^7.27.1",
        "@babel/helper-validator-identifier": "^7.28.5"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@bcoe/v8-coverage": {
      "version": "0.2.3",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@esbuild/darwin-arm64": {
      "version": "0.21.5",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/@isaacs/cliui": {
      "version": "8.0.2",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "string-width": "^5.1.2",
        "string-width-cjs": "npm:string-width@^4.2.0",
        "strip-ansi": "^7.0.1",
        "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
        "wrap-ansi": "^8.1.0",
        "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
      },
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/@istanbuljs/schema": {
      "version": "0.1.3",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/@jridgewell/gen-mapping": {
      "version": "0.3.13",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@jridgewell/sourcemap-codec": "^1.5.0",
        "@jridgewell/trace-mapping": "^0.3.24"
      }
    },
    "node_modules/@jridgewell/resolve-uri": {
      "version": "3.1.2",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.0.0"
      }
    },
    "node_modules/@jridgewell/sourcemap-codec": {
      "version": "1.5.5",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@jridgewell/trace-mapping": {
      "version": "0.3.31",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@jridgewell/resolve-uri": "^3.1.0",
        "@jridgewell/sourcemap-codec": "^1.4.14"
      }
    },
    "node_modules/@pkgjs/parseargs": {
      "version": "0.11.0",
      "dev": true,
      "license": "MIT",
      "optional": true,
      "engines": {
        "node": ">=14"
      }
    },
    "node_modules/@rollup/rollup-darwin-arm64": {
      "version": "4.57.1",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ]
    },
    "node_modules/@sinclair/typebox": {
      "version": "0.34.48",
      "license": "MIT"
    },
    "node_modules/@types/emscripten": {
      "version": "1.41.5",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@types/estree": {
      "version": "1.0.8",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@types/node": {
      "version": "22.19.10",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "undici-types": "~6.21.0"
      }
    },
    "node_modules/@types/sql.js": {
      "version": "1.4.9",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@types/emscripten": "*",
        "@types/node": "*"
      }
    },
    "node_modules/@vitest/coverage-v8": {
      "version": "2.1.9",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@ampproject/remapping": "^2.3.0",
        "@bcoe/v8-coverage": "^0.2.3",
        "debug": "^4.3.7",
        "istanbul-lib-coverage": "^3.2.2",
        "istanbul-lib-report": "^3.0.1",
        "istanbul-lib-source-maps": "^5.0.6",
        "istanbul-reports": "^3.1.7",
        "magic-string": "^0.30.12",
        "magicast": "^0.3.5",
        "std-env": "^3.8.0",
        "test-exclude": "^7.0.1",
        "tinyrainbow": "^1.2.0"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      },
      "peerDependencies": {
        "@vitest/browser": "2.1.9",
        "vitest": "2.1.9"
      },
      "peerDependenciesMeta": {
        "@vitest/browser": {
          "optional": true
        }
      }
    },
    "node_modules/@vitest/expect": {
      "version": "2.1.9",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@vitest/spy": "2.1.9",
        "@vitest/utils": "2.1.9",
        "chai": "^5.1.2",
        "tinyrainbow": "^1.2.0"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      }
    },
    "node_modules/@vitest/mocker": {
      "version": "2.1.9",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@vitest/spy": "2.1.9",
        "estree-walker": "^3.0.3",
        "magic-string": "^0.30.12"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      },
      "peerDependencies": {
        "msw": "^2.4.9",
        "vite": "^5.0.0"
      },
      "peerDependenciesMeta": {
        "msw": {
          "optional": true
        },
        "vite": {
          "optional": true
        }
      }
    },
    "node_modules/@vitest/pretty-format": {
      "version": "2.1.9",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "tinyrainbow": "^1.2.0"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      }
    },
    "node_modules/@vitest/runner": {
      "version": "2.1.9",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@vitest/utils": "2.1.9",
        "pathe": "^1.1.2"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      }
    },
    "node_modules/@vitest/snapshot": {
      "version": "2.1.9",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@vitest/pretty-format": "2.1.9",
        "magic-string": "^0.30.12",
        "pathe": "^1.1.2"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      }
    },
    "node_modules/@vitest/spy": {
      "version": "2.1.9",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "tinyspy": "^3.0.2"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      }
    },
    "node_modules/@vitest/utils": {
      "version": "2.1.9",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@vitest/pretty-format": "2.1.9",
        "loupe": "^3.1.2",
        "tinyrainbow": "^1.2.0"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      }
    },
    "node_modules/ansi-regex": {
      "version": "6.2.2",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://github.com/chalk/ansi-regex?sponsor=1"
      }
    },
    "node_modules/ansi-styles": {
      "version": "4.3.0",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "color-convert": "^2.0.1"
      },
      "engines": {
        "node": ">=8"
      },
      "funding": {
        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
      }
    },
    "node_modules/assertion-error": {
      "version": "2.0.1",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/balanced-match": {
      "version": "1.0.2",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/brace-expansion": {
      "version": "2.0.2",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "balanced-match": "^1.0.0"
      }
    },
    "node_modules/cac": {
      "version": "6.7.14",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/chai": {
      "version": "5.3.3",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "assertion-error": "^2.0.1",
        "check-error": "^2.1.1",
        "deep-eql": "^5.0.1",
        "loupe": "^3.1.0",
        "pathval": "^2.0.0"
      },
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/check-error": {
      "version": "2.1.3",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 16"
      }
    },
    "node_modules/color-convert": {
      "version": "2.0.1",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "color-name": "~1.1.4"
      },
      "engines": {
        "node": ">=7.0.0"
      }
    },
    "node_modules/color-name": {
      "version": "1.1.4",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/cross-spawn": {
      "version": "7.0.6",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "path-key": "^3.1.0",
        "shebang-command": "^2.0.0",
        "which": "^2.0.1"
      },
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/debug": {
      "version": "4.4.3",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "ms": "^2.1.3"
      },
      "engines": {
        "node": ">=6.0"
      },
      "peerDependenciesMeta": {
        "supports-color": {
          "optional": true
        }
      }
    },
    "node_modules/deep-eql": {
      "version": "5.0.2",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/eastasianwidth": {
      "version": "0.2.0",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/emoji-regex": {
      "version": "9.2.2",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/es-module-lexer": {
      "version": "1.7.0",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/esbuild": {
      "version": "0.21.5",
      "dev": true,
      "hasInstallScript": true,
      "license": "MIT",
      "bin": {
        "esbuild": "bin/esbuild"
      },
      "engines": {
        "node": ">=12"
      },
      "optionalDependencies": {
        "@esbuild/aix-ppc64": "0.21.5",
        "@esbuild/android-arm": "0.21.5",
        "@esbuild/android-arm64": "0.21.5",
        "@esbuild/android-x64": "0.21.5",
        "@esbuild/darwin-arm64": "0.21.5",
        "@esbuild/darwin-x64": "0.21.5",
        "@esbuild/freebsd-arm64": "0.21.5",
        "@esbuild/freebsd-x64": "0.21.5",
        "@esbuild/linux-arm": "0.21.5",
        "@esbuild/linux-arm64": "0.21.5",
        "@esbuild/linux-ia32": "0.21.5",
        "@esbuild/linux-loong64": "0.21.5",
        "@esbuild/linux-mips64el": "0.21.5",
        "@esbuild/linux-ppc64": "0.21.5",
        "@esbuild/linux-riscv64": "0.21.5",
        "@esbuild/linux-s390x": "0.21.5",
        "@esbuild/linux-x64": "0.21.5",
        "@esbuild/netbsd-x64": "0.21.5",
        "@esbuild/openbsd-x64": "0.21.5",
        "@esbuild/sunos-x64": "0.21.5",
        "@esbuild/win32-arm64": "0.21.5",
        "@esbuild/win32-ia32": "0.21.5",
        "@esbuild/win32-x64": "0.21.5"
      }
    },
    "node_modules/estree-walker": {
      "version": "3.0.3",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@types/estree": "^1.0.0"
      }
    },
    "node_modules/expect-type": {
      "version": "1.3.0",
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": ">=12.0.0"
      }
    },
    "node_modules/foreground-child": {
      "version": "3.3.1",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "cross-spawn": "^7.0.6",
        "signal-exit": "^4.0.1"
      },
      "engines": {
        "node": ">=14"
      },
      "funding": {
        "url": "https://github.com/sponsors/isaacs"
      }
    },
    "node_modules/fsevents": {
      "version": "2.3.3",
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
      }
    },
    "node_modules/glob": {
      "version": "10.5.0",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "foreground-child": "^3.1.0",
        "jackspeak": "^3.1.2",
        "minimatch": "^9.0.4",
        "minipass": "^7.1.2",
        "package-json-from-dist": "^1.0.0",
        "path-scurry": "^1.11.1"
      },
      "bin": {
        "glob": "dist/esm/bin.mjs"
      },
      "funding": {
        "url": "https://github.com/sponsors/isaacs"
      }
    },
    "node_modules/has-flag": {
      "version": "4.0.0",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/html-escaper": {
      "version": "2.0.2",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/is-fullwidth-code-point": {
      "version": "3.0.0",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/isexe": {
      "version": "2.0.0",
      "dev": true,
      "license": "ISC"
    },
    "node_modules/istanbul-lib-coverage": {
      "version": "3.2.2",
      "dev": true,
      "license": "BSD-3-Clause",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/istanbul-lib-report": {
      "version": "3.0.1",
      "dev": true,
      "license": "BSD-3-Clause",
      "dependencies": {
        "istanbul-lib-coverage": "^3.0.0",
        "make-dir": "^4.0.0",
        "supports-color": "^7.1.0"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/istanbul-lib-source-maps": {
      "version": "5.0.6",
      "dev": true,
      "license": "BSD-3-Clause",
      "dependencies": {
        "@jridgewell/trace-mapping": "^0.3.23",
        "debug": "^4.1.1",
        "istanbul-lib-coverage": "^3.0.0"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/istanbul-reports": {
      "version": "3.2.0",
      "dev": true,
      "license": "BSD-3-Clause",
      "dependencies": {
        "html-escaper": "^2.0.0",
        "istanbul-lib-report": "^3.0.0"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/jackspeak": {
      "version": "3.4.3",
      "dev": true,
      "license": "BlueOak-1.0.0",
      "dependencies": {
        "@isaacs/cliui": "^8.0.2"
      },
      "funding": {
        "url": "https://github.com/sponsors/isaacs"
      },
      "optionalDependencies": {
        "@pkgjs/parseargs": "^0.11.0"
      }
    },
    "node_modules/loupe": {
      "version": "3.2.1",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/lru-cache": {
      "version": "10.4.3",
      "dev": true,
      "license": "ISC"
    },
    "node_modules/magic-string": {
      "version": "0.30.21",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@jridgewell/sourcemap-codec": "^1.5.5"
      }
    },
    "node_modules/magicast": {
      "version": "0.3.5",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/parser": "^7.25.4",
        "@babel/types": "^7.25.4",
        "source-map-js": "^1.2.0"
      }
    },
    "node_modules/make-dir": {
      "version": "4.0.0",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "semver": "^7.5.3"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/minimatch": {
      "version": "9.0.5",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "brace-expansion": "^2.0.1"
      },
      "engines": {
        "node": ">=16 || 14 >=14.17"
      },
      "funding": {
        "url": "https://github.com/sponsors/isaacs"
      }
    },
    "node_modules/minipass": {
      "version": "7.1.2",
      "dev": true,
      "license": "ISC",
      "engines": {
        "node": ">=16 || 14 >=14.17"
      }
    },
    "node_modules/ms": {
      "version": "2.1.3",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/nanoid": {
      "version": "3.3.11",
      "dev": true,
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/ai"
        }
      ],
      "license": "MIT",
      "bin": {
        "nanoid": "bin/nanoid.cjs"
      },
      "engines": {
        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
      }
    },
    "node_modules/package-json-from-dist": {
      "version": "1.0.1",
      "dev": true,
      "license": "BlueOak-1.0.0"
    },
    "node_modules/path-key": {
      "version": "3.1.1",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/path-scurry": {
      "version": "1.11.1",
      "dev": true,
      "license": "BlueOak-1.0.0",
      "dependencies": {
        "lru-cache": "^10.2.0",
        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
      },
      "engines": {
        "node": ">=16 || 14 >=14.18"
      },
      "funding": {
        "url": "https://github.com/sponsors/isaacs"
      }
    },
    "node_modules/pathe": {
      "version": "1.1.2",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/pathval": {
      "version": "2.0.1",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 14.16"
      }
    },
    "node_modules/picocolors": {
      "version": "1.1.1",
      "dev": true,
      "license": "ISC"
    },
    "node_modules/postcss": {
      "version": "8.5.6",
      "dev": true,
      "funding": [
        {
          "type": "opencollective",
          "url": "https://opencollective.com/postcss/"
        },
        {
          "type": "tidelift",
          "url": "https://tidelift.com/funding/github/npm/postcss"
        },
        {
          "type": "github",
          "url": "https://github.com/sponsors/ai"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "nanoid": "^3.3.11",
        "picocolors": "^1.1.1",
        "source-map-js": "^1.2.1"
      },
      "engines": {
        "node": "^10 || ^12 || >=14"
      }
    },
    "node_modules/rollup": {
      "version": "4.57.1",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@types/estree": "1.0.8"
      },
      "bin": {
        "rollup": "dist/bin/rollup"
      },
      "engines": {
        "node": ">=18.0.0",
        "npm": ">=8.0.0"
      },
      "optionalDependencies": {
        "@rollup/rollup-android-arm-eabi": "4.57.1",
        "@rollup/rollup-android-arm64": "4.57.1",
        "@rollup/rollup-darwin-arm64": "4.57.1",
        "@rollup/rollup-darwin-x64": "4.57.1",
        "@rollup/rollup-freebsd-arm64": "4.57.1",
        "@rollup/rollup-freebsd-x64": "4.57.1",
        "@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
        "@rollup/rollup-linux-arm-musleabihf": "4.57.1",
        "@rollup/rollup-linux-arm64-gnu": "4.57.1",
        "@rollup/rollup-linux-arm64-musl": "4.57.1",
        "@rollup/rollup-linux-loong64-gnu": "4.57.1",
        "@rollup/rollup-linux-loong64-musl": "4.57.1",
        "@rollup/rollup-linux-ppc64-gnu": "4.57.1",
        "@rollup/rollup-linux-ppc64-musl": "4.57.1",
        "@rollup/rollup-linux-riscv64-gnu": "4.57.1",
        "@rollup/rollup-linux-riscv64-musl": "4.57.1",
        "@rollup/rollup-linux-s390x-gnu": "4.57.1",
        "@rollup/rollup-linux-x64-gnu": "4.57.1",
        "@rollup/rollup-linux-x64-musl": "4.57.1",
        "@rollup/rollup-openbsd-x64": "4.57.1",
        "@rollup/rollup-openharmony-arm64": "4.57.1",
        "@rollup/rollup-win32-arm64-msvc": "4.57.1",
        "@rollup/rollup-win32-ia32-msvc": "4.57.1",
        "@rollup/rollup-win32-x64-gnu": "4.57.1",
        "@rollup/rollup-win32-x64-msvc": "4.57.1",
        "fsevents": "~2.3.2"
      }
    },
    "node_modules/semver": {
      "version": "7.7.4",
      "dev": true,
      "license": "ISC",
      "bin": {
        "semver": "bin/semver.js"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/shebang-command": {
      "version": "2.0.0",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "shebang-regex": "^3.0.0"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/shebang-regex": {
      "version": "3.0.0",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/siginfo": {
      "version": "2.0.0",
      "dev": true,
      "license": "ISC"
    },
    "node_modules/signal-exit": {
      "version": "4.1.0",
      "dev": true,
      "license": "ISC",
      "engines": {
        "node": ">=14"
      },
      "funding": {
        "url": "https://github.com/sponsors/isaacs"
      }
    },
    "node_modules/source-map-js": {
      "version": "1.2.1",
      "dev": true,
      "license": "BSD-3-Clause",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/sql.js": {
      "version": "1.13.0",
      "license": "MIT"
    },
    "node_modules/stackback": {
      "version": "0.0.2",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/std-env": {
      "version": "3.10.0",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/string-width": {
      "version": "5.1.2",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "eastasianwidth": "^0.2.0",
        "emoji-regex": "^9.2.2",
        "strip-ansi": "^7.0.1"
      },
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/string-width-cjs": {
      "name": "string-width",
      "version": "4.2.3",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "emoji-regex": "^8.0.0",
        "is-fullwidth-code-point": "^3.0.0",
        "strip-ansi": "^6.0.1"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/string-width-cjs/node_modules/ansi-regex": {
      "version": "5.0.1",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/string-width-cjs/node_modules/emoji-regex": {
      "version": "8.0.0",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/string-width-cjs/node_modules/strip-ansi": {
      "version": "6.0.1",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "ansi-regex": "^5.0.1"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/strip-ansi": {
      "version": "7.1.2",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "ansi-regex": "^6.0.1"
      },
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://github.com/chalk/strip-ansi?sponsor=1"
      }
    },
    "node_modules/strip-ansi-cjs": {
      "name": "strip-ansi",
      "version": "6.0.1",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "ansi-regex": "^5.0.1"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
      "version": "5.0.1",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/supports-color": {
      "version": "7.2.0",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "has-flag": "^4.0.0"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/test-exclude": {
      "version": "7.0.1",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "@istanbuljs/schema": "^0.1.2",
        "glob": "^10.4.1",
        "minimatch": "^9.0.4"
      },
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/tinybench": {
      "version": "2.9.0",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/tinyexec": {
      "version": "0.3.2",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/tinypool": {
      "version": "1.1.1",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": "^18.0.0 || >=20.0.0"
      }
    },
    "node_modules/tinyrainbow": {
      "version": "1.2.0",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/tinyspy": {
      "version": "3.0.2",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/typescript": {
      "version": "5.9.3",
      "dev": true,
      "license": "Apache-2.0",
      "bin": {
        "tsc": "bin/tsc",
        "tsserver": "bin/tsserver"
      },
      "engines": {
        "node": ">=14.17"
      }
    },
    "node_modules/undici-types": {
      "version": "6.21.0",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/vite": {
      "version": "5.4.21",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "esbuild": "^0.21.3",
        "postcss": "^8.4.43",
        "rollup": "^4.20.0"
      },
      "bin": {
        "vite": "bin/vite.js"
      },
      "engines": {
        "node": "^18.0.0 || >=20.0.0"
      },
      "funding": {
        "url": "https://github.com/vitejs/vite?sponsor=1"
      },
      "optionalDependencies": {
        "fsevents": "~2.3.3"
      },
      "peerDependencies": {
        "@types/node": "^18.0.0 || >=20.0.0",
        "less": "*",
        "lightningcss": "^1.21.0",
        "sass": "*",
        "sass-embedded": "*",
        "stylus": "*",
        "sugarss": "*",
        "terser": "^5.4.0"
      },
      "peerDependenciesMeta": {
        "@types/node": {
          "optional": true
        },
        "less": {
          "optional": true
        },
        "lightningcss": {
          "optional": true
        },
        "sass": {
          "optional": true
        },
        "sass-embedded": {
          "optional": true
        },
        "stylus": {
          "optional": true
        },
        "sugarss": {
          "optional": true
        },
        "terser": {
          "optional": true
        }
      }
    },
    "node_modules/vite-node": {
      "version": "2.1.9",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "cac": "^6.7.14",
        "debug": "^4.3.7",
        "es-module-lexer": "^1.5.4",
        "pathe": "^1.1.2",
        "vite": "^5.0.0"
      },
      "bin": {
        "vite-node": "vite-node.mjs"
      },
      "engines": {
        "node": "^18.0.0 || >=20.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      }
    },
    "node_modules/vitest": {
      "version": "2.1.9",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@vitest/expect": "2.1.9",
        "@vitest/mocker": "2.1.9",
        "@vitest/pretty-format": "^2.1.9",
        "@vitest/runner": "2.1.9",
        "@vitest/snapshot": "2.1.9",
        "@vitest/spy": "2.1.9",
        "@vitest/utils": "2.1.9",
        "chai": "^5.1.2",
        "debug": "^4.3.7",
        "expect-type": "^1.1.0",
        "magic-string": "^0.30.12",
        "pathe": "^1.1.2",
        "std-env": "^3.8.0",
        "tinybench": "^2.9.0",
        "tinyexec": "^0.3.1",
        "tinypool": "^1.0.1",
        "tinyrainbow": "^1.2.0",
        "vite": "^5.0.0",
        "vite-node": "2.1.9",
        "why-is-node-running": "^2.3.0"
      },
      "bin": {
        "vitest": "vitest.mjs"
      },
      "engines": {
        "node": "^18.0.0 || >=20.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      },
      "peerDependencies": {
        "@edge-runtime/vm": "*",
        "@types/node": "^18.0.0 || >=20.0.0",
        "@vitest/browser": "2.1.9",
        "@vitest/ui": "2.1.9",
        "happy-dom": "*",
        "jsdom": "*"
      },
      "peerDependenciesMeta": {
        "@edge-runtime/vm": {
          "optional": true
        },
        "@types/node": {
          "optional": true
        },
        "@vitest/browser": {
          "optional": true
        },
        "@vitest/ui": {
          "optional": true
        },
        "happy-dom": {
          "optional": true
        },
        "jsdom": {
          "optional": true
        }
      }
    },
    "node_modules/which": {
      "version": "2.0.2",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "isexe": "^2.0.0"
      },
      "bin": {
        "node-which": "bin/node-which"
      },
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/why-is-node-running": {
      "version": "2.3.0",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "siginfo": "^2.0.0",
        "stackback": "0.0.2"
      },
      "bin": {
        "why-is-node-running": "cli.js"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/wrap-ansi": {
      "version": "8.1.0",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "ansi-styles": "^6.1.0",
        "string-width": "^5.0.1",
        "strip-ansi": "^7.0.1"
      },
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
      }
    },
    "node_modules/wrap-ansi-cjs": {
      "name": "wrap-ansi",
      "version": "7.0.0",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "ansi-styles": "^4.0.0",
        "string-width": "^4.1.0",
        "strip-ansi": "^6.0.0"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
      }
    },
    "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
      "version": "5.0.1",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
      "version": "8.0.0",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/wrap-ansi-cjs/node_modules/string-width": {
      "version": "4.2.3",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "emoji-regex": "^8.0.0",
        "is-fullwidth-code-point": "^3.0.0",
        "strip-ansi": "^6.0.1"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
      "version": "6.0.1",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "ansi-regex": "^5.0.1"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/wrap-ansi/node_modules/ansi-styles": {
      "version": "6.2.3",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
      }
    }
  }
}


path: package.json
content: {
  "name": "memory-tools",
  "version": "2.0.4",
  "description": "Memory-as-Tools plugin for OpenClaw - Agent-controlled persistent memory with QMD-powered search",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "clean": "rm -rf dist",
    "build": "npm run clean && tsc",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "lint": "oxlint",
    "typecheck": "tsc --noEmit",
    "verify:release": "node scripts/verify-release.mjs",
    "prepublishOnly": "npm run build && npm run verify:release"
  },
  "dependencies": {
    "@sinclair/typebox": "0.34.48",
    "sql.js": "^1.13.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "@types/sql.js": "^1.4.9",
    "@vitest/coverage-v8": "^2.1.0",
    "typescript": "^5.7.0",
    "vitest": "^2.1.0"
  },
  "peerDependencies": {
    "openclaw": ">=2026.0.0"
  },
  "peerDependenciesMeta": {
    "openclaw": {
      "optional": true
    }
  },
  "openclaw": {
    "extensions": [
      "./dist/index.js"
    ]
  },
  "files": [
    "dist",
    "README.md"
  ],
  "keywords": [
    "openclaw",
    "memory",
    "ai-agent",
    "plugin",
    "vector-search",
    "qmd",
    "local-llm"
  ],
  "author": "Purple Horizons",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/Purple-Horizons/memory-tools.git"
  }
}


path: scripts/verify-release.mjs
content: #!/usr/bin/env node

import fs from 'node:fs';
import path from 'node:path';

const root = process.cwd();
const distDir = path.join(root, 'dist');

function fail(message) {
  console.error(`[verify:release] ${message}`);
  process.exit(1);
}

if (!fs.existsSync(distDir)) {
  fail('dist/ is missing. Run npm run build first.');
}

const bannedFiles = [
  'config-v1.js',
  'config-v1.d.ts',
  'index-v1.js',
  'index-v1.d.ts',
  'store-v1.js',
  'store-v1.d.ts',
  'embeddings.js',
  'embeddings.d.ts',
];

for (const rel of bannedFiles) {
  if (fs.existsSync(path.join(distDir, rel))) {
    fail(`legacy artifact found in dist: ${rel}`);
  }
}

const bannedPatterns = [
  /from ['"]openai['"]/i,
  /from ['"]@lancedb\/lancedb['"]/i,
  /OPENAI_API_KEY/i,
  /embedding\s*:\s*\{\s*apiKey/i,
];

const files = fs.readdirSync(distDir).filter(f => f.endsWith('.js') || f.endsWith('.d.ts'));
for (const file of files) {
  const fullPath = path.join(distDir, file);
  const content = fs.readFileSync(fullPath, 'utf8');
  for (const pattern of bannedPatterns) {
    if (pattern.test(content)) {
      fail(`banned pattern ${pattern} found in dist/${file}`);
    }
  }
}

console.log('[verify:release] OK');


path: src/config.ts
content: /**
 * Configuration Schema for Memory-as-Tools v2
 *
 * v2 uses QMD for search - no OpenAI API key required!
 */

import { Type, type Static } from '@sinclair/typebox';
import { MEMORY_CATEGORIES } from './types.js';

export const memoryToolsConfigSchema = Type.Object({
  // Path to store memories (markdown files)
  memoriesPath: Type.Optional(Type.String()),

  // Legacy v1 database path (for migration detection)
  legacyDbPath: Type.Optional(Type.String()),

  // Auto-inject standing instructions at conversation start
  autoInjectInstructions: Type.Optional(Type.Boolean()),

  // Auto-migrate legacy v1 database on startup
  autoMigrateLegacy: Type.Optional(Type.Boolean()),

  // QMD collection name
  qmdCollection: Type.Optional(Type.String()),
});

export type MemoryToolsConfig = Static<typeof memoryToolsConfigSchema>;

/**
 * Expand environment variables in a string.
 * Supports ${VAR_NAME} syntax.
 */
function expandEnvVars(value: string): string {
  return value.replace(/\$\{([^}]+)\}/g, (_, varName) => {
    return process.env[varName] ?? '';
  });
}

/**
 * Expand ~ to home directory
 */
function expandPath(p: string): string {
  if (p.startsWith('~/')) {
    return p.replace('~', process.env.HOME || process.env.USERPROFILE || '');
  }
  return p;
}

export function parseConfig(raw: unknown): MemoryToolsConfig {
  const config = (raw ?? {}) as Record<string, unknown>;

  // Default paths
  let memoriesPath = (config.memoriesPath as string) || '~/.openclaw/memories';
  let legacyDbPath = (config.legacyDbPath as string) || (config.dbPath as string) || '~/.openclaw/memory/tools';

  // Expand environment variables and paths
  memoriesPath = expandPath(expandEnvVars(memoriesPath));
  legacyDbPath = expandPath(expandEnvVars(legacyDbPath));

  return {
    memoriesPath,
    legacyDbPath,
    autoInjectInstructions: config.autoInjectInstructions === true,
    autoMigrateLegacy: config.autoMigrateLegacy === true,
    qmdCollection: (config.qmdCollection as string) || 'memories',
  };
}

export { MEMORY_CATEGORIES };


path: src/file-manager.ts
content: /**
 * File Manager for Memory Storage
 *
 * Handles reading/writing memories as markdown files with YAML frontmatter.
 * Each memory is stored as a single .md file organized by category.
 */

import * as fs from 'node:fs';
import * as path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { Memory, MemoryCategory, CreateMemoryInput, UpdateMemoryInput } from './types.js';

// Simple YAML frontmatter parser/serializer (no external deps)
function parseYamlFrontmatter(content: string): { frontmatter: Record<string, unknown>; body: string } {
  const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
  if (!match) {
    return { frontmatter: {}, body: content };
  }

  const [, yaml, body] = match;
  const frontmatter: Record<string, unknown> = {};

  for (const line of yaml.split('\n')) {
    const colonIdx = line.indexOf(':');
    if (colonIdx === -1) continue;

    const key = line.slice(0, colonIdx).trim();
    let value: unknown = line.slice(colonIdx + 1).trim();

    // Parse arrays: [item1, item2]
    if (typeof value === 'string' && value.startsWith('[') && value.endsWith(']')) {
      value = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
    }
    // Parse numbers
    else if (typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value)) {
      value = parseFloat(value);
    }
    // Parse booleans
    else if (value === 'true') value = true;
    else if (value === 'false') value = false;
    else if (value === 'null') value = null;
    // Remove quotes from strings
    else if (typeof value === 'string') {
      value = value.replace(/^["']|["']$/g, '');
    }

    frontmatter[key] = value;
  }

  return { frontmatter, body: body.trim() };
}

function serializeYamlFrontmatter(data: Record<string, unknown>): string {
  const lines: string[] = [];

  for (const [key, value] of Object.entries(data)) {
    if (value === undefined) continue;
    if (value === null) {
      lines.push(`${key}: null`);
    } else if (Array.isArray(value)) {
      lines.push(`${key}: [${value.map(v => `"${v}"`).join(', ')}]`);
    } else if (typeof value === 'string') {
      // Quote strings with special chars
      if (value.includes(':') || value.includes('#') || value.includes('\n')) {
        lines.push(`${key}: "${value.replace(/"/g, '\\"')}"`);
      } else {
        lines.push(`${key}: ${value}`);
      }
    } else {
      lines.push(`${key}: ${value}`);
    }
  }

  return lines.join('\n');
}

export class MemoryFileManager {
  private memoriesPath: string;
  private deletedPath: string;

  constructor(memoriesPath: string) {
    this.memoriesPath = memoriesPath;
    this.deletedPath = path.join(memoriesPath, '.deleted');
    this.ensureDirectories();
  }

  private ensureDirectories(): void {
    // Create category directories
    const categories = ['facts', 'preferences', 'events', 'relationships', 'contexts', 'instructions', 'decisions', 'entities'];
    for (const cat of categories) {
      const dir = path.join(this.memoriesPath, cat);
      if (!fs.existsSync(dir)) {
        fs.mkdirSync(dir, { recursive: true });
      }
    }
    // Create deleted directory
    if (!fs.existsSync(this.deletedPath)) {
      fs.mkdirSync(this.deletedPath, { recursive: true });
    }
  }

  private categoryToDir(category: MemoryCategory): string {
    // Pluralize category names for directory
    const map: Record<MemoryCategory, string> = {
      fact: 'facts',
      preference: 'preferences',
      event: 'events',
      relationship: 'relationships',
      context: 'contexts',
      instruction: 'instructions',
      decision: 'decisions',
      entity: 'entities',
    };
    return map[category];
  }

  private getFilePath(id: string, category: MemoryCategory): string {
    return path.join(this.memoriesPath, this.categoryToDir(category), `${id}.md`);
  }

  /**
   * Create a new memory file
   */
  create(input: CreateMemoryInput): Memory {
    const id = randomUUID();
    const now = Date.now();

    const memory: Memory = {
      id,
      content: input.content,
      category: input.category,
      confidence: input.confidence ?? 0.8,
      importance: input.importance ?? 0.5,
      createdAt: now,
      updatedAt: now,
      lastAccessedAt: now,
      decayDays: input.decayDays ?? null,
      sourceChannel: input.sourceChannel,
      sourceMessageId: input.sourceMessageId,
      tags: input.tags ?? [],
      supersedes: undefined,
      deletedAt: undefined,
      deleteReason: undefined,
    };

    this.writeMemory(memory);
    return memory;
  }

  /**
   * Read a memory by ID
   */
  get(id: string): Memory | null {
    // Try to find in any category directory
    const categories: MemoryCategory[] = ['fact', 'preference', 'event', 'relationship', 'context', 'instruction', 'decision', 'entity'];

    for (const category of categories) {
      const filePath = this.getFilePath(id, category);
      if (fs.existsSync(filePath)) {
        return this.readMemoryFile(filePath);
      }

      // Also try short ID match
      if (id.length === 8) {
        const dir = path.join(this.memoriesPath, this.categoryToDir(category));
        if (fs.existsSync(dir)) {
          const files = fs.readdirSync(dir);
          const match = files.find(f => f.startsWith(id) && f.endsWith('.md'));
          if (match) {
            return this.readMemoryFile(path.join(dir, match));
          }
        }
      }
    }

    return null;
  }

  /**
   * Update an existing memory
   */
  update(id: string, updates: UpdateMemoryInput): Memory | null {
    const existing = this.get(id);
    if (!existing) return null;

    const updated: Memory = {
      ...existing,
      ...updates,
      content: updates.content ?? existing.content,
      updatedAt: Date.now(),
      tags: updates.tags ?? existing.tags,
    };

    // If category changed, move the file
    if (existing.category !== updated.category) {
      const oldPath = this.getFilePath(existing.id, existing.category);
      fs.unlinkSync(oldPath);
    }

    this.writeMemory(updated);
    return updated;
  }

  /**
   * Soft delete a memory (move to .deleted folder)
   */
  delete(id: string, reason?: string): boolean {
    const memory = this.get(id);
    if (!memory) return false;

    const oldPath = this.getFilePath(memory.id, memory.category);
    const deletedPath = path.join(this.deletedPath, `${memory.id}.md`);

    // Update memory with deletion info
    memory.deletedAt = Date.now();
    memory.deleteReason = reason;

    // Write to deleted folder
    this.writeMemoryToPath(memory, deletedPath);

    // Remove from active folder
    if (fs.existsSync(oldPath)) {
      fs.unlinkSync(oldPath);
    }

    return true;
  }

  /**
   * List all memories, optionally filtered
   */
  list(options: {
    category?: MemoryCategory;
    limit?: number;
    offset?: number;
    sortBy?: string;
    sortOrder?: 'asc' | 'desc';
  } = {}): { total: number; items: Memory[] } {
    const memories: Memory[] = [];
    const categories: MemoryCategory[] = options.category
      ? [options.category]
      : ['fact', 'preference', 'event', 'relationship', 'context', 'instruction', 'decision', 'entity'];

    for (const category of categories) {
      const dir = path.join(this.memoriesPath, this.categoryToDir(category));
      if (!fs.existsSync(dir)) continue;

      const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
      for (const file of files) {
        const memory = this.readMemoryFile(path.join(dir, file));
        if (memory && !memory.deletedAt) {
          memories.push(memory);
        }
      }
    }

    // Sort
    const sortBy = options.sortBy ?? 'createdAt';
    const sortOrder = options.sortOrder ?? 'desc';
    memories.sort((a, b) => {
      const aVal = (a as any)[sortBy] ?? 0;
      const bVal = (b as any)[sortBy] ?? 0;
      return sortOrder === 'desc' ? bVal - aVal : aVal - bVal;
    });

    const total = memories.length;
    const offset = options.offset ?? 0;
    const limit = options.limit ?? 20;
    const items = memories.slice(offset, offset + limit);

    return { total, items };
  }

  /**
   * Get memories by category
   */
  getByCategory(category: MemoryCategory, limit: number = 50): Memory[] {
    const dir = path.join(this.memoriesPath, this.categoryToDir(category));
    if (!fs.existsSync(dir)) return [];

    const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
    const memories: Memory[] = [];

    for (const file of files) {
      if (memories.length >= limit) break;
      const memory = this.readMemoryFile(path.join(dir, file));
      if (memory && !memory.deletedAt) {
        memories.push(memory);
      }
    }

    // Sort by importance
    memories.sort((a, b) => b.importance - a.importance);
    return memories.slice(0, limit);
  }

  /**
   * Count non-deleted memories
   */
  count(): number {
    let total = 0;
    const categories: MemoryCategory[] = ['fact', 'preference', 'event', 'relationship', 'context', 'instruction', 'decision', 'entity'];

    for (const category of categories) {
      const dir = path.join(this.memoriesPath, this.categoryToDir(category));
      if (fs.existsSync(dir)) {
        total += fs.readdirSync(dir).filter(f => f.endsWith('.md')).length;
      }
    }

    return total;
  }

  /**
   * Update last accessed time for memories
   */
  touchMany(ids: string[]): void {
    const now = Date.now();
    for (const id of ids) {
      const memory = this.get(id);
      if (memory) {
        memory.lastAccessedAt = now;
        this.writeMemory(memory);
      }
    }
  }

  /**
   * Get the memories directory path (for QMD collection)
   */
  getMemoriesPath(): string {
    return this.memoriesPath;
  }

  /**
   * Write a memory to its file
   */
  private writeMemory(memory: Memory): void {
    const filePath = this.getFilePath(memory.id, memory.category);
    this.writeMemoryToPath(memory, filePath);
  }

  private writeMemoryToPath(memory: Memory, filePath: string): void {
    const frontmatter = serializeYamlFrontmatter({
      id: memory.id,
      category: memory.category,
      confidence: memory.confidence,
      importance: memory.importance,
      created_at: new Date(memory.createdAt).toISOString(),
      updated_at: new Date(memory.updatedAt).toISOString(),
      last_accessed_at: memory.lastAccessedAt ? new Date(memory.lastAccessedAt).toISOString() : null,
      decay_days: memory.decayDays,
      source_channel: memory.sourceChannel || null,
      source_message_id: memory.sourceMessageId || null,
      tags: memory.tags,
      supersedes: memory.supersedes || null,
      deleted_at: memory.deletedAt ? new Date(memory.deletedAt).toISOString() : null,
      delete_reason: memory.deleteReason || null,
    });

    const content = `---\n${frontmatter}\n---\n\n${memory.content}\n`;

    // Atomic write: write to temp file, then rename
    const tempPath = `${filePath}.tmp`;
    fs.writeFileSync(tempPath, content, 'utf-8');
    fs.renameSync(tempPath, filePath);
  }

  /**
   * Read a memory from file
   */
  private readMemoryFile(filePath: string): Memory | null {
    try {
      const content = fs.readFileSync(filePath, 'utf-8');
      const { frontmatter, body } = parseYamlFrontmatter(content);

      return {
        id: frontmatter.id as string,
        content: body,
        category: frontmatter.category as MemoryCategory,
        confidence: (frontmatter.confidence as number) ?? 0.8,
        importance: (frontmatter.importance as number) ?? 0.5,
        createdAt: frontmatter.created_at ? new Date(frontmatter.created_at as string).getTime() : Date.now(),
        updatedAt: frontmatter.updated_at ? new Date(frontmatter.updated_at as string).getTime() : Date.now(),
        lastAccessedAt: frontmatter.last_accessed_at ? new Date(frontmatter.last_accessed_at as string).getTime() : Date.now(),
        decayDays: frontmatter.decay_days as number | null,
        sourceChannel: frontmatter.source_channel as string | undefined,
        sourceMessageId: frontmatter.source_message_id as string | undefined,
        tags: (frontmatter.tags as string[]) ?? [],
        supersedes: frontmatter.supersedes as string | undefined,
        deletedAt: frontmatter.deleted_at ? new Date(frontmatter.deleted_at as string).getTime() : undefined,
        deleteReason: frontmatter.delete_reason as string | undefined,
      };
    } catch (err) {
      console.error(`Failed to read memory file: ${filePath}`, err);
      return null;
    }
  }
}


path: src/index.ts
content: /**
 * OpenClaw Memory-as-Tools Plugin v2
 *
 * Agent-controlled memory with QMD-powered search.
 * File-based storage, no external APIs required.
 *
 * Key features:
 * - Six memory tools: store, update, forget, search, summarize, list
 * - File-based storage (markdown with YAML frontmatter)
 * - QMD search (BM25 + vector + reranking, all local)
 * - Auto-migration from v1
 * - No OpenAI dependency
 */

import type { OpenClawPluginApi } from './plugin-types.js';
import { parseConfig } from './config.js';
import { MemoryStoreV2 } from './store.js';
import { createMemoryTools } from './tools.js';
import {
  hasLegacyDatabase,
  hasNewMemories,
  migrateFromV1,
  printMigrationRequired,
  printMigrationSuccess,
} from './migration.js';

// Plugin definition
const memoryToolsPlugin = {
  id: 'memory-tools',
  name: 'Memory Tools',
  description: 'Agent-controlled memory with QMD-powered search (v2)',
  kind: 'memory' as const,

  async register(api: OpenClawPluginApi) {
    const cfg = parseConfig(api.pluginConfig);
    const memoriesPath = api.resolvePath(cfg.memoriesPath!);
    const legacyDbPath = api.resolvePath(cfg.legacyDbPath!);

    // ═══════════════════════════════════════════════════════════════════════
    // Auto-Migration from v1
    // ═══════════════════════════════════════════════════════════════════════

    const hasLegacy = hasLegacyDatabase(legacyDbPath);
    const hasNew = hasNewMemories(memoriesPath);

    if (cfg.autoMigrateLegacy === true && hasLegacy && !hasNew) {
      // Need to migrate
      printMigrationRequired(legacyDbPath);
      api.logger.info('memory-tools: Starting migration from v1...');

      const result = await migrateFromV1(legacyDbPath, memoriesPath);

      if (result.success || result.migratedCount > 0) {
        printMigrationSuccess(result);
        api.logger.info(`memory-tools: Migration complete (${result.migratedCount} memories)`);
      } else {
        api.logger.error(`memory-tools: Migration failed: ${result.errors.join(', ')}`);
        throw new Error(`Migration failed: ${result.errors.join(', ')}`);
      }
    } else if (cfg.autoMigrateLegacy !== true && hasLegacy && !hasNew) {
      api.logger.warn(
        'memory-tools: legacy database detected but autoMigrateLegacy is disabled by default; skipping migration'
      );
    }

    // ═══════════════════════════════════════════════════════════════════════
    // Initialize Store
    // ═══════════════════════════════════════════════════════════════════════

    const store = new MemoryStoreV2(memoriesPath, cfg.qmdCollection);

    // Check QMD availability
    const qmdAvailable = await store.isQMDAvailable();
    if (!qmdAvailable) {
      api.logger.warn(
        'memory-tools: QMD not installed. Install with: npm install -g @tobilu/qmd'
      );
      api.logger.warn('memory-tools: Semantic search will be limited until QMD is installed.');
    } else {
      try {
        await store.init();
        api.logger.info(`memory-tools: initialized with QMD (path: ${memoriesPath})`);
      } catch (err: any) {
        api.logger.warn(
          `memory-tools: QMD initialization failed, continuing in basic mode: ${err?.message || err}`
        );
      }
    }

    const tools = createMemoryTools(store as any); // Cast for compatibility

    // ═══════════════════════════════════════════════════════════════════════
    // Register Tools
    // ═══════════════════════════════════════════════════════════════════════

    api.registerTool(
      {
        name: tools.memory_store.name,
        label: tools.memory_store.label,
        description: tools.memory_store.description,
        parameters: tools.memory_store.parameters,
        execute: (id, params) => tools.memory_store.execute(id, params as any, {}),
      },
      { name: 'memory_store' }
    );

    api.registerTool(
      {
        name: tools.memory_update.name,
        label: tools.memory_update.label,
        description: tools.memory_update.description,
        parameters: tools.memory_update.parameters,
        execute: (id, params) => tools.memory_update.execute(id, params as any),
      },
      { name: 'memory_update' }
    );

    api.registerTool(
      {
        name: tools.memory_forget.name,
        label: tools.memory_forget.label,
        description: tools.memory_forget.description,
        parameters: tools.memory_forget.parameters,
        execute: (id, params) => tools.memory_forget.execute(id, params as any),
      },
      { name: 'memory_forget' }
    );

    api.registerTool(
      {
        name: tools.memory_search.name,
        label: tools.memory_search.label,
        description: tools.memory_search.description,
        parameters: tools.memory_search.parameters,
        execute: (id, params) => tools.memory_search.execute(id, params as any),
      },
      { name: 'memory_search' }
    );

    api.registerTool(
      {
        name: tools.memory_summarize.name,
        label: tools.memory_summarize.label,
        description: tools.memory_summarize.description,
        parameters: tools.memory_summarize.parameters,
        execute: (id, params) => tools.memory_summarize.execute(id, params as any),
      },
      { name: 'memory_summarize' }
    );

    api.registerTool(
      {
        name: tools.memory_list.name,
        label: tools.memory_list.label,
        description: tools.memory_list.description,
        parameters: tools.memory_list.parameters,
        execute: (id, params) => tools.memory_list.execute(id, params as any),
      },
      { name: 'memory_list' }
    );

    // ═══════════════════════════════════════════════════════════════════════
    // Lifecycle Hooks
    // ═══════════════════════════════════════════════════════════════════════

    // Auto-inject standing instructions at conversation start
    if (cfg.autoInjectInstructions === true) {
      api.on('before_agent_start', async (event: { prompt?: string }) => {
        const instructions = store.getByCategory('instruction', 10);

        if (instructions.length === 0) {
          return undefined;
        }

        const instructionList = instructions
          .map((m: { content: string }) => `- ${m.content}`)
          .join('\n');

        api.logger.info?.(`memory-tools: injecting ${instructions.length} standing instructions`);

        return {
          prependContext: `<standing-instructions>\nRemember these user instructions:\n${instructionList}\n</standing-instructions>`,
        };
      });
    }

    // ═══════════════════════════════════════════════════════════════════════
    // CLI Commands
    // ═══════════════════════════════════════════════════════════════════════

    api.registerCli(
      ({ program }: { program: any }) => {
        const memory = program
          .command('memory-tools')
          .description('Memory-as-Tools plugin commands (v2)');

        memory
          .command('stats')
          .description('Show memory statistics')
          .action(async () => {
            const total = store.count();
            const instructions = store.getByCategory('instruction').length;
            const facts = store.getByCategory('fact').length;
            const preferences = store.getByCategory('preference').length;

            console.log(`Memory Statistics (v2):`);
            console.log(`  Total: ${total}`);
            console.log(`  Instructions: ${instructions}`);
            console.log(`  Facts: ${facts}`);
            console.log(`  Preferences: ${preferences}`);
            console.log(`  Storage: ${memoriesPath}`);
          });

        memory
          .command('list')
          .description('List memories')
          .option('-c, --category <category>', 'Filter by category')
          .option('-l, --limit <n>', 'Max results', '20')
          .action(async (opts: { category?: string; limit?: string }) => {
            const results = store.list({
              category: opts.category as any,
              limit: parseInt(opts.limit ?? '20'),
            });

            console.log(`Showing ${results.items.length} of ${results.total} memories:\n`);
            for (const m of results.items) {
              console.log(`[${m.id.slice(0, 8)}] [${m.category}] ${m.content.slice(0, 60)}...`);
            }
          });

        memory
          .command('search <query>')
          .description('Search memories using QMD')
          .option('-l, --limit <n>', 'Max results', '10')
          .action(async (query: string, opts: { limit?: string }) => {
            const results = await store.search({
              query,
              limit: parseInt(opts.limit ?? '10'),
            });

            console.log(`Found ${results.length} memories:\n`);
            for (const r of results) {
              console.log(`[${r.memory.id.slice(0, 8)}] (${(r.score * 100).toFixed(0)}%) ${r.memory.content}`);
            }
          });

        memory
          .command('export')
          .description('Export all memories as JSON')
          .action(async () => {
            const results = store.list({ limit: 10000 });
            console.log(JSON.stringify(results.items, null, 2));
          });

        memory
          .command('reindex')
          .description('Force QMD to re-index all memories')
          .action(async () => {
            console.log('Re-indexing memories with QMD...');
            await store.reindex();
            console.log('Done!');
          });

        memory
          .command('path')
          .description('Show memories storage path')
          .action(() => {
            console.log(memoriesPath);
          });
      },
      { commands: ['memory-tools'] }
    );

    // ═══════════════════════════════════════════════════════════════════════
    // Service (lifecycle management)
    // ═══════════════════════════════════════════════════════════════════════

    api.registerService({
      id: 'memory-tools',
      start: async () => {
        const count = store.count();
        api.logger.info(`memory-tools: service started (${count} memories, v2)`);
      },
      stop: () => {
        store.close();
        api.logger.info('memory-tools: service stopped');
      },
    });
  },
};

export default memoryToolsPlugin;

// Re-export types for external use
export * from './types.js';
export { MemoryStoreV2 as MemoryStore } from './store.js';
export { createMemoryTools } from './tools.js';
export { MemoryFileManager } from './file-manager.js';
export { QMDClient } from './qmd.js';


path: src/migration.ts
content: /**
 * Migration from v1 to v2
 *
 * Automatically detects legacy SQLite + LanceDB storage and migrates
 * memories to the new file-based format.
 */

import * as fs from 'node:fs';
import * as path from 'node:path';
import initSqlJs from 'sql.js';
import { MemoryFileManager } from './file-manager.js';
import type { Memory, MemoryCategory } from './types.js';

interface LegacyMemoryRow {
  id: string;
  content: string;
  category: string;
  confidence: number;
  importance: number;
  created_at: number;
  updated_at: number;
  last_accessed_at: number | null;
  decay_days: number | null;
  source_channel: string | null;
  source_message_id: string | null;
  tags: string;
  supersedes: string | null;
  deleted_at: number | null;
  delete_reason: string | null;
}

export interface MigrationResult {
  success: boolean;
  migratedCount: number;
  skippedCount: number;
  errors: string[];
  legacyPath: string;
  newPath: string;
}

/**
 * Check if legacy v1 databases exist
 */
export function hasLegacyDatabase(legacyDbPath: string): boolean {
  const sqlitePath = path.join(legacyDbPath, 'memory.db');
  const vectorsPath = path.join(legacyDbPath, 'vectors');

  return fs.existsSync(sqlitePath) || fs.existsSync(vectorsPath);
}

/**
 * Check if new v2 memories directory exists and has content
 */
export function hasNewMemories(memoriesPath: string): boolean {
  if (!fs.existsSync(memoriesPath)) return false;

  // Check if any category directory has .md files
  const categories = ['facts', 'preferences', 'events', 'relationships', 'contexts', 'instructions', 'decisions', 'entities'];

  for (const cat of categories) {
    const catDir = path.join(memoriesPath, cat);
    if (fs.existsSync(catDir)) {
      const files = fs.readdirSync(catDir).filter(f => f.endsWith('.md'));
      if (files.length > 0) return true;
    }
  }

  return false;
}

/**
 * Migrate from v1 SQLite database to v2 file-based storage
 */
export async function migrateFromV1(
  legacyDbPath: string,
  newMemoriesPath: string
): Promise<MigrationResult> {
  const result: MigrationResult = {
    success: false,
    migratedCount: 0,
    skippedCount: 0,
    errors: [],
    legacyPath: legacyDbPath,
    newPath: newMemoriesPath,
  };

  const sqlitePath = path.join(legacyDbPath, 'memory.db');

  if (!fs.existsSync(sqlitePath)) {
    result.errors.push('Legacy SQLite database not found');
    return result;
  }

  try {
    // Initialize sql.js
    const SQL = await initSqlJs();
    const buffer = fs.readFileSync(sqlitePath);
    const db = new SQL.Database(buffer);

    // Create file manager for new storage
    const fileManager = new MemoryFileManager(newMemoriesPath);

    // Read all memories from SQLite
    const stmt = db.prepare(`
      SELECT * FROM memories
      WHERE deleted_at IS NULL
      ORDER BY created_at ASC
    `);

    while (stmt.step()) {
      const row = stmt.getAsObject() as unknown as LegacyMemoryRow;

      try {
        // Convert row to Memory object
        const memory: Memory = {
          id: row.id,
          content: row.content,
          category: row.category as MemoryCategory,
          confidence: row.confidence ?? 0.8,
          importance: row.importance ?? 0.5,
          createdAt: row.created_at,
          updatedAt: row.updated_at,
          lastAccessedAt: row.last_accessed_at ?? row.created_at,
          decayDays: row.decay_days,
          sourceChannel: row.source_channel ?? undefined,
          sourceMessageId: row.source_message_id ?? undefined,
          tags: JSON.parse(row.tags || '[]'),
          supersedes: row.supersedes ?? undefined,
          deletedAt: undefined,
          deleteReason: undefined,
        };

        // Write to new file-based storage
        writeMemoryFile(fileManager, memory);
        result.migratedCount++;
      } catch (err: any) {
        result.errors.push(`Failed to migrate memory ${row.id}: ${err.message}`);
        result.skippedCount++;
      }
    }

    stmt.free();
    db.close();

    // Also migrate deleted memories to .deleted folder
    await migrateDeletedMemories(sqlitePath, newMemoriesPath);

    result.success = result.errors.length === 0;

    // Create a migration marker file
    const markerPath = path.join(legacyDbPath, '.migrated-to-v2');
    fs.writeFileSync(markerPath, JSON.stringify({
      migratedAt: new Date().toISOString(),
      migratedCount: result.migratedCount,
      newPath: newMemoriesPath,
    }, null, 2));

    return result;
  } catch (err: any) {
    result.errors.push(`Migration failed: ${err.message}`);
    return result;
  }
}

/**
 * Migrate deleted memories to .deleted folder
 */
async function migrateDeletedMemories(sqlitePath: string, newMemoriesPath: string): Promise<void> {
  const SQL = await initSqlJs();
  const buffer = fs.readFileSync(sqlitePath);
  const db = new SQL.Database(buffer);

  const deletedPath = path.join(newMemoriesPath, '.deleted');
  if (!fs.existsSync(deletedPath)) {
    fs.mkdirSync(deletedPath, { recursive: true });
  }

  const stmt = db.prepare(`
    SELECT * FROM memories
    WHERE deleted_at IS NOT NULL
    ORDER BY deleted_at ASC
  `);

  while (stmt.step()) {
    const row = stmt.getAsObject() as unknown as LegacyMemoryRow;

    try {
      const memory: Memory = {
        id: row.id,
        content: row.content,
        category: row.category as MemoryCategory,
        confidence: row.confidence ?? 0.8,
        importance: row.importance ?? 0.5,
        createdAt: row.created_at,
        updatedAt: row.updated_at,
        lastAccessedAt: row.last_accessed_at ?? row.created_at,
        decayDays: row.decay_days,
        sourceChannel: row.source_channel ?? undefined,
        sourceMessageId: row.source_message_id ?? undefined,
        tags: JSON.parse(row.tags || '[]'),
        supersedes: row.supersedes ?? undefined,
        deletedAt: row.deleted_at ?? undefined,
        deleteReason: row.delete_reason ?? undefined,
      };

      // Write to .deleted folder
      const filePath = path.join(deletedPath, `${memory.id}.md`);
      writeMemoryToFile(memory, filePath);
    } catch {
      // Ignore errors for deleted memories
    }
  }

  stmt.free();
  db.close();
}

/**
 * Write a memory to the file manager
 */
function writeMemoryFile(fileManager: MemoryFileManager, memory: Memory): void {
  // Use internal method to write with existing ID
  const memoriesPath = fileManager.getMemoriesPath();
  const categoryDir = getCategoryDir(memory.category);
  const filePath = path.join(memoriesPath, categoryDir, `${memory.id}.md`);

  writeMemoryToFile(memory, filePath);
}

/**
 * Write a memory to a specific file path
 */
function writeMemoryToFile(memory: Memory, filePath: string): void {
  const frontmatter = [
    `id: ${memory.id}`,
    `category: ${memory.category}`,
    `confidence: ${memory.confidence}`,
    `importance: ${memory.importance}`,
    `created_at: ${new Date(memory.createdAt).toISOString()}`,
    `updated_at: ${new Date(memory.updatedAt).toISOString()}`,
    `last_accessed_at: ${memory.lastAccessedAt ? new Date(memory.lastAccessedAt).toISOString() : 'null'}`,
    `decay_days: ${memory.decayDays ?? 'null'}`,
    `source_channel: ${memory.sourceChannel || 'null'}`,
    `source_message_id: ${memory.sourceMessageId || 'null'}`,
    `tags: [${memory.tags.map(t => `"${t}"`).join(', ')}]`,
    `supersedes: ${memory.supersedes || 'null'}`,
  ];

  if (memory.deletedAt) {
    frontmatter.push(`deleted_at: ${new Date(memory.deletedAt).toISOString()}`);
    frontmatter.push(`delete_reason: ${memory.deleteReason || 'null'}`);
  }

  const content = `---\n${frontmatter.join('\n')}\n---\n\n${memory.content}\n`;

  // Ensure directory exists
  const dir = path.dirname(filePath);
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }

  fs.writeFileSync(filePath, content, 'utf-8');
}

function getCategoryDir(category: MemoryCategory): string {
  const map: Record<MemoryCategory, string> = {
    fact: 'facts',
    preference: 'preferences',
    event: 'events',
    relationship: 'relationships',
    context: 'contexts',
    instruction: 'instructions',
    decision: 'decisions',
    entity: 'entities',
  };
  return map[category];
}

/**
 * Print migration banner for users
 */
export function printMigrationRequired(legacyPath: string): void {
  console.log(`
╔════════════════════════════════════════════════════════════════════╗
║                     MIGRATION REQUIRED                             ║
╠════════════════════════════════════════════════════════════════════╣
║                                                                    ║
║  memory-tools v2 uses a new file-based storage format.             ║
║                                                                    ║
║  Your existing memories at:                                        ║
║    ${legacyPath.padEnd(54)}║
║                                                                    ║
║  Will be automatically migrated to markdown files.                 ║
║  Your original database will be preserved as a backup.             ║
║                                                                    ║
║  This is a one-time migration. It should only take a moment.       ║
║                                                                    ║
╚════════════════════════════════════════════════════════════════════╝
`);
}

/**
 * Print migration success message
 */
export function printMigrationSuccess(result: MigrationResult): void {
  console.log(`
╔════════════════════════════════════════════════════════════════════╗
║                   MIGRATION COMPLETE                               ║
╠════════════════════════════════════════════════════════════════════╣
║                                                                    ║
║  ✓ Migrated ${String(result.migratedCount).padEnd(4)} memories to file-based storage            ║
${result.skippedCount > 0 ? `║  ⚠ Skipped ${String(result.skippedCount).padEnd(4)} memories (see errors below)              ║\n` : ''}║                                                                    ║
║  New storage location:                                             ║
║    ${result.newPath.padEnd(54)}║
║                                                                    ║
║  Your original database has been preserved at:                     ║
║    ${result.legacyPath.padEnd(54)}║
║                                                                    ║
║  You can now use memory-tools with QMD-powered search!             ║
║                                                                    ║
╚════════════════════════════════════════════════════════════════════╝
`);

  if (result.errors.length > 0) {
    console.log('\nMigration warnings:');
    for (const err of result.errors.slice(0, 10)) {
      console.log(`  - ${err}`);
    }
    if (result.errors.length > 10) {
      console.log(`  ... and ${result.errors.length - 10} more`);
    }
  }
}


path: src/plugin-types.ts
content: /**
 * OpenClaw Plugin SDK Types
 *
 * Minimal type definitions for OpenClaw plugin development.
 * These match the OpenClaw plugin API.
 */

export interface PluginLogger {
  debug?: (message: string) => void;
  info: (message: string) => void;
  warn: (message: string) => void;
  error: (message: string) => void;
}

export interface OpenClawPluginApi {
  id: string;
  name: string;
  version?: string;
  description?: string;
  source: string;
  config: Record<string, unknown>;
  pluginConfig?: Record<string, unknown>;
  logger: PluginLogger;
  registerTool: (tool: AnyAgentTool, opts?: { name?: string }) => void;
  registerHook: (events: string | string[], handler: unknown, opts?: unknown) => void;
  registerCli: (registrar: (ctx: { program: unknown }) => void, opts?: { commands?: string[] }) => void;
  registerService: (service: { id: string; start: () => void; stop?: () => void }) => void;
  resolvePath: (input: string) => string;
  on: (hookName: string, handler: unknown, opts?: { priority?: number }) => void;
}

export interface AnyAgentTool {
  name: string;
  label?: string;
  description: string;
  parameters: unknown;
  execute: (toolCallId: string, params: unknown) => Promise<ToolResult>;
}

export interface ToolResult {
  content: Array<{ type: 'text'; text: string }>;
  details?: Record<string, unknown>;
}

export interface PluginHookBeforeAgentStartResult {
  systemPrompt?: string;
  prependContext?: string;
}


path: src/qmd.ts
content: /**
 * QMD CLI Wrapper
 *
 * Handles interaction with the QMD search engine via CLI.
 * QMD provides BM25 + vector search + LLM reranking, all local.
 */

import { spawn } from 'node:child_process';

export interface QMDSearchResult {
  docid: string;
  filepath: string;
  file?: string; // QMD sometimes uses 'file' instead of 'filepath'
  line?: number;
  score: number;
  title?: string;
  context?: string;
  snippet?: string;
}

export interface QMDStatus {
  collections: Array<{
    name: string;
    path: string;
    documents: number;
  }>;
  totalDocuments: number;
  indexed: boolean;
}

export class QMDClient {
  private collectionName: string;
  private memoriesPath: string;
  private initialized: boolean = false;
  private qmdAvailable: boolean | null = null;
  private updateDebounceTimer: NodeJS.Timeout | null = null;
  private pendingUpdate: boolean = false;
  private disabled: boolean = false;
  private disableReason: string | null = null;

  constructor(memoriesPath: string, collectionName: string = 'memories', options?: { disabled?: boolean }) {
    this.memoriesPath = memoriesPath;
    this.collectionName = this.validateCollectionName(collectionName);
    // Allow disabling QMD for tests via env var or option
    this.disabled = options?.disabled || process.env.MEMORY_TOOLS_DISABLE_QMD === 'true';
  }

  /**
   * Check and cache QMD availability
   */
  private async checkQMDAvailable(): Promise<boolean> {
    if (this.disabled) return false;
    if (this.qmdAvailable !== null) return this.qmdAvailable;
    this.qmdAvailable = await this.isInstalled();
    return this.qmdAvailable;
  }

  /**
   * Check if QMD is installed
   */
  async isInstalled(): Promise<boolean> {
    try {
      await this.runQmd(['--version'], { timeout: 5000 });
      return true;
    } catch {
      return false;
    }
  }

  /**
   * Initialize QMD collection if needed
   */
  async ensureCollection(): Promise<void> {
    if (this.disabled) return;
    if (this.initialized) return;

    const installed = await this.isInstalled();
    if (!installed) {
      throw new Error(
        'QMD is not installed. Install it with: npm install -g @tobilu/qmd'
      );
    }

    // Check if collection exists by parsing text output
    try {
      const { stdout } = await this.runQmd(['collection', 'list'], { timeout: 10000 });

      // Parse text output - look for collection name in the output
      const exists = stdout.includes(`${this.collectionName} (qmd://`) ||
                     stdout.includes(`${this.collectionName}(`);

      if (!exists) {
        // Create collection
        await this.runQmd(['collection', 'add', this.memoriesPath, '--name', this.collectionName], {
          timeout: 30000,
        });
        console.log(`[memory-tools] Created QMD collection: ${this.collectionName}`);

        // Initial embedding
        await this.forceUpdate();
      }

      this.initialized = true;
    } catch (err: any) {
      // Collection list might fail if no collections exist yet
      if (err.message?.includes('No collections') || err.message?.includes('Collections (0)')) {
        await this.runQmd(['collection', 'add', this.memoriesPath, '--name', this.collectionName], {
          timeout: 30000,
        });
        await this.forceUpdate();
        this.initialized = true;
      } else {
        this.maybeDisableForFatalQmdError(err);
        throw err;
      }
    }
  }

  /**
   * Hybrid search (BM25 + vector + reranking)
   * This is the highest quality search mode
   */
  async query(query: string, limit: number = 10): Promise<QMDSearchResult[]> {
    const available = await this.checkQMDAvailable();
    if (!available) return [];

    try {
      await this.ensureCollection();
      const { stdout } = await this.runQmd(
        ['query', query, '-n', String(limit), '--json', '-c', this.collectionName],
        { timeout: 30000 } // 30s timeout for reranking
      );

      return this.parseResults(stdout);
    } catch (err: any) {
      // Fallback to vector search if query fails
      console.warn('[memory-tools] QMD query failed, falling back to vsearch:', err.message);
      return this.vectorSearch(query, limit);
    }
  }

  /**
   * Vector-only semantic search
   * Faster than query but no BM25 or reranking
   */
  async vectorSearch(query: string, limit: number = 10): Promise<QMDSearchResult[]> {
    const available = await this.checkQMDAvailable();
    if (!available) return [];

    try {
      await this.ensureCollection();
      const { stdout } = await this.runQmd(
        ['vsearch', query, '-n', String(limit), '--json', '-c', this.collectionName],
        { timeout: 10000 }
      );

      return this.parseResults(stdout);
    } catch (err: any) {
      console.error('[memory-tools] QMD vsearch failed:', err.message);
      return [];
    }
  }

  /**
   * BM25 keyword search
   * Fastest, good for exact matches
   */
  async search(query: string, limit: number = 10): Promise<QMDSearchResult[]> {
    const available = await this.checkQMDAvailable();
    if (!available) return [];

    try {
      await this.ensureCollection();
      const { stdout } = await this.runQmd(
        ['search', query, '-n', String(limit), '--json', '-c', this.collectionName],
        { timeout: 5000 }
      );

      return this.parseResults(stdout);
    } catch (err: any) {
      console.error('[memory-tools] QMD search failed:', err.message);
      return [];
    }
  }

  /**
   * Find similar documents (for duplicate detection)
   */
  async findSimilar(content: string, threshold: number = 0.9): Promise<QMDSearchResult[]> {
    const results = await this.vectorSearch(content, 3);

    // Filter by threshold
    return results.filter(r => r.score >= threshold);
  }

  /**
   * Update the QMD index (debounced)
   * Called after file changes
   */
  scheduleUpdate(): void {
    // Check availability synchronously using cached value
    if (this.qmdAvailable === false) return;

    this.pendingUpdate = true;

    if (this.updateDebounceTimer) {
      clearTimeout(this.updateDebounceTimer);
    }

    // Debounce updates by 2 seconds
    this.updateDebounceTimer = setTimeout(async () => {
      if (this.pendingUpdate) {
        // Check QMD availability before updating
        const available = await this.checkQMDAvailable();
        if (available) {
          await this.update();
        }
        this.pendingUpdate = false;
      }
    }, 2000);
  }

  /**
   * Immediately update the QMD index
   */
  async update(): Promise<void> {
    try {
      await this.runQmd(['update'], { timeout: 60000 });
    } catch (err: any) {
      console.error('[memory-tools] QMD update failed:', err.message);
    }
  }

  /**
   * Force re-embed all documents
   */
  async forceUpdate(): Promise<void> {
    try {
      console.log('[memory-tools] Running QMD embed (this may take a moment on first run)...');
      await this.runQmd(['embed'], { timeout: 300000 }); // 5 min timeout for initial embed
      console.log('[memory-tools] QMD embedding complete');
    } catch (err: any) {
      console.error('[memory-tools] QMD embed failed:', err.message);
    }
  }

  /**
   * Get QMD status
   */
  async status(): Promise<QMDStatus | null> {
    try {
      const { stdout } = await this.runQmd(['status', '--json'], { timeout: 10000 });
      return JSON.parse(stdout);
    } catch {
      return null;
    }
  }

  /**
   * Extract memory ID from QMD filepath result
   */
  extractMemoryId(result: QMDSearchResult): string | null {
    // filepath formats:
    // - qmd://memories/facts/abc123-def4-5678-90ab-cdef12345678.md
    // - memories/facts/abc123-def4-5678-90ab-cdef12345678.md
    // - qmd://memories/preferences/test-pref-1.md (non-UUID names)

    const filepath = result.filepath || result.file || '';

    // Try to extract UUID
    const uuidMatch = filepath.match(/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\.md$/i);
    if (uuidMatch) {
      return uuidMatch[1];
    }

    // Fall back to extracting filename without extension
    const filenameMatch = filepath.match(/\/([^/]+)\.md$/);
    if (filenameMatch) {
      return filenameMatch[1];
    }

    return null;
  }

  private validateCollectionName(name: string): string {
    if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
      throw new Error(
        `Invalid qmd collection name "${name}". Use letters, numbers, "_" or "-" (max 64 chars).`
      );
    }
    return name;
  }

  private maybeDisableForFatalQmdError(err: unknown): void {
    const message = err instanceof Error ? err.message : String(err);
    const fatalPatterns = [
      /NODE_MODULE_VERSION/i,
      /better-sqlite3/i,
      /was compiled against a different Node\.js version/i,
    ];

    if (!fatalPatterns.some(p => p.test(message))) return;
    if (this.disabled) return;

    this.disabled = true;
    this.qmdAvailable = false;
    this.disableReason = message;
    console.warn('[memory-tools] QMD disabled due to runtime incompatibility:', message);
  }

  private async runQmd(
    args: string[],
    options: { timeout?: number } = {}
  ): Promise<{ stdout: string; stderr: string }> {
    const timeoutMs = options.timeout ?? 10000;

    return new Promise((resolve, reject) => {
      const child = spawn('qmd', args, { stdio: ['ignore', 'pipe', 'pipe'] });
      let stdout = '';
      let stderr = '';
      let timedOut = false;

      const timer = setTimeout(() => {
        timedOut = true;
        child.kill('SIGTERM');
      }, timeoutMs);

      child.stdout.on('data', chunk => {
        stdout += chunk.toString();
      });

      child.stderr.on('data', chunk => {
        stderr += chunk.toString();
      });

      child.on('error', err => {
        clearTimeout(timer);
        this.maybeDisableForFatalQmdError(err);
        reject(err);
      });

      child.on('close', code => {
        clearTimeout(timer);
        if (timedOut) {
          reject(new Error(`qmd ${args.join(' ')} timed out after ${timeoutMs}ms`));
          return;
        }

        if (code !== 0) {
          const err = new Error(
            `qmd ${args.join(' ')} failed with code ${code}: ${(stderr || stdout).trim()}`
          );
          this.maybeDisableForFatalQmdError(err);
          reject(err);
          return;
        }

        resolve({ stdout, stderr });
      });
    });
  }

  private parseResults(stdout: string): QMDSearchResult[] {
    try {
      // QMD sometimes outputs progress text before JSON, extract just the JSON array
      // Look for [ ... ] pattern that could be the JSON array
      const jsonMatch = stdout.match(/\[\s*\{[\s\S]*\}\s*\]/);
      if (!jsonMatch) return [];

      const parsed = JSON.parse(jsonMatch[0]);
      // QMD returns array of results
      if (Array.isArray(parsed)) {
        return parsed.map(r => ({
          docid: r.docid || '',
          filepath: r.filepath || r.file || '', // QMD uses 'file' field
          line: r.line,
          score: r.score || 0,
          title: r.title,
          context: r.context,
          snippet: r.snippet,
        }));
      }
      return [];
    } catch {
      return [];
    }
  }
}


path: src/store.test.ts
content: /**
 * Tests for Memory Store v2
 *
 * Tests file-based storage and migration from v1.
 */

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { MemoryFileManager } from './file-manager.js';
import { MemoryStore } from './store.js';
import {
  hasLegacyDatabase,
  hasNewMemories,
  migrateFromV1,
} from './migration.js';

// Helper to create temp directory
function createTempDir(): string {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'memory-tools-test-'));
}

// Helper to clean up temp directory
function cleanupTempDir(dir: string): void {
  if (fs.existsSync(dir)) {
    fs.rmSync(dir, { recursive: true, force: true });
  }
}

describe('MemoryFileManager', () => {
  let tempDir: string;
  let fileManager: MemoryFileManager;

  beforeEach(() => {
    tempDir = createTempDir();
    fileManager = new MemoryFileManager(tempDir);
  });

  afterEach(() => {
    cleanupTempDir(tempDir);
  });

  it('should create memory files with correct structure', () => {
    const memory = fileManager.create({
      content: 'User prefers dark mode',
      category: 'preference',
      confidence: 0.9,
      importance: 0.7,
      tags: ['ui', 'settings'],
    });

    expect(memory.id).toBeDefined();
    expect(memory.content).toBe('User prefers dark mode');
    expect(memory.category).toBe('preference');
    expect(memory.confidence).toBe(0.9);
    expect(memory.importance).toBe(0.7);
    expect(memory.tags).toEqual(['ui', 'settings']);

    // Check file exists
    const filePath = path.join(tempDir, 'preferences', `${memory.id}.md`);
    expect(fs.existsSync(filePath)).toBe(true);

    // Check file content
    const content = fs.readFileSync(filePath, 'utf-8');
    expect(content).toContain('---');
    expect(content).toContain('category: preference');
    expect(content).toContain('confidence: 0.9');
    expect(content).toContain('User prefers dark mode');
  });

  it('should read memory by ID', () => {
    const created = fileManager.create({
      content: 'Test memory content',
      category: 'fact',
    });

    const retrieved = fileManager.get(created.id);
    expect(retrieved).not.toBeNull();
    expect(retrieved!.id).toBe(created.id);
    expect(retrieved!.content).toBe('Test memory content');
    expect(retrieved!.category).toBe('fact');
  });

  it('should read memory by short ID (first 8 chars)', () => {
    const created = fileManager.create({
      content: 'Test memory',
      category: 'fact',
    });

    const shortId = created.id.slice(0, 8);
    const retrieved = fileManager.get(shortId);
    expect(retrieved).not.toBeNull();
    expect(retrieved!.id).toBe(created.id);
  });

  it('should update memory', async () => {
    const created = fileManager.create({
      content: 'Original content',
      category: 'fact',
      confidence: 0.5,
    });

    // Small delay to ensure timestamp difference
    await new Promise(resolve => setTimeout(resolve, 10));

    const updated = fileManager.update(created.id, {
      content: 'Updated content',
      confidence: 0.9,
    });

    expect(updated).not.toBeNull();
    expect(updated!.content).toBe('Updated content');
    expect(updated!.confidence).toBe(0.9);
    expect(updated!.updatedAt).toBeGreaterThanOrEqual(created.updatedAt);
  });

  it('should soft delete memory', () => {
    const created = fileManager.create({
      content: 'To be deleted',
      category: 'fact',
    });

    const success = fileManager.delete(created.id, 'Test deletion');
    expect(success).toBe(true);

    // Should not be retrievable
    const retrieved = fileManager.get(created.id);
    expect(retrieved).toBeNull();

    // Should exist in .deleted folder
    const deletedPath = path.join(tempDir, '.deleted', `${created.id}.md`);
    expect(fs.existsSync(deletedPath)).toBe(true);

    // Check deletion metadata
    const content = fs.readFileSync(deletedPath, 'utf-8');
    expect(content).toContain('delete_reason: Test deletion');
  });

  it('should list memories with pagination', () => {
    // Create 5 memories
    for (let i = 0; i < 5; i++) {
      fileManager.create({
        content: `Memory ${i}`,
        category: 'fact',
      });
    }

    const page1 = fileManager.list({ limit: 2, offset: 0 });
    expect(page1.total).toBe(5);
    expect(page1.items.length).toBe(2);

    const page2 = fileManager.list({ limit: 2, offset: 2 });
    expect(page2.total).toBe(5);
    expect(page2.items.length).toBe(2);

    const page3 = fileManager.list({ limit: 2, offset: 4 });
    expect(page3.total).toBe(5);
    expect(page3.items.length).toBe(1);
  });

  it('should filter by category', () => {
    fileManager.create({ content: 'Fact 1', category: 'fact' });
    fileManager.create({ content: 'Fact 2', category: 'fact' });
    fileManager.create({ content: 'Preference 1', category: 'preference' });

    const facts = fileManager.list({ category: 'fact' });
    expect(facts.total).toBe(2);
    expect(facts.items.every(m => m.category === 'fact')).toBe(true);

    const prefs = fileManager.list({ category: 'preference' });
    expect(prefs.total).toBe(1);
  });

  it('should count memories', () => {
    expect(fileManager.count()).toBe(0);

    fileManager.create({ content: 'Memory 1', category: 'fact' });
    fileManager.create({ content: 'Memory 2', category: 'preference' });

    expect(fileManager.count()).toBe(2);

    // Delete one
    const toDelete = fileManager.list().items[0];
    fileManager.delete(toDelete.id);

    expect(fileManager.count()).toBe(1);
  });

  it('should get memories by category sorted by importance', () => {
    fileManager.create({ content: 'Low importance', category: 'fact', importance: 0.3 });
    fileManager.create({ content: 'High importance', category: 'fact', importance: 0.9 });
    fileManager.create({ content: 'Medium importance', category: 'fact', importance: 0.6 });

    const facts = fileManager.getByCategory('fact');
    expect(facts.length).toBe(3);
    expect(facts[0].importance).toBe(0.9);
    expect(facts[1].importance).toBe(0.6);
    expect(facts[2].importance).toBe(0.3);
  });
});

describe('Migration', () => {
  let legacyDir: string;
  let newDir: string;

  beforeEach(() => {
    legacyDir = createTempDir();
    newDir = createTempDir();
  });

  afterEach(() => {
    cleanupTempDir(legacyDir);
    cleanupTempDir(newDir);
  });

  it('should detect legacy database', () => {
    expect(hasLegacyDatabase(legacyDir)).toBe(false);

    // Create fake legacy database
    fs.writeFileSync(path.join(legacyDir, 'memory.db'), 'fake db');
    expect(hasLegacyDatabase(legacyDir)).toBe(true);
  });

  it('should detect new memories directory', () => {
    expect(hasNewMemories(newDir)).toBe(false);

    // Create category directory with a memory file
    const factsDir = path.join(newDir, 'facts');
    fs.mkdirSync(factsDir, { recursive: true });
    fs.writeFileSync(path.join(factsDir, 'test.md'), 'test');

    expect(hasNewMemories(newDir)).toBe(true);
  });

  // Note: Full migration test requires a real SQLite database
  // This is tested in the integration tests with Docker
});

describe('MemoryStore', () => {
  let tempDir: string;
  let store: MemoryStore;

  beforeEach(() => {
    tempDir = createTempDir();
    store = new MemoryStore(tempDir);
  });

  afterEach(() => {
    store.close();
    cleanupTempDir(tempDir);
  });

  it('should create and retrieve memories', async () => {
    const memory = await store.create({
      content: 'Test memory',
      category: 'fact',
      confidence: 0.8,
    });

    expect(memory.id).toBeDefined();
    expect(memory.content).toBe('Test memory');

    const retrieved = await store.getAsync(memory.id);
    expect(retrieved).not.toBeNull();
    expect(retrieved!.content).toBe('Test memory');
  });

  it('should update memories', async () => {
    const memory = await store.create({
      content: 'Original',
      category: 'fact',
    });

    const updated = await store.update(memory.id, {
      content: 'Updated',
      confidence: 0.95,
    });

    expect(updated.content).toBe('Updated');
    expect(updated.confidence).toBe(0.95);
  });

  it('should delete memories', async () => {
    const memory = await store.create({
      content: 'To delete',
      category: 'fact',
    });

    await store.delete(memory.id, 'Test reason');

    const retrieved = await store.getAsync(memory.id);
    expect(retrieved).toBeNull();
  });

  it('should list memories', async () => {
    await store.create({ content: 'Memory 1', category: 'fact' });
    await store.create({ content: 'Memory 2', category: 'preference' });
    await store.create({ content: 'Memory 3', category: 'fact' });

    const all = await store.listAsync();
    expect(all.total).toBe(3);

    const factsOnly = await store.listAsync({ category: 'fact' });
    expect(factsOnly.total).toBe(2);
  });

  it('should count memories', async () => {
    expect(await store.countAsync()).toBe(0);

    await store.create({ content: 'Memory 1', category: 'fact' });
    await store.create({ content: 'Memory 2', category: 'fact' });

    expect(await store.countAsync()).toBe(2);
  });

  it('should get memories by category', async () => {
    await store.create({ content: 'Instruction 1', category: 'instruction', importance: 0.9 });
    await store.create({ content: 'Instruction 2', category: 'instruction', importance: 0.5 });
    await store.create({ content: 'Fact 1', category: 'fact' });

    const instructions = await store.getByCategoryAsync('instruction');
    expect(instructions.length).toBe(2);
    expect(instructions[0].importance).toBe(0.9); // Sorted by importance
  });

  // Note: Search tests require QMD to be installed
  // They are skipped if QMD is not available
  it.skip('should search memories with QMD', async () => {
    // This test requires QMD to be installed
    await store.init();

    await store.create({ content: 'User loves TypeScript programming', category: 'preference' });
    await store.create({ content: 'User has a meeting tomorrow', category: 'event' });

    // Wait for QMD index update
    await new Promise(resolve => setTimeout(resolve, 3000));

    const results = await store.search({ query: 'programming languages' });
    expect(results.length).toBeGreaterThan(0);
    expect(results[0].memory.content).toContain('TypeScript');
  });
});


path: src/store.ts
content: /**
 * Memory Store v2
 *
 * File-based storage with QMD search.
 * No SQLite, no LanceDB, no OpenAI.
 */

import type {
  Memory,
  MemorySearchResult,
  CreateMemoryInput,
  UpdateMemoryInput,
  SearchOptions,
  ListOptions,
  MemoryCategory,
} from './types.js';
import { MemoryFileManager } from './file-manager.js';
import { QMDClient } from './qmd.js';

export class MemoryStoreV2 {
  private fileManager: MemoryFileManager;
  private qmd: QMDClient;
  private memoriesPath: string;

  constructor(memoriesPath: string, qmdCollection: string = 'memories') {
    this.memoriesPath = memoriesPath;
    this.fileManager = new MemoryFileManager(memoriesPath);
    this.qmd = new QMDClient(memoriesPath, qmdCollection);
  }

  /**
   * Initialize the store and QMD collection
   */
  async init(): Promise<void> {
    await this.qmd.ensureCollection();
  }

  /**
   * Check if QMD is available
   */
  async isQMDAvailable(): Promise<boolean> {
    return this.qmd.isInstalled();
  }

  /**
   * Create a new memory
   */
  async create(input: CreateMemoryInput): Promise<Memory> {
    const memory = this.fileManager.create(input);

    // Schedule QMD index update
    this.qmd.scheduleUpdate();

    return memory;
  }

  /**
   * Get a memory by ID
   */
  get(id: string): Memory | null {
    return this.fileManager.get(id);
  }

  /**
   * Async version of get (for API compatibility)
   */
  async getAsync(id: string): Promise<Memory | null> {
    return this.fileManager.get(id);
  }

  /**
   * Update an existing memory
   */
  async update(id: string, updates: UpdateMemoryInput): Promise<Memory> {
    const updated = this.fileManager.update(id, updates);
    if (!updated) {
      throw new Error(`Memory ${id} not found`);
    }

    // Schedule QMD index update
    this.qmd.scheduleUpdate();

    return updated;
  }

  /**
   * Delete a memory (soft delete)
   */
  async delete(id: string, reason?: string): Promise<void> {
    const success = this.fileManager.delete(id, reason);
    if (!success) {
      throw new Error(`Memory ${id} not found`);
    }

    // Schedule QMD index update
    this.qmd.scheduleUpdate();
  }

  /**
   * Search memories using QMD
   */
  async search(opts: SearchOptions): Promise<MemorySearchResult[]> {
    const limit = opts.limit ?? 10;
    let results: MemorySearchResult[] = [];

    // If we have a query, use QMD semantic search
    if (opts.query) {
      const qmdResults = await this.qmd.query(opts.query, limit * 2); // Over-fetch for filtering

      if (qmdResults.length === 0) {
        return this.fallbackQuerySearch(opts.query, opts, limit);
      }

      for (const qr of qmdResults) {
        const memoryId = this.qmd.extractMemoryId(qr);
        if (!memoryId) continue;

        const memory = this.fileManager.get(memoryId);
        if (!memory || memory.deletedAt) continue;

        // Apply filters
        if (opts.category && memory.category !== opts.category) continue;
        if (opts.minConfidence !== undefined && memory.confidence < opts.minConfidence) continue;
        if (opts.minImportance !== undefined && memory.importance < opts.minImportance) continue;

        if (opts.tags?.length) {
          const hasAllTags = opts.tags.every(tag => memory.tags.includes(tag));
          if (!hasAllTags) continue;
        }

        // Check decay
        if (opts.excludeDecayed !== false && memory.decayDays) {
          const expiresAt = memory.createdAt + (memory.decayDays * 86400000);
          if (Date.now() > expiresAt) continue;
        }

        results.push({ memory, score: qr.score });
      }
    } else {
      // No query - just list with filters
      const listed = this.fileManager.list({
        category: opts.category,
        limit: limit,
      });

      results = listed.items
        .filter(memory => {
          if (opts.minConfidence !== undefined && memory.confidence < opts.minConfidence) return false;
          if (opts.minImportance !== undefined && memory.importance < opts.minImportance) return false;

          if (opts.tags?.length) {
            const hasAllTags = opts.tags.every(tag => memory.tags.includes(tag));
            if (!hasAllTags) return false;
          }

          if (opts.excludeDecayed !== false && memory.decayDays) {
            const expiresAt = memory.createdAt + (memory.decayDays * 86400000);
            if (Date.now() > expiresAt) return false;
          }

          return true;
        })
        .map(memory => ({ memory, score: 1.0 }));
    }

    return results.slice(0, limit);
  }

  private fallbackQuerySearch(
    query: string,
    opts: SearchOptions,
    limit: number
  ): MemorySearchResult[] {
    const all = this.fileManager.list({ category: opts.category, limit: 10000 }).items;
    const queryText = query.toLowerCase().trim();
    const queryTokens = queryText.split(/\s+/).filter(t => t.length > 1);

    const scored = all
      .filter(memory => {
        if (memory.deletedAt) return false;
        if (opts.minConfidence !== undefined && memory.confidence < opts.minConfidence) return false;
        if (opts.minImportance !== undefined && memory.importance < opts.minImportance) return false;

        if (opts.tags?.length) {
          const hasAllTags = opts.tags.every(tag => memory.tags.includes(tag));
          if (!hasAllTags) return false;
        }

        if (opts.excludeDecayed !== false && memory.decayDays) {
          const expiresAt = memory.createdAt + (memory.decayDays * 86400000);
          if (Date.now() > expiresAt) return false;
        }

        return true;
      })
      .map(memory => {
        const haystack = `${memory.content} ${memory.tags.join(' ')}`.toLowerCase();
        let score = 0;

        if (haystack.includes(queryText)) {
          score += 0.8;
        }

        if (queryTokens.length > 0) {
          let tokenHits = 0;
          for (const token of queryTokens) {
            if (haystack.includes(token)) tokenHits++;
          }
          score += (tokenHits / queryTokens.length) * 0.2;
        }

        return { memory, score };
      })
      .filter(r => r.score > 0)
      .sort((a, b) => b.score - a.score);

    return scored.slice(0, limit);
  }

  /**
   * Find potential duplicates using semantic similarity
   */
  async findDuplicates(content: string, threshold: number = 0.95): Promise<MemorySearchResult[]> {
    const qmdResults = await this.qmd.findSimilar(content, threshold);

    const results: MemorySearchResult[] = [];
    for (const qr of qmdResults) {
      const memoryId = this.qmd.extractMemoryId(qr);
      if (!memoryId) continue;

      const memory = this.fileManager.get(memoryId);
      if (!memory || memory.deletedAt) continue;

      results.push({ memory, score: qr.score });
    }

    return results;
  }

  /**
   * List memories with pagination
   */
  list(opts: ListOptions = {}): { total: number; items: Memory[] } {
    return this.fileManager.list(opts);
  }

  /**
   * Async version of list
   */
  async listAsync(opts: ListOptions = {}): Promise<{ total: number; items: Memory[] }> {
    return this.fileManager.list(opts);
  }

  /**
   * Get memories by category
   */
  getByCategory(category: MemoryCategory, limit: number = 50): Memory[] {
    return this.fileManager.getByCategory(category, limit);
  }

  /**
   * Async version of getByCategory
   */
  async getByCategoryAsync(category: MemoryCategory, limit: number = 50): Promise<Memory[]> {
    return this.fileManager.getByCategory(category, limit);
  }

  /**
   * Update last accessed time for multiple memories
   */
  touchMany(ids: string[]): void {
    this.fileManager.touchMany(ids);
  }

  /**
   * Async version of touchMany
   */
  async touchManyAsync(ids: string[]): Promise<void> {
    this.fileManager.touchMany(ids);
  }

  /**
   * Count non-deleted memories
   */
  count(): number {
    return this.fileManager.count();
  }

  /**
   * Async version of count
   */
  async countAsync(): Promise<number> {
    return this.fileManager.count();
  }

  /**
   * Get the memories directory path
   */
  getMemoriesPath(): string {
    return this.memoriesPath;
  }

  /**
   * Force QMD to re-index
   */
  async reindex(): Promise<void> {
    await this.qmd.forceUpdate();
  }

  /**
   * Close the store (cleanup)
   */
  close(): void {
    // Nothing to close - file-based storage
  }
}

// Export as MemoryStore for backwards compatibility
export { MemoryStoreV2 as MemoryStore };


path: src/tools.test.ts
content: /**
 * Memory Tools Tests (v2)
 *
 * Tests for the six agent-controlled memory operations.
 * Note: Search tests require QMD to be installed.
 */

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createMemoryTools } from './tools.js';
import { MemoryStore } from './store.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';

describe('Memory Tools', () => {
  let store: MemoryStore;
  let tools: ReturnType<typeof createMemoryTools>;
  let testDir: string;

  beforeEach(() => {
    testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-tools-test-'));
    store = new MemoryStore(testDir);
    tools = createMemoryTools(store as any);
  });

  afterEach(() => {
    store.close();
    if (testDir && fs.existsSync(testDir)) {
      fs.rmSync(testDir, { recursive: true, force: true });
    }
  });

  describe('memory_store', () => {
    it('should store a new memory', async () => {
      const result = await tools.memory_store.execute('test', {
        content: 'User likes coffee',
        category: 'preference',
        confidence: 0.9,
        importance: 0.7,
      });

      expect(result.content[0].text).toContain('Stored');
      expect(result.details?.action).toBe('created');
      expect(result.details?.category).toBe('preference');
    });

    it('should store memory with tags', async () => {
      const result = await tools.memory_store.execute('test', {
        content: 'User birthday is January 15',
        category: 'fact',
        tags: ['personal', 'dates'],
      });

      expect(result.details?.action).toBe('created');

      // Verify tags stored
      const memory = store.get(result.details?.id as string);
      expect(memory?.tags).toContain('personal');
      expect(memory?.tags).toContain('dates');
    });

    it('should use default confidence and importance', async () => {
      const result = await tools.memory_store.execute('test', {
        content: 'Some fact',
        category: 'fact',
      });

      const memory = store.get(result.details?.id as string);
      expect(memory?.confidence).toBe(0.8);
      expect(memory?.importance).toBe(0.5);
    });
  });

  describe('memory_update', () => {
    it('should update memory content', async () => {
      // Create initial memory
      const createResult = await tools.memory_store.execute('test', {
        content: 'User dog name is Max',
        category: 'fact',
      });
      const id = createResult.details?.id as string;

      // Update it
      const updateResult = await tools.memory_update.execute('test', {
        id,
        content: 'User dog name is Rex',
      });

      expect(updateResult.content[0].text).toContain('Updated');
      expect(updateResult.content[0].text).toContain('Rex');
    });

    it('should update confidence score', async () => {
      const createResult = await tools.memory_store.execute('test', {
        content: 'User might like hiking',
        category: 'preference',
        confidence: 0.5,
      });
      const id = createResult.details?.id as string;

      await tools.memory_update.execute('test', {
        id,
        confidence: 0.95,
      });

      const memory = store.get(id);
      expect(memory?.confidence).toBe(0.95);
    });

    it('should return error for non-existent memory', async () => {
      const result = await tools.memory_update.execute('test', {
        id: 'non-existent-id',
        content: 'Updated content',
      });

      expect(result.content[0].text).toContain('not found');
      expect(result.details?.error).toBe('not_found');
    });
  });

  describe('memory_forget', () => {
    it('should delete memory by id', async () => {
      const createResult = await tools.memory_store.execute('test', {
        content: 'Memory to delete',
        category: 'fact',
      });
      const id = createResult.details?.id as string;

      const deleteResult = await tools.memory_forget.execute('test', {
        id,
        reason: 'Test deletion',
      });

      expect(deleteResult.content[0].text).toContain('Forgotten');
      expect(deleteResult.details?.action).toBe('deleted');

      // Verify deletion
      const memory = store.get(id);
      expect(memory).toBeNull();
    });

    it('should return error for non-existent memory', async () => {
      const result = await tools.memory_forget.execute('test', {
        id: 'non-existent-id',
      });

      expect(result.content[0].text).toContain('not found');
    });

    it('should require id or query', async () => {
      const result = await tools.memory_forget.execute('test', {});

      expect(result.content[0].text).toContain('Provide id or query');
    });
  });

  describe('memory_list', () => {
    beforeEach(async () => {
      // Create test memories
      await tools.memory_store.execute('test', {
        content: 'Fact 1',
        category: 'fact',
        importance: 0.5,
      });
      await tools.memory_store.execute('test', {
        content: 'Fact 2',
        category: 'fact',
        importance: 0.8,
      });
      await tools.memory_store.execute('test', {
        content: 'Preference 1',
        category: 'preference',
        importance: 0.6,
      });
    });

    it('should list all memories', async () => {
      const result = await tools.memory_list.execute('test', {});

      expect(result.content[0].text).toContain('3');
      expect(result.details?.total).toBe(3);
    });

    it('should filter by category', async () => {
      const result = await tools.memory_list.execute('test', {
        category: 'fact',
      });

      expect(result.details?.total).toBe(2);
      const memories = result.details?.memories as any[];
      expect(memories.every((m: any) => m.category === 'fact')).toBe(true);
    });

    it('should sort by importance', async () => {
      const result = await tools.memory_list.execute('test', {
        sortBy: 'importance',
      });

      const memories = result.details?.memories as any[];
      expect(memories[0].importance).toBeGreaterThanOrEqual(memories[1].importance);
    });

    it('should paginate', async () => {
      const result = await tools.memory_list.execute('test', {
        limit: 2,
        offset: 0,
      });

      expect(result.details?.count).toBe(2);
      expect(result.details?.total).toBe(3);
    });
  });

  // Note: Search and summarize tests require QMD
  // They are skipped in environments without QMD
  describe('memory_search (without QMD)', () => {
    it('should return no results without QMD', async () => {
      await tools.memory_store.execute('test', {
        content: 'User loves TypeScript',
        category: 'preference',
      });

      // Without QMD, semantic search returns empty
      const result = await tools.memory_search.execute('test', {
        query: 'programming',
      });

      // May return no results without QMD, which is expected
      expect(result.content[0].text).toBeDefined();
    });
  });
});


path: src/tools.ts
content: /**
 * Memory Tools
 *
 * The six agent-controlled memory operations:
 * - memory_store: Save new memories
 * - memory_update: Modify existing memories
 * - memory_forget: Delete memories
 * - memory_search: Semantic search
 * - memory_summarize: Get topic summary
 * - memory_list: Browse all memories
 */

import { Type } from '@sinclair/typebox';
import type { MemoryStore } from './store.js';
import { MEMORY_CATEGORIES, type MemoryCategory } from './types.js';

// Type helper for string enums (OpenClaw compatible)
function stringEnum<T extends string>(values: readonly T[]) {
  return Type.Unsafe<T>({ type: 'string', enum: [...values] });
}

export function createMemoryTools(store: MemoryStore) {
  return {
    // ═══════════════════════════════════════════════════════════════════════
    // STORE - Add new memory
    // ═══════════════════════════════════════════════════════════════════════
    memory_store: {
      name: 'memory_store',
      label: 'Memory Store',
      description: `Store a new memory about the user. Use when you learn something worth remembering long-term.

WHEN to use:
- User shares personal info (name, birthday, preferences)
- User gives standing instructions ("always summarize emails")
- User mentions relationships ("my wife Sarah")
- User states preferences ("I prefer bullet points")
- Important decisions are made

WHEN NOT to use:
- Trivial conversation (weather, greetings)
- Already stored (use memory_update instead)
- Temporary context (use conversation history)`,

      parameters: Type.Object({
        content: Type.String({
          description: 'The fact/preference/info to remember. Be specific and atomic.'
        }),
        category: stringEnum(MEMORY_CATEGORIES),
        confidence: Type.Optional(Type.Number({
          minimum: 0,
          maximum: 1,
          description: 'How confident this is accurate. 1.0 = explicitly stated, 0.5 = inferred'
        })),
        importance: Type.Optional(Type.Number({
          minimum: 0,
          maximum: 1,
          description: 'How important is this. 1.0 = critical instruction, 0.3 = nice to know'
        })),
        decayDays: Type.Optional(Type.Number({
          description: 'Days until memory becomes stale. Omit for permanent. Events should have decay.'
        })),
        tags: Type.Optional(Type.Array(Type.String(), {
          description: 'Tags for categorization and retrieval'
        })),
        supersedes: Type.Optional(Type.String({
          description: 'ID of memory this replaces (will delete the old one)'
        })),
      }),

      async execute(
        _toolCallId: string,
        params: {
          content: string;
          category: MemoryCategory;
          confidence?: number;
          importance?: number;
          decayDays?: number;
          tags?: string[];
          supersedes?: string;
        },
        ctx?: { messageChannel?: string; }
      ) {
        // Handle explicit supersedes first (user knows what to replace)
        let supersededId: string | undefined = params.supersedes;
        if (params.supersedes) {
          await store.delete(params.supersedes, 'superseded by new memory');
        }

        // Check for similar/conflicting memories
        // Use low threshold (0.4) to catch potential conflicts, then decide based on score
        const similar = await store.findDuplicates(params.content, 0.4);

        if (similar.length > 0 && !params.supersedes) {
          const match = similar[0];
          const isHighSimilarity = match.score > 0.92;
          const isSameCategory = match.memory.category === params.category;

          // High similarity = likely duplicate (exact same info)
          if (isHighSimilarity) {
            return {
              content: [{
                type: 'text' as const,
                text: `Similar memory already exists: "${match.memory.content}" (${(match.score * 100).toFixed(0)}% match). Use memory_update to modify it, or pass supersedes="${match.memory.id}" to replace it.`
              }],
              details: {
                action: 'duplicate',
                existingId: match.memory.id,
                existingContent: match.memory.content,
                similarity: match.score,
              },
            };
          }
          // Same category + moderate similarity = conflicting info -> AUTO-REPLACE
          // This handles corrections like "favorite color is blue" -> "favorite color is purple"
          else if (isSameCategory && match.score > 0.5) {
            await store.delete(match.memory.id, 'auto-superseded by updated info');
            supersededId = match.memory.id;
          }
        }

        const memory = await store.create({
          content: params.content,
          category: params.category,
          confidence: params.confidence ?? 0.8,
          importance: params.importance ?? 0.5,
          decayDays: params.decayDays,
          tags: params.tags ?? [],
          sourceChannel: ctx?.messageChannel,
        });

        // Build response message
        const contentPreview = `${params.content.slice(0, 80)}${params.content.length > 80 ? '...' : ''}`;
        const message = supersededId
          ? `Updated: "${contentPreview}" [${params.category}] (replaced previous entry)`
          : `Stored: "${contentPreview}" [${params.category}]`;

        return {
          content: [{
            type: 'text' as const,
            text: message,
          }],
          details: {
            action: supersededId ? 'replaced' : 'created',
            id: memory.id,
            category: memory.category,
            confidence: memory.confidence,
            supersededId,
          },
        };
      },
    },

    // ═══════════════════════════════════════════════════════════════════════
    // UPDATE - Modify existing memory
    // ═══════════════════════════════════════════════════════════════════════
    memory_update: {
      name: 'memory_update',
      label: 'Memory Update',
      description: `Update an existing memory when information changes or was incorrect.

Use when:
- User corrects previous info ("actually my dog's name is Rex, not Max")
- Information becomes more specific ("my meeting is at 3pm, not just 'afternoon'")
- Confidence changes (user confirms something you inferred)`,

      parameters: Type.Object({
        id: Type.String({
          description: 'ID of memory to update (from memory_search results)'
        }),
        content: Type.Optional(Type.String({
          description: 'Updated content'
        })),
        confidence: Type.Optional(Type.Number({
          minimum: 0,
          maximum: 1,
          description: 'Updated confidence score'
        })),
        importance: Type.Optional(Type.Number({
          minimum: 0,
          maximum: 1,
          description: 'Updated importance score'
        })),
      }),

      async execute(
        _toolCallId: string,
        params: {
          id: string;
          content?: string;
          confidence?: number;
          importance?: number;
        }
      ) {
        const existing = await store.getAsync(params.id);
        if (!existing) {
          return {
            content: [{ type: 'text' as const, text: `Memory ${params.id} not found.` }],
            details: { error: 'not_found' },
          };
        }

        const memory = await store.update(params.id, {
          content: params.content,
          confidence: params.confidence,
          importance: params.importance,
        });

        return {
          content: [{
            type: 'text' as const,
            text: `Updated memory: "${memory.content.slice(0, 80)}${memory.content.length > 80 ? '...' : ''}"`
          }],
          details: {
            action: 'updated',
            id: memory.id,
            content: memory.content,
            confidence: memory.confidence,
          },
        };
      },
    },

    // ═══════════════════════════════════════════════════════════════════════
    // FORGET - Delete memory
    // ═══════════════════════════════════════════════════════════════════════
    memory_forget: {
      name: 'memory_forget',
      label: 'Memory Forget',
      description: `Delete a memory permanently.

Use when:
- User explicitly asks you to forget something
- Information is no longer relevant ("I sold that car")
- Memory was stored in error`,

      parameters: Type.Object({
        id: Type.Optional(Type.String({
          description: 'ID of memory to delete (if known)'
        })),
        query: Type.Optional(Type.String({
          description: 'Search query to find memory to delete (if ID unknown)'
        })),
        reason: Type.Optional(Type.String({
          description: 'Why this memory is being forgotten (for audit log)'
        })),
      }),

      async execute(
        _toolCallId: string,
        params: {
          id?: string;
          query?: string;
          reason?: string;
        }
      ) {
        if (params.id) {
          const existing = await store.getAsync(params.id);
          if (!existing) {
            return {
              content: [{ type: 'text' as const, text: `Memory ${params.id} not found.` }],
              details: { error: 'not_found' },
            };
          }

          await store.delete(params.id, params.reason);
          return {
            content: [{ type: 'text' as const, text: `Forgotten: "${existing.content.slice(0, 60)}..."` }],
            details: { action: 'deleted', id: params.id },
          };
        }

        if (params.query) {
          const results = await store.search({
            query: params.query,
            limit: 5,
            minConfidence: 0.3,
          });

          if (results.length === 0) {
            return {
              content: [{ type: 'text' as const, text: 'No matching memories found.' }],
              details: { found: 0 },
            };
          }

          // Check if query text appears in any result (case-insensitive exact match)
          const queryLower = params.query.toLowerCase();
          const exactMatch = results.find(r =>
            r.memory.content.toLowerCase().includes(queryLower)
          );

          // Auto-delete if:
          // 1. Single high-confidence match (score > 0.9), OR
          // 2. Query text appears literally in top result, OR
          // 3. Top result has significantly higher score than second (clear winner)
          const topResult = results[0];
          const secondScore = results.length > 1 ? results[1].score : 0;
          const clearWinner = topResult.score > 0.5 && topResult.score > secondScore * 1.5;

          if (exactMatch || (results.length === 1 && topResult.score > 0.9) || clearWinner) {
            const toDelete = exactMatch || topResult;
            await store.delete(toDelete.memory.id, params.reason);
            return {
              content: [{
                type: 'text' as const,
                text: `Forgotten: "${toDelete.memory.content.slice(0, 60)}..."`
              }],
              details: { action: 'deleted', id: toDelete.memory.id },
            };
          }

          // Return candidates for user selection
          const list = results
            .map(r => `- [${r.memory.id.slice(0, 8)}] ${r.memory.content.slice(0, 50)}... (${(r.score * 100).toFixed(0)}%)`)
            .join('\n');

          return {
            content: [{
              type: 'text' as const,
              text: `Found ${results.length} candidates. Specify id:\n${list}`
            }],
            details: {
              action: 'candidates',
              candidates: results.map(r => ({
                id: r.memory.id,
                content: r.memory.content,
                score: r.score,
              })),
            },
          };
        }

        return {
          content: [{ type: 'text' as const, text: 'Provide id or query.' }],
          details: { error: 'missing_param' },
        };
      },
    },

    // ═══════════════════════════════════════════════════════════════════════
    // SEARCH - Semantic search
    // ═══════════════════════════════════════════════════════════════════════
    memory_search: {
      name: 'memory_search',
      label: 'Memory Search',
      description: `Search memories by semantic similarity and/or filters.

Use when:
- You need context about the user to answer well
- User references something from the past ("remember when I told you...")
- You want to personalize a response
- Before storing, to check if memory already exists`,

      parameters: Type.Object({
        query: Type.Optional(Type.String({
          description: 'Semantic search query'
        })),
        category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
        tags: Type.Optional(Type.Array(Type.String(), {
          description: 'Filter by tags (AND logic)'
        })),
        minConfidence: Type.Optional(Type.Number({
          minimum: 0,
          maximum: 1,
          description: 'Minimum confidence threshold (default: 0.5)'
        })),
        limit: Type.Optional(Type.Number({
          maximum: 50,
          description: 'Max results to return (default: 10)'
        })),
      }),

      async execute(
        _toolCallId: string,
        params: {
          query?: string;
          category?: MemoryCategory;
          tags?: string[];
          minConfidence?: number;
          limit?: number;
        }
      ) {
        const results = await store.search({
          query: params.query,
          category: params.category,
          tags: params.tags,
          minConfidence: params.minConfidence ?? 0.5,
          limit: params.limit ?? 10,
          excludeDecayed: true,
        });

        // Update last accessed
        await store.touchManyAsync(results.map(r => r.memory.id));

        if (results.length === 0) {
          return {
            content: [{ type: 'text' as const, text: 'No relevant memories found.' }],
            details: { count: 0 },
          };
        }

        const text = results
          .map((r, i) =>
            `${i + 1}. [${r.memory.category}] ${r.memory.content} (${(r.score * 100).toFixed(0)}% match, ${(r.memory.confidence * 100).toFixed(0)}% confident)`
          )
          .join('\n');

        return {
          content: [{
            type: 'text' as const,
            text: `Found ${results.length} memories:\n\n${text}`
          }],
          details: {
            count: results.length,
            memories: results.map(r => ({
              id: r.memory.id,
              content: r.memory.content,
              category: r.memory.category,
              confidence: r.memory.confidence,
              importance: r.memory.importance,
              score: r.score,
              tags: r.memory.tags,
            })),
          },
        };
      },
    },

    // ═══════════════════════════════════════════════════════════════════════
    // SUMMARIZE - Topic summary
    // ═══════════════════════════════════════════════════════════════════════
    memory_summarize: {
      name: 'memory_summarize',
      label: 'Memory Summarize',
      description: `Get a summary of memories related to a topic.

Use when:
- Starting a conversation and want general context
- Topic is broad ("what do I know about user's work")
- Too many memories would be retrieved with search`,

      parameters: Type.Object({
        topic: Type.String({
          description: 'Topic to summarize memories about'
        }),
        maxMemories: Type.Optional(Type.Number({
          description: 'Max memories to include in summary (default: 20)'
        })),
      }),

      async execute(
        _toolCallId: string,
        params: {
          topic: string;
          maxMemories?: number;
        }
      ) {
        const results = await store.search({
          query: params.topic,
          limit: params.maxMemories ?? 20,
          excludeDecayed: true,
        });

        if (results.length === 0) {
          return {
            content: [{
              type: 'text' as const,
              text: `No memories found about "${params.topic}".`
            }],
            details: { memoryCount: 0 },
          };
        }

        // Group by category
        const byCategory = new Map<string, string[]>();
        for (const r of results) {
          const cat = r.memory.category;
          if (!byCategory.has(cat)) byCategory.set(cat, []);
          byCategory.get(cat)!.push(r.memory.content);
        }

        // Format summary
        const sections = Array.from(byCategory.entries())
          .map(([cat, items]) => `**${cat}**:\n${items.map(i => `- ${i}`).join('\n')}`)
          .join('\n\n');

        return {
          content: [{
            type: 'text' as const,
            text: `Summary of "${params.topic}" (${results.length} memories):\n\n${sections}`
          }],
          details: {
            memoryCount: results.length,
            categories: Object.fromEntries(byCategory),
          },
        };
      },
    },

    // ═══════════════════════════════════════════════════════════════════════
    // LIST - Browse all memories
    // ═══════════════════════════════════════════════════════════════════════
    memory_list: {
      name: 'memory_list',
      label: 'Memory List',
      description: `List all memories, optionally filtered. Use for browsing/auditing, not semantic search.`,

      parameters: Type.Object({
        category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
        sortBy: Type.Optional(stringEnum([
          'createdAt', 'updatedAt', 'importance', 'confidence', 'lastAccessedAt'
        ] as const)),
        limit: Type.Optional(Type.Number({
          description: 'Max results (default: 20)'
        })),
        offset: Type.Optional(Type.Number({
          description: 'Skip first N results (for pagination)'
        })),
      }),

      async execute(
        _toolCallId: string,
        params: {
          category?: MemoryCategory;
          sortBy?: 'createdAt' | 'updatedAt' | 'importance' | 'confidence' | 'lastAccessedAt';
          limit?: number;
          offset?: number;
        }
      ) {
        const results = await store.listAsync({
          category: params.category,
          sortBy: params.sortBy ?? 'createdAt',
          sortOrder: 'desc',
          limit: params.limit ?? 20,
          offset: params.offset ?? 0,
        });

        if (results.items.length === 0) {
          return {
            content: [{ type: 'text' as const, text: 'No memories found.' }],
            details: { total: 0, count: 0 },
          };
        }

        const text = results.items
          .map((m, i) =>
            `${i + 1}. [${m.category}] ${m.content.slice(0, 60)}${m.content.length > 60 ? '...' : ''} (${(m.confidence * 100).toFixed(0)}%)`
          )
          .join('\n');

        return {
          content: [{
            type: 'text' as const,
            text: `Showing ${results.items.length} of ${results.total} memories:\n\n${text}`
          }],
          details: {
            total: results.total,
            count: results.items.length,
            memories: results.items.map(m => ({
              id: m.id,
              content: m.content,
              category: m.category,
              confidence: m.confidence,
              importance: m.importance,
              createdAt: m.createdAt,
            })),
          },
        };
      },
    },
  };
}

export type MemoryTools = ReturnType<typeof createMemoryTools>;


path: src/types.ts
content: /**
 * Memory-as-Tools Type Definitions
 *
 * Core types for the agent-controlled memory system with
 * confidence scoring, decay, and semantic search.
 */

export const MEMORY_CATEGORIES = [
  'fact',         // "User's dog is named Rex"
  'preference',   // "User prefers dark mode"
  'event',        // "User has dentist appointment Tuesday"
  'relationship', // "User's sister is named Sarah"
  'context',      // "User is working on a React project"
  'instruction',  // "Always respond in Spanish"
  'decision',     // "We decided to use PostgreSQL"
  'entity',       // Contact info, phone numbers, emails
] as const;

export type MemoryCategory = typeof MEMORY_CATEGORIES[number];

export interface Memory {
  id: string;
  content: string;
  category: MemoryCategory;
  confidence: number;        // 0.0 - 1.0: how sure are we this is accurate
  importance: number;        // 0.0 - 1.0: how important is this

  // Temporal
  createdAt: number;         // Unix timestamp ms
  updatedAt: number;         // Unix timestamp ms
  lastAccessedAt: number;    // Unix timestamp ms
  decayDays: number | null;  // null = permanent

  // Provenance
  sourceChannel?: string;    // 'whatsapp' | 'telegram' | 'discord' | etc
  sourceMessageId?: string;  // for traceability

  // Relations
  tags: string[];
  supersedes?: string;       // id of memory this updates/replaces

  // Soft delete
  deletedAt?: number;
  deleteReason?: string;
}

export interface MemorySearchResult {
  memory: Memory;
  score: number;             // Similarity score 0-1
}

export interface CreateMemoryInput {
  content: string;
  category: MemoryCategory;
  confidence?: number;
  importance?: number;
  decayDays?: number | null;
  tags?: string[];
  sourceChannel?: string;
  sourceMessageId?: string;
}

export interface UpdateMemoryInput {
  content?: string;
  confidence?: number;
  importance?: number;
  decayDays?: number | null;
  tags?: string[];
}

export interface SearchOptions {
  query?: string;
  category?: MemoryCategory;
  tags?: string[];
  minConfidence?: number;
  minImportance?: number;
  limit?: number;
  excludeDecayed?: boolean;
  includeDeleted?: boolean;
}

export interface ListOptions {
  category?: MemoryCategory;
  sortBy?: 'createdAt' | 'updatedAt' | 'importance' | 'confidence' | 'lastAccessedAt';
  sortOrder?: 'asc' | 'desc';
  limit?: number;
  offset?: number;
}


path: test/docker/docker-compose.yml
content: version: '3.8'

services:
  migration-test:
    build:
      context: ../..
      dockerfile: test/docker/Dockerfile
    volumes:
      - ../..:/app/memory-tools:ro
      - test-data:/home/testuser/.openclaw
    environment:
      - HOME=/home/testuser
      - NODE_ENV=test

volumes:
  test-data:


path: test/docker/run-tests.sh
content: #!/bin/bash
# Run migration tests in Docker
#
# Usage: ./run-tests.sh

set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"

echo "Building and running migration tests..."
echo ""

cd "$PROJECT_ROOT"

# Build the Docker image
docker build -t memory-tools-test -f test/docker/Dockerfile .

# Run the tests
docker run --rm memory-tools-test

echo ""
echo "Done!"


path: test/docker/seed-v1-data.js
content: #!/usr/bin/env node
/**
 * Seed v1 Database with Test Data
 *
 * Creates a SQLite database with sample memories to test migration.
 */

import initSqlJs from 'sql.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { randomUUID } from 'node:crypto';

const DB_PATH = process.env.HOME + '/.openclaw/memory/tools';
const DB_FILE = path.join(DB_PATH, 'memory.db');

async function main() {
  // Ensure directory exists
  fs.mkdirSync(DB_PATH, { recursive: true });

  // Initialize sql.js
  const SQL = await initSqlJs();
  const db = new SQL.Database();

  // Create schema (same as v1)
  db.run(`
    CREATE TABLE IF NOT EXISTS memories (
      id TEXT PRIMARY KEY,
      content TEXT NOT NULL,
      category TEXT NOT NULL,
      confidence REAL DEFAULT 0.8,
      importance REAL DEFAULT 0.5,
      created_at INTEGER NOT NULL,
      updated_at INTEGER NOT NULL,
      last_accessed_at INTEGER,
      decay_days INTEGER,
      source_channel TEXT,
      source_message_id TEXT,
      tags TEXT,
      supersedes TEXT,
      deleted_at INTEGER,
      delete_reason TEXT
    )
  `);

  db.run(`CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category)`);
  db.run(`CREATE INDEX IF NOT EXISTS idx_memories_confidence ON memories(confidence)`);

  // Seed test data
  const testMemories = [
    {
      content: 'User name is Alice',
      category: 'fact',
      confidence: 1.0,
      importance: 0.9,
      tags: ['identity'],
    },
    {
      content: 'User prefers dark mode in all applications',
      category: 'preference',
      confidence: 0.95,
      importance: 0.7,
      tags: ['ui', 'settings'],
    },
    {
      content: 'User has a meeting with Bob on Friday',
      category: 'event',
      confidence: 0.8,
      importance: 0.6,
      decayDays: 7,
      tags: ['calendar'],
    },
    {
      content: 'Always respond in a friendly, casual tone',
      category: 'instruction',
      confidence: 1.0,
      importance: 1.0,
      tags: ['communication'],
    },
    {
      content: 'User sister is named Sarah',
      category: 'relationship',
      confidence: 0.9,
      importance: 0.5,
      tags: ['family'],
    },
    {
      content: 'User is working on a React project called "Dashboard"',
      category: 'context',
      confidence: 0.7,
      importance: 0.6,
      tags: ['work', 'programming'],
    },
    {
      content: 'We decided to use PostgreSQL for the database',
      category: 'decision',
      confidence: 1.0,
      importance: 0.8,
      tags: ['architecture', 'database'],
    },
    {
      content: 'User email: [REDACTED_SECRET]',
      category: 'entity',
      confidence: 1.0,
      importance: 0.9,
      tags: ['contact'],
    },
    // Add a deleted memory
    {
      content: 'User favorite color is blue',
      category: 'preference',
      confidence: 0.8,
      importance: 0.3,
      tags: ['personal'],
      deleted: true,
      deleteReason: 'User corrected: favorite color is green',
    },
  ];

  const now = Date.now();
  let insertCount = 0;

  for (const mem of testMemories) {
    const id = randomUUID();
    const createdAt = now - Math.random() * 86400000 * 30; // Random time in last 30 days

    db.run(`
      INSERT INTO memories (
        id, content, category, confidence, importance,
        created_at, updated_at, last_accessed_at, decay_days,
        tags, deleted_at, delete_reason
      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `, [
      id,
      mem.content,
      mem.category,
      mem.confidence,
      mem.importance,
      createdAt,
      createdAt,
      createdAt,
      mem.decayDays ?? null,
      JSON.stringify(mem.tags || []),
      mem.deleted ? now : null,
      mem.deleted ? mem.deleteReason : null,
    ]);

    insertCount++;
    console.log(`Created memory: [${mem.category}] ${mem.content.slice(0, 50)}...`);
  }

  // Save database
  const data = db.export();
  fs.writeFileSync(DB_FILE, Buffer.from(data));
  db.close();

  console.log(`\n✓ Created v1 database with ${insertCount} memories at ${DB_FILE}`);
}

main().catch(console.error);


path: test/docker/test-migration.sh
content: #!/bin/bash
# Test Migration from v1 to v2
#
# This script:
# 1. Seeds a v1 database with test data
# 2. Installs memory-tools v2
# 3. Runs the migration
# 4. Verifies memories were migrated correctly

set -e

echo "========================================"
echo "Memory-Tools Migration Test"
echo "========================================"
echo ""

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

# Paths
V1_DB_PATH="$HOME/.openclaw/memory/tools"
V2_MEMORIES_PATH="$HOME/.openclaw/memories"

# Step 1: Seed v1 database
echo -e "${YELLOW}Step 1: Creating v1 database with test data...${NC}"
cd /app/memory-tools
node test/docker/seed-v1-data.js

# Verify v1 database exists
if [ -f "$V1_DB_PATH/memory.db" ]; then
    echo -e "${GREEN}✓ v1 database created${NC}"
    echo "  Location: $V1_DB_PATH/memory.db"
    echo "  Size: $(du -h "$V1_DB_PATH/memory.db" | cut -f1)"
else
    echo -e "${RED}✗ Failed to create v1 database${NC}"
    exit 1
fi
echo ""

# Step 2: Install memory-tools v2
echo -e "${YELLOW}Step 2: Installing memory-tools v2...${NC}"
npm install
npm run build
echo -e "${GREEN}✓ memory-tools v2 installed${NC}"
echo ""

# Step 3: Run migration test
echo -e "${YELLOW}Step 3: Testing migration...${NC}"

# Create a simple test script that imports and runs migration
node --experimental-vm-modules -e "
import { hasLegacyDatabase, hasNewMemories, migrateFromV1 } from './dist/migration.js';

const legacyPath = '$V1_DB_PATH';
const newPath = '$V2_MEMORIES_PATH';

console.log('Checking for legacy database...');
const hasLegacy = hasLegacyDatabase(legacyPath);
console.log('Has legacy database:', hasLegacy);

console.log('Checking for new memories...');
const hasNew = hasNewMemories(newPath);
console.log('Has new memories:', hasNew);

if (hasLegacy && !hasNew) {
    console.log('Starting migration...');
    const result = await migrateFromV1(legacyPath, newPath);
    console.log('Migration result:', JSON.stringify(result, null, 2));

    if (result.success || result.migratedCount > 0) {
        console.log('Migration successful!');
        process.exit(0);
    } else {
        console.error('Migration failed:', result.errors);
        process.exit(1);
    }
} else if (hasNew) {
    console.log('New memories already exist, skipping migration');
    process.exit(0);
} else {
    console.error('No legacy database found');
    process.exit(1);
}
"

echo ""

# Step 4: Verify migration
echo -e "${YELLOW}Step 4: Verifying migration...${NC}"

# Check that .md files were created
FACT_COUNT=$(find "$V2_MEMORIES_PATH/facts" -name "*.md" 2>/dev/null | wc -l || echo 0)
PREF_COUNT=$(find "$V2_MEMORIES_PATH/preferences" -name "*.md" 2>/dev/null | wc -l || echo 0)
INSTR_COUNT=$(find "$V2_MEMORIES_PATH/instructions" -name "*.md" 2>/dev/null | wc -l || echo 0)
DELETED_COUNT=$(find "$V2_MEMORIES_PATH/.deleted" -name "*.md" 2>/dev/null | wc -l || echo 0)

echo "Migrated memories:"
echo "  Facts: $FACT_COUNT"
echo "  Preferences: $PREF_COUNT"
echo "  Instructions: $INSTR_COUNT"
echo "  Deleted: $DELETED_COUNT"

TOTAL=$((FACT_COUNT + PREF_COUNT + INSTR_COUNT))

if [ "$TOTAL" -gt 0 ]; then
    echo -e "${GREEN}✓ Migration verification passed${NC}"
else
    echo -e "${RED}✗ No memories were migrated${NC}"
    exit 1
fi
echo ""

# Step 5: Check file content
echo -e "${YELLOW}Step 5: Checking file content...${NC}"

# Find first fact file and display it
FIRST_FILE=$(find "$V2_MEMORIES_PATH" -name "*.md" -not -path "*/.deleted/*" | head -1)
if [ -n "$FIRST_FILE" ]; then
    echo "Sample migrated file: $FIRST_FILE"
    echo "---"
    cat "$FIRST_FILE"
    echo "---"
    echo -e "${GREEN}✓ File content looks good${NC}"
else
    echo -e "${RED}✗ No memory files found${NC}"
    exit 1
fi
echo ""

# Step 6: Verify original database preserved
echo -e "${YELLOW}Step 6: Checking backup...${NC}"

if [ -f "$V1_DB_PATH/memory.db" ]; then
    echo -e "${GREEN}✓ Original v1 database preserved at $V1_DB_PATH${NC}"
else
    echo -e "${RED}✗ Original database was deleted${NC}"
    exit 1
fi

if [ -f "$V1_DB_PATH/.migrated-to-v2" ]; then
    echo -e "${GREEN}✓ Migration marker created${NC}"
else
    echo -e "${YELLOW}⚠ Migration marker not found (non-critical)${NC}"
fi
echo ""

# Final summary
echo "========================================"
echo -e "${GREEN}ALL TESTS PASSED${NC}"
echo "========================================"
echo ""
echo "Summary:"
echo "  - v1 database: $V1_DB_PATH/memory.db (preserved)"
echo "  - v2 memories: $V2_MEMORIES_PATH/"
echo "  - Total migrated: $TOTAL active + $DELETED_COUNT deleted"
echo ""
echo "Next steps:"
echo "  1. Install QMD: npm install -g @tobilu/qmd"
echo "  2. Create collection: qmd collection add $V2_MEMORIES_PATH --name memories"
echo "  3. Index: qmd embed"
echo ""


path: tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022"],
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": false
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}


path: vitest.config.ts
content: import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['src/**/*.test.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules',
        'dist',
        '**/*.test.ts',
        'vitest.config.ts',
      ],
    },
    testTimeout: 30000,
  },
});


Security scan verdicts — ClawScan: clean | static analysis:  | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)