sync: harden standalone mcp and chunk recovery
This commit is contained in:
@@ -9,7 +9,7 @@
|
|||||||
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝
|
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝
|
||||||
```
|
```
|
||||||
|
|
||||||
**TL;DR:** Clone this repository and you'll have a local SQLite database on your computer. The database schema is structured so external AI agents can continuously read and write to it, building your knowledge graph externally.
|
**TL;DR:** Clone this repository and you'll have a local SQLite database on your computer. External AI agents can read and write nodes in it. The app owns chunking and embedding from node source.
|
||||||
|
|
||||||
[](https://youtu.be/IA02YB8mInM?si=WoWpNE9QZEKEukvZ)
|
[](https://youtu.be/IA02YB8mInM?si=WoWpNE9QZEKEukvZ)
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ Add to your `~/.claude.json`:
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"ra-h": {
|
"ra-h": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["--yes", "ra-h-mcp-server@2.1.1"]
|
"args": ["--yes", "ra-h-mcp-server@2.1.2"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ If you publish a newer MCP release and want clients to use it immediately, bump
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**What happens:** Once connected, Claude should use `queryNodes` for specific existing-node lookup, `retrieveQueryContext` when broader graph context would help, and `getContext` only for orientation. It proactively captures knowledge. When a new insight, decision, person, or reference surfaces, it should propose a specific node with a strong title, description, source, and metadata. Context is optional and should only be set when the primary scope is obvious.
|
**What happens:** Once connected, Claude should use `queryNodes` for specific existing-node lookup, `retrieveQueryContext` when broader graph context would help, and `getContext` only for orientation. It proactively captures knowledge. When a new insight, decision, person, or reference surfaces, it should propose a specific node with a strong title, description, source, and metadata. Context is optional and should only be set when the primary scope is obvious. The MCP server stores source on the node. The app later turns that source into chunks and embeddings.
|
||||||
|
|
||||||
Available tools:
|
Available tools:
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# RA-H MCP Server
|
# RA-H MCP Server
|
||||||
|
|
||||||
Connect Claude Code and Claude Desktop to your RA-H knowledge base. Direct SQLite access - works without the RA-H app running.
|
Connect Claude Code and Claude Desktop to your RA-H knowledge base. Direct SQLite access to an existing RA-H database.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx --yes ra-h-mcp-server@2.1.1
|
npx --yes ra-h-mcp-server@2.1.2
|
||||||
```
|
```
|
||||||
|
|
||||||
That's it. No manual setup required.
|
Run RA-H once first so the database exists, then use the MCP server from any client.
|
||||||
|
|
||||||
## Configure Claude Code / Claude Desktop
|
## Configure Claude Code / Claude Desktop
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ Add to your Claude config (`~/.claude.json` or Claude Desktop settings):
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"ra-h": {
|
"ra-h": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["--yes", "ra-h-mcp-server@2.1.1"]
|
"args": ["--yes", "ra-h-mcp-server@2.1.2"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,8 @@ If you publish a newer MCP release and need this client to pick it up immediatel
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- Database is created automatically at `~/Library/Application Support/RA-H/db/rah.sqlite` on first connection
|
- An existing RA-H database at `~/Library/Application Support/RA-H/db/rah.sqlite`
|
||||||
|
- Run the RA-H app at least once before using the standalone MCP server
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
@@ -52,6 +53,13 @@ Once connected, Claude will:
|
|||||||
- **Read skills for complex tasks** — skills are editable and shared across internal + external agents
|
- **Read skills for complex tasks** — skills are editable and shared across internal + external agents
|
||||||
- **Search before creating** to avoid duplicates
|
- **Search before creating** to avoid duplicates
|
||||||
|
|
||||||
|
## Source Processing
|
||||||
|
|
||||||
|
- The standalone MCP server stores source text on the node.
|
||||||
|
- It does **not** split source into chunks or generate embeddings itself.
|
||||||
|
- The RA-H app owns chunking and embedding when the app is running.
|
||||||
|
- If the app is closed, writes still land in `nodes.source`, and the app processes them later.
|
||||||
|
|
||||||
## Recommended Agent Memory Line
|
## Recommended Agent Memory Line
|
||||||
|
|
||||||
If you use external agents through this MCP server, you may add one short instruction line to your agent memory file (`AGENTS.md`, `CLAUDE.md`, etc.) as optional reinforcement:
|
If you use external agents through this MCP server, you may add one short instruction line to your agent memory file (`AGENTS.md`, `CLAUDE.md`, etc.) as optional reinforcement:
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ async function main() {
|
|||||||
'createNode',
|
'createNode',
|
||||||
{
|
{
|
||||||
title: 'Add RA-H node',
|
title: 'Add RA-H node',
|
||||||
description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. If the user explicitly wants context, use `context_name` rather than a numeric ID. Title: max 160 chars, clear and descriptive. Description is strongly recommended and should explicitly describe what the thing is and any surrounding context available, but the write will never be blocked over description quality. Use "link" ONLY for external content (URL, video, article) — omit for synthesis/ideas derived from existing nodes. "source" = verbatim or canonical content for embedding. Legacy "content" and "chunk" are mapped to source for compatibility.',
|
description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. If the user explicitly wants context, use `context_name` rather than a numeric ID. Title: max 160 chars, clear and descriptive. Description is strongly recommended and should explicitly describe what the thing is and any surrounding context available, but the write will never be blocked over description quality. Use "link" ONLY for external content (URL, video, article) — omit for synthesis/ideas derived from existing nodes. "source" = verbatim or canonical content stored on the node. The RA-H app owns chunking and embedding from source. Legacy "content" and "chunk" are mapped to source for compatibility.',
|
||||||
inputSchema: addNodeInputSchema
|
inputSchema: addNodeInputSchema
|
||||||
},
|
},
|
||||||
async ({ title, content, source, link, description, context_name, metadata, chunk }) => {
|
async ({ title, content, source, link, description, context_name, metadata, chunk }) => {
|
||||||
@@ -512,7 +512,7 @@ async function main() {
|
|||||||
'updateNode',
|
'updateNode',
|
||||||
{
|
{
|
||||||
title: 'Update RA-H node',
|
title: 'Update RA-H node',
|
||||||
description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear. Context is preserved by default. If the user explicitly wants to change context, use `context_name`. Use `clear_context` only when the user explicitly wants the context removed. Description updates should explicitly state what this thing is and any surrounding context available, but the write will never be blocked over description quality. Source content lives in "source". Legacy "content" is mapped to source for compatibility. Title, description, and link are overwritten. Call getNodesById first to verify current state before updating.',
|
description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear. Context is preserved by default. If the user explicitly wants to change context, use `context_name`. Use `clear_context` only when the user explicitly wants the context removed. Description updates should explicitly state what this thing is and any surrounding context available, but the write will never be blocked over description quality. Source content lives in "source". The RA-H app owns chunking and embedding from source. Legacy "content" is mapped to source for compatibility. Title, description, and link are overwritten. Call getNodesById first to verify current state before updating.',
|
||||||
inputSchema: updateNodeInputSchema
|
inputSchema: updateNodeInputSchema
|
||||||
},
|
},
|
||||||
async ({ id, updates }) => {
|
async ({ id, updates }) => {
|
||||||
@@ -819,7 +819,7 @@ async function main() {
|
|||||||
'searchContentEmbeddings',
|
'searchContentEmbeddings',
|
||||||
{
|
{
|
||||||
title: 'Search RA-H source content',
|
title: 'Search RA-H source content',
|
||||||
description: 'Search through source content (transcripts, books, articles) stored as chunks. Use when you need to find specific text within a node\'s full source material. For node-level search (titles, descriptions), use queryNodes instead.',
|
description: 'Search through source content (transcripts, books, articles) stored as chunks. Use when you need to find specific text within a node\'s full source material. This only works after the RA-H app has chunked the node source. For node-level search (titles, descriptions), use queryNodes instead.',
|
||||||
inputSchema: searchContentInputSchema
|
inputSchema: searchContentInputSchema
|
||||||
},
|
},
|
||||||
async ({ query: searchQuery, node_id, limit = 5 }) => {
|
async ({ query: searchQuery, node_id, limit = 5 }) => {
|
||||||
|
|||||||
+5
-5
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@ra-h/mcp-server",
|
"name": "ra-h-mcp-server",
|
||||||
"version": "1.10.1",
|
"version": "2.1.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@ra-h/mcp-server",
|
"name": "ra-h-mcp-server",
|
||||||
"version": "1.10.1",
|
"version": "2.1.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"ra-h-mcp": "index.js"
|
"ra-h-mcp-server": "index.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "ra-h-mcp-server",
|
"name": "ra-h-mcp-server",
|
||||||
"version": "2.1.1",
|
"version": "2.1.2",
|
||||||
"description": "Connect Claude Code/Desktop to your RA-H knowledge base. Direct SQLite access - no web app required.",
|
"description": "Connect Claude Code/Desktop to your RA-H knowledge base. Direct SQLite access to an existing RA-H database.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
"ra-h-mcp-server": "./index.js"
|
"ra-h-mcp-server": "index.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js"
|
"start": "node index.js",
|
||||||
|
"validate:syntax": "node --check index.js && node --check services/*.js",
|
||||||
|
"validate:publish": "npm run validate:syntax && npm pack --dry-run >/dev/null",
|
||||||
|
"prepublishOnly": "npm run validate:publish"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"mcp",
|
"mcp",
|
||||||
@@ -26,7 +29,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/bradwmorris/ra-h"
|
"url": "git+https://github.com/bradwmorris/ra-h.git"
|
||||||
},
|
},
|
||||||
"homepage": "https://ra-h.app",
|
"homepage": "https://ra-h.app",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
|
|||||||
@@ -158,67 +158,8 @@ function sanitizeTitle(title) {
|
|||||||
return clean.slice(0, 160);
|
return clean.slice(0, 160);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHUNK_SIZE = 1000;
|
function getChunkStatusForSource(sourceText) {
|
||||||
const CHUNK_OVERLAP = 200;
|
return normalizeString(sourceText) ? 'not_chunked' : null;
|
||||||
|
|
||||||
function splitSourceIntoChunks(sourceText) {
|
|
||||||
const text = String(sourceText || '').trim();
|
|
||||||
if (!text) return [];
|
|
||||||
|
|
||||||
const chunks = [];
|
|
||||||
let start = 0;
|
|
||||||
|
|
||||||
while (start < text.length) {
|
|
||||||
const end = Math.min(start + CHUNK_SIZE, text.length);
|
|
||||||
const chunkText = text.slice(start, end).trim();
|
|
||||||
|
|
||||||
if (chunkText) {
|
|
||||||
chunks.push({
|
|
||||||
text: chunkText,
|
|
||||||
start_char: start,
|
|
||||||
end_char: end
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end >= text.length) break;
|
|
||||||
start = Math.max(end - CHUNK_OVERLAP, start + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceNodeChunks(db, nodeId, title, sourceText) {
|
|
||||||
db.prepare('DELETE FROM chunks WHERE node_id = ?').run(nodeId);
|
|
||||||
|
|
||||||
const chunks = splitSourceIntoChunks(sourceText);
|
|
||||||
if (chunks.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const insertChunk = db.prepare(`
|
|
||||||
INSERT INTO chunks (node_id, chunk_idx, text, embedding_type, metadata, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
|
|
||||||
chunks.forEach((chunk, index) => {
|
|
||||||
insertChunk.run(
|
|
||||||
nodeId,
|
|
||||||
index,
|
|
||||||
chunk.text,
|
|
||||||
'text-embedding-3-small',
|
|
||||||
JSON.stringify({
|
|
||||||
node_id: nodeId,
|
|
||||||
chunk_index: index,
|
|
||||||
start_char: chunk.start_char,
|
|
||||||
end_char: chunk.end_char,
|
|
||||||
title
|
|
||||||
}),
|
|
||||||
now
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return chunks.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -243,7 +184,7 @@ function createNode(nodeData) {
|
|||||||
|
|
||||||
const sourceToStore = source ?? ([title, description].filter(Boolean).join('\n\n').trim() || null);
|
const sourceToStore = source ?? ([title, description].filter(Boolean).join('\n\n').trim() || null);
|
||||||
const effectiveContextId = context_id ?? null;
|
const effectiveContextId = context_id ?? null;
|
||||||
const hasSource = !!normalizeString(sourceToStore);
|
const chunkStatus = getChunkStatusForSource(sourceToStore);
|
||||||
|
|
||||||
const nodeId = transaction(() => {
|
const nodeId = transaction(() => {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
@@ -258,19 +199,13 @@ function createNode(nodeData) {
|
|||||||
link ?? null,
|
link ?? null,
|
||||||
event_date ?? null,
|
event_date ?? null,
|
||||||
JSON.stringify(canonicalMetadata),
|
JSON.stringify(canonicalMetadata),
|
||||||
hasSource ? 'chunked' : null,
|
chunkStatus,
|
||||||
effectiveContextId ?? null,
|
effectiveContextId ?? null,
|
||||||
now,
|
now,
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
|
|
||||||
const id = Number(result.lastInsertRowid);
|
return Number(result.lastInsertRowid);
|
||||||
|
|
||||||
if (hasSource) {
|
|
||||||
replaceNodeChunks(db, id, title, sourceToStore);
|
|
||||||
}
|
|
||||||
|
|
||||||
return id;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return getNodeById(nodeId);
|
return getNodeById(nodeId);
|
||||||
@@ -294,7 +229,6 @@ function updateNode(id, updates, options = {}) {
|
|||||||
? buildCanonicalMetadata({ existing: existing.metadata, metadata })
|
? buildCanonicalMetadata({ existing: existing.metadata, metadata })
|
||||||
: undefined;
|
: undefined;
|
||||||
const sourceWasProvided = Object.prototype.hasOwnProperty.call(updates, 'source');
|
const sourceWasProvided = Object.prototype.hasOwnProperty.call(updates, 'source');
|
||||||
const updatedTitle = title !== undefined ? title : existing.title;
|
|
||||||
const normalizedSource = sourceWasProvided ? normalizeString(source) : undefined;
|
const normalizedSource = sourceWasProvided ? normalizeString(source) : undefined;
|
||||||
|
|
||||||
transaction(() => {
|
transaction(() => {
|
||||||
@@ -323,7 +257,7 @@ function updateNode(id, updates, options = {}) {
|
|||||||
}
|
}
|
||||||
if (sourceWasProvided) {
|
if (sourceWasProvided) {
|
||||||
setFields.push('chunk_status = ?');
|
setFields.push('chunk_status = ?');
|
||||||
params.push(normalizedSource ? 'chunked' : null);
|
params.push(getChunkStatusForSource(normalizedSource));
|
||||||
}
|
}
|
||||||
if (Object.prototype.hasOwnProperty.call(updates, 'context_id')) {
|
if (Object.prototype.hasOwnProperty.call(updates, 'context_id')) {
|
||||||
setFields.push('context_id = ?');
|
setFields.push('context_id = ?');
|
||||||
@@ -344,10 +278,6 @@ function updateNode(id, updates, options = {}) {
|
|||||||
stmt.run(...params);
|
stmt.run(...params);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sourceWasProvided) {
|
|
||||||
replaceNodeChunks(db, id, updatedTitle, normalizedSource || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return getNodeById(id);
|
return getNodeById(id);
|
||||||
|
|||||||
@@ -27,6 +27,51 @@ function getDatabasePath() {
|
|||||||
|
|
||||||
let db = null;
|
let db = null;
|
||||||
|
|
||||||
|
function getExistingColumnNames(db, tableName) {
|
||||||
|
return db.prepare(`PRAGMA table_info(${tableName})`).all().map(c => c.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateExistingRahSchema(db) {
|
||||||
|
const requiredTables = ['contexts', 'nodes', 'edges', 'chunks'];
|
||||||
|
const missingTables = requiredTables.filter(
|
||||||
|
tableName => !db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?").get(tableName)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingTables.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`[RA-H MCP] Database is missing required tables: ${missingTables.join(', ')}. ` +
|
||||||
|
'MCP startup will not create or migrate tables. Open the app and repair the database first.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredNodeColumns = [
|
||||||
|
'title', 'description', 'source', 'link', 'event_date', 'metadata',
|
||||||
|
'embedding', 'embedding_updated_at', 'embedding_text', 'chunk_status', 'context_id',
|
||||||
|
'created_at', 'updated_at'
|
||||||
|
];
|
||||||
|
const requiredContextColumns = ['name', 'description', 'icon', 'created_at', 'updated_at'];
|
||||||
|
const requiredEdgeColumns = ['from_node_id', 'to_node_id', 'source', 'created_at', 'context', 'explanation'];
|
||||||
|
const requiredChunkColumns = ['node_id', 'chunk_idx', 'text', 'embedding_type', 'metadata', 'created_at'];
|
||||||
|
|
||||||
|
const schemaChecks = [
|
||||||
|
['nodes', requiredNodeColumns],
|
||||||
|
['contexts', requiredContextColumns],
|
||||||
|
['edges', requiredEdgeColumns],
|
||||||
|
['chunks', requiredChunkColumns],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [tableName, requiredColumns] of schemaChecks) {
|
||||||
|
const existingColumns = getExistingColumnNames(db, tableName);
|
||||||
|
const missingColumns = requiredColumns.filter(columnName => !existingColumns.includes(columnName));
|
||||||
|
if (missingColumns.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`[RA-H MCP] Database table ${tableName} is missing required columns: ${missingColumns.join(', ')}. ` +
|
||||||
|
'MCP startup will not migrate schema. Open the app and repair the database first.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the database connection.
|
* Initialize the database connection.
|
||||||
* Call this once at startup.
|
* Call this once at startup.
|
||||||
@@ -38,168 +83,21 @@ function initDatabase() {
|
|||||||
|
|
||||||
const dbPath = getDatabasePath();
|
const dbPath = getDatabasePath();
|
||||||
|
|
||||||
// Auto-create database if it doesn't exist
|
|
||||||
if (!fs.existsSync(dbPath)) {
|
if (!fs.existsSync(dbPath)) {
|
||||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
throw new Error(
|
||||||
db = new Database(dbPath);
|
`[RA-H MCP] Database not found at ${dbPath}. MCP startup will not create a new database. Open RA-H first to create or repair the app database.`
|
||||||
console.error('[RA-H] Creating new database at:', dbPath);
|
);
|
||||||
console.error('[RA-H] Database created successfully');
|
|
||||||
} else {
|
|
||||||
db = new Database(dbPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
db = new Database(dbPath);
|
||||||
|
|
||||||
// Configure SQLite for performance
|
// Configure SQLite for performance
|
||||||
db.pragma('journal_mode = WAL');
|
db.pragma('journal_mode = WAL');
|
||||||
db.pragma('synchronous = NORMAL');
|
db.pragma('synchronous = NORMAL');
|
||||||
db.pragma('cache_size = 5000');
|
db.pragma('cache_size = 5000');
|
||||||
db.pragma('busy_timeout = 5000');
|
db.pragma('busy_timeout = 5000');
|
||||||
|
|
||||||
ensureCoreSchema(db);
|
validateExistingRahSchema(db);
|
||||||
|
|
||||||
// Migrations for existing databases
|
|
||||||
const edgeCols = db.prepare('PRAGMA table_info(edges)').all().map(c => c.name);
|
|
||||||
const nodeCols = db.prepare('PRAGMA table_info(nodes)').all().map(c => c.name);
|
|
||||||
const contextCols = db.prepare('PRAGMA table_info(contexts)').all().map(c => c.name);
|
|
||||||
|
|
||||||
let hasSourceColumn = nodeCols.includes('source');
|
|
||||||
if (!hasSourceColumn) {
|
|
||||||
db.exec('ALTER TABLE nodes ADD COLUMN source TEXT;');
|
|
||||||
hasSourceColumn = true;
|
|
||||||
console.error('[RA-H] Migrated nodes: added source column');
|
|
||||||
}
|
|
||||||
if (nodeCols.includes('content')) {
|
|
||||||
db.exec(`
|
|
||||||
UPDATE nodes
|
|
||||||
SET source = content
|
|
||||||
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
|
|
||||||
AND content IS NOT NULL
|
|
||||||
AND LENGTH(TRIM(content)) > 0;
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
if (nodeCols.includes('notes')) {
|
|
||||||
db.exec(`
|
|
||||||
UPDATE nodes
|
|
||||||
SET source = notes
|
|
||||||
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
|
|
||||||
AND notes IS NOT NULL
|
|
||||||
AND LENGTH(TRIM(notes)) > 0;
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
if (nodeCols.includes('chunk')) {
|
|
||||||
db.exec(`
|
|
||||||
UPDATE nodes
|
|
||||||
SET source = chunk
|
|
||||||
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
|
|
||||||
AND chunk IS NOT NULL
|
|
||||||
AND LENGTH(TRIM(chunk)) > 0;
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
if (hasSourceColumn) {
|
|
||||||
db.exec(`
|
|
||||||
UPDATE nodes
|
|
||||||
SET source = title || CASE
|
|
||||||
WHEN description IS NOT NULL AND LENGTH(TRIM(description)) > 0
|
|
||||||
THEN char(10) || char(10) || description
|
|
||||||
ELSE ''
|
|
||||||
END
|
|
||||||
WHERE source IS NULL OR LENGTH(TRIM(source)) = 0;
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
if (!edgeCols.includes('explanation')) {
|
|
||||||
db.exec('ALTER TABLE edges ADD COLUMN explanation TEXT;');
|
|
||||||
try {
|
|
||||||
db.exec(`
|
|
||||||
UPDATE edges SET explanation = json_extract(context, '$.explanation')
|
|
||||||
WHERE explanation IS NULL AND json_extract(context, '$.explanation') IS NOT NULL;
|
|
||||||
`);
|
|
||||||
} catch {}
|
|
||||||
console.error('[RA-H] Migrated edges: added explanation column');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nodeCols.includes('context_id')) {
|
|
||||||
db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;');
|
|
||||||
console.error('[RA-H] Migrated nodes: added context_id column');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!contextCols.includes('description')) {
|
|
||||||
db.exec("ALTER TABLE contexts ADD COLUMN description TEXT NOT NULL DEFAULT '';");
|
|
||||||
}
|
|
||||||
if (!contextCols.includes('icon')) {
|
|
||||||
db.exec('ALTER TABLE contexts ADD COLUMN icon TEXT;');
|
|
||||||
}
|
|
||||||
if (!contextCols.includes('created_at')) {
|
|
||||||
db.exec("ALTER TABLE contexts ADD COLUMN created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
|
|
||||||
}
|
|
||||||
if (!contextCols.includes('updated_at')) {
|
|
||||||
db.exec("ALTER TABLE contexts ADD COLUMN updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
|
|
||||||
}
|
|
||||||
|
|
||||||
db.exec(`
|
|
||||||
UPDATE contexts
|
|
||||||
SET description = COALESCE(NULLIF(TRIM(description), ''), name)
|
|
||||||
WHERE description IS NULL OR LENGTH(TRIM(description)) = 0;
|
|
||||||
`);
|
|
||||||
|
|
||||||
db.exec(`
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized
|
|
||||||
ON contexts(LOWER(TRIM(name)));
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_nodes_context_id ON nodes(context_id);
|
|
||||||
`);
|
|
||||||
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS dimension_migration_snapshots (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
migrated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
dimension_count INTEGER NOT NULL,
|
|
||||||
assignment_count INTEGER NOT NULL,
|
|
||||||
payload TEXT
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
const hasLegacyDimensions = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='dimensions'").get();
|
|
||||||
const hasLegacyNodeDimensions = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='node_dimensions'").get();
|
|
||||||
if (hasLegacyDimensions || hasLegacyNodeDimensions) {
|
|
||||||
const snapshotCount = Number((db.prepare('SELECT COUNT(*) as count FROM dimension_migration_snapshots').get() || {}).count || 0);
|
|
||||||
|
|
||||||
if (snapshotCount === 0) {
|
|
||||||
const dimensionCount = hasLegacyDimensions
|
|
||||||
? Number((db.prepare('SELECT COUNT(*) as count FROM dimensions').get() || {}).count || 0)
|
|
||||||
: 0;
|
|
||||||
const assignmentCount = hasLegacyNodeDimensions
|
|
||||||
? Number((db.prepare('SELECT COUNT(*) as count FROM node_dimensions').get() || {}).count || 0)
|
|
||||||
: 0;
|
|
||||||
const payload = hasLegacyNodeDimensions
|
|
||||||
? ((db.prepare(`
|
|
||||||
SELECT COALESCE(
|
|
||||||
json_group_array(
|
|
||||||
json_object(
|
|
||||||
'node_id', nd.node_id,
|
|
||||||
'dimension', nd.dimension,
|
|
||||||
'description', d.description,
|
|
||||||
'icon', d.icon,
|
|
||||||
'is_priority', d.is_priority
|
|
||||||
)
|
|
||||||
),
|
|
||||||
'[]'
|
|
||||||
) AS payload
|
|
||||||
FROM node_dimensions nd
|
|
||||||
LEFT JOIN dimensions d ON d.name = nd.dimension
|
|
||||||
`).get() || {}).payload || '[]')
|
|
||||||
: '[]';
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO dimension_migration_snapshots (dimension_count, assignment_count, payload)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
`).run(dimensionCount, assignmentCount, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
db.exec(`
|
|
||||||
DROP INDEX IF EXISTS idx_dim_by_dimension;
|
|
||||||
DROP INDEX IF EXISTS idx_dim_by_node;
|
|
||||||
DROP TABLE IF EXISTS node_dimensions;
|
|
||||||
DROP TABLE IF EXISTS dimensions;
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
|
|||||||
|
|
||||||
- `title`: clear and specific.
|
- `title`: clear and specific.
|
||||||
- `description`: concrete object-level description, not vague summaries.
|
- `description`: concrete object-level description, not vague summaries.
|
||||||
- `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). This is what gets chunked and embedded for semantic search.
|
- `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). The standalone MCP server stores this on the node. The RA-H app later chunks and embeds it for semantic search.
|
||||||
- For idea capture from chat, the `source` should usually be the raw user thought, not a compressed assistant summary.
|
- For idea capture from chat, the `source` should usually be the raw user thought, not a compressed assistant summary.
|
||||||
- `link`: external source URL only.
|
- `link`: external source URL only.
|
||||||
- Normal writes should omit context entirely unless the user explicitly wants one.
|
- Normal writes should omit context entirely unless the user explicitly wants one.
|
||||||
|
|||||||
+2
-2
@@ -63,13 +63,13 @@ RA-OS is designed to be the knowledge backend for your AI workflows:
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"ra-h": {
|
"ra-h": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["--yes", "ra-h-mcp-server@2.1.1"]
|
"args": ["--yes", "ra-h-mcp-server@2.1.2"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Add this to `~/.claude.json` and restart Claude. Works without RA-OS running. If you publish a newer MCP release and need clients to pick it up immediately, bump the pinned version here and restart the client.
|
Add this to `~/.claude.json` and restart Claude. Run RA-H once first so the database exists. The standalone MCP server can write nodes without the app running, but the app owns chunking and embedding from node source. If you publish a newer MCP release and need clients to pick it up immediately, bump the pinned version here and restart the client.
|
||||||
|
|
||||||
Core tools include: `queryNodes`, `retrieveQueryContext`, `createNode`, `writeContext`, `updateNode`, `getNodesById`, `createEdge`, `updateEdge`, `queryEdge`, `queryContexts`, `listSkills`, `readSkill`
|
Core tools include: `queryNodes`, `retrieveQueryContext`, `createNode`, `writeContext`, `updateNode`, `getNodesById`, `createEdge`, `updateEdge`, `queryEdge`, `queryContexts`, `listSkills`, `readSkill`
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -49,7 +49,7 @@ Add to your `~/.claude.json`:
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"ra-h": {
|
"ra-h": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["--yes", "ra-h-mcp-server@2.1.1"]
|
"args": ["--yes", "ra-h-mcp-server@2.1.2"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ Add to your `~/.claude.json`:
|
|||||||
|
|
||||||
If you publish a newer MCP release and need clients to use it immediately, bump the pinned version here and restart the client. Do not assume plain `npx ra-h-mcp-server` always refreshes instantly.
|
If you publish a newer MCP release and need clients to use it immediately, bump the pinned version here and restart the client. Do not assume plain `npx ra-h-mcp-server` always refreshes instantly.
|
||||||
|
|
||||||
Works without RA-OS running. See [MCP docs](./8_mcp.md) for alternatives.
|
Run RA-H once first so the database exists. The standalone MCP server can write nodes without the app running, but the app owns chunking and embedding from node source. See [MCP docs](./8_mcp.md) for alternatives.
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { startAutoEmbedRecovery } from '@/services/embedding/autoEmbedQueue';
|
||||||
|
|
||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||||
|
startAutoEmbedRecovery();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,10 +19,49 @@ fi
|
|||||||
|
|
||||||
DB_DIR="$(dirname "$DB_PATH")"
|
DB_DIR="$(dirname "$DB_PATH")"
|
||||||
DB_NAME="$(basename "$DB_PATH")"
|
DB_NAME="$(basename "$DB_PATH")"
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
VEC_EXTENSION_PATH="${SQLITE_VEC_EXTENSION_PATH:-$ROOT_DIR/vendor/sqlite-extensions/vec0.dylib}"
|
||||||
TS=$(date +"%Y%m%d_%H%M%S")
|
TS=$(date +"%Y%m%d_%H%M%S")
|
||||||
RAW_BACKUP_DIR="$DB_DIR/working/fts_repair_${TS}"
|
RAW_BACKUP_DIR="$DB_DIR/working/fts_repair_${TS}"
|
||||||
REBUILT_DB="$DB_DIR/${DB_NAME}.rebuilt.${TS}"
|
REBUILT_DB="$DB_DIR/${DB_NAME}.rebuilt.${TS}"
|
||||||
QUOTED_DB_PATH=${DB_PATH//\'/\'\'}
|
QUOTED_DB_PATH=${DB_PATH//\'/\'\'}
|
||||||
|
HAS_SOURCE_VEC_NODES=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='vec_nodes';")
|
||||||
|
HAS_SOURCE_VEC_CHUNKS=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='vec_chunks';")
|
||||||
|
|
||||||
|
VEC_SQL_HEADER=""
|
||||||
|
VEC_SQL_BODY=""
|
||||||
|
if [ -f "$VEC_EXTENSION_PATH" ]; then
|
||||||
|
if [ "$HAS_SOURCE_VEC_NODES" -gt 0 ] || [ "$HAS_SOURCE_VEC_CHUNKS" -gt 0 ]; then
|
||||||
|
VEC_SQL_HEADER=".load $VEC_EXTENSION_PATH"
|
||||||
|
VEC_SQL_BODY="
|
||||||
|
CREATE VIRTUAL TABLE vec_nodes USING vec0(
|
||||||
|
node_id INTEGER PRIMARY KEY,
|
||||||
|
embedding FLOAT[1536]
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE vec_chunks USING vec0(
|
||||||
|
chunk_id INTEGER PRIMARY KEY,
|
||||||
|
embedding FLOAT[1536]
|
||||||
|
);
|
||||||
|
"
|
||||||
|
|
||||||
|
if [ "$HAS_SOURCE_VEC_NODES" -gt 0 ]; then
|
||||||
|
VEC_SQL_BODY="$VEC_SQL_BODY
|
||||||
|
INSERT INTO vec_nodes(node_id, embedding)
|
||||||
|
SELECT node_id, embedding FROM source.vec_nodes;
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HAS_SOURCE_VEC_CHUNKS" -gt 0 ]; then
|
||||||
|
VEC_SQL_BODY="$VEC_SQL_BODY
|
||||||
|
INSERT INTO vec_chunks(chunk_id, embedding)
|
||||||
|
SELECT chunk_id, embedding FROM source.vec_chunks;
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
elif [ "$HAS_SOURCE_VEC_NODES" -gt 0 ] || [ "$HAS_SOURCE_VEC_CHUNKS" -gt 0 ]; then
|
||||||
|
echo "Warning: sqlite-vec extension not found at $VEC_EXTENSION_PATH; vec tables will not be restored." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$RAW_BACKUP_DIR"
|
mkdir -p "$RAW_BACKUP_DIR"
|
||||||
|
|
||||||
@@ -46,6 +85,7 @@ sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM chunks_fts;" | sed 's/^/ chunks_fts: /
|
|||||||
rm -f "$REBUILT_DB" "${REBUILT_DB}-wal" "${REBUILT_DB}-shm"
|
rm -f "$REBUILT_DB" "${REBUILT_DB}-wal" "${REBUILT_DB}-shm"
|
||||||
|
|
||||||
sqlite3 "$REBUILT_DB" <<SQL
|
sqlite3 "$REBUILT_DB" <<SQL
|
||||||
|
$VEC_SQL_HEADER
|
||||||
PRAGMA journal_mode = DELETE;
|
PRAGMA journal_mode = DELETE;
|
||||||
PRAGMA synchronous = NORMAL;
|
PRAGMA synchronous = NORMAL;
|
||||||
PRAGMA foreign_keys = OFF;
|
PRAGMA foreign_keys = OFF;
|
||||||
@@ -253,6 +293,8 @@ CREATE VIRTUAL TABLE chunks_fts USING fts5(
|
|||||||
content_rowid='id'
|
content_rowid='id'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$VEC_SQL_BODY
|
||||||
|
|
||||||
INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');
|
INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');
|
||||||
INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild');
|
INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild');
|
||||||
|
|
||||||
@@ -369,6 +411,12 @@ sqlite3 "$REBUILT_DB" "PRAGMA quick_check;" | sed 's/^/ quick_check: /'
|
|||||||
sqlite3 "$REBUILT_DB" "PRAGMA integrity_check;" | sed 's/^/ integrity_check: /'
|
sqlite3 "$REBUILT_DB" "PRAGMA integrity_check;" | sed 's/^/ integrity_check: /'
|
||||||
sqlite3 "$REBUILT_DB" "SELECT COUNT(*) FROM nodes_fts;" | sed 's/^/ nodes_fts: /'
|
sqlite3 "$REBUILT_DB" "SELECT COUNT(*) FROM nodes_fts;" | sed 's/^/ nodes_fts: /'
|
||||||
sqlite3 "$REBUILT_DB" "SELECT COUNT(*) FROM chunks_fts;" | sed 's/^/ chunks_fts: /'
|
sqlite3 "$REBUILT_DB" "SELECT COUNT(*) FROM chunks_fts;" | sed 's/^/ chunks_fts: /'
|
||||||
|
if [ "$HAS_SOURCE_VEC_NODES" -gt 0 ] && [ -f "$VEC_EXTENSION_PATH" ]; then
|
||||||
|
sqlite3 "$REBUILT_DB" ".load $VEC_EXTENSION_PATH" "SELECT COUNT(*) FROM vec_nodes;" | sed 's/^/ vec_nodes: /'
|
||||||
|
fi
|
||||||
|
if [ "$HAS_SOURCE_VEC_CHUNKS" -gt 0 ] && [ -f "$VEC_EXTENSION_PATH" ]; then
|
||||||
|
sqlite3 "$REBUILT_DB" ".load $VEC_EXTENSION_PATH" "SELECT COUNT(*) FROM vec_chunks;" | sed 's/^/ vec_chunks: /'
|
||||||
|
fi
|
||||||
|
|
||||||
mv "$DB_PATH" "${DB_PATH}.corrupt.${TS}"
|
mv "$DB_PATH" "${DB_PATH}.corrupt.${TS}"
|
||||||
if [ -f "${DB_PATH}-wal" ]; then
|
if [ -f "${DB_PATH}-wal" ]; then
|
||||||
|
|||||||
@@ -260,33 +260,40 @@ export class ChunkService {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const vectorString = `[${queryEmbedding.join(',')}]`;
|
const vectorString = `[${queryEmbedding.join(',')}]`;
|
||||||
|
|
||||||
// When searching specific nodes, first get their chunk_ids to scope the vector search
|
const vectorLimit = Math.max(matchCount * 10, 50);
|
||||||
if (nodeIds && nodeIds.length > 0) {
|
|
||||||
// Get chunk IDs for the target nodes
|
|
||||||
const chunkIdsQuery = `SELECT id FROM chunks WHERE node_id IN (${nodeIds.map(() => '?').join(',')})`;
|
|
||||||
const chunkIdsResult = sqlite.query<{id: number}>(chunkIdsQuery, nodeIds);
|
|
||||||
const chunkIds = chunkIdsResult.rows.map(r => r.id);
|
|
||||||
|
|
||||||
if (chunkIds.length === 0) {
|
// vec0 requires the knn constraint to live directly on the vec table query.
|
||||||
|
// A previous change pushed node-scoping into that WHERE clause in a way vec0 rejects,
|
||||||
|
// which made every node-scoped vector search throw and silently fall back to text.
|
||||||
|
if (nodeIds && nodeIds.length > 0) {
|
||||||
|
const chunkCountQuery = `SELECT COUNT(*) AS count FROM chunks WHERE node_id IN (${nodeIds.map(() => '?').join(',')})`;
|
||||||
|
const chunkCountResult = sqlite.query<{ count: number }>(chunkCountQuery, nodeIds);
|
||||||
|
const chunkCount = Number(chunkCountResult.rows[0]?.count ?? 0);
|
||||||
|
|
||||||
|
if (chunkCount === 0) {
|
||||||
console.log(`🔍 Node-scoped search: no chunks found for nodes ${nodeIds.join(', ')}`);
|
console.log(`🔍 Node-scoped search: no chunks found for nodes ${nodeIds.join(', ')}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🔍 Node-scoped search: ${chunkIds.length} chunks in nodes ${nodeIds.join(', ')}`);
|
console.log(`🔍 Node-scoped search: ${chunkCount} chunks in nodes ${nodeIds.join(', ')}`);
|
||||||
|
|
||||||
// Search ONLY within those chunks using rowid filter
|
let query = `
|
||||||
const query = `
|
|
||||||
SELECT c.*, (1.0 / (1.0 + v.distance)) AS similarity
|
SELECT c.*, (1.0 / (1.0 + v.distance)) AS similarity
|
||||||
FROM vec_chunks v
|
FROM (
|
||||||
|
SELECT chunk_id, distance
|
||||||
|
FROM vec_chunks
|
||||||
|
WHERE embedding MATCH ?
|
||||||
|
ORDER BY distance
|
||||||
|
LIMIT ?
|
||||||
|
) v
|
||||||
JOIN chunks c ON c.id = v.chunk_id
|
JOIN chunks c ON c.id = v.chunk_id
|
||||||
WHERE v.embedding MATCH ?
|
WHERE c.node_id IN (${nodeIds.map(() => '?').join(',')})
|
||||||
AND v.chunk_id IN (${chunkIds.map(() => '?').join(',')})
|
|
||||||
AND (1.0 / (1.0 + v.distance)) >= ?
|
AND (1.0 / (1.0 + v.distance)) >= ?
|
||||||
ORDER BY v.distance
|
ORDER BY similarity DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const params = [vectorString, ...chunkIds, similarityThreshold, matchCount];
|
const params = [vectorString, vectorLimit, ...nodeIds, similarityThreshold, matchCount];
|
||||||
const result = sqlite.query<Chunk & { similarity: number }>(query, params);
|
const result = sqlite.query<Chunk & { similarity: number }>(query, params);
|
||||||
const searchTime = Date.now() - startTime;
|
const searchTime = Date.now() - startTime;
|
||||||
|
|
||||||
@@ -299,8 +306,6 @@ export class ChunkService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Global search (no node filter)
|
// Global search (no node filter)
|
||||||
const vectorLimit = Math.max(matchCount * 10, 50);
|
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
WITH vector_results AS (
|
WITH vector_results AS (
|
||||||
SELECT chunk_id, distance
|
SELECT chunk_id, distance
|
||||||
|
|||||||
@@ -1238,12 +1238,20 @@ class SQLiteClient {
|
|||||||
createSql: string,
|
createSql: string,
|
||||||
triggerSql: string,
|
triggerSql: string,
|
||||||
rebuildSql: string,
|
rebuildSql: string,
|
||||||
|
isStale: (sql: string) => boolean,
|
||||||
) => {
|
) => {
|
||||||
const existing = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name=?").get(tableName) as { sql?: string } | undefined;
|
const existing = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name=?").get(tableName) as { sql?: string } | undefined;
|
||||||
|
const existingSql = (existing?.sql || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
||||||
|
const missing = !existingSql;
|
||||||
|
const stale = !missing && isStale(existingSql);
|
||||||
|
|
||||||
if (!existing?.sql) {
|
if (missing) {
|
||||||
this.db.exec(createSql);
|
this.db.exec(createSql);
|
||||||
this.db.exec(rebuildSql);
|
this.db.exec(rebuildSql);
|
||||||
|
} else if (stale) {
|
||||||
|
console.warn(
|
||||||
|
`[SQLiteFTS] ${tableName} schema is stale. Skipping destructive startup rebuild; use the offline repair/rebuild path instead.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.db.exec(triggerSql);
|
this.db.exec(triggerSql);
|
||||||
@@ -1272,6 +1280,12 @@ class SQLiteClient {
|
|||||||
END;
|
END;
|
||||||
`,
|
`,
|
||||||
"INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');",
|
"INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');",
|
||||||
|
(sql) =>
|
||||||
|
!/\bfts5\(\s*title\s*,\s*source\s*,\s*description\b/.test(sql) ||
|
||||||
|
!sql.includes("content='nodes'") ||
|
||||||
|
!sql.includes("content_rowid='id'") ||
|
||||||
|
/\bnotes\b/.test(sql) ||
|
||||||
|
/\btitle\s*,\s*content\b/.test(sql),
|
||||||
);
|
);
|
||||||
|
|
||||||
ensureFts(
|
ensureFts(
|
||||||
@@ -1297,6 +1311,10 @@ class SQLiteClient {
|
|||||||
END;
|
END;
|
||||||
`,
|
`,
|
||||||
"INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild');",
|
"INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild');",
|
||||||
|
(sql) =>
|
||||||
|
!/\bfts5\(\s*text\b/.test(sql) ||
|
||||||
|
!sql.includes("content='chunks'") ||
|
||||||
|
!sql.includes("content_rowid='id'"),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to ensure FTS tables:', error);
|
console.warn('Failed to ensure FTS tables:', error);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface AutoEmbedTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes between automatic runs per node
|
const DEFAULT_COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes between automatic runs per node
|
||||||
|
const RECOVERY_INTERVAL_MS = 60 * 1000;
|
||||||
|
|
||||||
export class AutoEmbedQueue {
|
export class AutoEmbedQueue {
|
||||||
private readonly queue: number[] = [];
|
private readonly queue: number[] = [];
|
||||||
@@ -88,10 +89,17 @@ export class AutoEmbedQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async executeTask(task: AutoEmbedTask) {
|
private async executeTask(task: AutoEmbedTask) {
|
||||||
const sqlite = getSQLiteClient();
|
const integrity = getSQLiteClient().getIntegrityReport();
|
||||||
const integrity = sqlite.getIntegrityReport();
|
if (!integrity.ftsTables.chunks) {
|
||||||
if (!sqlite.canUseFtsTable('chunks')) {
|
|
||||||
console.warn('[AutoEmbedQueue] Skipping chunk write because chunks FTS is degraded:', integrity.summary);
|
console.warn('[AutoEmbedQueue] Skipping chunk write because chunks FTS is degraded:', integrity.summary);
|
||||||
|
try {
|
||||||
|
const node = await nodeService.getNodeById(task.nodeId);
|
||||||
|
if (node && node.chunk_status !== 'chunked' && node.chunk_status !== 'error') {
|
||||||
|
await nodeService.updateNode(task.nodeId, { chunk_status: 'error' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[AutoEmbedQueue] Failed to mark node as error while chunks FTS is degraded', task.nodeId, error);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,12 +129,30 @@ export class AutoEmbedQueue {
|
|||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line no-var
|
// eslint-disable-next-line no-var
|
||||||
var autoEmbedQueue: AutoEmbedQueue | undefined;
|
var autoEmbedQueue: AutoEmbedQueue | undefined;
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var autoEmbedRecoveryStarted: boolean | undefined;
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var autoEmbedRecoveryTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const autoEmbedQueue = globalThis.autoEmbedQueue ?? new AutoEmbedQueue();
|
export const autoEmbedQueue = globalThis.autoEmbedQueue ?? new AutoEmbedQueue();
|
||||||
if (!globalThis.autoEmbedQueue) {
|
if (!globalThis.autoEmbedQueue) {
|
||||||
globalThis.autoEmbedQueue = autoEmbedQueue;
|
globalThis.autoEmbedQueue = autoEmbedQueue;
|
||||||
autoEmbedQueue.recoverStuckNodes().catch(error => {
|
}
|
||||||
console.error('[AutoEmbedQueue] Startup recovery failed', error);
|
|
||||||
});
|
export function startAutoEmbedRecovery(): void {
|
||||||
|
if (globalThis.autoEmbedRecoveryStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
globalThis.autoEmbedRecoveryStarted = true;
|
||||||
|
|
||||||
|
const runRecovery = () => {
|
||||||
|
autoEmbedQueue.recoverStuckNodes().catch(error => {
|
||||||
|
console.error('[AutoEmbedQueue] Startup recovery failed', error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
runRecovery();
|
||||||
|
globalThis.autoEmbedRecoveryTimer = setInterval(runRecovery, RECOVERY_INTERVAL_MS);
|
||||||
|
globalThis.autoEmbedRecoveryTimer.unref?.();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user