feat: port holistic node refinement contract
This commit is contained in:
@@ -58,7 +58,7 @@ Open [localhost:3000](http://localhost:3000). Done.
|
||||
|
||||
With a key, you get:
|
||||
- Auto-generated descriptions when you add nodes
|
||||
- Automatic dimension/tag assignment
|
||||
- Automatic node descriptions
|
||||
- Semantic search (find similar content, not just keyword matches)
|
||||
|
||||
**Cost:** Less than $0.10/day for heavy use. Most users spend $1-2/month.
|
||||
@@ -115,13 +115,13 @@ Restart Claude Code fully (**Cmd+Q on Mac**, not just closing the window).
|
||||
}
|
||||
```
|
||||
|
||||
**What happens:** Once connected, Claude calls `getContext` first to orient itself (stats, contexts, hub nodes, dimensions, available skills). It proactively captures knowledge — when a new insight, decision, person, or reference surfaces, it proposes a specific node (title, dimensions, description) so you can approve with minimal friction. For complex tasks it reads skills to follow your graph conventions and workflows.
|
||||
**What happens:** Once connected, Claude calls `getContext` first to orient itself (stats, contexts, hub nodes, available skills). 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.
|
||||
|
||||
Available tools:
|
||||
|
||||
| Tool | What it does |
|
||||
|------|--------------|
|
||||
| `getContext` | Get graph overview — stats, contexts, hub nodes, dimensions, recent activity |
|
||||
| `getContext` | Get graph overview — stats, contexts, hub nodes, recent activity |
|
||||
| `queryContexts` | List contexts, inspect a context, or search contexts |
|
||||
| `queryNodes` | Find nodes by keyword |
|
||||
| `createNode` | Create a new node |
|
||||
@@ -130,10 +130,6 @@ Available tools:
|
||||
| `createEdge` | Link two nodes together |
|
||||
| `updateEdge` | Update an edge explanation |
|
||||
| `queryEdge` | Find connections |
|
||||
| `queryDimensions` | List all tags/categories |
|
||||
| `createDimension` | Create a new dimension |
|
||||
| `updateDimension` | Update/rename a dimension |
|
||||
| `deleteDimension` | Delete a dimension |
|
||||
| `listSkills` | List available skills |
|
||||
| `readSkill` | Read a skill by name |
|
||||
| `writeSkill` | Create or update a custom skill |
|
||||
@@ -145,7 +141,7 @@ Available tools:
|
||||
- "What's in my knowledge graph?"
|
||||
- "Search my knowledge base for notes about React performance"
|
||||
- "Add a node about the article I just read on transformers"
|
||||
- "What nodes are connected to my 'research' dimension?"
|
||||
- "Show me the nodes connected to my research context"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
|
||||
export interface DimensionContext {
|
||||
name: string;
|
||||
description: string | null;
|
||||
isPriority: boolean;
|
||||
nodeCount: number;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
try {
|
||||
const { name } = await params;
|
||||
const decodedName = decodeURIComponent(name);
|
||||
|
||||
const db = getSQLiteClient();
|
||||
|
||||
// Get dimension metadata
|
||||
const dimension = db.query<{
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_priority: number;
|
||||
}>(
|
||||
'SELECT name, description, is_priority FROM dimensions WHERE name = ?',
|
||||
[decodedName]
|
||||
).rows[0];
|
||||
|
||||
if (!dimension) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Dimension not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Count nodes in this dimension (via node_dimensions join table)
|
||||
const countResult = db.query<{ count: number }>(
|
||||
`SELECT COUNT(DISTINCT node_id) as count FROM node_dimensions WHERE dimension = ?`,
|
||||
[decodedName]
|
||||
).rows[0];
|
||||
|
||||
const context: DimensionContext = {
|
||||
name: dimension.name,
|
||||
description: dimension.description,
|
||||
isPriority: false,
|
||||
nodeCount: countResult?.count || 0,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: context,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching dimension context:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch dimension context' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return getPopularDimensionsSQLite();
|
||||
} catch (error) {
|
||||
console.error('Error fetching popular dimensions:', error);
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch popular dimensions'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PostgreSQL path removed in SQLite-only consolidation
|
||||
|
||||
async function getPopularDimensionsSQLite() {
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const result = sqlite.query(`
|
||||
WITH dimension_counts AS (
|
||||
SELECT nd.dimension, COUNT(*) AS count
|
||||
FROM node_dimensions nd
|
||||
GROUP BY nd.dimension
|
||||
)
|
||||
SELECT d.name AS dimension,
|
||||
COALESCE(dc.count, 0) AS count,
|
||||
d.description
|
||||
FROM dimensions d
|
||||
LEFT JOIN dimension_counts dc ON dc.dimension = d.name
|
||||
ORDER BY LOWER(d.name) ASC
|
||||
`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: result.rows.map((row: any) => ({
|
||||
dimension: row.dimension,
|
||||
count: Number(row.count),
|
||||
isPriority: false,
|
||||
description: row.description || null
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { dimension } = await request.json();
|
||||
|
||||
if (!dimension || typeof dimension !== 'string') {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Dimension name is required'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
dimension,
|
||||
is_priority: false
|
||||
},
|
||||
message: 'Priority dimensions are no longer part of the product model.'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error toggling dimension priority:', error);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Internal server error'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
import { eventBroadcaster } from '@/services/events';
|
||||
import { normalizeDimensionName, validateDimensionDescription } from '@/services/database/quality';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
// Get all dimensions with their counts
|
||||
const result = sqlite.query(`
|
||||
WITH dimension_counts AS (
|
||||
SELECT nd.dimension, COUNT(*) AS count
|
||||
FROM node_dimensions nd
|
||||
GROUP BY nd.dimension
|
||||
)
|
||||
SELECT
|
||||
d.name AS dimension,
|
||||
d.description,
|
||||
d.icon,
|
||||
COALESCE(dc.count, 0) AS count
|
||||
FROM dimensions d
|
||||
LEFT JOIN dimension_counts dc ON dc.dimension = d.name
|
||||
ORDER BY d.name ASC
|
||||
`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result.rows.map((row: any) => ({
|
||||
dimension: row.dimension,
|
||||
description: row.description,
|
||||
icon: row.icon || null,
|
||||
isPriority: false,
|
||||
count: Number(row.count)
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching dimensions:', error);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Failed to fetch dimensions'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const rawName = typeof body?.name === 'string' ? normalizeDimensionName(body.name) : '';
|
||||
const description = typeof body?.description === 'string' ? body.description.trim() : null;
|
||||
const icon = typeof body?.icon === 'string' ? body.icon.trim() || null : null;
|
||||
|
||||
if (!rawName) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Dimension name is required'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const descriptionError = description !== null
|
||||
? validateDimensionDescription(description)
|
||||
: null;
|
||||
if (descriptionError) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: descriptionError
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const sqlite = getSQLiteClient();
|
||||
const result = sqlite.query(`
|
||||
INSERT INTO dimensions(name, description, icon, is_priority, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
description = COALESCE(?, description),
|
||||
icon = COALESCE(?, icon),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING name, description, icon, is_priority
|
||||
`, [rawName, description, icon, 0, description, icon]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Failed to create dimension');
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
const dimension = row.name as string;
|
||||
const descriptionValue = row.description as string | null;
|
||||
const iconValue = (row.icon as string | null) || null;
|
||||
|
||||
eventBroadcaster.broadcast({
|
||||
type: 'DIMENSION_UPDATED',
|
||||
data: { dimension, isPriority: false, description: descriptionValue, icon: iconValue, count: 0 }
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
dimension,
|
||||
description: descriptionValue,
|
||||
icon: iconValue,
|
||||
isPriority: false
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating dimension:', error);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Failed to create dimension'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const currentName = typeof body?.currentName === 'string' ? normalizeDimensionName(body.currentName) : '';
|
||||
const newName = typeof body?.newName === 'string' ? normalizeDimensionName(body.newName) : '';
|
||||
const name = typeof body?.name === 'string' ? normalizeDimensionName(body.name) : '';
|
||||
const description = typeof body?.description === 'string' ? body.description.trim() : '';
|
||||
const icon = body?.icon !== undefined ? (typeof body.icon === 'string' ? body.icon.trim() || null : null) : undefined;
|
||||
|
||||
// Handle dimension name change
|
||||
if (currentName && newName && currentName !== newName) {
|
||||
if (!newName) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'New dimension name is required'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
// Check if new name already exists
|
||||
const existingCheck = sqlite.query(`
|
||||
SELECT name FROM dimensions WHERE name = ?
|
||||
`, [newName]);
|
||||
|
||||
if (existingCheck.rows.length > 0) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'A dimension with this name already exists'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Update dimension name in transaction (also handle description and isPriority if provided)
|
||||
const updateResult = sqlite.transaction(() => {
|
||||
// Build update query with optional fields
|
||||
const updates: string[] = ['name = ?', 'updated_at = CURRENT_TIMESTAMP'];
|
||||
const values: any[] = [newName];
|
||||
|
||||
if (description !== '') {
|
||||
updates.push('description = ?');
|
||||
values.push(description || null);
|
||||
}
|
||||
|
||||
if (icon !== undefined) {
|
||||
updates.push('icon = ?');
|
||||
values.push(icon);
|
||||
}
|
||||
|
||||
values.push(currentName);
|
||||
|
||||
const dimUpdate = sqlite.prepare(`
|
||||
UPDATE dimensions
|
||||
SET ${updates.join(', ')}
|
||||
WHERE name = ?
|
||||
`).run(...values);
|
||||
|
||||
// Update node_dimensions table
|
||||
const nodeDimUpdate = sqlite.prepare(`
|
||||
UPDATE node_dimensions
|
||||
SET dimension = ?
|
||||
WHERE dimension = ?
|
||||
`).run(newName, currentName);
|
||||
|
||||
return {
|
||||
dimensionUpdated: dimUpdate.changes > 0,
|
||||
nodeLinksUpdated: nodeDimUpdate.changes
|
||||
};
|
||||
});
|
||||
|
||||
if (!updateResult.dimensionUpdated) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Dimension not found'
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
eventBroadcaster.broadcast({
|
||||
type: 'DIMENSION_UPDATED',
|
||||
data: {
|
||||
dimension: newName,
|
||||
previousName: currentName,
|
||||
description: description || undefined,
|
||||
isPriority: false,
|
||||
renamed: true
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
dimension: newName,
|
||||
previousName: currentName,
|
||||
description: description || undefined,
|
||||
isPriority: false,
|
||||
nodeLinksUpdated: updateResult.nodeLinksUpdated
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle description and/or isPriority update (existing functionality)
|
||||
const targetName = name || currentName;
|
||||
if (!targetName) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Dimension name is required'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
if (description) {
|
||||
const descriptionError = validateDimensionDescription(description);
|
||||
if (descriptionError) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: descriptionError
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (description !== '' || icon !== undefined) {
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
// Build update query
|
||||
const updates: string[] = ['updated_at = CURRENT_TIMESTAMP'];
|
||||
const values: any[] = [];
|
||||
|
||||
if (description !== '') {
|
||||
updates.push('description = ?');
|
||||
values.push(description || null);
|
||||
}
|
||||
|
||||
if (icon !== undefined) {
|
||||
updates.push('icon = ?');
|
||||
values.push(icon);
|
||||
}
|
||||
|
||||
values.push(targetName);
|
||||
|
||||
const updateResult = sqlite.prepare(`
|
||||
UPDATE dimensions
|
||||
SET ${updates.join(', ')}
|
||||
WHERE name = ?
|
||||
`).run(...values);
|
||||
|
||||
if (updateResult.changes === 0) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Dimension not found'
|
||||
}, { status: 404 });
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'At least one update field (description, icon, or newName) must be provided'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
eventBroadcaster.broadcast({
|
||||
type: 'DIMENSION_UPDATED',
|
||||
data: {
|
||||
dimension: targetName,
|
||||
description: description !== '' ? description : undefined,
|
||||
icon: icon !== undefined ? icon : undefined,
|
||||
isPriority: false
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
dimension: targetName,
|
||||
description: description !== '' ? description : undefined,
|
||||
icon: icon !== undefined ? icon : undefined,
|
||||
isPriority: false
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating dimension:', error);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Failed to update dimension'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const dimension = (request.nextUrl.searchParams.get('name') || '').trim();
|
||||
if (!dimension) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Dimension name is required'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const sqlite = getSQLiteClient();
|
||||
const removal = sqlite.transaction(() => {
|
||||
const nodeDimStmt = sqlite.prepare('DELETE FROM node_dimensions WHERE dimension = ?');
|
||||
const dimStmt = sqlite.prepare('DELETE FROM dimensions WHERE name = ?');
|
||||
const removedLinks = nodeDimStmt.run(dimension).changes ?? 0;
|
||||
const removedRow = dimStmt.run(dimension).changes ?? 0;
|
||||
return {
|
||||
removedLinks,
|
||||
removedRow
|
||||
};
|
||||
});
|
||||
|
||||
if (!removal.removedLinks && !removal.removedRow) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Dimension not found'
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
eventBroadcaster.broadcast({
|
||||
type: 'DIMENSION_UPDATED',
|
||||
data: { dimension, deleted: true }
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
dimension,
|
||||
deleted: true
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting dimension:', error);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Failed to delete dimension'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const query = searchParams.get('q') || '';
|
||||
|
||||
if (!query.trim()) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Search query is required'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
return searchDimensionsSQLite(query);
|
||||
} catch (error) {
|
||||
console.error('Error searching dimensions:', error);
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to search dimensions'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PostgreSQL path removed in SQLite-only consolidation
|
||||
|
||||
async function searchDimensionsSQLite(query: string) {
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const result = sqlite.query(`
|
||||
SELECT nd.dimension, COUNT(*) AS count
|
||||
FROM node_dimensions nd
|
||||
WHERE LOWER(nd.dimension) LIKE LOWER(?)
|
||||
GROUP BY nd.dimension
|
||||
ORDER BY count DESC, nd.dimension ASC
|
||||
LIMIT 20
|
||||
`, [`%${query}%`]);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: result.rows.map((row: any) => ({
|
||||
dimension: row.dimension,
|
||||
count: Number(row.count)
|
||||
}))
|
||||
});
|
||||
}
|
||||
+397
-369
@@ -1,103 +1,126 @@
|
||||
/**
|
||||
* RA-H Remote MCP Endpoint
|
||||
*
|
||||
* A stateless MCP server for Vercel/serverless deployments.
|
||||
* Exposes rah_* tools for external agents to query (and optionally modify) the knowledge graph.
|
||||
*
|
||||
* Environment variables:
|
||||
* MCP_ALLOW_WRITES=true - Enable write tools (add_node, create_edge, etc.)
|
||||
*
|
||||
* Usage:
|
||||
* claude mcp add --transport http my-rah https://my-deployment.vercel.app/api/mcp
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { nodeService, edgeService } from '@/services/database';
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 30;
|
||||
|
||||
const ALLOW_WRITES = process.env.MCP_ALLOW_WRITES === 'true';
|
||||
|
||||
const SERVER_INFO = {
|
||||
name: 'ra-h-mcp',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
function buildInstructions(): string {
|
||||
const lines = [
|
||||
'RA-H Knowledge Graph - a local-first research workspace.',
|
||||
'Use rah_search_nodes to find content by keyword.',
|
||||
'Use rah_get_nodes to load full node content by ID.',
|
||||
'Use rah_query_edges to explore connections between nodes.',
|
||||
'Use rah_list_dimensions to see content categories.',
|
||||
];
|
||||
const instructions = [
|
||||
'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
|
||||
'Core concepts: contexts (optional soft scopes), nodes (knowledge units), and edges (connections with explanations).',
|
||||
'Always call rah_get_context first to orient yourself — it returns contexts, hub nodes, stats, and available guides.',
|
||||
'Use contexts only when they are explicit and helpful. Do not expect automatic context assignment.',
|
||||
'Search before creating: use rah_search_nodes to check if content already exists.',
|
||||
'Every edge needs an explanation: why does this connection exist?',
|
||||
].join(' ');
|
||||
|
||||
if (ALLOW_WRITES) {
|
||||
lines.push('Write operations are enabled. Use rah_add_node to create new nodes.');
|
||||
} else {
|
||||
lines.push('This is a read-only endpoint.');
|
||||
function getBaseUrl(request: NextRequest): string {
|
||||
const envBase = process.env.RAH_MCP_TARGET_URL || process.env.NEXT_PUBLIC_BASE_URL;
|
||||
return (envBase || request.nextUrl.origin).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
return lines.join(' ');
|
||||
async function callRaHApi(request: NextRequest, pathname: string, options: RequestInit = {}) {
|
||||
const response = await fetch(`${getBaseUrl(request)}${pathname}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || payload.success === false) {
|
||||
throw new Error(payload?.error || `RA-H API request failed at ${pathname}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh MCP server instance with rah_* tools
|
||||
*/
|
||||
function createRAHServer(): McpServer {
|
||||
return payload;
|
||||
}
|
||||
|
||||
function createServer(request: NextRequest): McpServer {
|
||||
const server = new McpServer(SERVER_INFO, {
|
||||
instructions: buildInstructions(),
|
||||
instructions,
|
||||
capabilities: { tools: {} },
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// READ TOOLS (always enabled)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
'rah_add_node',
|
||||
{
|
||||
title: 'Add RA-H node',
|
||||
description: 'Create a new node in the local RA-H knowledge base. Set context explicitly when clear and useful; otherwise leave it empty.',
|
||||
inputSchema: {
|
||||
title: z.string().min(1).max(160),
|
||||
content: z.string().max(20000).optional(),
|
||||
source: z.string().max(50000).optional(),
|
||||
link: z.string().url().optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
context_id: z.number().int().positive().nullable().optional(),
|
||||
context_name: z.string().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
chunk: z.string().max(50000).optional(),
|
||||
},
|
||||
},
|
||||
async ({ title, content, source, link, description, context_id, context_name, metadata, chunk }) => {
|
||||
const payload = await callRaHApi(request, '/api/nodes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
source: source?.trim() || content?.trim() || chunk?.trim() || undefined,
|
||||
link: link?.trim() || undefined,
|
||||
description: description?.trim() || undefined,
|
||||
context_id,
|
||||
context_name: context_name?.trim() || undefined,
|
||||
metadata: metadata || {},
|
||||
}),
|
||||
});
|
||||
|
||||
const node = payload.data;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Created node #${node.id}: ${node.title}` }],
|
||||
structuredContent: {
|
||||
nodeId: node.id,
|
||||
title: node.title,
|
||||
message: payload.message || `Created node #${node.id}: ${node.title}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// rah_search_nodes - Full-text search
|
||||
server.registerTool(
|
||||
'rah_search_nodes',
|
||||
{
|
||||
title: 'Search RA-H nodes',
|
||||
description: 'Search the knowledge graph by keyword. Returns matching nodes with title, description, dimensions.',
|
||||
description: 'Find existing RA-H entries that mention a topic before adding new ones.',
|
||||
inputSchema: {
|
||||
query: z.string().min(1).max(400).describe('Search query (keywords)'),
|
||||
limit: z.number().min(1).max(50).optional().describe('Max results (default 20)'),
|
||||
dimensions: z.array(z.string()).max(5).optional().describe('Filter by dimensions'),
|
||||
query: z.string().min(1).max(400),
|
||||
limit: z.number().min(1).max(25).optional(),
|
||||
contextId: z.number().int().positive().optional(),
|
||||
},
|
||||
},
|
||||
async ({ query, limit = 20, dimensions }) => {
|
||||
const filters: any = {
|
||||
search: query.trim(),
|
||||
limit: Math.min(Math.max(limit, 1), 50),
|
||||
};
|
||||
async ({ query, limit = 10, contextId }) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('search', query.trim());
|
||||
params.set('limit', String(Math.min(Math.max(limit, 1), 25)));
|
||||
if (contextId) params.set('contextId', String(contextId));
|
||||
|
||||
if (dimensions && dimensions.length > 0) {
|
||||
filters.dimensions = dimensions;
|
||||
}
|
||||
|
||||
const nodes = await nodeService.getNodes(filters);
|
||||
|
||||
const summary = nodes.length === 0
|
||||
? `No results found for "${query}".`
|
||||
: `Found ${nodes.length} result(s) for "${query}".`;
|
||||
const payload = await callRaHApi(request, `/api/nodes?${params.toString()}`);
|
||||
const nodes = Array.isArray(payload.data) ? payload.data : [];
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
content: [{ type: 'text', text: nodes.length === 0 ? 'No existing RA-H nodes mention that topic yet.' : `Found ${nodes.length} node(s) mentioning that topic.` }],
|
||||
structuredContent: {
|
||||
count: nodes.length,
|
||||
nodes: nodes.map((node: any) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
source: node.source ?? null,
|
||||
description: node.description ?? null,
|
||||
link: node.link ?? null,
|
||||
dimensions: node.dimensions || [],
|
||||
updated_at: node.updated_at,
|
||||
})),
|
||||
},
|
||||
@@ -105,220 +128,104 @@ function createRAHServer(): McpServer {
|
||||
}
|
||||
);
|
||||
|
||||
// rah_get_nodes - Load full node content by ID
|
||||
server.registerTool(
|
||||
'rah_get_nodes',
|
||||
'rah_query_contexts',
|
||||
{
|
||||
title: 'Get RA-H nodes by ID',
|
||||
description: 'Load full content of specific nodes by their IDs.',
|
||||
title: 'Query RA-H contexts',
|
||||
description: 'List or inspect contexts, the soft organizational scope layer for the graph.',
|
||||
inputSchema: {
|
||||
nodeIds: z.array(z.number().int().positive()).min(1).max(10).describe('Node IDs to load (max 10)'),
|
||||
contextId: z.number().int().positive().optional(),
|
||||
name: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
limit: z.number().min(1).max(100).optional(),
|
||||
includeNodes: z.boolean().optional(),
|
||||
},
|
||||
},
|
||||
async ({ nodeIds }) => {
|
||||
const uniqueIds = Array.from(new Set(nodeIds.filter(id => Number.isFinite(id) && id > 0)));
|
||||
async ({ contextId, name, search, limit = 50, includeNodes = false }) => {
|
||||
const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : '';
|
||||
const normalizedSearch = typeof search === 'string' ? search.trim().toLowerCase() : '';
|
||||
|
||||
if (uniqueIds.length === 0) {
|
||||
let contexts: any[] = [];
|
||||
if (contextId) {
|
||||
const payload = await callRaHApi(request, `/api/contexts/${contextId}`);
|
||||
contexts = payload.data ? [payload.data] : [];
|
||||
} else {
|
||||
const payload = await callRaHApi(request, '/api/contexts');
|
||||
contexts = Array.isArray(payload.data) ? payload.data : [];
|
||||
}
|
||||
|
||||
if (normalizedName) {
|
||||
contexts = contexts.filter((context) => context.name.toLowerCase() === normalizedName);
|
||||
}
|
||||
|
||||
if (normalizedSearch) {
|
||||
contexts = contexts.filter((context) =>
|
||||
context.name.toLowerCase().includes(normalizedSearch) ||
|
||||
(context.description || '').toLowerCase().includes(normalizedSearch)
|
||||
);
|
||||
}
|
||||
|
||||
contexts = contexts.slice(0, Math.min(Math.max(limit, 1), 100));
|
||||
|
||||
const structuredContexts = await Promise.all(
|
||||
contexts.map(async (context) => {
|
||||
if (!(includeNodes && contexts.length === 1)) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'No valid node IDs provided.' }],
|
||||
structuredContent: { count: 0, nodes: [] },
|
||||
id: context.id,
|
||||
name: context.name,
|
||||
description: context.description ?? null,
|
||||
icon: context.icon ?? null,
|
||||
count: context.count ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
const nodes: any[] = [];
|
||||
for (const id of uniqueIds) {
|
||||
try {
|
||||
const node = await nodeService.getNodeById(id);
|
||||
if (node) {
|
||||
nodes.push({
|
||||
const nodesPayload = await callRaHApi(request, `/api/contexts/${context.id}/nodes`);
|
||||
const nodes = Array.isArray(nodesPayload.data) ? nodesPayload.data : [];
|
||||
|
||||
return {
|
||||
id: context.id,
|
||||
name: context.name,
|
||||
description: context.description ?? null,
|
||||
icon: context.icon ?? null,
|
||||
count: context.count ?? nodes.length,
|
||||
nodes: nodes.map((node: any) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
description: node.description ?? null,
|
||||
source: node.source ?? null,
|
||||
link: node.link ?? null,
|
||||
dimensions: node.dimensions || [],
|
||||
metadata: node.metadata || {},
|
||||
created_at: node.created_at,
|
||||
context_id: node.context_id ?? null,
|
||||
updated_at: node.updated_at,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip missing nodes
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Loaded ${nodes.length} of ${uniqueIds.length} nodes.` }],
|
||||
structuredContent: { count: nodes.length, nodes },
|
||||
})),
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// rah_query_edges - Find connections
|
||||
server.registerTool(
|
||||
'rah_query_edges',
|
||||
{
|
||||
title: 'Query RA-H edges',
|
||||
description: 'Find connections (edges) between nodes. Use nodeId to get all connections for a specific node.',
|
||||
inputSchema: {
|
||||
nodeId: z.number().int().positive().optional().describe('Find edges connected to this node'),
|
||||
limit: z.number().min(1).max(100).optional().describe('Max edges to return (default 50)'),
|
||||
},
|
||||
},
|
||||
async ({ nodeId, limit = 50 }) => {
|
||||
let edges: any[];
|
||||
|
||||
if (nodeId) {
|
||||
const connections = await edgeService.getNodeConnections(nodeId);
|
||||
edges = connections.slice(0, limit).map(c => c.edge);
|
||||
} else {
|
||||
edges = await edgeService.getEdges();
|
||||
edges = edges.slice(0, limit);
|
||||
}
|
||||
|
||||
const parseContext = (ctx: any) => {
|
||||
if (typeof ctx === 'string') {
|
||||
try { return JSON.parse(ctx); } catch { return {}; }
|
||||
}
|
||||
return ctx || {};
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Found ${edges.length} connection(s).` }],
|
||||
content: [{ type: 'text', text: structuredContexts.length === 0 ? 'No contexts found.' : `Found ${structuredContexts.length} context(s).` }],
|
||||
structuredContent: {
|
||||
count: edges.length,
|
||||
edges: edges.map((e: any) => {
|
||||
const ctx = parseContext(e.context);
|
||||
return {
|
||||
id: e.id,
|
||||
from_node_id: e.from_node_id,
|
||||
to_node_id: e.to_node_id,
|
||||
explanation: ctx.explanation ?? null,
|
||||
type: ctx.type ?? null,
|
||||
created_at: e.created_at,
|
||||
};
|
||||
}),
|
||||
count: structuredContexts.length,
|
||||
contexts: structuredContexts,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// rah_list_dimensions - List all dimensions
|
||||
server.registerTool(
|
||||
'rah_list_dimensions',
|
||||
{
|
||||
title: 'List RA-H dimensions',
|
||||
description: 'List all dimensions (categories/tags) in the knowledge graph with node counts.',
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const result = sqlite.query(`
|
||||
WITH dimension_counts AS (
|
||||
SELECT nd.dimension, COUNT(*) AS count
|
||||
FROM node_dimensions nd
|
||||
GROUP BY nd.dimension
|
||||
)
|
||||
SELECT
|
||||
d.name AS dimension,
|
||||
d.description,
|
||||
d.is_priority AS isPriority,
|
||||
COALESCE(dc.count, 0) AS count
|
||||
FROM dimensions d
|
||||
LEFT JOIN dimension_counts dc ON dc.dimension = d.name
|
||||
ORDER BY dc.count DESC, d.name ASC
|
||||
`);
|
||||
|
||||
const dimensions = result.rows.map((row: any) => ({
|
||||
name: row.dimension,
|
||||
description: row.description ?? null,
|
||||
isPriority: Boolean(row.isPriority),
|
||||
count: Number(row.count),
|
||||
}));
|
||||
|
||||
const totalNodes = dimensions.reduce((sum, d) => sum + d.count, 0);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `${dimensions.length} dimensions, ${totalNodes} total nodes.` }],
|
||||
structuredContent: {
|
||||
count: dimensions.length,
|
||||
totalNodes,
|
||||
dimensions,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// WRITE TOOLS (only when MCP_ALLOW_WRITES=true)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
if (ALLOW_WRITES) {
|
||||
// rah_add_node
|
||||
server.registerTool(
|
||||
'rah_add_node',
|
||||
{
|
||||
title: 'Add RA-H node',
|
||||
description: 'Create a new node in the knowledge graph.',
|
||||
inputSchema: {
|
||||
title: z.string().min(1).max(160).describe('Node title'),
|
||||
content: z.string().max(20000).optional().describe('Legacy content field; mapped to source'),
|
||||
source: z.string().max(50000).optional().describe('Full source text'),
|
||||
link: z.string().url().optional().describe('Source URL'),
|
||||
description: z.string().max(2000).optional().describe('Short description'),
|
||||
dimensions: z.array(z.string()).min(1).max(5).describe('Categories/tags (at least 1)'),
|
||||
metadata: z.record(z.any()).optional().describe('Additional metadata'),
|
||||
},
|
||||
},
|
||||
async ({ title, content, source, link, description, dimensions, metadata }) => {
|
||||
// Call the nodes API internally
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
source: source?.trim() || content?.trim(),
|
||||
link: link?.trim(),
|
||||
description: description?.trim(),
|
||||
dimensions,
|
||||
metadata: metadata || {},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to create node');
|
||||
}
|
||||
|
||||
const node = result.data;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Created node #${node.id}: ${node.title}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
nodeId: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || dimensions,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// rah_update_node
|
||||
server.registerTool(
|
||||
'rah_update_node',
|
||||
{
|
||||
title: 'Update RA-H node',
|
||||
description: 'Update an existing node. Source content is canonical and dimensions are replaced.',
|
||||
description: 'Update an existing node. Context remains optional and explicit.',
|
||||
inputSchema: {
|
||||
id: z.number().int().positive().describe('Node ID to update'),
|
||||
id: z.number().int().positive(),
|
||||
updates: z.object({
|
||||
title: z.string().optional().describe('New title'),
|
||||
content: z.string().optional().describe('Legacy alias for source'),
|
||||
source: z.string().optional().describe('Canonical source text'),
|
||||
link: z.string().optional().describe('New link'),
|
||||
dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'),
|
||||
}).describe('Fields to update'),
|
||||
title: z.string().optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
content: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
link: z.string().optional(),
|
||||
context_id: z.number().int().positive().nullable().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ id, updates }) => {
|
||||
@@ -328,218 +235,340 @@ function createRAHServer(): McpServer {
|
||||
}
|
||||
delete mappedUpdates.content;
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes/${id}`, {
|
||||
const payload = await callRaHApi(request, `/api/nodes/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mappedUpdates),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success && !result.node) {
|
||||
throw new Error(result.error || 'Failed to update node');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Updated node #${id}` }],
|
||||
structuredContent: { success: true, nodeId: id },
|
||||
structuredContent: {
|
||||
success: true,
|
||||
nodeId: payload.node?.id || id,
|
||||
message: payload.message || `Updated node #${id}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_get_nodes',
|
||||
{
|
||||
title: 'Get RA-H nodes by ID',
|
||||
description: 'Load full node records by their IDs.',
|
||||
inputSchema: {
|
||||
nodeIds: z.array(z.number().int().positive()).min(1).max(10),
|
||||
},
|
||||
},
|
||||
async ({ nodeIds }) => {
|
||||
const uniqueIds = Array.from(new Set(nodeIds.filter((id) => Number.isFinite(id) && id > 0)));
|
||||
const nodes: any[] = [];
|
||||
|
||||
for (const id of uniqueIds) {
|
||||
try {
|
||||
const payload = await callRaHApi(request, `/api/nodes/${id}`);
|
||||
if (payload.node) {
|
||||
nodes.push({
|
||||
id: payload.node.id,
|
||||
title: payload.node.title,
|
||||
source: payload.node.source ?? null,
|
||||
link: payload.node.link ?? null,
|
||||
updated_at: payload.node.updated_at,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip missing nodes.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Loaded ${nodes.length} of ${uniqueIds.length} nodes.` }],
|
||||
structuredContent: {
|
||||
count: nodes.length,
|
||||
nodes,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// rah_create_edge
|
||||
server.registerTool(
|
||||
'rah_create_edge',
|
||||
{
|
||||
title: 'Create RA-H edge',
|
||||
description: 'Create a connection between two nodes.',
|
||||
inputSchema: {
|
||||
fromNodeId: z.number().int().positive().describe('Source node ID'),
|
||||
toNodeId: z.number().int().positive().describe('Target node ID'),
|
||||
explanation: z.string().min(1).describe('Why does this connection exist?'),
|
||||
sourceId: z.number().int().positive(),
|
||||
targetId: z.number().int().positive(),
|
||||
explanation: z.string().min(1),
|
||||
},
|
||||
},
|
||||
async ({ fromNodeId, toNodeId, explanation }) => {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/edges`, {
|
||||
async ({ sourceId, targetId, explanation }) => {
|
||||
const payload = await callRaHApi(request, '/api/edges', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
from_node_id: fromNodeId,
|
||||
to_node_id: toNodeId,
|
||||
from_node_id: sourceId,
|
||||
to_node_id: targetId,
|
||||
explanation: explanation.trim(),
|
||||
source: 'helper_name',
|
||||
created_via: 'mcp',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to create edge');
|
||||
}
|
||||
|
||||
const edge = result.data;
|
||||
const edge = payload.edge || payload.data;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Created edge from #${fromNodeId} to #${toNodeId}` }],
|
||||
structuredContent: { success: true, edgeId: edge?.id },
|
||||
content: [{ type: 'text', text: `Created edge from #${sourceId} to #${targetId}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
edgeId: edge?.id || 0,
|
||||
message: payload.message || `Created edge from #${sourceId} to #${targetId}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_query_edges',
|
||||
{
|
||||
title: 'Query RA-H edges',
|
||||
description: 'Find connections between nodes.',
|
||||
inputSchema: {
|
||||
nodeId: z.number().int().positive().optional(),
|
||||
limit: z.number().min(1).max(50).optional(),
|
||||
},
|
||||
},
|
||||
async ({ nodeId, limit = 25 }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (nodeId) params.set('nodeId', String(nodeId));
|
||||
params.set('limit', String(Math.min(Math.max(limit, 1), 50)));
|
||||
|
||||
const payload = await callRaHApi(request, `/api/edges?${params.toString()}`);
|
||||
const edges = Array.isArray(payload.data) ? payload.data : [];
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Found ${edges.length} edge(s).` }],
|
||||
structuredContent: {
|
||||
count: edges.length,
|
||||
edges: edges.map((edge: any) => ({
|
||||
id: edge.id,
|
||||
source_id: edge.from_node_id,
|
||||
target_id: edge.to_node_id,
|
||||
type: edge.context?.type ?? null,
|
||||
weight: typeof edge.context?.confidence === 'number' ? edge.context.confidence : null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// rah_update_edge
|
||||
server.registerTool(
|
||||
'rah_update_edge',
|
||||
{
|
||||
title: 'Update RA-H edge',
|
||||
description: 'Update an existing edge explanation.',
|
||||
description: 'Update an existing edge connection.',
|
||||
inputSchema: {
|
||||
id: z.number().int().positive().describe('Edge ID to update'),
|
||||
explanation: z.string().min(1).describe('New explanation'),
|
||||
id: z.number().int().positive(),
|
||||
explanation: z.string().min(1),
|
||||
},
|
||||
},
|
||||
async ({ id, explanation }) => {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/edges/${id}`, {
|
||||
const payload = await callRaHApi(request, `/api/edges/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
context: { explanation: explanation.trim(), created_via: 'mcp' },
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success && !result.edge) {
|
||||
throw new Error(result.error || 'Failed to update edge');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Updated edge #${id}` }],
|
||||
structuredContent: { success: true, edgeId: id },
|
||||
structuredContent: {
|
||||
success: true,
|
||||
message: payload.message || `Updated edge #${id}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// rah_create_dimension
|
||||
server.registerTool(
|
||||
'rah_create_dimension',
|
||||
'rah_search_embeddings',
|
||||
{
|
||||
title: 'Create RA-H dimension',
|
||||
description: 'Create a new dimension (category/tag) for organizing nodes.',
|
||||
title: 'Semantic search RA-H',
|
||||
description: 'Search node content using semantic similarity.',
|
||||
inputSchema: {
|
||||
name: z.string().min(1).describe('Dimension name'),
|
||||
description: z.string().max(500).optional().describe('Description'),
|
||||
isPriority: z.boolean().optional().describe('Lock for auto-assignment'),
|
||||
query: z.string().min(1),
|
||||
limit: z.number().min(1).max(20).optional(),
|
||||
},
|
||||
},
|
||||
async ({ name, description, isPriority }) => {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions`, {
|
||||
async ({ query, limit = 10 }) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('q', query);
|
||||
params.set('limit', String(Math.min(Math.max(limit, 1), 20)));
|
||||
|
||||
const payload = await callRaHApi(request, `/api/nodes/search?${params.toString()}`);
|
||||
const results = Array.isArray(payload.data) ? payload.data : [];
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Found ${results.length} semantically similar result(s).` }],
|
||||
structuredContent: {
|
||||
count: results.length,
|
||||
results: results.map((result: any) => ({
|
||||
nodeId: result.node_id || result.nodeId || result.id,
|
||||
title: result.title || 'Untitled',
|
||||
chunkPreview: (result.source || '').slice(0, 200),
|
||||
similarity: result.similarity || result.score || 0,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_extract_url',
|
||||
{
|
||||
title: 'Extract URL content',
|
||||
description: 'Extract content from a webpage URL.',
|
||||
inputSchema: {
|
||||
url: z.string().url(),
|
||||
},
|
||||
},
|
||||
async ({ url }) => {
|
||||
const payload = await callRaHApi(request, '/api/extract/url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description, isPriority }),
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to create dimension');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Created dimension: ${name}` }],
|
||||
structuredContent: { success: true, dimension: name },
|
||||
content: [{ type: 'text', text: `Extracted content from: ${payload.title || 'webpage'}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
title: payload.title || 'Untitled',
|
||||
source: payload.source || '',
|
||||
metadata: payload.metadata || {},
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// rah_update_dimension
|
||||
server.registerTool(
|
||||
'rah_update_dimension',
|
||||
'rah_extract_youtube',
|
||||
{
|
||||
title: 'Update RA-H dimension',
|
||||
description: 'Update dimension properties (rename, description, priority).',
|
||||
title: 'Extract YouTube transcript',
|
||||
description: 'Extract transcript from a YouTube video.',
|
||||
inputSchema: {
|
||||
name: z.string().min(1).describe('Current dimension name'),
|
||||
newName: z.string().optional().describe('New name (for renaming)'),
|
||||
description: z.string().max(500).optional().describe('New description'),
|
||||
isPriority: z.boolean().optional().describe('Lock/unlock dimension'),
|
||||
url: z.string(),
|
||||
},
|
||||
},
|
||||
async ({ name, newName, description, isPriority }) => {
|
||||
const payload: any = {};
|
||||
if (newName) {
|
||||
payload.currentName = name;
|
||||
payload.newName = newName;
|
||||
} else {
|
||||
payload.name = name;
|
||||
}
|
||||
if (description !== undefined) payload.description = description;
|
||||
if (isPriority !== undefined) payload.isPriority = isPriority;
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
async ({ url }) => {
|
||||
const payload = await callRaHApi(request, '/api/extract/youtube', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update dimension');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Updated dimension: ${newName || name}` }],
|
||||
structuredContent: { success: true, dimension: newName || name },
|
||||
content: [{ type: 'text', text: `Extracted transcript from: ${payload.title || 'YouTube video'}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
title: payload.title || 'Untitled',
|
||||
channel: payload.channel || 'Unknown',
|
||||
source: payload.source || '',
|
||||
metadata: payload.metadata || {},
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// rah_delete_dimension
|
||||
server.registerTool(
|
||||
'rah_delete_dimension',
|
||||
'rah_extract_pdf',
|
||||
{
|
||||
title: 'Delete RA-H dimension',
|
||||
description: 'Delete a dimension and remove it from all nodes.',
|
||||
title: 'Extract PDF content',
|
||||
description: 'Extract content from a PDF file URL.',
|
||||
inputSchema: {
|
||||
name: z.string().min(1).describe('Dimension name to delete'),
|
||||
url: z.string().url(),
|
||||
},
|
||||
},
|
||||
async ({ name }) => {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions?name=${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
async ({ url }) => {
|
||||
const payload = await callRaHApi(request, '/api/extract/pdf', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to delete dimension');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Deleted dimension: ${name}` }],
|
||||
structuredContent: { success: true, dimension: name },
|
||||
content: [{ type: 'text', text: `Extracted content from: ${payload.title || 'PDF document'}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
title: payload.title || 'Untitled PDF',
|
||||
source: payload.source || '',
|
||||
metadata: payload.metadata || {},
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_get_context',
|
||||
{
|
||||
title: 'Get RA-H context',
|
||||
description: 'Get orientation context: contexts, hub nodes, stats, and available guides.',
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
const [hubPayload, contextsPayload, guidesPayload, countPayload] = await Promise.all([
|
||||
callRaHApi(request, '/api/nodes?sortBy=edges&limit=5'),
|
||||
callRaHApi(request, '/api/contexts'),
|
||||
callRaHApi(request, '/api/guides').catch(() => ({ data: [] })),
|
||||
callRaHApi(request, '/api/nodes?limit=1').catch(() => ({ total: 0 })),
|
||||
]);
|
||||
|
||||
const hubNodes = Array.isArray(hubPayload.data) ? hubPayload.data.map((node: any) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
description: node.description ?? null,
|
||||
edgeCount: node.edge_count ?? 0,
|
||||
})) : [];
|
||||
|
||||
const contexts = Array.isArray(contextsPayload.data) ? contextsPayload.data.map((context: any) => ({
|
||||
id: context.id,
|
||||
name: context.name,
|
||||
description: context.description ?? null,
|
||||
icon: context.icon ?? null,
|
||||
count: context.count ?? 0,
|
||||
})) : [];
|
||||
|
||||
const guides = Array.isArray(guidesPayload.data) ? guidesPayload.data.map((guide: any) => guide.name) : [];
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Knowledge graph: ${contexts.length} contexts, ${hubNodes.length} hub nodes, ${guides.length} guides available.` }],
|
||||
structuredContent: {
|
||||
stats: {
|
||||
nodeCount: countPayload.total ?? 0,
|
||||
edgeCount: 0,
|
||||
contextCount: contexts.length,
|
||||
},
|
||||
hubNodes,
|
||||
contexts,
|
||||
guides,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle MCP POST requests (tool calls)
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const server = createRAHServer();
|
||||
const server = createServer(request);
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
const response = await transport.handleRequest(req);
|
||||
|
||||
const response = await transport.handleRequest(request);
|
||||
await transport.close();
|
||||
await server.close();
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('MCP request error:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
@@ -559,9 +588,6 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle preflight CORS requests
|
||||
*/
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
@@ -573,28 +599,30 @@ export async function OPTIONS() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET returns server info
|
||||
*/
|
||||
export async function GET() {
|
||||
const tools = ['rah_search_nodes', 'rah_get_nodes', 'rah_query_edges', 'rah_list_dimensions'];
|
||||
|
||||
if (ALLOW_WRITES) {
|
||||
tools.push(
|
||||
'rah_add_node', 'rah_update_node',
|
||||
'rah_create_edge', 'rah_update_edge',
|
||||
'rah_create_dimension', 'rah_update_dimension', 'rah_delete_dimension'
|
||||
);
|
||||
}
|
||||
export async function GET(request: NextRequest) {
|
||||
const tools = [
|
||||
'rah_add_node',
|
||||
'rah_search_nodes',
|
||||
'rah_query_contexts',
|
||||
'rah_update_node',
|
||||
'rah_get_nodes',
|
||||
'rah_create_edge',
|
||||
'rah_query_edges',
|
||||
'rah_update_edge',
|
||||
'rah_search_embeddings',
|
||||
'rah_extract_url',
|
||||
'rah_extract_youtube',
|
||||
'rah_extract_pdf',
|
||||
'rah_get_context',
|
||||
];
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
name: SERVER_INFO.name,
|
||||
version: SERVER_INFO.version,
|
||||
description: 'RA-H Knowledge Graph - Remote MCP Server',
|
||||
writesEnabled: ALLOW_WRITES,
|
||||
target: getBaseUrl(request),
|
||||
tools,
|
||||
usage: 'claude mcp add --transport http my-rah https://your-deployment.vercel.app/api/mcp',
|
||||
},
|
||||
{
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
|
||||
@@ -4,7 +4,14 @@ import { generateDescription } from '@/services/database/descriptionService';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
type NodeMetadata = { source?: string; channel_name?: string; author?: string; site_name?: string; type?: string } & Record<string, unknown>;
|
||||
type NodeMetadata = {
|
||||
source?: string;
|
||||
channel_name?: string;
|
||||
author?: string;
|
||||
site_name?: string;
|
||||
type?: string;
|
||||
source_metadata?: Record<string, unknown>;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
function parseMetadata(raw: unknown): NodeMetadata | undefined {
|
||||
if (!raw) return undefined;
|
||||
@@ -27,9 +34,14 @@ async function enrichYoutubeMetadataIfMissing(link: string, metadata: NodeMetada
|
||||
if (!url.includes('youtube.com') && !url.includes('youtu.be')) return metadata;
|
||||
|
||||
const existing = metadata || {};
|
||||
const existingSourceMetadata = typeof existing.source_metadata === 'object' && existing.source_metadata
|
||||
? existing.source_metadata
|
||||
: {};
|
||||
const hasCreatorHint = Boolean(
|
||||
(typeof existing.author === 'string' && existing.author.trim()) ||
|
||||
(typeof existing.channel_name === 'string' && existing.channel_name.trim())
|
||||
(typeof existing.channel_name === 'string' && existing.channel_name.trim()) ||
|
||||
(typeof existingSourceMetadata.author === 'string' && existingSourceMetadata.author.trim()) ||
|
||||
(typeof existingSourceMetadata.channel_name === 'string' && existingSourceMetadata.channel_name.trim())
|
||||
);
|
||||
if (hasCreatorHint) return existing;
|
||||
|
||||
@@ -44,9 +56,20 @@ async function enrichYoutubeMetadataIfMissing(link: string, metadata: NodeMetada
|
||||
|
||||
return {
|
||||
...existing,
|
||||
source: typeof existing.source === 'string' && existing.source.trim().length > 0 ? existing.source : 'youtube',
|
||||
channel_name: typeof existing.channel_name === 'string' && existing.channel_name.trim().length > 0 ? existing.channel_name : authorName,
|
||||
site_name: typeof existing.site_name === 'string' && existing.site_name.trim().length > 0 ? existing.site_name : (providerName || 'YouTube'),
|
||||
type: typeof existing.type === 'string' && existing.type.trim().length > 0 ? existing.type : 'youtube',
|
||||
source_metadata: {
|
||||
...existingSourceMetadata,
|
||||
channel_name: typeof existing.channel_name === 'string' && existing.channel_name.trim().length > 0
|
||||
? existing.channel_name
|
||||
: (typeof existingSourceMetadata.channel_name === 'string' && existingSourceMetadata.channel_name.trim().length > 0
|
||||
? existingSourceMetadata.channel_name
|
||||
: authorName),
|
||||
site_name: typeof existing.site_name === 'string' && existing.site_name.trim().length > 0
|
||||
? existing.site_name
|
||||
: (typeof existingSourceMetadata.site_name === 'string' && existingSourceMetadata.site_name.trim().length > 0
|
||||
? existingSourceMetadata.site_name
|
||||
: (providerName || 'YouTube')),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return existing;
|
||||
@@ -88,8 +111,6 @@ export async function POST(
|
||||
source: node.source || node.description || undefined,
|
||||
link: node.link || undefined,
|
||||
metadata: enrichedMetadata,
|
||||
|
||||
dimensions: node.dimensions || []
|
||||
});
|
||||
|
||||
// Update the node with the new description
|
||||
|
||||
+15
-20
@@ -2,8 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { contextService, nodeService } from '@/services/database';
|
||||
import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue';
|
||||
import { hasSufficientContent } from '@/services/embedding/constants';
|
||||
import { coerceDescriptionForStorage, normalizeDimensions } from '@/services/database/quality';
|
||||
import { formatUnknownDimensionsError, getUnknownDimensions } from '@/services/database/dimensionValidation';
|
||||
import { coerceDescriptionForStorage } from '@/services/database/quality';
|
||||
import { normalizeNodeLink } from '@/utils/nodeLink';
|
||||
import { mergeNodeMetadata } from '@/services/nodes/metadata';
|
||||
|
||||
@@ -94,26 +93,26 @@ export async function PUT(
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(body.dimensions)) {
|
||||
updates.dimensions = normalizeDimensions(body.dimensions, 5);
|
||||
const unknownDimensions = getUnknownDimensions(updates.dimensions as string[]);
|
||||
if (unknownDimensions.length > 0) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: formatUnknownDimensionsError(unknownDimensions)
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
delete updates.notes;
|
||||
delete updates.chunk;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, 'context_id') || Object.prototype.hasOwnProperty.call(body, 'context_name')) {
|
||||
if (body.metadata !== undefined) {
|
||||
updates.metadata = mergeNodeMetadata(existingNode.metadata, body.metadata);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, 'context_name')) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'context_name is only supported on node creation. Use context_id for updates.'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, 'context_id')) {
|
||||
try {
|
||||
updates.context_id = await contextService.resolveContextId({
|
||||
const resolvedContextId = await contextService.resolveContextId({
|
||||
context_id: body.context_id,
|
||||
context_name: body.context_name,
|
||||
});
|
||||
updates.context_id = resolvedContextId;
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
@@ -122,10 +121,6 @@ export async function PUT(
|
||||
}
|
||||
}
|
||||
|
||||
if (body.metadata !== undefined) {
|
||||
updates.metadata = mergeNodeMetadata(existingNode.metadata, body.metadata);
|
||||
}
|
||||
|
||||
const incomingSource = typeof body.source === 'string' ? body.source : undefined;
|
||||
const existingSource = existingNode.source ?? '';
|
||||
|
||||
|
||||
+14
-54
@@ -4,11 +4,9 @@ import { Node, NodeFilters } from '@/types/database';
|
||||
import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue';
|
||||
import { generateDescription } from '@/services/database/descriptionService';
|
||||
import { scheduleAutoEdgeCreation } from '@/services/agents/autoEdge';
|
||||
import { coerceDescriptionForStorage, normalizeDimensions } from '@/services/database/quality';
|
||||
import { formatUnknownDimensionsError, getUnknownDimensions } from '@/services/database/dimensionValidation';
|
||||
import { coerceDescriptionForStorage } from '@/services/database/quality';
|
||||
import { normalizeNodeLink } from '@/utils/nodeLink';
|
||||
import { buildCanonicalNodeMetadata } from '@/services/nodes/metadata';
|
||||
import { inferBestContextIdForNode } from '@/services/context/contextAssignment';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -30,18 +28,6 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dimensions parameter (comma-separated)
|
||||
const dimensionsParam = searchParams.get('dimensions');
|
||||
if (dimensionsParam) {
|
||||
filters.dimensions = dimensionsParam.split(',').map(dim => dim.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// Handle dimensionsMatch parameter (any|all)
|
||||
const dimensionsMatchParam = searchParams.get('dimensionsMatch');
|
||||
if (dimensionsMatchParam === 'all') {
|
||||
filters.dimensionsMatch = 'all';
|
||||
}
|
||||
|
||||
// Handle sortBy parameter (sortBy=edges|updated|created)
|
||||
const sortByParam = searchParams.get('sortBy');
|
||||
if (sortByParam === 'edges' || sortByParam === 'updated' || sortByParam === 'created' || sortByParam === 'event_date') {
|
||||
@@ -115,16 +101,6 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
const eventDate = typeof body.event_date === 'string' ? body.event_date : null;
|
||||
|
||||
// Process provided dimensions first (needed for description generation)
|
||||
const trimmedProvidedDimensions = normalizeDimensions(body.dimensions, 5);
|
||||
const unknownDimensions = getUnknownDimensions(trimmedProvidedDimensions);
|
||||
if (unknownDimensions.length > 0) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: formatUnknownDimensionsError(unknownDimensions)
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Use provided description if present, otherwise auto-generate
|
||||
const isUserSuppliedDescription = typeof body.description === 'string' && body.description.trim().length > 0;
|
||||
let nodeDescription: string | undefined = isUserSuppliedDescription
|
||||
@@ -138,7 +114,6 @@ export async function POST(request: NextRequest) {
|
||||
source: rawSource?.slice(0, 2000) || undefined,
|
||||
link: normalizedLink || undefined,
|
||||
metadata: body.metadata,
|
||||
dimensions: trimmedProvidedDimensions
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating description:', error);
|
||||
@@ -160,8 +135,6 @@ export async function POST(request: NextRequest) {
|
||||
console.warn(`[DescriptionQuality] Weak description for node "${body.title}": "${finalDescription}"`);
|
||||
}
|
||||
|
||||
// Use only provided dimensions (no auto-assignment)
|
||||
const finalDimensions = trimmedProvidedDimensions;
|
||||
const sourceToStore = rawSource || [body.title, nodeDescription].filter(Boolean).join('\n\n').trim() || null;
|
||||
let chunkStatus: Node['chunk_status'];
|
||||
|
||||
@@ -169,6 +142,13 @@ export async function POST(request: NextRequest) {
|
||||
chunkStatus = 'not_chunked';
|
||||
}
|
||||
|
||||
const inferredType =
|
||||
typeof body.metadata?.type === 'string'
|
||||
? body.metadata.type
|
||||
: typeof body.metadata?.source === 'string'
|
||||
? body.metadata.source
|
||||
: undefined;
|
||||
|
||||
let resolvedContextId: number | null | undefined;
|
||||
try {
|
||||
resolvedContextId = await contextService.resolveContextId({
|
||||
@@ -176,25 +156,10 @@ export async function POST(request: NextRequest) {
|
||||
context_name: body.context_name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[nodes.create] Invalid explicit context input, falling back to inheritance/inference:', error);
|
||||
resolvedContextId = undefined;
|
||||
}
|
||||
|
||||
if (resolvedContextId === undefined && typeof body.active_context_id === 'number' && Number.isInteger(body.active_context_id) && body.active_context_id > 0) {
|
||||
const inherited = await contextService.getContextById(body.active_context_id);
|
||||
if (inherited) {
|
||||
resolvedContextId = inherited.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedContextId == null) {
|
||||
resolvedContextId = await inferBestContextIdForNode({
|
||||
title: body.title,
|
||||
description: finalDescription,
|
||||
source: sourceToStore,
|
||||
dimensions: finalDimensions,
|
||||
metadata: body.metadata,
|
||||
});
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Invalid context input'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const node = await nodeService.createNode({
|
||||
@@ -203,16 +168,11 @@ export async function POST(request: NextRequest) {
|
||||
source: sourceToStore ?? undefined,
|
||||
event_date: eventDate ?? undefined,
|
||||
link: normalizedLink ?? undefined,
|
||||
dimensions: finalDimensions,
|
||||
chunk_status: chunkStatus,
|
||||
context_id: resolvedContextId,
|
||||
metadata: buildCanonicalNodeMetadata({
|
||||
metadata: body.metadata || {},
|
||||
type: typeof body.metadata?.type === 'string'
|
||||
? body.metadata.type
|
||||
: typeof body.metadata?.source === 'string'
|
||||
? body.metadata.source
|
||||
: undefined,
|
||||
type: inferredType,
|
||||
state: body.metadata?.state === 'processed' ? 'processed' : 'not_processed',
|
||||
})
|
||||
});
|
||||
@@ -229,7 +189,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: node,
|
||||
message: `Node created successfully with dimensions: ${finalDimensions.join(', ')}`
|
||||
message: `Node created successfully`
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating node:', error);
|
||||
|
||||
@@ -29,7 +29,6 @@ export async function GET(request: NextRequest) {
|
||||
const results = nodes.map(node => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
getAutoContextSettings,
|
||||
setAutoContextEnabled,
|
||||
} from '@/services/settings/autoContextSettings';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const settings = getAutoContextSettings();
|
||||
return NextResponse.json({ success: true, data: settings });
|
||||
} catch (error) {
|
||||
console.error('Failed to read auto-context settings:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unable to read auto-context settings' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body || typeof body.autoContextEnabled !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'autoContextEnabled boolean is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const updated = setAutoContextEnabled(body.autoContextEnabled);
|
||||
return NextResponse.json({ success: true, data: updated });
|
||||
} catch (error) {
|
||||
console.error('Failed to update auto-context settings:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unable to update auto-context settings' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+759
-221
File diff suppressed because it is too large
Load Diff
+7
-5
@@ -1,10 +1,11 @@
|
||||
import './globals.css';
|
||||
import { DimensionIconsProvider } from '@/context/DimensionIconsContext';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import AutoUpdateManager from '@/components/system/AutoUpdateManager';
|
||||
import ExternalNavigationManager from '@/components/system/ExternalNavigationManager';
|
||||
|
||||
export const metadata = {
|
||||
title: 'RA-H Open Source',
|
||||
description: 'Local-first research workspace with a BYO-key AI orchestrator',
|
||||
title: 'RA-H - Knowledge Management System',
|
||||
description: 'A simplified, open-source knowledge management system',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -39,10 +40,11 @@ export default function RootLayout({
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<DimensionIconsProvider>
|
||||
<AuthProvider>
|
||||
<AutoUpdateManager />
|
||||
<ExternalNavigationManager />
|
||||
{children}
|
||||
</DimensionIconsProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -41,8 +41,8 @@ Restart Claude. Done.
|
||||
## What to Expect
|
||||
|
||||
Once connected, Claude will:
|
||||
- **Call `getContext` first** to orient itself (stats, contexts, hub nodes, dimensions, skills)
|
||||
- **Proactively capture knowledge** — when a new insight, decision, person, or reference surfaces, it proposes a specific node (title, dimensions, description) so you can approve with minimal friction
|
||||
- **Call `getContext` first** to orient itself (stats, contexts, anchors/hubs, skills)
|
||||
- **Proactively capture knowledge** — when a new insight, decision, person, or reference surfaces, it proposes a specific node (title, description, optional context) so you can approve with minimal friction
|
||||
- **Read skills for complex tasks** — skills are editable and shared across internal + external agents
|
||||
- **Search before creating** to avoid duplicates
|
||||
|
||||
@@ -50,7 +50,7 @@ Once connected, Claude will:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `getContext` | Get graph overview — stats, contexts, hub nodes, dimensions, recent activity |
|
||||
| `getContext` | Get graph overview — stats, contexts, recent activity |
|
||||
| `createNode` | Create a new node |
|
||||
| `queryNodes` | Search nodes by keyword |
|
||||
| `queryContexts` | List or inspect contexts |
|
||||
@@ -59,10 +59,6 @@ Once connected, Claude will:
|
||||
| `createEdge` | Create connection between nodes |
|
||||
| `updateEdge` | Update an edge explanation |
|
||||
| `queryEdge` | Find edges for a node |
|
||||
| `queryDimensions` | List all dimensions |
|
||||
| `createDimension` | Create a dimension |
|
||||
| `updateDimension` | Update/rename a dimension |
|
||||
| `deleteDimension` | Delete a dimension |
|
||||
| `listSkills` | List available skills |
|
||||
| `readSkill` | Read a skill by name |
|
||||
| `writeSkill` | Create or update a skill |
|
||||
@@ -70,6 +66,27 @@ Once connected, Claude will:
|
||||
| `searchContentEmbeddings` | Search through source content (transcripts, books, articles) |
|
||||
| `sqliteQuery` | Execute read-only SQL queries (SELECT/WITH/PRAGMA) |
|
||||
|
||||
## Node Metadata Contract
|
||||
|
||||
When `createNode` or `updateNode` includes metadata, prefer the canonical shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "website | youtube | pdf | tweet | note | chat | ...",
|
||||
"state": "processed | not_processed",
|
||||
"captured_method": "quick_add_note | website_extract | ...",
|
||||
"captured_by": "human | agent",
|
||||
"source_metadata": {}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `source_metadata` is for small factual source-specific fields only
|
||||
- metadata updates merge with the existing object; they do not replace the full blob
|
||||
- use `captured_by = "human"` for direct user creation and user-requested agent capture
|
||||
- reserve `captured_by = "agent"` for autonomous/background creation only
|
||||
|
||||
## Skills
|
||||
|
||||
Skills are detailed instruction sets that teach agents how to work with your knowledge base. The default seeded skills are editable and shared by internal + external agents.
|
||||
|
||||
@@ -27,18 +27,18 @@ try {
|
||||
const { z } = require('zod');
|
||||
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
||||
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
||||
const packageJson = require('./package.json');
|
||||
|
||||
const { initDatabase, getDatabasePath, closeDatabase, getDb, query } = require('./services/sqlite-client');
|
||||
const nodeService = require('./services/nodeService');
|
||||
const edgeService = require('./services/edgeService');
|
||||
const contextService = require('./services/contextService');
|
||||
const dimensionService = require('./services/dimensionService');
|
||||
const skillService = require('./services/skillService');
|
||||
|
||||
// Server info
|
||||
const serverInfo = {
|
||||
name: 'ra-h-standalone',
|
||||
version: '1.10.1'
|
||||
version: packageJson.version
|
||||
};
|
||||
|
||||
function buildInstructions() {
|
||||
@@ -59,18 +59,14 @@ function buildInstructions() {
|
||||
return `Today's date: ${now}. RA-H is the user's personal knowledge graph — local SQLite, fully on-device.
|
||||
|
||||
## Quick start
|
||||
1. Call getContext for orientation (stats, contexts, dimensions, anchors/hubs).
|
||||
1. Call getContext for orientation (stats, contexts, anchors/hubs).
|
||||
2. For simple tasks, tool descriptions have everything you need.
|
||||
3. For complex tasks, call readSkill("db-operations").
|
||||
|
||||
## Knowledge capture
|
||||
Proactively offer to save valuable information when insights, decisions, or references surface.
|
||||
Propose: "I'd add this as: [title] in [dimensions] — want me to?"
|
||||
Propose: "I'd add this as: [title] — want me to?"
|
||||
Always search before creating to avoid duplicates.
|
||||
Use only existing dimensions returned by getContext or queryDimensions.
|
||||
Do not invent new dimensions from node titles, concepts, or phrasing.
|
||||
Only call createDimension when the user explicitly instructs you to create a new dimension.
|
||||
Use contexts as the primary scope layer. Query contexts before assigning when needed.
|
||||
|
||||
## Available skills
|
||||
${skillIndex}
|
||||
@@ -82,22 +78,20 @@ All data stays on this device.`;
|
||||
// Tool schemas
|
||||
const addNodeInputSchema = {
|
||||
title: z.string().min(1).max(160).describe('Clear, descriptive title'),
|
||||
content: z.string().max(20000).optional().describe('Legacy content field; mapped to source'),
|
||||
source: z.string().max(50000).optional().describe('Full source text'),
|
||||
content: z.string().max(20000).optional().describe('Legacy alias for source content'),
|
||||
source: z.string().max(50000).optional().describe('Canonical source content for embedding'),
|
||||
link: z.string().url().optional().describe('Source URL'),
|
||||
description: z.string().optional().describe('Strongly recommended. Write the description as natural prose, not labels or a checklist. It should make clear what the artifact is and any surrounding context available. RA-H will accept whatever description is provided and will not block the write.'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID.'),
|
||||
context_name: z.string().optional().describe('Optional convenience context name.'),
|
||||
dimensions: z.array(z.string()).min(1).max(5).describe('1-5 existing categories. Call queryDimensions first to use existing ones. Do not invent new dimensions.'),
|
||||
metadata: z.record(z.any()).optional().describe('Optional metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.'),
|
||||
chunk: z.string().max(50000).optional().describe('Full source text')
|
||||
chunk: z.string().max(50000).optional().describe('Legacy alias for source text')
|
||||
};
|
||||
|
||||
const searchNodesInputSchema = {
|
||||
query: z.string().min(1).max(400).describe('Search query'),
|
||||
limit: z.number().min(1).max(25).optional().describe('Max results (default 10)'),
|
||||
contextId: z.number().int().positive().optional().describe('Optional primary context filter.'),
|
||||
dimensions: z.array(z.string()).max(5).optional().describe('Filter by dimensions'),
|
||||
created_after: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'),
|
||||
created_before: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'),
|
||||
event_after: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes with event_date on or after this date.'),
|
||||
@@ -113,12 +107,11 @@ const updateNodeInputSchema = {
|
||||
updates: z.object({
|
||||
title: z.string().optional().describe('New title'),
|
||||
description: z.string().optional().describe('Recommended replacement description. Keep it as natural prose that says what this artifact is and any surrounding context available. RA-H will accept whatever description is provided and will not block the write.'),
|
||||
content: z.string().optional().describe('Content to APPEND'),
|
||||
content: z.string().optional().describe('Legacy alias for source'),
|
||||
source: z.string().optional().describe('Canonical source content for embedding'),
|
||||
link: z.string().optional().describe('New link'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Primary context ID. Omit to preserve existing context; use null to clear it.'),
|
||||
dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'),
|
||||
metadata: z.record(z.any()).optional().describe('New metadata')
|
||||
metadata: z.record(z.any()).optional().describe('Metadata patch. It now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.')
|
||||
}).describe('Fields to update')
|
||||
};
|
||||
|
||||
@@ -146,25 +139,6 @@ const queryContextsInputSchema = {
|
||||
includeNodes: z.boolean().optional().describe('Include nodes for an exact single-context lookup')
|
||||
};
|
||||
|
||||
const listDimensionsInputSchema = {};
|
||||
|
||||
const createDimensionInputSchema = {
|
||||
name: z.string().min(1).describe('Dimension name'),
|
||||
description: z.string().max(500).optional().describe('Description'),
|
||||
isPriority: z.boolean().optional().describe('Lock for auto-assignment')
|
||||
};
|
||||
|
||||
const updateDimensionInputSchema = {
|
||||
name: z.string().min(1).describe('Current dimension name'),
|
||||
newName: z.string().optional().describe('New name (for renaming)'),
|
||||
description: z.string().max(500).optional().describe('New description'),
|
||||
isPriority: z.boolean().optional().describe('Lock/unlock dimension')
|
||||
};
|
||||
|
||||
const deleteDimensionInputSchema = {
|
||||
name: z.string().min(1).describe('Dimension name to delete')
|
||||
};
|
||||
|
||||
const readSkillInputSchema = {
|
||||
name: z.string().min(1).describe('Skill name (e.g. "db-operations", "onboarding", "persona")')
|
||||
};
|
||||
@@ -189,24 +163,6 @@ const sqliteQueryInputSchema = {
|
||||
format: z.enum(['json', 'table']).optional().describe('Output format (default json)')
|
||||
};
|
||||
|
||||
// Helper to sanitize dimensions
|
||||
function sanitizeDimensions(raw) {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
for (const value of raw) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) continue;
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (seen.has(lowered)) continue;
|
||||
seen.add(lowered);
|
||||
result.push(trimmed);
|
||||
if (result.length >= 5) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// FTS5 helpers
|
||||
function sanitizeFtsQuery(input) {
|
||||
return input
|
||||
@@ -314,7 +270,7 @@ async function main() {
|
||||
'getContext',
|
||||
{
|
||||
title: 'Get RA-H context',
|
||||
description: 'Get knowledge graph overview: stats, contexts, hub nodes (secondary diagnostics), dimensions, recent activity, and available skills. Call this first to orient yourself. For deeper operating policy, follow up with readSkill("db-operations").',
|
||||
description: 'Get knowledge graph overview: stats, contexts, hub nodes (secondary diagnostics), recent activity, and available skills. Call this first to orient yourself. For deeper operating policy, follow up with readSkill("db-operations").',
|
||||
inputSchema: {}
|
||||
},
|
||||
async () => {
|
||||
@@ -335,7 +291,7 @@ async function main() {
|
||||
};
|
||||
}
|
||||
|
||||
const summary = `Graph: ${context.stats.contextCount || 0} contexts, ${context.stats.nodeCount} nodes, ${context.stats.edgeCount} edges, ${context.stats.dimensionCount} dimensions, ${skills.length} skills.`;
|
||||
const summary = `Graph: ${context.stats.contextCount || 0} contexts, ${context.stats.nodeCount} nodes, ${context.stats.edgeCount} edges, ${skills.length} skills.`;
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: context
|
||||
@@ -349,40 +305,36 @@ async function main() {
|
||||
'createNode',
|
||||
{
|
||||
title: 'Add RA-H node',
|
||||
description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. Set context explicitly when clear; otherwise RA-H will infer the best-fit context automatically on create. Title: max 160 chars, clear and descriptive. Description is REQUIRED and should be natural prose that makes clear what the thing is, why it belongs in the graph, and workflow status. 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. Assign 1-5 dimensions — call queryDimensions first to use existing ones.',
|
||||
description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. Set context explicitly when clear and useful. 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.',
|
||||
inputSchema: addNodeInputSchema
|
||||
},
|
||||
async ({ title, content, source, link, description, context_id, context_name, dimensions, metadata, chunk }) => {
|
||||
const normalizedDimensions = sanitizeDimensions(dimensions);
|
||||
if (normalizedDimensions.length === 0) {
|
||||
throw new Error('At least one dimension is required.');
|
||||
}
|
||||
async ({ title, content, source, link, description, context_id, context_name, metadata, chunk }) => {
|
||||
const sourceText = source?.trim() || content?.trim() || chunk?.trim();
|
||||
const normalizedDescription = typeof description === 'string' ? description.trim() : description;
|
||||
|
||||
let resolvedContextId;
|
||||
try {
|
||||
resolvedContextId = contextService.resolveContextId({ context_id, context_name });
|
||||
} catch (error) {
|
||||
log('Warning: invalid explicit context input on createNode; falling back to automatic inference:', error.message);
|
||||
resolvedContextId = undefined;
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
const node = nodeService.createNode({
|
||||
title: title.trim(),
|
||||
source: source?.trim() || chunk?.trim() || content?.trim(),
|
||||
source: sourceText,
|
||||
link: link?.trim(),
|
||||
description: typeof description === 'string' ? description.trim() : description,
|
||||
description: normalizedDescription,
|
||||
context_id: resolvedContextId,
|
||||
dimensions: normalizedDimensions,
|
||||
metadata: metadata || {}
|
||||
});
|
||||
|
||||
const summary = `Created node #${node.id}: ${node.title} [${node.dimensions.join(', ')}]`;
|
||||
const summary = `Created node #${node.id}: ${node.title}`;
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: {
|
||||
nodeId: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions,
|
||||
message: summary
|
||||
}
|
||||
};
|
||||
@@ -393,26 +345,25 @@ async function main() {
|
||||
'queryNodes',
|
||||
{
|
||||
title: 'Search RA-H nodes',
|
||||
description: 'Search nodes by keyword across title, description, and source fields. Multi-word queries find nodes containing all words (not exact phrases). Returns up to 25 results (default 10). Call before creating nodes to check for duplicates. Optionally filter by dimensions. NOT for searching source documents (transcripts, articles) — use searchContentEmbeddings for that.',
|
||||
description: 'Search nodes by keyword across title, description, and source fields. Multi-word queries find nodes containing all words (not exact phrases). Returns up to 25 results (default 10). Call before creating nodes to check for duplicates. Optionally filter by context. NOT for searching source documents (transcripts, articles) — use searchContentEmbeddings for that.',
|
||||
inputSchema: searchNodesInputSchema
|
||||
},
|
||||
async ({ query: searchQuery, limit = 10, contextId, dimensions, created_after, created_before, event_after, event_before }) => {
|
||||
const normalizedDimensions = sanitizeDimensions(dimensions || []);
|
||||
async ({ query: searchQuery, limit = 10, contextId, created_after, created_before, event_after, event_before }) => {
|
||||
const safeLimit = Math.min(Math.max(limit, 1), 25);
|
||||
const trimmedQuery = searchQuery.trim();
|
||||
const fts = checkFtsAvailability();
|
||||
|
||||
if (contextId) {
|
||||
const nodes = nodeService.getNodes({
|
||||
search: trimmedQuery,
|
||||
limit: safeLimit,
|
||||
contextId,
|
||||
dimensions: normalizedDimensions,
|
||||
createdAfter: created_after,
|
||||
createdBefore: created_before,
|
||||
eventAfter: event_after,
|
||||
eventBefore: event_before,
|
||||
});
|
||||
|
||||
const summary = nodes.length === 0
|
||||
? 'No matching RA-H nodes found in that context.'
|
||||
: `Found ${nodes.length} node(s) in that context.`;
|
||||
? 'No nodes found matching that query.'
|
||||
: `Found ${nodes.length} node(s).`;
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
@@ -424,148 +375,11 @@ async function main() {
|
||||
source: node.source ?? null,
|
||||
description: node.description ?? null,
|
||||
link: node.link ?? null,
|
||||
dimensions: node.dimensions || [],
|
||||
context_id: node.context_id ?? null,
|
||||
created_at: node.created_at,
|
||||
updated_at: node.updated_at,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Build temporal filter clauses
|
||||
const temporalClauses = [];
|
||||
const temporalParams = [];
|
||||
if (created_after) { temporalClauses.push('n.created_at >= ?'); temporalParams.push(created_after); }
|
||||
if (created_before) { temporalClauses.push('n.created_at < ?'); temporalParams.push(created_before); }
|
||||
if (event_after) { temporalClauses.push('n.event_date >= ?'); temporalParams.push(event_after); }
|
||||
if (event_before) { temporalClauses.push('n.event_date < ?'); temporalParams.push(event_before); }
|
||||
const temporalSQL = temporalClauses.length > 0 ? temporalClauses.map(c => `AND ${c}`).join(' ') : '';
|
||||
|
||||
let nodes = null;
|
||||
|
||||
// Try FTS5 first (handles multi-word queries naturally)
|
||||
if (fts.nodes) {
|
||||
const ftsQuery = sanitizeFtsQuery(trimmedQuery);
|
||||
if (ftsQuery) {
|
||||
try {
|
||||
let sql, params;
|
||||
|
||||
if (normalizedDimensions.length > 0) {
|
||||
sql = `
|
||||
WITH fts_matches AS (
|
||||
SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT 100
|
||||
)
|
||||
SELECT n.id, n.title, n.description, n.source, n.link,
|
||||
n.created_at, n.updated_at, n.event_date,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||
FROM fts_matches fm
|
||||
JOIN nodes n ON n.id = fm.rowid
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM node_dimensions nd
|
||||
WHERE nd.node_id = n.id
|
||||
AND nd.dimension IN (${normalizedDimensions.map(() => '?').join(',')})
|
||||
)
|
||||
${temporalSQL}
|
||||
ORDER BY fm.rank
|
||||
LIMIT ?
|
||||
`;
|
||||
params = [ftsQuery, ...normalizedDimensions, ...temporalParams, safeLimit];
|
||||
} else {
|
||||
sql = `
|
||||
WITH fts_matches AS (
|
||||
SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT ?
|
||||
)
|
||||
SELECT n.id, n.title, n.description, n.source, n.link,
|
||||
n.created_at, n.updated_at, n.event_date,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||
FROM fts_matches fm
|
||||
JOIN nodes n ON n.id = fm.rowid
|
||||
${temporalSQL ? 'WHERE ' + temporalClauses.join(' AND ') : ''}
|
||||
ORDER BY fm.rank
|
||||
`;
|
||||
params = [ftsQuery, safeLimit, ...temporalParams];
|
||||
}
|
||||
|
||||
const rows = query(sql, params);
|
||||
nodes = rows.map(row => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
source: row.source ?? null,
|
||||
description: row.description ?? null,
|
||||
link: row.link ?? null,
|
||||
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
event_date: row.event_date ?? null
|
||||
}));
|
||||
} catch (err) {
|
||||
log('FTS search failed, falling back to LIKE:', err.message);
|
||||
nodes = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: LIKE with word splitting (each word must appear somewhere)
|
||||
if (nodes === null) {
|
||||
const words = trimmedQuery.split(/\s+/).filter(w => w.length > 0);
|
||||
|
||||
let sql = `
|
||||
SELECT n.id, n.title, n.description, n.source, n.link,
|
||||
n.created_at, n.updated_at, n.event_date,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||
FROM nodes n
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
for (const word of words) {
|
||||
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
|
||||
params.push(`%${word}%`, `%${word}%`, `%${word}%`);
|
||||
}
|
||||
|
||||
if (normalizedDimensions.length > 0) {
|
||||
sql += ` AND EXISTS (
|
||||
SELECT 1 FROM node_dimensions nd
|
||||
WHERE nd.node_id = n.id
|
||||
AND nd.dimension IN (${normalizedDimensions.map(() => '?').join(',')})
|
||||
)`;
|
||||
params.push(...normalizedDimensions);
|
||||
}
|
||||
|
||||
// Temporal filters
|
||||
if (temporalSQL) {
|
||||
sql += ` ${temporalSQL}`;
|
||||
params.push(...temporalParams);
|
||||
}
|
||||
|
||||
sql += ` ORDER BY n.updated_at DESC LIMIT ?`;
|
||||
params.push(safeLimit);
|
||||
|
||||
const rows = query(sql, params);
|
||||
nodes = rows.map(row => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
source: row.source ?? null,
|
||||
description: row.description ?? null,
|
||||
link: row.link ?? null,
|
||||
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
event_date: row.event_date ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
const summary = nodes.length === 0
|
||||
? 'No nodes found matching that query.'
|
||||
: `Found ${nodes.length} node(s).`;
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: {
|
||||
count: nodes.length,
|
||||
nodes
|
||||
event_date: node.event_date ?? null,
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -589,8 +403,8 @@ async function main() {
|
||||
for (const id of uniqueIds) {
|
||||
const node = nodeService.getNodeById(id);
|
||||
if (node) {
|
||||
const rawChunk = node.source ?? null;
|
||||
const chunkTruncated = rawChunk ? rawChunk.length > CHUNK_LIMIT : false;
|
||||
const rawSource = node.source ?? null;
|
||||
const sourceTruncated = rawSource ? rawSource.length > CHUNK_LIMIT : false;
|
||||
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
@@ -598,10 +412,9 @@ async function main() {
|
||||
source: node.source ?? null,
|
||||
description: node.description ?? null,
|
||||
link: node.link ?? null,
|
||||
chunk: chunkTruncated ? rawChunk.substring(0, CHUNK_LIMIT) : rawChunk,
|
||||
chunk_truncated: chunkTruncated,
|
||||
chunk_length: rawChunk ? rawChunk.length : 0,
|
||||
dimensions: node.dimensions || [],
|
||||
chunk: sourceTruncated ? rawSource.substring(0, CHUNK_LIMIT) : rawSource,
|
||||
chunk_truncated: sourceTruncated,
|
||||
chunk_length: rawSource ? rawSource.length : 0,
|
||||
metadata: node.metadata ?? null,
|
||||
created_at: node.created_at,
|
||||
updated_at: node.updated_at,
|
||||
@@ -624,37 +437,44 @@ async function main() {
|
||||
'updateNode',
|
||||
{
|
||||
title: 'Update RA-H node',
|
||||
description: 'Update an existing node. Description is REQUIRED on every update and should be natural prose that makes clear what this thing is, why it belongs in the graph, and workflow status. Source content lives in "source". Legacy "content" and "chunk" are mapped to source for compatibility. Dimensions are REPLACED entirely with the new array. Title, description, and link are overwritten. Call getNodesById first to verify current state before updating.',
|
||||
description: 'Update an existing node. 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.',
|
||||
inputSchema: updateNodeInputSchema
|
||||
},
|
||||
async ({ id, updates }) => {
|
||||
if (!updates || Object.keys(updates).length === 0) {
|
||||
throw new Error('At least one field must be provided in updates.');
|
||||
}
|
||||
// Map MCP legacy fields to canonical source
|
||||
|
||||
// Backward compatibility: map legacy content/chunk → source
|
||||
const mappedUpdates = { ...updates };
|
||||
if (mappedUpdates.content !== undefined) {
|
||||
mappedUpdates.source = mappedUpdates.content;
|
||||
}
|
||||
if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) {
|
||||
mappedUpdates.source = mappedUpdates.chunk;
|
||||
}
|
||||
if (mappedUpdates.content !== undefined) {
|
||||
mappedUpdates.source = mappedUpdates.content;
|
||||
delete mappedUpdates.content;
|
||||
}
|
||||
delete mappedUpdates.chunk;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'description')) {
|
||||
mappedUpdates.description = typeof mappedUpdates.description === 'string'
|
||||
? mappedUpdates.description.trim()
|
||||
: mappedUpdates.description;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'context_id')) {
|
||||
mappedUpdates.context_id = contextService.resolveContextId({ context_id: mappedUpdates.context_id });
|
||||
}
|
||||
|
||||
const node = nodeService.updateNode(id, mappedUpdates);
|
||||
const message = `Updated node #${id}`;
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Updated node #${id}` }],
|
||||
content: [{ type: 'text', text: message }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
nodeId: node.id,
|
||||
message: `Updated node #${id}`
|
||||
message
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -738,145 +558,71 @@ async function main() {
|
||||
}
|
||||
);
|
||||
|
||||
// ========== DIMENSION TOOLS ==========
|
||||
|
||||
registerToolWithAliases(
|
||||
'queryDimensions',
|
||||
{
|
||||
title: 'List RA-H dimensions',
|
||||
description: 'Get all existing canonical dimensions with node counts. Call before creating nodes to assign existing dimensions. Only create a new dimension if the user explicitly instructs you to do so.',
|
||||
inputSchema: listDimensionsInputSchema
|
||||
},
|
||||
async () => {
|
||||
const dimensions = dimensionService.getDimensions();
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Found ${dimensions.length} dimension(s).` }],
|
||||
structuredContent: {
|
||||
count: dimensions.length,
|
||||
dimensions
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
// ========== CONTEXT TOOLS ==========
|
||||
|
||||
registerToolWithAliases(
|
||||
'queryContexts',
|
||||
{
|
||||
title: 'Query RA-H contexts',
|
||||
description: 'List contexts, inspect a specific context, or search contexts by name/description.',
|
||||
title: 'List RA-H contexts',
|
||||
description: 'List or inspect contexts, the soft organizational layer for the graph. Use this before assigning or filtering by context.',
|
||||
inputSchema: queryContextsInputSchema
|
||||
},
|
||||
async ({ contextId, name, search, limit = 50, includeNodes = false }) => {
|
||||
const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : '';
|
||||
const normalizedName = typeof name === 'string' ? name.trim() : '';
|
||||
const normalizedSearch = typeof search === 'string' ? search.trim().toLowerCase() : '';
|
||||
|
||||
let contexts = [];
|
||||
|
||||
if (contextId) {
|
||||
const context = contextService.getContextById(contextId);
|
||||
if (context) {
|
||||
contexts = [context];
|
||||
}
|
||||
} else if (normalizedName) {
|
||||
const context = contextService.getContextByName(normalizedName);
|
||||
if (context) {
|
||||
contexts = [context];
|
||||
}
|
||||
contexts = context ? [context] : [];
|
||||
} else {
|
||||
const all = contextService.listContexts();
|
||||
contexts = all.filter((context) => {
|
||||
if (search) {
|
||||
const haystack = `${context.name || ''} ${context.description || ''}`.toLowerCase();
|
||||
return haystack.includes(search.trim().toLowerCase());
|
||||
contexts = contextService.listContexts();
|
||||
}
|
||||
return true;
|
||||
}).slice(0, Math.min(Math.max(limit, 1), 100));
|
||||
|
||||
if (normalizedName) {
|
||||
contexts = contexts.filter((context) => context.name.toLowerCase() === normalizedName.toLowerCase());
|
||||
}
|
||||
|
||||
if (normalizedSearch) {
|
||||
contexts = contexts.filter((context) =>
|
||||
context.name.toLowerCase().includes(normalizedSearch) ||
|
||||
(context.description || '').toLowerCase().includes(normalizedSearch)
|
||||
);
|
||||
}
|
||||
|
||||
contexts = contexts.slice(0, Math.min(Math.max(limit, 1), 100));
|
||||
|
||||
const includeContextNodes = includeNodes && contexts.length === 1 && (contextId || normalizedName);
|
||||
const enriched = contexts.map((context) => {
|
||||
if (!includeContextNodes) return context;
|
||||
const structuredContexts = contexts.map((context) => {
|
||||
if (!includeContextNodes) {
|
||||
return context;
|
||||
}
|
||||
|
||||
const nodes = nodeService.getNodes({ contextId: context.id, limit: 500 });
|
||||
return { ...context, nodes };
|
||||
return {
|
||||
...context,
|
||||
nodes: nodes.map((node) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
description: node.description ?? null,
|
||||
link: node.link ?? null,
|
||||
context_id: node.context_id ?? null,
|
||||
updated_at: node.updated_at,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: enriched.length === 0 ? 'No matching contexts found.' : `Found ${enriched.length} context(s).` }],
|
||||
structuredContent: {
|
||||
count: enriched.length,
|
||||
contexts: enriched
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
const summary = structuredContexts.length === 0
|
||||
? 'No contexts found.'
|
||||
: `Found ${structuredContexts.length} context(s).`;
|
||||
|
||||
registerToolWithAliases(
|
||||
'createDimension',
|
||||
{
|
||||
title: 'Create RA-H dimension',
|
||||
description: 'Create a new dimension/category only when the user explicitly instructs you to do so. Use lowercase, singular form (e.g. "biology" not "Biology" or "biologies"). Set isPriority=true to lock it for automatic assignment to new nodes. Always include a description.',
|
||||
inputSchema: createDimensionInputSchema
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: {
|
||||
count: structuredContexts.length,
|
||||
contexts: structuredContexts,
|
||||
},
|
||||
async ({ name, description, isPriority }) => {
|
||||
const dimension = dimensionService.createDimension({
|
||||
name,
|
||||
description,
|
||||
isPriority
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Created dimension: ${dimension.dimension}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
dimension: dimension.dimension,
|
||||
message: `Created dimension: ${dimension.dimension}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
registerToolWithAliases(
|
||||
'updateDimension',
|
||||
{
|
||||
title: 'Update RA-H dimension',
|
||||
description: 'Update or rename a dimension.',
|
||||
inputSchema: updateDimensionInputSchema
|
||||
},
|
||||
async ({ name, newName, description, isPriority }) => {
|
||||
const result = dimensionService.updateDimension({
|
||||
name,
|
||||
currentName: name,
|
||||
newName,
|
||||
description,
|
||||
isPriority
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Updated dimension: ${result.dimension}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
dimension: result.dimension,
|
||||
message: `Updated dimension: ${result.dimension}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
registerToolWithAliases(
|
||||
'deleteDimension',
|
||||
{
|
||||
title: 'Delete RA-H dimension',
|
||||
description: 'Delete a dimension and remove it from all nodes. WARNING: This is destructive — the dimension will be removed from ALL nodes that use it. Consider checking node counts with queryDimensions first.',
|
||||
inputSchema: deleteDimensionInputSchema
|
||||
},
|
||||
async ({ name }) => {
|
||||
const result = dimensionService.deleteDimension(name);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Deleted dimension: ${name}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
message: `Deleted dimension: ${name}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -1096,7 +842,7 @@ async function main() {
|
||||
'sqliteQuery',
|
||||
{
|
||||
title: 'Execute read-only SQL',
|
||||
description: 'Execute read-only SQL queries against the knowledge graph database. Tables: nodes, edges, dimensions, node_dimensions, chunks. Use PRAGMA table_info(tablename) for schema. Only SELECT/WITH/PRAGMA allowed. Use when structured tools are insufficient — e.g., complex JOINs, aggregations, or custom filtering. Read readSkill("db-operations") for table definitions and query patterns.',
|
||||
description: 'Execute read-only SQL queries against the knowledge graph database. Tables: nodes, contexts, edges, chunks, and migration snapshots. Use PRAGMA table_info(tablename) for schema. Only SELECT/WITH/PRAGMA allowed. Use when structured tools are insufficient — e.g., complex JOINs, aggregations, or custom filtering. Read readSkill("schema") for table definitions and query patterns.',
|
||||
inputSchema: sqliteQueryInputSchema
|
||||
},
|
||||
async ({ sql: userSql, format = 'json' }) => {
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const { query, transaction, getDb } = require('./sqlite-client');
|
||||
|
||||
/**
|
||||
* Get all dimensions with counts.
|
||||
*/
|
||||
function getDimensions() {
|
||||
const sql = `
|
||||
WITH dimension_counts AS (
|
||||
SELECT nd.dimension, COUNT(*) AS count
|
||||
FROM node_dimensions nd
|
||||
GROUP BY nd.dimension
|
||||
)
|
||||
SELECT
|
||||
d.name AS dimension,
|
||||
d.description,
|
||||
d.icon,
|
||||
d.is_priority AS isPriority,
|
||||
COALESCE(dc.count, 0) AS count
|
||||
FROM dimensions d
|
||||
LEFT JOIN dimension_counts dc ON dc.dimension = d.name
|
||||
ORDER BY d.is_priority DESC, d.name ASC
|
||||
`;
|
||||
|
||||
const rows = query(sql);
|
||||
|
||||
return rows.map(row => ({
|
||||
dimension: row.dimension,
|
||||
description: row.description,
|
||||
icon: row.icon || null,
|
||||
isPriority: Boolean(row.isPriority),
|
||||
count: Number(row.count)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a dimension.
|
||||
*/
|
||||
function createDimension(data) {
|
||||
const { name, description, isPriority = false } = data;
|
||||
const db = getDb();
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
throw new Error('Dimension name is required');
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO dimensions(name, description, is_priority, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
description = COALESCE(?, description),
|
||||
is_priority = COALESCE(?, is_priority),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING name, description, is_priority
|
||||
`);
|
||||
|
||||
const rows = stmt.all(
|
||||
trimmedName,
|
||||
description ?? null,
|
||||
isPriority ? 1 : 0,
|
||||
description ?? null,
|
||||
isPriority ? 1 : 0
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new Error('Failed to create dimension');
|
||||
}
|
||||
|
||||
return {
|
||||
dimension: rows[0].name,
|
||||
description: rows[0].description,
|
||||
isPriority: Boolean(rows[0].is_priority)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a dimension.
|
||||
*/
|
||||
function updateDimension(data) {
|
||||
const { name, currentName, newName, description, isPriority } = data;
|
||||
const db = getDb();
|
||||
|
||||
// Handle rename
|
||||
if (currentName && newName && currentName !== newName) {
|
||||
// Check if new name already exists
|
||||
const existing = query('SELECT name FROM dimensions WHERE name = ?', [newName]);
|
||||
if (existing.length > 0) {
|
||||
throw new Error('A dimension with this name already exists');
|
||||
}
|
||||
|
||||
transaction(() => {
|
||||
// Update dimensions table
|
||||
const dimStmt = db.prepare(`
|
||||
UPDATE dimensions
|
||||
SET name = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE name = ?
|
||||
`);
|
||||
const dimResult = dimStmt.run(newName, currentName);
|
||||
|
||||
if (dimResult.changes === 0) {
|
||||
throw new Error('Dimension not found. Use rah_list_dimensions to see all dimensions.');
|
||||
}
|
||||
|
||||
// Update node_dimensions
|
||||
const nodeDimStmt = db.prepare(`
|
||||
UPDATE node_dimensions
|
||||
SET dimension = ?
|
||||
WHERE dimension = ?
|
||||
`);
|
||||
nodeDimStmt.run(newName, currentName);
|
||||
});
|
||||
|
||||
return {
|
||||
dimension: newName,
|
||||
previousName: currentName,
|
||||
renamed: true
|
||||
};
|
||||
}
|
||||
|
||||
// Handle update (description/isPriority)
|
||||
const targetName = name || currentName;
|
||||
if (!targetName) {
|
||||
throw new Error('Dimension name is required');
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
const params = [];
|
||||
|
||||
if (description !== undefined) {
|
||||
updates.push('description = ?');
|
||||
params.push(description);
|
||||
}
|
||||
|
||||
if (isPriority !== undefined) {
|
||||
updates.push('is_priority = ?');
|
||||
params.push(isPriority ? 1 : 0);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw new Error('At least one update field must be provided (description, isPriority, or newName).');
|
||||
}
|
||||
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||
params.push(targetName);
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE dimensions
|
||||
SET ${updates.join(', ')}
|
||||
WHERE name = ?
|
||||
`);
|
||||
|
||||
const result = stmt.run(...params);
|
||||
|
||||
if (result.changes === 0) {
|
||||
throw new Error('Dimension not found. Use rah_list_dimensions to see all dimensions.');
|
||||
}
|
||||
|
||||
return {
|
||||
dimension: targetName,
|
||||
description,
|
||||
isPriority
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a dimension.
|
||||
*/
|
||||
function deleteDimension(name) {
|
||||
const db = getDb();
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
throw new Error('Dimension name is required');
|
||||
}
|
||||
|
||||
const removal = transaction(() => {
|
||||
const nodeDimStmt = db.prepare('DELETE FROM node_dimensions WHERE dimension = ?');
|
||||
const dimStmt = db.prepare('DELETE FROM dimensions WHERE name = ?');
|
||||
|
||||
const removedLinks = nodeDimStmt.run(name).changes ?? 0;
|
||||
const removedRow = dimStmt.run(name).changes ?? 0;
|
||||
|
||||
return { removedLinks, removedRow };
|
||||
});
|
||||
|
||||
if (!removal.removedLinks && !removal.removedRow) {
|
||||
throw new Error('Dimension not found. Use rah_list_dimensions to see all dimensions.');
|
||||
}
|
||||
|
||||
return {
|
||||
dimension: name,
|
||||
deleted: true,
|
||||
removedLinks: removal.removedLinks
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDimensions,
|
||||
createDimension,
|
||||
updateDimension,
|
||||
deleteDimension
|
||||
};
|
||||
@@ -53,10 +53,8 @@ function buildCanonicalMetadata({ existing, metadata }) {
|
||||
function mapNodeRow(row) {
|
||||
return {
|
||||
...row,
|
||||
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
||||
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
|
||||
context: row.context_json ? JSON.parse(row.context_json) : null,
|
||||
dimensions_json: undefined,
|
||||
context_json: undefined,
|
||||
};
|
||||
}
|
||||
@@ -65,13 +63,11 @@ function mapNodeRow(row) {
|
||||
* Get nodes with optional filtering.
|
||||
*/
|
||||
function getNodes(filters = {}) {
|
||||
const { dimensions, search, limit = 100, offset = 0, contextId } = filters;
|
||||
const { search, limit = 100, offset = 0, contextId } = filters;
|
||||
|
||||
let sql = `
|
||||
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
||||
n.created_at, n.updated_at, n.context_id,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||
CASE
|
||||
WHEN c.id IS NULL THEN NULL
|
||||
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
|
||||
@@ -82,16 +78,6 @@ function getNodes(filters = {}) {
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
// Filter by dimensions
|
||||
if (dimensions && dimensions.length > 0) {
|
||||
sql += ` AND EXISTS (
|
||||
SELECT 1 FROM node_dimensions nd
|
||||
WHERE nd.node_id = n.id
|
||||
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
|
||||
)`;
|
||||
params.push(...dimensions);
|
||||
}
|
||||
|
||||
// Text search
|
||||
if (search) {
|
||||
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
|
||||
@@ -135,8 +121,6 @@ function getNodeById(id) {
|
||||
const sql = `
|
||||
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
||||
n.created_at, n.updated_at, n.context_id,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||
CASE
|
||||
WHEN c.id IS NULL THEN NULL
|
||||
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
|
||||
@@ -164,134 +148,6 @@ function sanitizeTitle(title) {
|
||||
return clean.slice(0, 160);
|
||||
}
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'i',
|
||||
'in', 'is', 'it', 'its', 'of', 'on', 'or', 'that', 'the', 'their', 'this',
|
||||
'to', 'was', 'with', 'you', 'your'
|
||||
]);
|
||||
|
||||
function normalizeText(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.toLowerCase().replace(/[^a-z0-9\s]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function tokenize(value) {
|
||||
return normalizeText(value)
|
||||
.split(' ')
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length >= 2 && !STOP_WORDS.has(token));
|
||||
}
|
||||
|
||||
function uniqueTokens(values) {
|
||||
return [...new Set(values.flatMap((value) => tokenize(value || '')))];
|
||||
}
|
||||
|
||||
function safeStringify(value) {
|
||||
try {
|
||||
return JSON.stringify(value ?? {});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function fetchContextCandidates() {
|
||||
return query(`
|
||||
WITH context_counts AS (
|
||||
SELECT c.id, c.name, c.description, COUNT(n.id) AS count
|
||||
FROM contexts c
|
||||
LEFT JOIN nodes n ON n.context_id = c.id
|
||||
GROUP BY c.id
|
||||
),
|
||||
ranked_anchors AS (
|
||||
SELECT
|
||||
c.id AS context_id,
|
||||
n.title AS anchor_title,
|
||||
n.description AS anchor_description,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY c.id
|
||||
ORDER BY COUNT(e.id) DESC, n.updated_at DESC, n.id ASC
|
||||
) AS anchor_rank
|
||||
FROM contexts c
|
||||
LEFT JOIN nodes n ON n.context_id = c.id
|
||||
LEFT JOIN edges e ON (e.from_node_id = n.id OR e.to_node_id = n.id)
|
||||
GROUP BY c.id, n.id
|
||||
)
|
||||
SELECT
|
||||
cc.id,
|
||||
cc.name,
|
||||
cc.description,
|
||||
cc.count,
|
||||
ra.anchor_title,
|
||||
ra.anchor_description
|
||||
FROM context_counts cc
|
||||
LEFT JOIN ranked_anchors ra
|
||||
ON ra.context_id = cc.id
|
||||
AND ra.anchor_rank = 1
|
||||
ORDER BY cc.name COLLATE NOCASE ASC
|
||||
`).map((row) => ({
|
||||
id: Number(row.id),
|
||||
name: row.name,
|
||||
description: row.description ?? null,
|
||||
count: Number(row.count ?? 0),
|
||||
anchor_title: row.anchor_title ?? null,
|
||||
anchor_description: row.anchor_description ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function scoreContextCandidate(candidate, input) {
|
||||
const titleText = normalizeText(input.title || '');
|
||||
const descriptionText = normalizeText(input.description || '');
|
||||
const sourceText = normalizeText(String(input.source || '').slice(0, 4000));
|
||||
const metadataText = normalizeText(safeStringify(input.metadata));
|
||||
const dimensionTokens = uniqueTokens(input.dimensions || []);
|
||||
const contextName = normalizeText(candidate.name);
|
||||
const contextNameTokens = tokenize(candidate.name);
|
||||
const contextDescriptorTokens = uniqueTokens([
|
||||
candidate.description,
|
||||
candidate.anchor_title,
|
||||
candidate.anchor_description,
|
||||
]);
|
||||
|
||||
let score = 0;
|
||||
if (contextName && (titleText.includes(contextName) || descriptionText.includes(contextName))) score += 80;
|
||||
if (contextName && sourceText.includes(contextName)) score += 40;
|
||||
|
||||
for (const token of contextNameTokens) {
|
||||
if (dimensionTokens.includes(token)) score += 30;
|
||||
if (titleText.includes(token)) score += 16;
|
||||
if (descriptionText.includes(token)) score += 12;
|
||||
if (sourceText.includes(token)) score += 6;
|
||||
if (metadataText.includes(token)) score += 4;
|
||||
}
|
||||
|
||||
for (const token of contextDescriptorTokens) {
|
||||
if (dimensionTokens.includes(token)) score += 8;
|
||||
if (titleText.includes(token)) score += 4;
|
||||
if (descriptionText.includes(token)) score += 3;
|
||||
if (sourceText.includes(token)) score += 2;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function inferBestContextIdForNode(input) {
|
||||
const contexts = fetchContextCandidates();
|
||||
if (contexts.length === 0) return null;
|
||||
|
||||
const ranked = contexts
|
||||
.map((context) => ({ context, score: scoreContextCandidate(context, input) }))
|
||||
.sort((a, b) => b.score - a.score || (b.context.count - a.context.count) || a.context.id - b.context.id);
|
||||
|
||||
const best = ranked[0];
|
||||
if (!best) return null;
|
||||
if (best.score > 0) return best.context.id;
|
||||
|
||||
const research = contexts.find((context) => context.name.trim().toLowerCase() === 'research');
|
||||
if (research) return research.id;
|
||||
|
||||
return best.context.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new node.
|
||||
*/
|
||||
@@ -302,7 +158,6 @@ function createNode(nodeData) {
|
||||
source,
|
||||
link,
|
||||
event_date,
|
||||
dimensions = [],
|
||||
metadata = {},
|
||||
context_id
|
||||
} = nodeData;
|
||||
@@ -314,9 +169,7 @@ function createNode(nodeData) {
|
||||
const db = getDb();
|
||||
|
||||
const sourceToStore = source ?? ([title, description].filter(Boolean).join('\n\n').trim() || null);
|
||||
const effectiveContextId = context_id == null
|
||||
? inferBestContextIdForNode({ title, description, source: sourceToStore, dimensions, metadata: canonicalMetadata })
|
||||
: context_id;
|
||||
const effectiveContextId = context_id ?? null;
|
||||
|
||||
const nodeId = transaction(() => {
|
||||
const stmt = db.prepare(`
|
||||
@@ -338,16 +191,6 @@ function createNode(nodeData) {
|
||||
|
||||
const id = Number(result.lastInsertRowid);
|
||||
|
||||
// Insert dimensions
|
||||
if (dimensions.length > 0) {
|
||||
const dimStmt = db.prepare(
|
||||
'INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)'
|
||||
);
|
||||
for (const dimension of dimensions) {
|
||||
dimStmt.run(id, dimension);
|
||||
}
|
||||
}
|
||||
|
||||
return id;
|
||||
});
|
||||
|
||||
@@ -358,7 +201,7 @@ function createNode(nodeData) {
|
||||
* Update an existing node.
|
||||
*/
|
||||
function updateNode(id, updates, options = {}) {
|
||||
const { title, description, source, link, event_date, dimensions, metadata } = updates;
|
||||
const { title, description, source, link, event_date, metadata } = updates;
|
||||
const now = new Date().toISOString();
|
||||
const db = getDb();
|
||||
|
||||
@@ -415,14 +258,6 @@ function updateNode(id, updates, options = {}) {
|
||||
stmt.run(...params);
|
||||
}
|
||||
|
||||
// Handle dimensions separately
|
||||
if (Array.isArray(dimensions)) {
|
||||
db.prepare('DELETE FROM node_dimensions WHERE node_id = ?').run(id);
|
||||
const dimStmt = db.prepare('INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)');
|
||||
for (const dim of dimensions) {
|
||||
dimStmt.run(id, dim);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return getNodeById(id);
|
||||
@@ -449,21 +284,15 @@ function getNodeCount() {
|
||||
|
||||
/**
|
||||
* Get knowledge graph context overview.
|
||||
* Returns stats, contexts, hub nodes, dimensions, and recent activity.
|
||||
* Returns stats, contexts, hub nodes, and recent activity.
|
||||
*/
|
||||
function getContext() {
|
||||
const nodeCount = query('SELECT COUNT(*) as count FROM nodes')[0].count;
|
||||
const edgeCount = query('SELECT COUNT(*) as count FROM edges')[0].count;
|
||||
|
||||
const dimensionService = require('./dimensionService');
|
||||
const dimensions = dimensionService.getDimensions();
|
||||
|
||||
const recentNodes = query(`
|
||||
SELECT n.id, n.title, n.description,
|
||||
GROUP_CONCAT(nd.dimension) as dimensions
|
||||
SELECT n.id, n.title, n.description
|
||||
FROM nodes n
|
||||
LEFT JOIN node_dimensions nd ON n.id = nd.node_id
|
||||
GROUP BY n.id
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT 5
|
||||
`);
|
||||
@@ -478,9 +307,8 @@ function getContext() {
|
||||
`);
|
||||
|
||||
return {
|
||||
stats: { nodeCount, edgeCount, dimensionCount: dimensions.length, contextCount: contextService.listContexts().length },
|
||||
stats: { nodeCount, edgeCount, dimensionCount: 0, contextCount: contextService.listContexts().length },
|
||||
contexts: contextService.listContexts(),
|
||||
dimensions,
|
||||
recentNodes,
|
||||
hubNodes
|
||||
};
|
||||
|
||||
@@ -43,75 +43,6 @@ function initDatabase() {
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
db = new Database(dbPath);
|
||||
console.error('[RA-H] Creating new database at:', dbPath);
|
||||
|
||||
// Create core schema
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
source TEXT,
|
||||
link TEXT,
|
||||
event_date TEXT,
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
metadata TEXT,
|
||||
embedding BLOB,
|
||||
embedding_updated_at TEXT,
|
||||
embedding_text TEXT,
|
||||
chunk_status TEXT DEFAULT 'not_chunked',
|
||||
context_id INTEGER,
|
||||
FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS edges (
|
||||
id INTEGER PRIMARY KEY,
|
||||
from_node_id INTEGER NOT NULL,
|
||||
to_node_id INTEGER NOT NULL,
|
||||
source TEXT,
|
||||
created_at TEXT,
|
||||
context TEXT,
|
||||
explanation TEXT,
|
||||
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS node_dimensions (
|
||||
node_id INTEGER NOT NULL,
|
||||
dimension TEXT NOT NULL,
|
||||
PRIMARY KEY (node_id, dimension),
|
||||
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX IF NOT EXISTS idx_dim_by_dimension ON node_dimensions(dimension, node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dim_by_node ON node_dimensions(node_id, dimension);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dimensions (
|
||||
name TEXT PRIMARY KEY,
|
||||
description TEXT,
|
||||
icon TEXT,
|
||||
is_priority INTEGER DEFAULT 0,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contexts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Seed default dimensions
|
||||
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('research', 1);
|
||||
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('ideas', 1);
|
||||
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('projects', 1);
|
||||
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('memory', 1);
|
||||
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('preferences', 1);
|
||||
`);
|
||||
|
||||
console.error('[RA-H] Database created successfully');
|
||||
} else {
|
||||
db = new Database(dbPath);
|
||||
@@ -123,9 +54,18 @@ function initDatabase() {
|
||||
db.pragma('cache_size = 5000');
|
||||
db.pragma('busy_timeout = 5000');
|
||||
|
||||
ensureCoreSchema(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);
|
||||
if (!nodeCols.includes('source')) {
|
||||
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(`
|
||||
@@ -154,22 +94,33 @@ function initDatabase() {
|
||||
AND LENGTH(TRIM(chunk)) > 0;
|
||||
`);
|
||||
}
|
||||
if (!nodeCols.includes('context_id')) {
|
||||
db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;');
|
||||
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');
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS contexts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
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');
|
||||
}
|
||||
|
||||
const contextCols = db.prepare('PRAGMA table_info(contexts)').all().map(c => c.name);
|
||||
if (!contextCols.includes('description')) {
|
||||
db.exec("ALTER TABLE contexts ADD COLUMN description TEXT NOT NULL DEFAULT '';");
|
||||
}
|
||||
@@ -182,30 +133,230 @@ function initDatabase() {
|
||||
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);
|
||||
`);
|
||||
|
||||
const edgeCols = db.prepare('PRAGMA table_info(edges)').all().map(c => c.name);
|
||||
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;
|
||||
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;
|
||||
`);
|
||||
} catch {}
|
||||
console.error('[RA-H] Migrated edges: added explanation column');
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
function ensureCoreSchema(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS contexts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
source TEXT,
|
||||
link TEXT,
|
||||
event_date TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
metadata TEXT,
|
||||
embedding BLOB,
|
||||
embedding_updated_at TEXT,
|
||||
embedding_text TEXT,
|
||||
chunk_status TEXT DEFAULT 'not_chunked',
|
||||
context_id INTEGER,
|
||||
FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized
|
||||
ON contexts(LOWER(TRIM(name)));
|
||||
`);
|
||||
|
||||
ensureEdgesTableSchema(db);
|
||||
}
|
||||
|
||||
function ensureEdgesTableSchema(db) {
|
||||
const hasEdgesTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='edges'").get();
|
||||
|
||||
if (!hasEdgesTable) {
|
||||
db.exec(`
|
||||
CREATE TABLE edges (
|
||||
id INTEGER PRIMARY KEY,
|
||||
from_node_id INTEGER NOT NULL,
|
||||
to_node_id INTEGER NOT NULL,
|
||||
source TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
context TEXT,
|
||||
explanation TEXT,
|
||||
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} else {
|
||||
const edgeColNames = new Set(db.prepare('PRAGMA table_info(edges)').all().map((col) => col.name));
|
||||
const needsLegacyRewrite =
|
||||
!edgeColNames.has('from_node_id') ||
|
||||
!edgeColNames.has('to_node_id') ||
|
||||
!edgeColNames.has('source') ||
|
||||
!edgeColNames.has('created_at') ||
|
||||
!edgeColNames.has('context') ||
|
||||
edgeColNames.has('from_id') ||
|
||||
edgeColNames.has('to_id') ||
|
||||
edgeColNames.has('description') ||
|
||||
edgeColNames.has('updated_at');
|
||||
|
||||
if (needsLegacyRewrite) {
|
||||
rebuildLegacyEdgesTable(db, edgeColNames);
|
||||
}
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node_id);
|
||||
`);
|
||||
}
|
||||
|
||||
function rebuildLegacyEdgesTable(db, edgeColNames) {
|
||||
const fromExpr = edgeColNames.has('from_node_id')
|
||||
? 'from_node_id'
|
||||
: edgeColNames.has('from_id')
|
||||
? 'from_id'
|
||||
: 'NULL';
|
||||
const toExpr = edgeColNames.has('to_node_id')
|
||||
? 'to_node_id'
|
||||
: edgeColNames.has('to_id')
|
||||
? 'to_id'
|
||||
: 'NULL';
|
||||
const sourceExpr = edgeColNames.has('source') ? 'source' : "'legacy'";
|
||||
const createdAtExpr = edgeColNames.has('created_at') ? 'created_at' : 'CURRENT_TIMESTAMP';
|
||||
const contextExpr = edgeColNames.has('context') ? 'context' : 'NULL';
|
||||
const explanationExpr = edgeColNames.has('explanation')
|
||||
? 'explanation'
|
||||
: edgeColNames.has('description')
|
||||
? 'description'
|
||||
: edgeColNames.has('context')
|
||||
? "CASE WHEN json_valid(context) THEN json_extract(context, '$.explanation') ELSE NULL END"
|
||||
: 'NULL';
|
||||
|
||||
console.error('[RA-H] Migrating legacy edges table to canonical schema');
|
||||
|
||||
let flippedForeignKeys = false;
|
||||
try {
|
||||
db.exec('PRAGMA foreign_keys=OFF;');
|
||||
flippedForeignKeys = true;
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
db.exec('BEGIN TRANSACTION;');
|
||||
db.exec(`
|
||||
DROP INDEX IF EXISTS idx_edges_from;
|
||||
DROP INDEX IF EXISTS idx_edges_to;
|
||||
ALTER TABLE edges RENAME TO edges_legacy_migration;
|
||||
CREATE TABLE edges (
|
||||
id INTEGER PRIMARY KEY,
|
||||
from_node_id INTEGER NOT NULL,
|
||||
to_node_id INTEGER NOT NULL,
|
||||
source TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
context TEXT,
|
||||
explanation TEXT,
|
||||
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO edges (id, from_node_id, to_node_id, source, created_at, context, explanation)
|
||||
SELECT
|
||||
id,
|
||||
${fromExpr},
|
||||
${toExpr},
|
||||
${sourceExpr},
|
||||
COALESCE(${createdAtExpr}, CURRENT_TIMESTAMP),
|
||||
${contextExpr},
|
||||
${explanationExpr}
|
||||
FROM edges_legacy_migration
|
||||
WHERE ${fromExpr} IS NOT NULL
|
||||
AND ${toExpr} IS NOT NULL;
|
||||
DROP TABLE edges_legacy_migration;
|
||||
COMMIT;
|
||||
`);
|
||||
} catch (error) {
|
||||
try {
|
||||
db.exec('ROLLBACK;');
|
||||
} catch {}
|
||||
throw error;
|
||||
} finally {
|
||||
if (flippedForeignKeys) {
|
||||
try {
|
||||
db.exec('PRAGMA foreign_keys=ON;');
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database instance.
|
||||
* Throws if not initialized.
|
||||
|
||||
@@ -9,7 +9,7 @@ description: "Use for structured review, QA, cleanup, or governance checks acros
|
||||
|
||||
1. Node quality: duplicates, vague descriptions, missing dates, weak titles.
|
||||
2. Edge quality: missing links, weak explanations, wrong directionality.
|
||||
3. Dimension quality: drift, overlap, low-signal categories.
|
||||
3. Context quality: drift, overlap, low-signal buckets, or contexts being overused where stronger node metadata should carry the meaning.
|
||||
4. Skill quality: trigger clarity, overlap, dead/unused skills.
|
||||
|
||||
## Output Format
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: DB Operations
|
||||
description: "Use this for all graph read/write operations with strict data quality standards."
|
||||
description: "Use for graph read, write, connect, classify, or traverse operations with strict data quality standards."
|
||||
---
|
||||
|
||||
# DB Operations
|
||||
@@ -8,30 +8,72 @@ description: "Use this for all graph read/write operations with strict data qual
|
||||
## Core Rules
|
||||
|
||||
1. Search before create to avoid duplicates.
|
||||
2. Every create/update should aim for a natural description that makes clear what the thing is and any surrounding context available, but description quality is guidance only. RA-H should never block or rewrite a write because of description quality.
|
||||
2. Always try to include a natural description that clearly says what the thing is and any surrounding context available. But description quality is guidance only; RA-H should never block or rewrite a write because of description quality.
|
||||
3. Use event dates when known (when it happened, not when saved).
|
||||
4. Apply dimensions deliberately; prefer existing dimensions over creating noisy new ones.
|
||||
4. Apply contexts only when they are explicit and helpful. One node gets at most one context. If explicit context is missing on create, leave it empty instead of guessing.
|
||||
5. Do not rely on dimensions. Node quality comes from title, description, source, metadata, and strong edges.
|
||||
5. Create edges when relationships are meaningful; edge explanations should read as a sentence.
|
||||
6. For user-authored ideas, notes, or dictated thoughts, preserve the user's wording in `source` as fully as possible with only minimal cleanup.
|
||||
|
||||
## Write Quality Contract
|
||||
|
||||
- `title`: clear and specific.
|
||||
- `description`: natural prose, not labels. It should still make what / why / status clear when possible.
|
||||
- `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. For user-authored ideas or dictated notes, preserve the user's original wording with minimal cleanup.
|
||||
- `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.
|
||||
- For idea capture from chat, the `source` should usually be the raw user thought, not a compressed assistant summary.
|
||||
- `link`: external source URL only.
|
||||
- `metadata`: prefer canonical keys `type`, `state`, `captured_method`, `captured_by`, and `source_metadata`.
|
||||
- `context_id`: the node's primary context. Prefer setting it when the scope is explicit. Leave it null rather than guessing.
|
||||
- `metadata`: use the canonical node metadata contract when metadata is needed:
|
||||
- `type`
|
||||
- `state` (`processed` or `not_processed`)
|
||||
- `captured_method`
|
||||
- `captured_by`
|
||||
- `source_metadata`
|
||||
- `source_metadata`: factual source-specific details only. Keep it compact. No AI summaries or reasoning text.
|
||||
- metadata updates are merge-safe patches, not full-blob replacements. Do not assume `updateNode.metadata` wipes existing keys.
|
||||
- Derived analysis, briefs, and research notes should be stored in a separate linked node, not appended to the source node.
|
||||
|
||||
## Description Standard
|
||||
|
||||
Every node description should read like natural prose, not a template or checklist.
|
||||
|
||||
It must still make three things clear:
|
||||
1. What — what the artifact is in simple explicit terms (format + creator + core claim)
|
||||
2. Why — why it is in the graph; what Brad is interested in; what it connects to
|
||||
3. Status — where it sits in his workflow (queued, in progress, processed, unknown)
|
||||
|
||||
If the agent has graph context (active context, context anchor, context capsule, focused nodes), it should infer the why from that context and write it naturally.
|
||||
|
||||
If the why genuinely cannot be inferred, say that naturally. Do not use labels like `WHAT:`, `WHY:`, or `STATUS:` and do not substitute vague filler like `insightful for understanding` or `relevant to Brad's work`.
|
||||
|
||||
If status is unknown, say naturally that it has not been reviewed yet.
|
||||
|
||||
Ask a clarification question only when a missing detail would materially change the node being created. If the user has already given enough substance to infer the artifact, title, and likely why, do the work instead of bouncing it back.
|
||||
|
||||
For user-authored idea capture, do not treat the inferred description as final if the "why" or status was mostly inferred. Save the node first, then tell the user what description framing you inferred and invite one short correction pass on:
|
||||
- what this is
|
||||
- why it belongs here
|
||||
- where it sits in their workflow
|
||||
|
||||
Keep it concise, but do not block the write over length or quality.
|
||||
|
||||
## Metadata Semantics
|
||||
|
||||
- Direct user creation, quick add, and user-requested agent capture should default to `captured_by = "human"`.
|
||||
- Only autonomous/background creation without direct user instruction should use `captured_by = "agent"`.
|
||||
- Prefer leaving `type` blank over forcing a weak label.
|
||||
- `state` is the user-visible processed flag. If no state is known, default to `not_processed`.
|
||||
|
||||
## Execution Pattern
|
||||
|
||||
1. Read context (search + relevant nodes + relevant edges).
|
||||
2. Decide: create vs update vs connect.
|
||||
3. Execute minimum required writes.
|
||||
4. Verify result reflects user intent exactly.
|
||||
5. If description framing was materially inferred, complete the write first and then invite one concise user feedback pass instead of blocking creation.
|
||||
4. If the node is a user-authored idea and the contextual framing was inferred, offer one concise feedback pass after the write.
|
||||
5. Verify result reflects user intent exactly.
|
||||
|
||||
## Do Not
|
||||
|
||||
- Create duplicate nodes when an update is correct.
|
||||
- Write vague descriptions ("discusses", "explores", "is about").
|
||||
- Replace a user's raw idea/source with a thin summary.
|
||||
- Create weak or directionless edges.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: Node Context Enrichment
|
||||
description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with dimension review and edge suggestions."
|
||||
description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with context review and edge suggestions."
|
||||
---
|
||||
|
||||
# Node Context Enrichment
|
||||
@@ -17,15 +17,21 @@ Replace weak descriptions with a single clean natural description that captures:
|
||||
2. Why it is in Brad's graph
|
||||
3. Status in Brad's workflow
|
||||
|
||||
Also review whether the node needs dimension fixes or obvious edge suggestions.
|
||||
Also review whether the node needs context cleanup or obvious edge suggestions.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Load the node and inspect title, description, source, link, metadata, dimensions, and nearby edges.
|
||||
2. Search for adjacent context before rewriting.
|
||||
1. Load the node and inspect title, description, source, link, metadata, context, and nearby edges.
|
||||
2. Search for adjacent context before rewriting:
|
||||
- the node's primary context and its anchor node when present
|
||||
- recently connected project or belief nodes
|
||||
- related nodes with overlapping titles, creators, or neighboring context
|
||||
3. Infer the best available "why" from that context.
|
||||
4. Rewrite the full description from scratch in natural prose. Do not append to the old text or use labels like WHAT:, WHY:, or STATUS:.
|
||||
5. Review dimensions.
|
||||
5. Review context fit:
|
||||
- keep the current context when it is clearly the primary scope
|
||||
- suggest clearing context when it is weak or misleading
|
||||
- suggest a different context only when the primary scope is explicit
|
||||
6. Suggest 1-3 high-signal edges when obvious.
|
||||
7. Update the node once the description is strong enough to be useful.
|
||||
8. After the update, tell the user what changed and ask whether they want to refine the important framing:
|
||||
@@ -34,3 +40,52 @@ Also review whether the node needs dimension fixes or obvious edge suggestions.
|
||||
- status / current relevance / workflow position
|
||||
|
||||
The user feedback pass is required whenever the enriched "why" or status was inferred rather than directly stated in the node/source.
|
||||
|
||||
## Description Standard
|
||||
|
||||
Every rewritten description must naturally cover:
|
||||
|
||||
1. What
|
||||
- explicit artifact type
|
||||
- creator/author/speaker when known
|
||||
- core subject, claim, or function
|
||||
2. Why
|
||||
- why Brad saved it
|
||||
- what project, belief, question, or theme it connects to
|
||||
- if genuinely unknown, say that naturally without inventing context
|
||||
3. Status
|
||||
- queued, in progress, processed, not yet reviewed, saved for later, etc.
|
||||
- if unknown, say naturally that it has not been reviewed yet
|
||||
|
||||
Max 500 characters.
|
||||
|
||||
## Batch Mode
|
||||
|
||||
Use batch enrichment when cleaning up many nodes with the same failure mode.
|
||||
|
||||
1. Pull a tight node set first.
|
||||
2. Group by pattern:
|
||||
- vague imported links
|
||||
- thin quick-add captures
|
||||
- old source nodes missing workflow state
|
||||
3. Enrich each node individually. Do not reuse boilerplate "why" text across unrelated nodes.
|
||||
4. Return a compact summary of:
|
||||
- nodes updated
|
||||
- context assignments to review
|
||||
- edge suggestions not yet created
|
||||
|
||||
## Quality Bar
|
||||
|
||||
- No filler phrases like `insightful for understanding`, `relevant to`, or `important for`.
|
||||
- No generic summaries that only restate the topic.
|
||||
- No invented certainty. If context is weak, say so explicitly.
|
||||
- Prefer one compact 3-sentence description over bloated prose.
|
||||
|
||||
## Output Pattern
|
||||
|
||||
For each node:
|
||||
|
||||
- New description
|
||||
- Context change: keep / change / clear
|
||||
- Edge suggestions: source -> target with explicit explanation
|
||||
- One short invitation for user feedback when contextual framing was inferred
|
||||
|
||||
@@ -1,35 +1,166 @@
|
||||
---
|
||||
name: Onboarding
|
||||
description: "Onboard a new user by mapping goals, projects, worldview, and preferences into a strong initial graph."
|
||||
when_to_use: "New user setup or major reset of account context."
|
||||
when_not_to_use: "User asks for a narrow tactical operation only."
|
||||
success_criteria: "User has an initial context structure, anchor candidates, and clear next steps for graph growth."
|
||||
description: "Use for new-user setup, empty or near-empty graphs, or major resets to map goals, projects, worldview, and preferences into an initial graph."
|
||||
---
|
||||
|
||||
# Onboarding
|
||||
|
||||
## Goal
|
||||
## Your Job
|
||||
|
||||
Understand the user deeply enough to bootstrap a useful externalized context graph.
|
||||
Three things: help the user understand the basic structure of the system, help them start building useful context in it, and bootstrap the context capsule when durable cross-session facts become clear.
|
||||
|
||||
Adapt to the user.
|
||||
|
||||
- If they already know what they want to add, help them add it.
|
||||
- If they want guidance, guide them with simple prompts.
|
||||
- Do not force a rigid interview if they are already giving you usable context.
|
||||
|
||||
## Start With Orientation, Not Setup Friction
|
||||
|
||||
For signed-in cloud/mac users, do not start by asking whether the app is open or whether they added API keys. The app is already open, and billing-backed cloud usage does not require the old local setup checklist.
|
||||
|
||||
Start with product orientation and goal discovery first.
|
||||
|
||||
Only bring up setup details if the user actually needs them:
|
||||
|
||||
1. If they are on local/BYO-key mode, point them to Settings → API Keys.
|
||||
2. If they ask about the database location, tell them the default macOS path is `~/Library/Application Support/RA-H/db/rah.sqlite`.
|
||||
3. If API keys are relevant, explain them plainly:
|
||||
- **OpenAI** — powers embeddings, semantic retrieval, and extraction-related AI work.
|
||||
- **Anthropic** — mainly relevant for compatible runtime paths and local/dev setups.
|
||||
4. If they are not ready to configure anything yet, keep onboarding. They can still learn the structure and add manual content.
|
||||
|
||||
## Capsule Bootstrap
|
||||
|
||||
As part of onboarding, also bootstrap the context capsule on the user's behalf.
|
||||
|
||||
When the user gives durable identity, preference, worldview, project, or agent-behavior information:
|
||||
|
||||
1. Call `readSkill('context-capsule')`
|
||||
2. Call `readCapsule`
|
||||
3. When you have enough confidence, call `writeCapsule`
|
||||
|
||||
Use the capsule for:
|
||||
- preferred name
|
||||
- agent name or greeting preference
|
||||
- interaction style
|
||||
- major projects
|
||||
- major interests
|
||||
- explicit worldview or belief signals
|
||||
- what the user is using RA-H for
|
||||
|
||||
Do not write the capsule for tentative, one-off, or low-confidence statements.
|
||||
Keep it compressed and canonical. Replace stale instructions instead of preserving contradictory history.
|
||||
|
||||
## Explain the System First
|
||||
|
||||
Before asking anything, orient the user. Be direct, not salesy:
|
||||
|
||||
> "RA-H is a context system built on a simple graph. The goal is to build context that persists and gets more useful over time."
|
||||
|
||||
Explain the structure in simple terms:
|
||||
|
||||
- **Contexts** — an optional soft organization layer. These are broad areas like health, job, life, or research. A node can belong to one context when it is explicit and useful, but context is not required.
|
||||
- **Nodes** — individual things. A project, idea, person, source, belief, decision, or topic. Each node must have a clear description of what it is and why it matters.
|
||||
- **Edges** — explicit connections between things. Each edge must clearly explain the relationship.
|
||||
- **Metadata and edges** — secondary structure that makes nodes more useful once the core artifact is clear.
|
||||
|
||||
Then say:
|
||||
|
||||
> "If you know specifically how you'd like to create your context corpus, feel free to tell me what you'd like to add and I can help you set things up. Otherwise, I can guide you through bootstrapping your context with a few suggested prompts."
|
||||
|
||||
Also explain one practical thing early:
|
||||
|
||||
> "You do not need to perfectly design this up front. We want a few concrete nodes, clear contexts when they matter, and a few clean edges so the graph becomes useful quickly."
|
||||
|
||||
## Interview Flow
|
||||
|
||||
1. Clarify outcomes: goals for this system and success horizon.
|
||||
2. Map active projects: responsibilities, timelines, decision pressure.
|
||||
3. Capture worldview: beliefs, principles, working assumptions, decision criteria.
|
||||
4. Capture identity/context anchors: roles, domains, recurring themes.
|
||||
5. Capture interaction preferences: style, rigor, speed vs depth, tone.
|
||||
Keep it conversational. Use these buckets and adapt based on what the user gives you.
|
||||
|
||||
## Graph Bootstrap
|
||||
**1. Projects and active work**
|
||||
- What are you working on right now?
|
||||
- What projects, responsibilities, or decisions should be part of your context?
|
||||
- What keeps coming up enough that it should probably live in the graph?
|
||||
|
||||
1. Propose 3-6 primary contexts with clear rationale.
|
||||
2. Identify one strong anchor candidate per context.
|
||||
3. Propose starter dimensions as secondary metadata and filters.
|
||||
4. Create initial edges between anchor nodes and active project nodes.
|
||||
5. Confirm with user before writing.
|
||||
**2. Goals, motivations, beliefs, world models**
|
||||
- What are you trying to achieve?
|
||||
- What motivations, principles, or beliefs shape how you work and make decisions?
|
||||
- Are there any mental models or recurring ways you think about things that should be captured?
|
||||
|
||||
## Output
|
||||
**3. Learning, exploration, and research**
|
||||
- What are you reading, watching, listening to, or researching lately?
|
||||
- Any podcasts, articles, papers, books, or rabbit holes that matter right now?
|
||||
- Are there specific people, thinkers, or sources you follow closely?
|
||||
|
||||
- Initial context map
|
||||
- Suggested first write actions
|
||||
- Suggested weekly maintenance rhythm
|
||||
**4. Interaction style and preferences**
|
||||
- How do you want me to work with you?
|
||||
- Do you want concise answers, deeper exploration, pushback, or straightforward execution?
|
||||
|
||||
## First-Run Teaching Points
|
||||
|
||||
Work these in naturally when they are relevant:
|
||||
|
||||
- **First node creation** — explain that a node is one concrete thing worth keeping: a project, source, person, belief, decision, or idea.
|
||||
- **Contexts** — explain that contexts are the primary folders or scopes for the graph.
|
||||
- **MCP connection** — if the user mentions Claude Code or external agents, offer a quick setup path and point them to the MCP docs/skill flow rather than reciting a giant config block immediately.
|
||||
- **What to do after setup** — once the graph has a few solid nodes, the next useful move is usually one of:
|
||||
- connect related nodes with explicit edges
|
||||
- ingest a source they care about
|
||||
- add one or two skills/preferences so future conversations stay grounded
|
||||
|
||||
## How to Work
|
||||
|
||||
Do your best to build the graph as useful context emerges.
|
||||
|
||||
- Add nodes when the user mentions concrete things worth keeping.
|
||||
- Assign a primary context when one is clear. Prefer leaving context empty over low-confidence guessing.
|
||||
- Add edges when relationships are clear enough to explain well.
|
||||
- Explain what you're adding in plain language so the user understands the structure as it develops.
|
||||
|
||||
When the graph is empty or nearly empty, bias toward creating a small, clean starter set rather than over-modeling everything.
|
||||
|
||||
## Write Standards
|
||||
|
||||
Before writing anything, call `readSkill('db-operations')` for full quality standards. Key points that matter most here:
|
||||
|
||||
- Search before creating — avoid duplicates from day one
|
||||
- Every description must be concrete: what it IS and why it matters to them, not what it "explores" or "discusses"
|
||||
- Contexts should hold the primary scope when clear, otherwise leave them empty
|
||||
- Every edge needs an explicit explanation sentence
|
||||
|
||||
## Propose Before Writing
|
||||
|
||||
When there is enough context, summarize the proposed structure before touching the database:
|
||||
|
||||
> "Here's what I'm planning to create: [list contexts with one-line rationale], [list starter nodes], [list key edges]. Does this look right? Anything to adjust?"
|
||||
|
||||
Write only after confirmation.
|
||||
|
||||
If onboarding also surfaced durable cross-session facts, include the capsule update in the proposal:
|
||||
|
||||
> "I also plan to bootstrap your context capsule with your name, interaction preferences, and current priorities so future chats start grounded. Sound right?"
|
||||
> "I also plan to bootstrap your context capsule with your name and interaction preferences so future chats start grounded. Sound right?"
|
||||
|
||||
For very early setup, include the first actionable next step too:
|
||||
|
||||
> "After this starter pass, the best next move will be [add a source / connect these nodes / capture another active project]."
|
||||
|
||||
## Completion
|
||||
|
||||
After writing, give a brief recap:
|
||||
- What was created
|
||||
- How the structure works
|
||||
- What would be useful to add next
|
||||
|
||||
If setup is still incomplete, end with the smallest next action, for example:
|
||||
- add OpenAI in Settings
|
||||
- connect Claude Code via MCP
|
||||
- add one more source node
|
||||
|
||||
## Do Not
|
||||
|
||||
- Create meta-nodes like "User Profile", "Preferences", or "Goals" — the graph IS the profile
|
||||
- Write anything before proposing the structure and getting confirmation
|
||||
- Skip the interview and go straight to writing
|
||||
- Write vague descriptions ("is about", "explores", "discusses", "touches on")
|
||||
- Ask one disconnected question at a time when a natural multi-part thread is cleaner
|
||||
|
||||
+103
-219
@@ -44,12 +44,9 @@ let logger = (message) => console.log(`[mcp] ${message}`);
|
||||
|
||||
const instructions = [
|
||||
'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
|
||||
'Core concepts: contexts (primary scopes), nodes (knowledge units), edges (connections with explanations), and dimensions (secondary metadata and filters).',
|
||||
'Always call rah_get_context first to orient yourself — it returns contexts, hub nodes, dimensions, stats, and available guides.',
|
||||
'Use contexts as the primary scope layer. Use rah_query_contexts before assigning or filtering by context when needed.',
|
||||
'When assigning dimensions, use only existing dimensions returned by rah_get_context or rah_query_dimensions.',
|
||||
'Do not invent new dimensions from node titles, concepts, or phrasing.',
|
||||
'Only call rah_create_dimension when the user explicitly instructs you to create a new dimension.',
|
||||
'Core concepts: contexts (optional soft scopes), nodes (knowledge units), and edges (connections with explanations).',
|
||||
'Always call rah_get_context first to orient yourself — it returns contexts, hub nodes, stats, and available guides.',
|
||||
'Use contexts only when they are explicit and helpful. Do not expect automatic context assignment.',
|
||||
'Search before creating: use rah_search_nodes to check if content already exists.',
|
||||
'Every edge needs an explanation: why does this connection exist?',
|
||||
'All data stays local on this device; nothing leaves 127.0.0.1.',
|
||||
@@ -70,23 +67,6 @@ const createServer = () =>
|
||||
|
||||
const mcpServer = createServer();
|
||||
|
||||
const sanitizeDimensions = (raw) => {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
for (const value of raw) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) continue;
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (seen.has(lowered)) continue;
|
||||
seen.add(lowered);
|
||||
result.push(trimmed);
|
||||
if (result.length >= 5) break;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const addNodeInputSchema = {
|
||||
title: z.string().min(1).max(160),
|
||||
content: z.string().max(20000).optional(),
|
||||
@@ -95,7 +75,6 @@ const addNodeInputSchema = {
|
||||
description: z.string().max(500).optional().describe('Description of the node. Write it as natural prose, not labels or a checklist. It must still make clear what the artifact is, why it is in the graph (infer from conversation context; ask the user if needed), and its current workflow status. Max 500 characters. If the reason is unclear, say that naturally instead of inventing it. Never use filler phrases like "insightful for understanding" or "relevant to the user\'s work".'),
|
||||
context_id: z.number().int().positive().nullable().optional(),
|
||||
context_name: z.string().optional(),
|
||||
dimensions: z.array(z.string()).min(1).max(5),
|
||||
metadata: z.record(z.any()).optional().describe('Optional metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.'),
|
||||
chunk: z.string().max(50000).optional()
|
||||
};
|
||||
@@ -103,25 +82,15 @@ const addNodeInputSchema = {
|
||||
const addNodeOutputSchema = {
|
||||
nodeId: z.number(),
|
||||
title: z.string(),
|
||||
dimensions: z.array(z.string()),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
const searchNodesInputSchema = {
|
||||
query: z.string().min(1).max(400),
|
||||
limit: z.number().min(1).max(25).optional(),
|
||||
dimensions: z.array(z.string()).max(5).optional(),
|
||||
contextId: z.number().int().positive().optional()
|
||||
};
|
||||
|
||||
const queryContextsInputSchema = {
|
||||
contextId: z.number().int().positive().optional(),
|
||||
name: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
limit: z.number().min(1).max(100).optional(),
|
||||
includeNodes: z.boolean().optional()
|
||||
};
|
||||
|
||||
const searchNodesOutputSchema = {
|
||||
count: z.number(),
|
||||
nodes: z.array(
|
||||
@@ -131,12 +100,42 @@ const searchNodesOutputSchema = {
|
||||
source: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
link: z.string().nullable(),
|
||||
dimensions: z.array(z.string()),
|
||||
updated_at: z.string()
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
const queryContextsInputSchema = {
|
||||
contextId: z.number().int().positive().optional(),
|
||||
name: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
limit: z.number().min(1).max(100).optional(),
|
||||
includeNodes: z.boolean().optional()
|
||||
};
|
||||
|
||||
const queryContextsOutputSchema = {
|
||||
count: z.number(),
|
||||
contexts: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
icon: z.string().nullable(),
|
||||
count: z.number(),
|
||||
nodes: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
link: z.string().nullable(),
|
||||
context_id: z.number().nullable().optional(),
|
||||
updated_at: z.string()
|
||||
})
|
||||
).optional()
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
// rah_update_node schemas
|
||||
const updateNodeInputSchema = {
|
||||
id: z.number().int().positive().describe('The ID of the node to update'),
|
||||
@@ -147,7 +146,6 @@ const updateNodeInputSchema = {
|
||||
source: z.string().optional().describe('Canonical source text for embedding.'),
|
||||
link: z.string().optional().describe('New link'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Primary context ID. Omit to preserve existing context; use null to clear.'),
|
||||
dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'),
|
||||
metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.')
|
||||
}).describe('Fields to update')
|
||||
};
|
||||
@@ -171,7 +169,6 @@ const getNodesOutputSchema = {
|
||||
title: z.string(),
|
||||
source: z.string().nullable(),
|
||||
link: z.string().nullable(),
|
||||
dimensions: z.array(z.string()),
|
||||
updated_at: z.string()
|
||||
})
|
||||
)
|
||||
@@ -220,43 +217,6 @@ const updateEdgeOutputSchema = {
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_create_dimension schemas
|
||||
const createDimensionInputSchema = {
|
||||
name: z.string().min(1).describe('Dimension name'),
|
||||
description: z.string().max(500).optional().describe('Dimension description'),
|
||||
isPriority: z.boolean().optional().describe('Lock dimension for auto-assignment')
|
||||
};
|
||||
|
||||
const createDimensionOutputSchema = {
|
||||
success: z.boolean(),
|
||||
dimension: z.string(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_update_dimension schemas
|
||||
const updateDimensionInputSchema = {
|
||||
name: z.string().min(1).describe('Current dimension name'),
|
||||
newName: z.string().optional().describe('New name (for renaming)'),
|
||||
description: z.string().max(500).optional().describe('New description'),
|
||||
isPriority: z.boolean().optional().describe('Lock/unlock dimension')
|
||||
};
|
||||
|
||||
const updateDimensionOutputSchema = {
|
||||
success: z.boolean(),
|
||||
dimension: z.string(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_delete_dimension schemas
|
||||
const deleteDimensionInputSchema = {
|
||||
name: z.string().min(1).describe('Dimension name to delete')
|
||||
};
|
||||
|
||||
const deleteDimensionOutputSchema = {
|
||||
success: z.boolean(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_search_embeddings schemas
|
||||
const searchEmbeddingsInputSchema = {
|
||||
query: z.string().min(1).describe('Semantic search query'),
|
||||
@@ -361,27 +321,18 @@ mcpServer.registerTool(
|
||||
'rah_add_node',
|
||||
{
|
||||
title: 'Add RA-H node',
|
||||
description: 'Create a new node in the local RA-H knowledge base. Set context explicitly when clear; otherwise RA-H will infer the best-fit context automatically on create. Use only existing dimensions; do not invent new ones.',
|
||||
description: 'Create a new node in the local RA-H knowledge base. Set context explicitly when clear and useful; otherwise leave it empty.',
|
||||
inputSchema: addNodeInputSchema,
|
||||
outputSchema: addNodeOutputSchema
|
||||
},
|
||||
async ({ title, content, source, link, description, context_id, context_name, dimensions, metadata, chunk }) => {
|
||||
const normalizedDimensions = sanitizeDimensions(dimensions);
|
||||
if (normalizedDimensions.length === 0) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
'At least one dimension/tag is required when creating a node.'
|
||||
);
|
||||
}
|
||||
|
||||
async ({ title, content, source, link, description, context_id, context_name, metadata, chunk }) => {
|
||||
const payload = {
|
||||
title: title.trim(),
|
||||
source: source?.trim() || content?.trim() || chunk?.trim() || undefined,
|
||||
link: link?.trim() || undefined,
|
||||
description: description?.trim() || undefined,
|
||||
context_id: context_id === null ? null : context_id,
|
||||
context_id,
|
||||
context_name: context_name?.trim() || undefined,
|
||||
dimensions: normalizedDimensions,
|
||||
metadata: metadata || {}
|
||||
};
|
||||
|
||||
@@ -391,14 +342,13 @@ mcpServer.registerTool(
|
||||
});
|
||||
|
||||
const node = result.data;
|
||||
const summary = `Created node #${node.id}: ${node.title} [${(node.dimensions || normalizedDimensions).join(', ')}]`;
|
||||
const summary = `Created node #${node.id}: ${node.title}`;
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: {
|
||||
nodeId: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || normalizedDimensions,
|
||||
message: result.message || summary
|
||||
}
|
||||
};
|
||||
@@ -413,15 +363,11 @@ mcpServer.registerTool(
|
||||
inputSchema: searchNodesInputSchema,
|
||||
outputSchema: searchNodesOutputSchema
|
||||
},
|
||||
async ({ query, limit = 10, dimensions, contextId }) => {
|
||||
async ({ query, limit = 10, contextId }) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('search', query.trim());
|
||||
params.set('limit', String(Math.min(Math.max(limit, 1), 25)));
|
||||
|
||||
const dimensionList = sanitizeDimensions(dimensions || []);
|
||||
if (dimensionList.length > 0) {
|
||||
params.set('dimensions', dimensionList.join(','));
|
||||
}
|
||||
if (contextId) {
|
||||
params.set('contextId', String(contextId));
|
||||
}
|
||||
@@ -445,7 +391,6 @@ mcpServer.registerTool(
|
||||
source: node.source ?? null,
|
||||
description: node.description ?? null,
|
||||
link: node.link ?? null,
|
||||
dimensions: node.dimensions || [],
|
||||
updated_at: node.updated_at
|
||||
}))
|
||||
}
|
||||
@@ -457,45 +402,80 @@ mcpServer.registerTool(
|
||||
'rah_query_contexts',
|
||||
{
|
||||
title: 'Query RA-H contexts',
|
||||
description: 'List contexts, inspect a specific context, or search contexts by name/description.',
|
||||
inputSchema: queryContextsInputSchema
|
||||
description: 'List or inspect contexts, the soft organizational layer for the graph. Use this before assigning or filtering by context.',
|
||||
inputSchema: queryContextsInputSchema,
|
||||
outputSchema: queryContextsOutputSchema
|
||||
},
|
||||
async ({ contextId, name, search, limit = 50, includeNodes = false }) => {
|
||||
const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : '';
|
||||
const normalizedName = typeof name === 'string' ? name.trim() : '';
|
||||
const normalizedSearch = typeof search === 'string' ? search.trim().toLowerCase() : '';
|
||||
|
||||
let contexts = [];
|
||||
|
||||
if (contextId) {
|
||||
const result = await callRaHApi(`/api/contexts/${contextId}`, { method: 'GET' });
|
||||
if (result?.data) {
|
||||
contexts = [result.data];
|
||||
}
|
||||
contexts = result.data ? [result.data] : [];
|
||||
} else {
|
||||
const result = await callRaHApi('/api/contexts', { method: 'GET' });
|
||||
const all = Array.isArray(result.data) ? result.data : [];
|
||||
contexts = all.filter((context) => {
|
||||
if (normalizedName && context.name?.trim().toLowerCase() !== normalizedName) {
|
||||
return false;
|
||||
contexts = Array.isArray(result.data) ? result.data : [];
|
||||
}
|
||||
if (search) {
|
||||
const haystack = `${context.name || ''} ${context.description || ''}`.toLowerCase();
|
||||
return haystack.includes(search.trim().toLowerCase());
|
||||
|
||||
if (normalizedName) {
|
||||
contexts = contexts.filter((context) => context.name.toLowerCase() === normalizedName.toLowerCase());
|
||||
}
|
||||
return true;
|
||||
}).slice(0, Math.min(Math.max(limit, 1), 100));
|
||||
|
||||
if (normalizedSearch) {
|
||||
contexts = contexts.filter((context) =>
|
||||
context.name.toLowerCase().includes(normalizedSearch) ||
|
||||
(context.description || '').toLowerCase().includes(normalizedSearch)
|
||||
);
|
||||
}
|
||||
|
||||
contexts = contexts.slice(0, Math.min(Math.max(limit, 1), 100));
|
||||
|
||||
const includeContextNodes = includeNodes && contexts.length === 1 && (contextId || normalizedName);
|
||||
const enriched = await Promise.all(contexts.map(async (context) => {
|
||||
if (!includeContextNodes) return context;
|
||||
const nodeResult = await callRaHApi(`/api/contexts/${context.id}/nodes`, { method: 'GET' });
|
||||
return { ...context, nodes: Array.isArray(nodeResult.data) ? nodeResult.data : [] };
|
||||
}));
|
||||
const structuredContexts = await Promise.all(
|
||||
contexts.map(async (context) => {
|
||||
if (!includeContextNodes) {
|
||||
return {
|
||||
id: context.id,
|
||||
name: context.name,
|
||||
description: context.description ?? null,
|
||||
icon: context.icon ?? null,
|
||||
count: context.count ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
const nodesResult = await callRaHApi(`/api/contexts/${context.id}/nodes`, { method: 'GET' });
|
||||
const nodes = Array.isArray(nodesResult.data) ? nodesResult.data : [];
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: enriched.length === 0 ? 'No matching contexts found.' : `Found ${enriched.length} context(s).` }],
|
||||
id: context.id,
|
||||
name: context.name,
|
||||
description: context.description ?? null,
|
||||
icon: context.icon ?? null,
|
||||
count: context.count ?? nodes.length,
|
||||
nodes: nodes.map((node) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
description: node.description ?? null,
|
||||
link: node.link ?? null,
|
||||
context_id: node.context_id ?? null,
|
||||
updated_at: node.updated_at
|
||||
}))
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const summary = structuredContexts.length === 0
|
||||
? 'No contexts found.'
|
||||
: `Found ${structuredContexts.length} context(s).`;
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: {
|
||||
count: enriched.length,
|
||||
contexts: enriched
|
||||
count: structuredContexts.length,
|
||||
contexts: structuredContexts
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -505,7 +485,7 @@ mcpServer.registerTool(
|
||||
'rah_update_node',
|
||||
{
|
||||
title: 'Update RA-H node',
|
||||
description: 'Update an existing node. Dimensions must be existing canonical dimensions; do not invent new ones.',
|
||||
description: 'Update an existing node. Context remains optional and explicit.',
|
||||
inputSchema: updateNodeInputSchema,
|
||||
outputSchema: updateNodeOutputSchema
|
||||
},
|
||||
@@ -566,7 +546,6 @@ mcpServer.registerTool(
|
||||
title: result.node.title,
|
||||
source: result.node.source ?? null,
|
||||
link: result.node.link ?? null,
|
||||
dimensions: result.node.dimensions || [],
|
||||
updated_at: result.node.updated_at
|
||||
});
|
||||
}
|
||||
@@ -683,95 +662,6 @@ mcpServer.registerTool(
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_create_dimension',
|
||||
{
|
||||
title: 'Create RA-H dimension',
|
||||
description: 'Create a new dimension/tag for organizing nodes only when the user explicitly instructs you to do so.',
|
||||
inputSchema: createDimensionInputSchema,
|
||||
outputSchema: createDimensionOutputSchema
|
||||
},
|
||||
async ({ name, description, isPriority }) => {
|
||||
const payload = { name };
|
||||
if (description) payload.description = description;
|
||||
if (isPriority !== undefined) payload.isPriority = isPriority;
|
||||
|
||||
const result = await callRaHApi('/api/dimensions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const dim = result.data?.dimension || name;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Created dimension: ${dim}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
dimension: dim,
|
||||
message: `Created dimension: ${dim}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_update_dimension',
|
||||
{
|
||||
title: 'Update RA-H dimension',
|
||||
description: 'Update dimension properties (rename, description, lock/unlock).',
|
||||
inputSchema: updateDimensionInputSchema,
|
||||
outputSchema: updateDimensionOutputSchema
|
||||
},
|
||||
async ({ name, newName, description, isPriority }) => {
|
||||
const payload = {};
|
||||
if (newName) {
|
||||
payload.currentName = name;
|
||||
payload.newName = newName;
|
||||
} else {
|
||||
payload.name = name;
|
||||
}
|
||||
if (description !== undefined) payload.description = description;
|
||||
if (isPriority !== undefined) payload.isPriority = isPriority;
|
||||
|
||||
const result = await callRaHApi('/api/dimensions', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const dim = result.data?.dimension || newName || name;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Updated dimension: ${dim}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
dimension: dim,
|
||||
message: `Updated dimension: ${dim}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_delete_dimension',
|
||||
{
|
||||
title: 'Delete RA-H dimension',
|
||||
description: 'Delete a dimension and remove it from all nodes.',
|
||||
inputSchema: deleteDimensionInputSchema,
|
||||
outputSchema: deleteDimensionOutputSchema
|
||||
},
|
||||
async ({ name }) => {
|
||||
const result = await callRaHApi(`/api/dimensions?name=${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Deleted dimension: ${name}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
message: `Deleted dimension: ${name}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_search_embeddings',
|
||||
{
|
||||
@@ -892,13 +782,12 @@ mcpServer.registerTool(
|
||||
'rah_get_context',
|
||||
{
|
||||
title: 'Get RA-H context',
|
||||
description: 'Get orientation context: contexts, hub nodes, dimensions, stats, and available guides. Call this first.',
|
||||
description: 'Get orientation context: contexts, hub nodes, stats, and available guides. Call this first.',
|
||||
inputSchema: {},
|
||||
outputSchema: {
|
||||
stats: z.object({ nodeCount: z.number(), edgeCount: z.number(), dimensionCount: z.number(), contextCount: z.number().optional() }),
|
||||
stats: z.object({ nodeCount: z.number(), edgeCount: z.number(), contextCount: z.number().optional() }),
|
||||
hubNodes: z.array(z.object({ id: z.number(), title: z.string(), description: z.string().nullable(), edgeCount: z.number() })),
|
||||
contexts: z.array(z.object({ id: z.number(), name: z.string(), description: z.string().nullable(), icon: z.string().nullable().optional(), count: z.number() })).optional(),
|
||||
dimensions: z.array(z.object({ name: z.string(), nodeCount: z.number(), description: z.string().nullable() })),
|
||||
guides: z.array(z.string())
|
||||
}
|
||||
},
|
||||
@@ -908,11 +797,6 @@ mcpServer.registerTool(
|
||||
id: n.id, title: n.title, description: n.description ?? null, edgeCount: n.edge_count ?? 0
|
||||
})) : [];
|
||||
|
||||
const dimResult = await callRaHApi('/api/dimensions', { method: 'GET' });
|
||||
const dimensions = Array.isArray(dimResult.data) ? dimResult.data.map(d => ({
|
||||
name: d.name, nodeCount: d.node_count ?? 0, description: d.description ?? null
|
||||
})) : [];
|
||||
|
||||
const contextResult = await callRaHApi('/api/contexts', { method: 'GET' });
|
||||
const contexts = Array.isArray(contextResult.data) ? contextResult.data.map(c => ({
|
||||
id: c.id, name: c.name, description: c.description ?? null, icon: c.icon ?? null, count: c.count ?? 0
|
||||
@@ -921,15 +805,15 @@ mcpServer.registerTool(
|
||||
const guideResult = await callRaHApi('/api/guides', { method: 'GET' });
|
||||
const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : [];
|
||||
|
||||
const stats = { nodeCount: 0, edgeCount: 0, dimensionCount: dimensions.length, contextCount: contexts.length };
|
||||
const stats = { nodeCount: 0, edgeCount: 0, contextCount: contexts.length };
|
||||
try {
|
||||
const countResult = await callRaHApi('/api/nodes?limit=1', { method: 'GET' });
|
||||
if (countResult.total !== undefined) stats.nodeCount = countResult.total;
|
||||
} catch { /* use defaults */ }
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Knowledge graph: ${stats.contextCount} contexts, ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.` }],
|
||||
structuredContent: { stats, hubNodes, contexts, dimensions, guides }
|
||||
content: [{ type: 'text', text: `Knowledge graph: ${stats.contextCount} contexts, ${hubNodes.length} hub nodes for graph grounding, ${guides.length} guides available.` }],
|
||||
structuredContent: { stats, hubNodes, contexts, guides }
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
+101
-223
@@ -11,12 +11,9 @@ const packageJson = require('../../package.json');
|
||||
|
||||
const instructions = [
|
||||
'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
|
||||
'Core concepts: contexts (primary scopes), nodes (knowledge units), edges (connections with explanations), and dimensions (secondary metadata and filters).',
|
||||
'Always call rah_get_context first to orient yourself — it returns contexts, hub nodes, dimensions, stats, and available guides.',
|
||||
'Use contexts as the primary scope layer. Use rah_query_contexts before assigning or filtering by context when needed.',
|
||||
'When assigning dimensions, use only existing dimensions returned by rah_get_context or rah_query_dimensions.',
|
||||
'Do not invent new dimensions from node titles, concepts, or phrasing.',
|
||||
'Only call rah_create_dimension when the user explicitly instructs you to create a new dimension.',
|
||||
'Core concepts: contexts (optional soft scopes), nodes (knowledge units), and edges (connections with explanations).',
|
||||
'Always call rah_get_context first to orient yourself — it returns contexts, hub nodes, stats, and available guides.',
|
||||
'Use contexts only when they are explicit and helpful. Do not expect automatic context assignment.',
|
||||
'Search before creating: use rah_search_nodes to check if content already exists.',
|
||||
'Every edge needs an explanation: why does this connection exist?',
|
||||
'All data stays local on this device; nothing leaves 127.0.0.1.',
|
||||
@@ -44,7 +41,6 @@ const addNodeInputSchema = {
|
||||
description: z.string().max(500).optional().describe('Description of the node. Write it as natural prose, not labels or a checklist. It must still make clear what the artifact is, why it is in the graph (infer from conversation context; ask the user if needed), and its current workflow status. Max 500 characters. If the reason is unclear, say that naturally instead of inventing it. Never use filler phrases like "insightful for understanding" or "relevant to the user\'s work".'),
|
||||
context_id: z.number().int().positive().nullable().optional(),
|
||||
context_name: z.string().optional(),
|
||||
dimensions: z.array(z.string()).min(1).max(5),
|
||||
metadata: z.record(z.any()).optional().describe('Optional metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.'),
|
||||
chunk: z.string().max(50000).optional()
|
||||
};
|
||||
@@ -52,25 +48,15 @@ const addNodeInputSchema = {
|
||||
const addNodeOutputSchema = {
|
||||
nodeId: z.number(),
|
||||
title: z.string(),
|
||||
dimensions: z.array(z.string()),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
const searchNodesInputSchema = {
|
||||
query: z.string().min(1).max(400),
|
||||
limit: z.number().min(1).max(25).optional(),
|
||||
dimensions: z.array(z.string()).max(5).optional(),
|
||||
contextId: z.number().int().positive().optional()
|
||||
};
|
||||
|
||||
const queryContextsInputSchema = {
|
||||
contextId: z.number().int().positive().optional(),
|
||||
name: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
limit: z.number().min(1).max(100).optional(),
|
||||
includeNodes: z.boolean().optional()
|
||||
};
|
||||
|
||||
const searchNodesOutputSchema = {
|
||||
count: z.number(),
|
||||
nodes: z.array(
|
||||
@@ -80,12 +66,42 @@ const searchNodesOutputSchema = {
|
||||
source: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
link: z.string().nullable(),
|
||||
dimensions: z.array(z.string()),
|
||||
updated_at: z.string()
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
const queryContextsInputSchema = {
|
||||
contextId: z.number().int().positive().optional(),
|
||||
name: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
limit: z.number().min(1).max(100).optional(),
|
||||
includeNodes: z.boolean().optional()
|
||||
};
|
||||
|
||||
const queryContextsOutputSchema = {
|
||||
count: z.number(),
|
||||
contexts: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
icon: z.string().nullable(),
|
||||
count: z.number(),
|
||||
nodes: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
link: z.string().nullable(),
|
||||
context_id: z.number().nullable().optional(),
|
||||
updated_at: z.string()
|
||||
})
|
||||
).optional()
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
// rah_update_node schemas
|
||||
const updateNodeInputSchema = {
|
||||
id: z.number().int().positive().describe('The ID of the node to update'),
|
||||
@@ -96,7 +112,6 @@ const updateNodeInputSchema = {
|
||||
source: z.string().optional().describe('Canonical source text for embedding.'),
|
||||
link: z.string().optional().describe('New link'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Primary context ID. Omit to preserve existing context; use null to clear.'),
|
||||
dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'),
|
||||
metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.')
|
||||
}).describe('Fields to update')
|
||||
};
|
||||
@@ -120,7 +135,6 @@ const getNodesOutputSchema = {
|
||||
title: z.string(),
|
||||
source: z.string().nullable(),
|
||||
link: z.string().nullable(),
|
||||
dimensions: z.array(z.string()),
|
||||
updated_at: z.string()
|
||||
})
|
||||
)
|
||||
@@ -169,43 +183,6 @@ const updateEdgeOutputSchema = {
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_create_dimension schemas
|
||||
const createDimensionInputSchema = {
|
||||
name: z.string().min(1).describe('Dimension name'),
|
||||
description: z.string().max(500).optional().describe('Dimension description'),
|
||||
isPriority: z.boolean().optional().describe('Lock dimension for auto-assignment')
|
||||
};
|
||||
|
||||
const createDimensionOutputSchema = {
|
||||
success: z.boolean(),
|
||||
dimension: z.string(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_update_dimension schemas
|
||||
const updateDimensionInputSchema = {
|
||||
name: z.string().min(1).describe('Current dimension name'),
|
||||
newName: z.string().optional().describe('New name (for renaming)'),
|
||||
description: z.string().max(500).optional().describe('New description'),
|
||||
isPriority: z.boolean().optional().describe('Lock/unlock dimension')
|
||||
};
|
||||
|
||||
const updateDimensionOutputSchema = {
|
||||
success: z.boolean(),
|
||||
dimension: z.string(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_delete_dimension schemas
|
||||
const deleteDimensionInputSchema = {
|
||||
name: z.string().min(1).describe('Dimension name to delete')
|
||||
};
|
||||
|
||||
const deleteDimensionOutputSchema = {
|
||||
success: z.boolean(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_search_embeddings schemas
|
||||
const searchEmbeddingsInputSchema = {
|
||||
query: z.string().min(1).describe('Semantic search query'),
|
||||
@@ -267,23 +244,6 @@ function logError(...args) {
|
||||
console.error('[ra-h-stdio]', ...args);
|
||||
}
|
||||
|
||||
const sanitizeDimensions = (raw) => {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
for (const value of raw) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) continue;
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (seen.has(lowered)) continue;
|
||||
seen.add(lowered);
|
||||
result.push(trimmed);
|
||||
if (result.length >= 5) break;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
function readStatusFile() {
|
||||
try {
|
||||
if (!fs.existsSync(STATUS_PATH)) {
|
||||
@@ -335,24 +295,18 @@ server.registerTool(
|
||||
'rah_add_node',
|
||||
{
|
||||
title: 'Add RA-H node',
|
||||
description: 'Create a new node in the local RA-H knowledge base. Set context explicitly when clear; otherwise RA-H will infer the best-fit context automatically on create. Use only existing dimensions; do not invent new ones.',
|
||||
description: 'Create a new node in the local RA-H knowledge base. Set context explicitly when clear and useful; otherwise leave it empty.',
|
||||
inputSchema: addNodeInputSchema,
|
||||
outputSchema: addNodeOutputSchema
|
||||
},
|
||||
async ({ title, content, source, link, description, context_id, context_name, dimensions, metadata, chunk }) => {
|
||||
const normalizedDimensions = sanitizeDimensions(dimensions);
|
||||
if (normalizedDimensions.length === 0) {
|
||||
throw new Error('At least one dimension/tag is required when creating a node.');
|
||||
}
|
||||
|
||||
async ({ title, content, source, link, description, context_id, context_name, metadata, chunk }) => {
|
||||
const payload = {
|
||||
title: title.trim(),
|
||||
source: source?.trim() || content?.trim() || chunk?.trim() || undefined,
|
||||
link: link?.trim() || undefined,
|
||||
description: description?.trim() || undefined,
|
||||
context_id: context_id === null ? null : context_id,
|
||||
context_id,
|
||||
context_name: context_name?.trim() || undefined,
|
||||
dimensions: normalizedDimensions,
|
||||
metadata: metadata || {}
|
||||
};
|
||||
|
||||
@@ -362,14 +316,13 @@ server.registerTool(
|
||||
});
|
||||
|
||||
const node = result.data;
|
||||
const summary = `Created node #${node.id}: ${node.title} [${(node.dimensions || normalizedDimensions).join(', ')}]`;
|
||||
const summary = `Created node #${node.id}: ${node.title}`;
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: {
|
||||
nodeId: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || normalizedDimensions,
|
||||
message: result.message || summary
|
||||
}
|
||||
};
|
||||
@@ -384,15 +337,11 @@ server.registerTool(
|
||||
inputSchema: searchNodesInputSchema,
|
||||
outputSchema: searchNodesOutputSchema
|
||||
},
|
||||
async ({ query, limit = 10, dimensions, contextId }) => {
|
||||
async ({ query, limit = 10, contextId }) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('search', query.trim());
|
||||
params.set('limit', String(Math.min(Math.max(limit, 1), 25)));
|
||||
|
||||
const dimensionList = sanitizeDimensions(dimensions || []);
|
||||
if (dimensionList.length > 0) {
|
||||
params.set('dimensions', dimensionList.join(','));
|
||||
}
|
||||
if (contextId) {
|
||||
params.set('contextId', String(contextId));
|
||||
}
|
||||
@@ -417,7 +366,6 @@ server.registerTool(
|
||||
source: node.source ?? null,
|
||||
description: node.description ?? null,
|
||||
link: node.link ?? null,
|
||||
dimensions: node.dimensions || [],
|
||||
updated_at: node.updated_at
|
||||
}))
|
||||
}
|
||||
@@ -429,45 +377,81 @@ server.registerTool(
|
||||
'rah_query_contexts',
|
||||
{
|
||||
title: 'Query RA-H contexts',
|
||||
description: 'List contexts, inspect a specific context, or search contexts by name/description.',
|
||||
inputSchema: queryContextsInputSchema
|
||||
description: 'List or inspect contexts, the soft organizational layer for the graph. Use this before assigning or filtering by context.',
|
||||
inputSchema: queryContextsInputSchema,
|
||||
outputSchema: queryContextsOutputSchema
|
||||
},
|
||||
async ({ contextId, name, search, limit = 50, includeNodes = false }) => {
|
||||
const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : '';
|
||||
const normalizedName = typeof name === 'string' ? name.trim() : '';
|
||||
const normalizedSearch = typeof search === 'string' ? search.trim().toLowerCase() : '';
|
||||
|
||||
let contexts = [];
|
||||
|
||||
if (contextId) {
|
||||
const result = await callRaHApi(`/api/contexts/${contextId}`, { method: 'GET' });
|
||||
if (result?.data) {
|
||||
contexts = [result.data];
|
||||
}
|
||||
contexts = result.data ? [result.data] : [];
|
||||
} else {
|
||||
const result = await callRaHApi('/api/contexts', { method: 'GET' });
|
||||
const all = Array.isArray(result.data) ? result.data : [];
|
||||
contexts = all.filter((context) => {
|
||||
if (normalizedName && context.name?.trim().toLowerCase() !== normalizedName) {
|
||||
return false;
|
||||
contexts = Array.isArray(result.data) ? result.data : [];
|
||||
}
|
||||
if (search) {
|
||||
const haystack = `${context.name || ''} ${context.description || ''}`.toLowerCase();
|
||||
return haystack.includes(search.trim().toLowerCase());
|
||||
|
||||
if (normalizedName) {
|
||||
contexts = contexts.filter((context) => context.name.toLowerCase() === normalizedName.toLowerCase());
|
||||
}
|
||||
return true;
|
||||
}).slice(0, Math.min(Math.max(limit, 1), 100));
|
||||
|
||||
if (normalizedSearch) {
|
||||
contexts = contexts.filter((context) =>
|
||||
context.name.toLowerCase().includes(normalizedSearch) ||
|
||||
(context.description || '').toLowerCase().includes(normalizedSearch)
|
||||
);
|
||||
}
|
||||
|
||||
contexts = contexts.slice(0, Math.min(Math.max(limit, 1), 100));
|
||||
|
||||
const includeContextNodes = includeNodes && contexts.length === 1 && (contextId || normalizedName);
|
||||
const enriched = await Promise.all(contexts.map(async (context) => {
|
||||
if (!includeContextNodes) return context;
|
||||
const nodeResult = await callRaHApi(`/api/contexts/${context.id}/nodes`, { method: 'GET' });
|
||||
return { ...context, nodes: Array.isArray(nodeResult.data) ? nodeResult.data : [] };
|
||||
}));
|
||||
const structuredContexts = await Promise.all(
|
||||
contexts.map(async (context) => {
|
||||
if (!includeContextNodes) {
|
||||
return {
|
||||
id: context.id,
|
||||
name: context.name,
|
||||
description: context.description ?? null,
|
||||
icon: context.icon ?? null,
|
||||
count: context.count ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
const nodesResult = await callRaHApi(`/api/contexts/${context.id}/nodes`, { method: 'GET' });
|
||||
const nodes = Array.isArray(nodesResult.data) ? nodesResult.data : [];
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: enriched.length === 0 ? 'No matching contexts found.' : `Found ${enriched.length} context(s).` }],
|
||||
id: context.id,
|
||||
name: context.name,
|
||||
description: context.description ?? null,
|
||||
icon: context.icon ?? null,
|
||||
count: context.count ?? nodes.length,
|
||||
nodes: nodes.map((node) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
description: node.description ?? null,
|
||||
link: node.link ?? null,
|
||||
context_id: node.context_id ?? null,
|
||||
updated_at: node.updated_at
|
||||
}))
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const summary =
|
||||
structuredContexts.length === 0
|
||||
? 'No contexts found.'
|
||||
: `Found ${structuredContexts.length} context(s).`;
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: {
|
||||
count: enriched.length,
|
||||
contexts: enriched
|
||||
count: structuredContexts.length,
|
||||
contexts: structuredContexts
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -477,7 +461,7 @@ server.registerTool(
|
||||
'rah_update_node',
|
||||
{
|
||||
title: 'Update RA-H node',
|
||||
description: 'Update an existing node. Dimensions must be existing canonical dimensions; do not invent new ones.',
|
||||
description: 'Update an existing node. Context remains optional and explicit.',
|
||||
inputSchema: updateNodeInputSchema,
|
||||
outputSchema: updateNodeOutputSchema
|
||||
},
|
||||
@@ -538,7 +522,6 @@ server.registerTool(
|
||||
title: result.node.title,
|
||||
source: result.node.source ?? null,
|
||||
link: result.node.link ?? null,
|
||||
dimensions: result.node.dimensions || [],
|
||||
updated_at: result.node.updated_at
|
||||
});
|
||||
}
|
||||
@@ -655,95 +638,6 @@ server.registerTool(
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_create_dimension',
|
||||
{
|
||||
title: 'Create RA-H dimension',
|
||||
description: 'Create a new dimension/tag for organizing nodes only when the user explicitly instructs you to do so.',
|
||||
inputSchema: createDimensionInputSchema,
|
||||
outputSchema: createDimensionOutputSchema
|
||||
},
|
||||
async ({ name, description, isPriority }) => {
|
||||
const payload = { name };
|
||||
if (description) payload.description = description;
|
||||
if (isPriority !== undefined) payload.isPriority = isPriority;
|
||||
|
||||
const result = await callRaHApi('/api/dimensions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const dim = result.data?.dimension || name;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Created dimension: ${dim}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
dimension: dim,
|
||||
message: `Created dimension: ${dim}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_update_dimension',
|
||||
{
|
||||
title: 'Update RA-H dimension',
|
||||
description: 'Update dimension properties (rename, description, lock/unlock).',
|
||||
inputSchema: updateDimensionInputSchema,
|
||||
outputSchema: updateDimensionOutputSchema
|
||||
},
|
||||
async ({ name, newName, description, isPriority }) => {
|
||||
const payload = {};
|
||||
if (newName) {
|
||||
payload.currentName = name;
|
||||
payload.newName = newName;
|
||||
} else {
|
||||
payload.name = name;
|
||||
}
|
||||
if (description !== undefined) payload.description = description;
|
||||
if (isPriority !== undefined) payload.isPriority = isPriority;
|
||||
|
||||
const result = await callRaHApi('/api/dimensions', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const dim = result.data?.dimension || newName || name;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Updated dimension: ${dim}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
dimension: dim,
|
||||
message: `Updated dimension: ${dim}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_delete_dimension',
|
||||
{
|
||||
title: 'Delete RA-H dimension',
|
||||
description: 'Delete a dimension and remove it from all nodes.',
|
||||
inputSchema: deleteDimensionInputSchema,
|
||||
outputSchema: deleteDimensionOutputSchema
|
||||
},
|
||||
async ({ name }) => {
|
||||
const result = await callRaHApi(`/api/dimensions?name=${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Deleted dimension: ${name}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
message: `Deleted dimension: ${name}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_search_embeddings',
|
||||
{
|
||||
@@ -864,7 +758,6 @@ const getContextOutputSchema = {
|
||||
stats: z.object({
|
||||
nodeCount: z.number(),
|
||||
edgeCount: z.number(),
|
||||
dimensionCount: z.number(),
|
||||
contextCount: z.number().optional()
|
||||
}),
|
||||
hubNodes: z.array(z.object({
|
||||
@@ -880,11 +773,6 @@ const getContextOutputSchema = {
|
||||
icon: z.string().nullable().optional(),
|
||||
count: z.number()
|
||||
})).optional(),
|
||||
dimensions: z.array(z.object({
|
||||
name: z.string(),
|
||||
nodeCount: z.number(),
|
||||
description: z.string().nullable()
|
||||
})),
|
||||
guides: z.array(z.string())
|
||||
};
|
||||
|
||||
@@ -892,7 +780,7 @@ server.registerTool(
|
||||
'rah_get_context',
|
||||
{
|
||||
title: 'Get RA-H context',
|
||||
description: 'Get orientation context: contexts, hub nodes, dimensions, stats, and available guides. Call this first.',
|
||||
description: 'Get orientation context: contexts, hub nodes, stats, and available guides. Call this first.',
|
||||
inputSchema: {},
|
||||
outputSchema: getContextOutputSchema
|
||||
},
|
||||
@@ -906,14 +794,6 @@ server.registerTool(
|
||||
edgeCount: n.edge_count ?? 0
|
||||
})) : [];
|
||||
|
||||
// Fetch dimensions
|
||||
const dimResult = await callRaHApi('/api/dimensions', { method: 'GET' });
|
||||
const dimensions = Array.isArray(dimResult.data) ? dimResult.data.map(d => ({
|
||||
name: d.name,
|
||||
nodeCount: d.node_count ?? 0,
|
||||
description: d.description ?? null
|
||||
})) : [];
|
||||
|
||||
const contextResult = await callRaHApi('/api/contexts', { method: 'GET' });
|
||||
const contexts = Array.isArray(contextResult.data) ? contextResult.data.map(c => ({
|
||||
id: c.id,
|
||||
@@ -932,7 +812,6 @@ server.registerTool(
|
||||
const stats = {
|
||||
nodeCount: nodeCount ?? hubNodes.reduce((_, n) => 0, 0),
|
||||
edgeCount: 0,
|
||||
dimensionCount: dimensions.length,
|
||||
contextCount: contexts.length
|
||||
};
|
||||
|
||||
@@ -944,7 +823,7 @@ server.registerTool(
|
||||
}
|
||||
} catch { /* use defaults */ }
|
||||
|
||||
const summary = `Knowledge graph: ${stats.contextCount} contexts, ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.`;
|
||||
const summary = `Knowledge graph: ${stats.contextCount} contexts, ${hubNodes.length} hub nodes for graph grounding, ${guides.length} guides available.`;
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
@@ -952,7 +831,6 @@ server.registerTool(
|
||||
stats,
|
||||
hubNodes,
|
||||
contexts,
|
||||
dimensions,
|
||||
guides
|
||||
}
|
||||
};
|
||||
|
||||
+5
-5
@@ -12,7 +12,7 @@ RA-OS is a minimal knowledge graph UI with MCP server integration. It provides a
|
||||
|
||||
**Agent-agnostic** — No built-in AI chat. Instead, RA-OS exposes an MCP server that any AI agent (Claude Code, custom agents) can connect to.
|
||||
|
||||
**Simple & focused** — 2-panel UI for browsing and editing your knowledge graph. No bloat.
|
||||
**Simple & focused** — A compact multi-pane UI for browsing and editing your knowledge graph. No bloat.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -23,8 +23,8 @@ RA-OS is a minimal knowledge graph UI with MCP server integration. It provides a
|
||||
|
||||
## What's Included
|
||||
|
||||
- 2-panel UI (nodes list + focus panel)
|
||||
- Node/Edge/Dimension CRUD
|
||||
- Multi-pane UI for nodes, contexts, map, table, and focus work
|
||||
- Node/Edge CRUD with optional contexts
|
||||
- Full-text and semantic search
|
||||
- MCP server with graph and skill tools
|
||||
- Skills system (shared instructions for internal + external agents)
|
||||
@@ -49,7 +49,7 @@ RA-OS is a minimal knowledge graph UI with MCP server integration. It provides a
|
||||
│ │ │
|
||||
│ • Search │ • Node content │
|
||||
│ • Filters │ • Connections │
|
||||
│ • List │ • Dimensions │
|
||||
│ • List │ • Context + metadata │
|
||||
│ │ │
|
||||
└─────────────┴─────────────────────────┘
|
||||
```
|
||||
@@ -71,7 +71,7 @@ RA-OS is designed to be the knowledge backend for your AI workflows:
|
||||
|
||||
Add this to `~/.claude.json` and restart Claude. Works without RA-OS running.
|
||||
|
||||
Core tools include: `createNode`, `queryNodes`, `updateNode`, `getNodesById`, `createEdge`, `queryEdge`, `queryDimensions`, `createDimension`, `updateDimension`, `deleteDimension`, `listSkills`, `readSkill`
|
||||
Core tools include: `createNode`, `queryNodes`, `updateNode`, `getNodesById`, `createEdge`, `queryEdge`, `queryContexts`, `listSkills`, `readSkill`
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
+65
-342
@@ -1,367 +1,90 @@
|
||||
# Database Schema
|
||||
|
||||
This page describes the database as it exists on disk, including some legacy tables and columns that remain for compatibility and historical records. The current shipped product uses a single RA-H assistant; old delegation-era fields should not be read as active UX concepts.
|
||||
|
||||
## Entity Relationship Diagram
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
nodes ||--o{ node_dimensions : "has"
|
||||
nodes ||--o{ edges : "from"
|
||||
nodes ||--o{ edges : "to"
|
||||
nodes ||--o{ chunks : "contains"
|
||||
nodes ||--o{ chats : "focused_on"
|
||||
dimensions ||--o{ node_dimensions : "tagged_with"
|
||||
chats }o--|| agent_delegations : "belongs_to"
|
||||
|
||||
nodes {
|
||||
INTEGER id PK
|
||||
TEXT title
|
||||
TEXT source
|
||||
TEXT description
|
||||
TEXT event_date
|
||||
BLOB embedding
|
||||
}
|
||||
|
||||
edges {
|
||||
INTEGER id PK
|
||||
INTEGER from_node_id FK
|
||||
INTEGER to_node_id FK
|
||||
TEXT context
|
||||
TEXT explanation
|
||||
}
|
||||
|
||||
dimensions {
|
||||
TEXT name PK
|
||||
INTEGER is_priority
|
||||
TEXT icon
|
||||
}
|
||||
|
||||
node_dimensions {
|
||||
INTEGER node_id FK
|
||||
TEXT dimension FK
|
||||
}
|
||||
|
||||
chunks {
|
||||
INTEGER id PK
|
||||
INTEGER node_id FK
|
||||
TEXT text
|
||||
}
|
||||
|
||||
chats {
|
||||
INTEGER id PK
|
||||
INTEGER focused_node_id FK
|
||||
TEXT user_message
|
||||
TEXT assistant_message
|
||||
}
|
||||
|
||||
agent_delegations {
|
||||
INTEGER id PK
|
||||
TEXT task
|
||||
TEXT status
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Why SQLite?
|
||||
|
||||
RA-H uses **SQLite** for local-first data ownership. Your knowledge stays on your machine - no cloud dependencies. SQLite provides:
|
||||
- **Zero configuration** - single file database
|
||||
- **sqlite-vec extension** - fast vector similarity search
|
||||
- **Full-text search (FTS5)** - Google-like text search
|
||||
- **Relational integrity** - foreign keys, triggers, transactions
|
||||
- **Portability** - database file migrates with Mac app
|
||||
|
||||
**Database Location:** `~/Library/Application Support/RA-H/db/rah.sqlite`
|
||||
|
||||
## Two-Layer Embedding Architecture
|
||||
|
||||
RA-H uses **two types of embeddings** for different search needs:
|
||||
|
||||
### 1. Node-Level Embeddings
|
||||
- **Storage:** `nodes.embedding` column (BLOB)
|
||||
- **Purpose:** Semantic search for nodes (legacy memory pipeline used this too)
|
||||
- **Model:** `text-embedding-3-small` (1536 dimensions)
|
||||
- **Used by:** Search/agent tools (legacy memory pipeline has been removed)
|
||||
|
||||
### 2. Chunk-Level Embeddings
|
||||
- **Storage:** `chunks` table (text) → `vec_chunks` virtual table (embeddings)
|
||||
- **Purpose:** Detailed content search within long documents
|
||||
- **Model:** `text-embedding-3-small` (1536 dimensions)
|
||||
- **Used by:** `searchContentEmbeddings` tool
|
||||
# RA-H Schema
|
||||
|
||||
## Core Tables
|
||||
|
||||
### nodes
|
||||
Primary knowledge storage. Each row is a discrete knowledge item.
|
||||
### `nodes`
|
||||
- `id`
|
||||
- `title`
|
||||
- `description`
|
||||
- `source`
|
||||
- `link`
|
||||
- `metadata`
|
||||
- `chunk_status`
|
||||
- `event_date`
|
||||
- `context_id` nullable FK to `contexts.id`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
**Columns:**
|
||||
- `id` (INTEGER PK) - Unique identifier
|
||||
- `title` (TEXT) - Node title
|
||||
- `description` (TEXT) - WHAT this is + WHY it matters (primary identity field)
|
||||
- `source` (TEXT) - Canonical source content used for chunking and embedding
|
||||
- `link` (TEXT) - External source URL (only for source nodes, not derived ideas)
|
||||
- `event_date` (TEXT) - When the thing actually happened (vs `created_at` = when it entered the graph)
|
||||
- `metadata` (TEXT) - JSON metadata
|
||||
- `chunk_status` (TEXT) - Chunking status (not_chunked, chunked)
|
||||
- `embedding` (BLOB) - Node-level embedding vector
|
||||
- `embedding_text` (TEXT) - Text that was embedded
|
||||
- `embedding_updated_at` (TEXT) - Embedding timestamp
|
||||
- `created_at`, `updated_at` (TEXT) - Timestamps
|
||||
### `contexts`
|
||||
- `id`
|
||||
- `name`
|
||||
- `description`
|
||||
- `icon`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
**Temporal dimensions:** Each node has three timestamps:
|
||||
- `created_at` - When the node entered the graph (transaction time)
|
||||
- `updated_at` - When the node was last modified
|
||||
- `event_date` - When the thing actually happened (valid time)
|
||||
### `edges`
|
||||
- `id`
|
||||
- `from_node_id`
|
||||
- `to_node_id`
|
||||
- `explanation`
|
||||
- `context`
|
||||
- `source`
|
||||
- `created_at`
|
||||
|
||||
**FTS:**
|
||||
- `nodes_fts` - Full-text search on title + source + description
|
||||
### `chunks`
|
||||
- `id`
|
||||
- `node_id`
|
||||
- `chunk_idx`
|
||||
- `text`
|
||||
- `embedding_type`
|
||||
- `metadata`
|
||||
- `created_at`
|
||||
|
||||
### edges
|
||||
Directed relationships between nodes (knowledge graph).
|
||||
### `dimension_migration_snapshots`
|
||||
- Stores one-time snapshots of legacy dimension data before dropping the old tables.
|
||||
- Exists for auditability and migration verification only.
|
||||
|
||||
**Important behavior:**
|
||||
- **Storage is directed** (`from_node_id → to_node_id`)
|
||||
- **UI treats connections as bidirectional** (a node shows edges where it is either `from` or `to`)
|
||||
- **Every new edge requires an explanation** (enforced in service layer)
|
||||
- **Every new edge is classified** into a structured `EdgeContext` (stored as JSON)
|
||||
## Search / Retrieval
|
||||
|
||||
**Columns (SQLite):**
|
||||
- `id` (INTEGER PK)
|
||||
- `from_node_id` (INTEGER FK → nodes.id) — directed “from”
|
||||
- `to_node_id` (INTEGER FK → nodes.id) — directed “to”
|
||||
- `source` (TEXT) — creation source (`user`, `helper_name`, `ai_similarity`)
|
||||
- `created_at` (TEXT)
|
||||
- `context` (TEXT) — JSON blob (canonical; see `EdgeContext` below)
|
||||
- `explanation` (TEXT) — legacy column (currently not the canonical source of truth)
|
||||
- `nodes_fts` indexes title, description, and source for full-text lookup.
|
||||
- `chunks_fts` indexes chunk text.
|
||||
- Vector tables store node and chunk embeddings.
|
||||
|
||||
**Indexes:**
|
||||
- `idx_edges_from` — fast “outgoing edges” queries
|
||||
- `idx_edges_to` — fast “incoming edges” queries
|
||||
## Important Constraints
|
||||
|
||||
#### EdgeContext (canonical relationship metadata)
|
||||
Stored as JSON in `edges.context`. This is the “Idea Genealogy” layer.
|
||||
- `dimensions` and `node_dimensions` are no longer canonical tables.
|
||||
- New installs should never create them.
|
||||
- Existing installs migrate by snapshotting old dimension data, then dropping the legacy tables.
|
||||
- `contexts` are optional. `nodes.context_id` must allow `NULL`.
|
||||
|
||||
```typescript
|
||||
interface EdgeContext {
|
||||
// SYSTEM-INFERRED (AI + heuristics classify from explanation + node context)
|
||||
category: 'attribution' | 'intellectual';
|
||||
type:
|
||||
| 'created_by' // attribution: authorship/creation/founding
|
||||
| 'features' // attribution: appears in / host / guest / explicitly mentioned
|
||||
| 'part_of' // attribution: membership/container (episode→podcast, chapter→book, video→channel)
|
||||
| 'source_of' // intellectual: idea/insight came from source
|
||||
| 'extends' // intellectual: builds on
|
||||
| 'supports' // intellectual: evidence for
|
||||
| 'contradicts' // intellectual: in tension
|
||||
| 'related_to'; // intellectual: fallback
|
||||
confidence: number; // 0–1
|
||||
inferred_at: string; // ISO timestamp
|
||||
## Common Queries
|
||||
|
||||
// PROVIDED BY USER/AGENT
|
||||
explanation: string; // required; free-form text (user can edit)
|
||||
|
||||
// SYSTEM-MANAGED
|
||||
created_via: 'ui' | 'agent' | 'mcp' | 'workflow' | 'quicklink';
|
||||
}
|
||||
```
|
||||
|
||||
#### Direction rule (how to write explanations)
|
||||
Explanations must read correctly **FROM → TO**:
|
||||
- `created_by`: **FROM** was created/authored/founded by **TO**
|
||||
- `features`: **FROM** features/mentions **TO**
|
||||
- `part_of`: **FROM** is part of **TO**
|
||||
- `source_of`: **FROM** came from / was inspired by **TO**
|
||||
|
||||
#### Inference + guardrails
|
||||
On edge create and on explanation edits, RA-H:
|
||||
- runs lightweight **heuristics** for common phrases (e.g., “Created by …”, “Part of …”, “Came from …”, “Features …”)
|
||||
- otherwise runs an AI classification step to populate `category/type/confidence`
|
||||
|
||||
The UI also provides 4 quick chips to reduce user cognitive load:
|
||||
- **Made by** → “Created by …”
|
||||
- **Part of** → “Part of …”
|
||||
- **Came from** → “Came from …”
|
||||
- **Related** → “Related to …”
|
||||
|
||||
#### Where edges get created/updated
|
||||
All edge creation funnels through the service layer enforcement:
|
||||
- UI (`FocusPanel`) — requires explanation; allows editing explanation (re-infers)
|
||||
- REST API `POST /api/edges` — requires `explanation`
|
||||
- Tooling (`createEdge` tool) — requires `explanation`
|
||||
- MCP (`rah_create_edge`) — requires `explanation`
|
||||
- Workflows (e.g. `connect`, `integrate`) — call `createEdge` with `explanation`
|
||||
|
||||
### chunks
|
||||
Long-form content split into searchable pieces.
|
||||
|
||||
**Columns:**
|
||||
- `id` (INTEGER PK)
|
||||
- `node_id` (INTEGER FK → nodes.id)
|
||||
- `chunk_idx` (INTEGER) - Sequence number
|
||||
- `text` (TEXT) - Chunk content
|
||||
- `embedding_type` (TEXT) - Model used
|
||||
- `metadata` (TEXT) - JSON metadata
|
||||
- `created_at` (TEXT)
|
||||
|
||||
**Indexes:**
|
||||
- `idx_chunks_by_node` - Fast node→chunks lookup
|
||||
- `idx_chunks_by_node_idx` - Ordered retrieval
|
||||
|
||||
**FTS:**
|
||||
- `chunks_fts` - Full-text search within chunks
|
||||
|
||||
### dimensions
|
||||
Master list of categorization tags.
|
||||
|
||||
**Columns:**
|
||||
- `name` (TEXT PK) - Dimension name
|
||||
- `is_priority` (INTEGER) - Legacy compatibility field retained in schema
|
||||
- `icon` (TEXT) - Icon identifier (persisted in database)
|
||||
- `updated_at` (TEXT)
|
||||
|
||||
### node_dimensions
|
||||
Many-to-many junction table (nodes ↔ dimensions).
|
||||
|
||||
**Columns:**
|
||||
- `node_id` (INTEGER FK → nodes.id)
|
||||
- `dimension` (TEXT FK → dimensions.name)
|
||||
- Primary key: `(node_id, dimension)`
|
||||
|
||||
**Indexes:**
|
||||
- `idx_dim_by_dimension` - Fast "all nodes in dimension X"
|
||||
- `idx_dim_by_node` - Fast "all dimensions for node X"
|
||||
|
||||
### chats
|
||||
Conversation history with token/cost tracking.
|
||||
|
||||
The chat schema still carries some legacy multi-agent fields. Current product framing is simpler: one RA-H assistant, with these columns mainly retained for older records, analytics, and backwards compatibility.
|
||||
|
||||
**Columns:**
|
||||
- `id` (INTEGER PK)
|
||||
- `chat_type` (TEXT) - Conversation type
|
||||
- `helper_name` (TEXT) - Legacy runtime label; current app sessions use `ra-h`
|
||||
- `agent_type` (TEXT) - Legacy role field retained for historical rows/analytics
|
||||
- `delegation_id` (INTEGER FK) - Legacy link to delegation records
|
||||
- `user_message` (TEXT)
|
||||
- `assistant_message` (TEXT)
|
||||
- `thread_id` (TEXT) - Conversation thread
|
||||
- `focused_node_id` (INTEGER FK → nodes.id)
|
||||
- `metadata` (TEXT) - JSON with token counts, costs, traces
|
||||
- `created_at` (TEXT)
|
||||
|
||||
**Indexes:**
|
||||
- `idx_chats_thread` - Fast thread retrieval
|
||||
|
||||
### agent_delegations
|
||||
Legacy delegation queue from the older multi-agent runtime. Retained so old rows and analytics remain readable; not an active user-facing feature in the current product.
|
||||
|
||||
**Columns:**
|
||||
- `id` (INTEGER PK)
|
||||
- `session_id` (TEXT UNIQUE) - Delegation identifier
|
||||
- `agent_type` (TEXT) - Delegate type (default: 'mini')
|
||||
- `task` (TEXT) - Task description
|
||||
- `context` (TEXT) - Execution context
|
||||
- `expected_outcome` (TEXT)
|
||||
- `status` (TEXT) - queued, in_progress, completed, failed
|
||||
- `summary` (TEXT) - Result summary
|
||||
- `created_at`, `updated_at` (TEXT)
|
||||
|
||||
### logs
|
||||
Activity audit trail (auto-pruned to last 10k).
|
||||
|
||||
**Columns:**
|
||||
- `id` (INTEGER PK)
|
||||
- `ts` (TEXT) - Timestamp
|
||||
- `table_name` (TEXT) - Affected table
|
||||
- `action` (TEXT) - INSERT, UPDATE, DELETE
|
||||
- `row_id` (INTEGER) - Affected row
|
||||
- `summary` (TEXT) - Human-readable summary
|
||||
- `snapshot_json` (TEXT) - Row snapshot
|
||||
- `enriched_summary` (TEXT) - Enriched log entry
|
||||
|
||||
**Indexes:**
|
||||
- `idx_logs_ts` - Chronological queries
|
||||
- `idx_logs_table_ts` - Per-table chronological
|
||||
- `idx_logs_table_row` - Per-row history
|
||||
- `idx_logs_enriched` - Enriched-only filtering
|
||||
|
||||
## Vector Tables (Auto-Created)
|
||||
|
||||
### vec_nodes
|
||||
Virtual table for node-level vector search (sqlite-vec).
|
||||
Nodes in a context:
|
||||
|
||||
```sql
|
||||
VIRTUAL TABLE USING vec0(
|
||||
node_id INTEGER PRIMARY KEY,
|
||||
embedding FLOAT[1536]
|
||||
)
|
||||
SELECT *
|
||||
FROM nodes
|
||||
WHERE context_id = ?
|
||||
ORDER BY updated_at DESC;
|
||||
```
|
||||
|
||||
**Supporting tables (auto-generated):**
|
||||
- `vec_nodes_info`, `vec_nodes_chunks`, `vec_nodes_rowids`, `vec_nodes_vector_chunks00`
|
||||
|
||||
### vec_chunks
|
||||
Virtual table for chunk-level vector search.
|
||||
Most connected nodes:
|
||||
|
||||
```sql
|
||||
VIRTUAL TABLE USING vec0(
|
||||
chunk_id INTEGER PRIMARY KEY,
|
||||
embedding FLOAT[1536]
|
||||
)
|
||||
```
|
||||
|
||||
**Supporting tables (auto-generated):**
|
||||
- `vec_chunks_info`, `vec_chunks_chunks`, `vec_chunks_rowids`, `vec_chunks_vector_chunks00`
|
||||
|
||||
**Note:** Vec tables are NOT pre-seeded in distribution. They auto-create on first app startup via `ensureVectorTables()` in `sqlite-client.ts:45`.
|
||||
|
||||
## Views
|
||||
|
||||
### nodes_v
|
||||
Nodes with dimensions aggregated as JSON array.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
n.id, n.title, n.description, n.source, n.link, n.metadata,
|
||||
n.event_date, n.created_at, n.updated_at,
|
||||
COALESCE(JSON_GROUP_ARRAY(d.dimension), '[]') AS dimensions_json
|
||||
SELECT n.id, n.title, COUNT(DISTINCT e.id) AS edge_count
|
||||
FROM nodes n
|
||||
LEFT JOIN node_dimensions d ON d.node_id = n.id
|
||||
LEFT JOIN edges e ON (e.from_node_id = n.id OR e.to_node_id = n.id)
|
||||
GROUP BY n.id
|
||||
ORDER BY edge_count DESC, n.updated_at DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
### logs_v
|
||||
Enriched logs with related data (node titles, edge titles, chat previews).
|
||||
|
||||
## Triggers
|
||||
|
||||
**Logging triggers:**
|
||||
- `trg_nodes_ai` / `trg_nodes_au` - Log node inserts/updates
|
||||
- `trg_edges_ai` / `trg_edges_au` - Log edge inserts/updates
|
||||
- `trg_chats_ai` - Log chat inserts (with token/cost/trace metadata)
|
||||
|
||||
**Maintenance triggers:**
|
||||
- `trg_edges_update_nodes_on_insert` - Touch node timestamps on edge creation
|
||||
- `trg_logs_prune` - Keep last 10,000 log rows
|
||||
|
||||
## Schema Version
|
||||
|
||||
**schema_version table:**
|
||||
- Tracks database migrations
|
||||
- Current: v1.0 (frozen for Mac app release)
|
||||
Recently updated nodes:
|
||||
|
||||
```sql
|
||||
CREATE TABLE schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
description TEXT
|
||||
);
|
||||
SELECT id, title, updated_at
|
||||
FROM nodes
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 25;
|
||||
```
|
||||
|
||||
## Seed Database
|
||||
|
||||
**Location:** `/dist/resources/rah_seed.sqlite`
|
||||
**Purpose:** Ships with Mac app for new users
|
||||
**Contents:** Clean schema, no data, no vec tables (auto-created on first run)
|
||||
**Size:** ~128KB
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> MCP tools for graph operations and skills for procedural guidance.
|
||||
|
||||
**How it works:** External agents call MCP tools to read and write your graph. For complex tasks, they read skills (`listSkills`/`readSkill`) to follow your preferred operating patterns.
|
||||
**How it works:** External agents call MCP tools to read and write your graph. Contexts are optional soft organization only; node quality should come from clear nodes and explicit edges.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,18 +14,15 @@ RA-OS exposes these core standalone MCP tools:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `getContext` | Graph overview: stats, contexts, hub nodes, dimensions, recent activity, skills |
|
||||
| `queryNodes` | Search nodes by keyword/dimensions/date |
|
||||
| `getContext` | Graph overview: stats, contexts, hub nodes, recent activity, skills |
|
||||
| `queryNodes` | Search nodes by keyword/date/context |
|
||||
| `getNodesById` | Fetch full nodes by ID |
|
||||
| `createNode` | Create a node |
|
||||
| `updateNode` | Update a node |
|
||||
| `createEdge` | Create an edge between nodes |
|
||||
| `queryEdge` | Query edges |
|
||||
| `updateEdge` | Update edge explanation |
|
||||
| `queryDimensions` | List dimensions |
|
||||
| `createDimension` | Create a dimension |
|
||||
| `updateDimension` | Update/rename a dimension |
|
||||
| `deleteDimension` | Delete a dimension |
|
||||
| `queryContexts` | List contexts and optional attached nodes |
|
||||
|
||||
### Skills + Search
|
||||
|
||||
|
||||
+17
-212
@@ -1,218 +1,23 @@
|
||||
# User Interface
|
||||
# UI Surfaces
|
||||
|
||||
> How to navigate and use RA-OS's interface.
|
||||
## Main Views
|
||||
|
||||
**How it works:** RA-OS uses a collapsible left navigation rail plus a flexible workspace that can show one or two panes at once. Nodes, dimensions, map, table, skills, and settings all live inside the same workspace.
|
||||
- `Feed` for recent and sortable node browsing
|
||||
- `Contexts` for optional context browsing
|
||||
- `Map` for graph structure
|
||||
- `Table` for dense inspection
|
||||
- `Skills` for editable agent instructions
|
||||
- `Chat` for agent-driven graph work
|
||||
|
||||
---
|
||||
## UI Contract After The Migration
|
||||
|
||||
## Workspace Layout
|
||||
- The app no longer exposes a dimensions pane.
|
||||
- Feed and table filtering are context-aware and no longer dimension-based.
|
||||
- Persisted pane layout should only hydrate valid pane types: `views`, `node`, `contexts`, `map`, `table`, `skills`.
|
||||
- Contexts are shown as a secondary organizational aid, not as a hard requirement for capture.
|
||||
|
||||
```
|
||||
┌────────┬──────────────────────┬──────────────────────┐
|
||||
│ NAV │ PANE A │ PANE B │
|
||||
│ │ │ │
|
||||
│ Search │ Nodes / Focus │ Optional second pane │
|
||||
│ Add │ Dimensions / Map │ for compare/browse │
|
||||
│ Views │ Table / Skills │ │
|
||||
└────────┴──────────────────────┴──────────────────────┘
|
||||
```
|
||||
## Focus And Capture
|
||||
|
||||
---
|
||||
|
||||
## Left Navigation
|
||||
|
||||
The left rail can stay compact or expand into a labeled navigation column.
|
||||
|
||||
### Features
|
||||
|
||||
- **Search** — Cmd+K opens global search
|
||||
- **Add Stuff** — open the quick-add flow
|
||||
- **Refresh** — reload pane data
|
||||
- **Workspace views** — Nodes, Skills, Map, Dimensions, and Table
|
||||
- **Settings** — open settings and MCP/config panels
|
||||
|
||||
---
|
||||
|
||||
## Nodes Pane
|
||||
|
||||
Browse and manage your knowledge base in the main feed.
|
||||
|
||||
### Features
|
||||
|
||||
- **Search bar** — filter nodes by text
|
||||
- **Dimension filters** — filter the feed with one or more dimensions
|
||||
- **Pending quick-add items** — processing placeholders appear in the feed
|
||||
- **Open in other pane** — send a node to the second pane for comparison
|
||||
|
||||
### Node Display
|
||||
|
||||
Each node shows:
|
||||
- Title and preview
|
||||
- Dimension tags
|
||||
- Last updated timestamp
|
||||
- Node ID badge
|
||||
|
||||
---
|
||||
|
||||
## Dimensions Pane
|
||||
|
||||
The dimensions pane is now a dedicated browser instead of a modal-only overlay.
|
||||
|
||||
### Features
|
||||
|
||||
- Browse dimension cards with counts and lock state
|
||||
- Create new dimensions from the pane header
|
||||
- Select a dimension to push that filter into the Nodes pane
|
||||
- Manage dimension metadata and node grouping from one place
|
||||
|
||||
---
|
||||
|
||||
## Focus Pane
|
||||
|
||||
Active workspace for the node(s) you're working with.
|
||||
|
||||
### Tabbed Interface
|
||||
|
||||
- **Primary tab** — Main focused node
|
||||
- **Additional tabs** — Related nodes opened from links
|
||||
- **Tab controls** — Close (×), reorder, switch
|
||||
|
||||
### Node Detail View
|
||||
|
||||
| Section | Content |
|
||||
|---------|---------|
|
||||
| **Header** | Title, node ID, trash icon |
|
||||
| **Content** | Full markdown notes with node tokens and links |
|
||||
| **Metadata** | Created, updated, type, link |
|
||||
| **Dimensions** | Editable dimension tags |
|
||||
| **Connections** | Incoming/outgoing edges |
|
||||
|
||||
### Content Rendering
|
||||
|
||||
- Markdown support
|
||||
- `[NODE:id:"title"]` renders as clickable links
|
||||
- Syntax highlighting for code blocks
|
||||
- YouTube embeds (if link is YouTube URL)
|
||||
|
||||
---
|
||||
|
||||
## Search (Cmd+K)
|
||||
|
||||
Global search modal with 4-tier relevance:
|
||||
|
||||
1. **Exact title match** — Highest priority
|
||||
2. **Title substring** — High priority
|
||||
3. **FTS content match** — Medium priority
|
||||
4. **Semantic embedding** — Conceptual matches
|
||||
|
||||
**Features:**
|
||||
- Type-ahead instant results
|
||||
- Keyboard navigation (↑↓, Enter)
|
||||
- Click or Enter to open in Focus panel
|
||||
|
||||
---
|
||||
|
||||
## Settings Panel
|
||||
|
||||
**Access:** Settings item in the left navigation
|
||||
|
||||
### Tabs
|
||||
|
||||
| Tab | Purpose |
|
||||
|-----|---------|
|
||||
| **API Keys** | Configure OpenAI/Tavily keys |
|
||||
| **Skills** | View, edit, create skills |
|
||||
| **Tools** | View available tools |
|
||||
| **Database** | Full node table with filters/sorting |
|
||||
| **Logs** | Activity feed (last 100 entries) |
|
||||
| **Context** | Context/system information viewer |
|
||||
| **Agents** | External agent (MCP) configuration |
|
||||
|
||||
---
|
||||
|
||||
## Map View
|
||||
|
||||
Visual graph of your knowledge network.
|
||||
|
||||
**Features:**
|
||||
- Dimension View and Hub View modes
|
||||
- Saved node positions per view mode
|
||||
- Pan/zoom with fit controls and minimap
|
||||
- Node size proportional to edge count
|
||||
- Top nodes labeled by title and dimension color
|
||||
- Click node to highlight connections
|
||||
- Selection shows connected nodes in green
|
||||
|
||||
---
|
||||
|
||||
## Database View
|
||||
|
||||
Full table view of all nodes.
|
||||
|
||||
**Columns:**
|
||||
- Node (title + ID)
|
||||
- Dimensions (folder badges)
|
||||
- Edges (count)
|
||||
- Updated (timestamp)
|
||||
|
||||
**Features:**
|
||||
- Search by title/content
|
||||
- Filter by dimensions
|
||||
- Sort by updated/edges/created
|
||||
- Pagination
|
||||
- Toolbar lives in the pane header for faster switching
|
||||
|
||||
---
|
||||
|
||||
## Dimension Icons
|
||||
|
||||
Each dimension can have a custom Lucide icon.
|
||||
|
||||
**To set:**
|
||||
1. Open Folder View → hover over dimension
|
||||
2. Click edit (pencil) icon
|
||||
3. Choose icon from curated options
|
||||
4. Icons persist in localStorage
|
||||
|
||||
---
|
||||
|
||||
## Node References
|
||||
|
||||
**Format:** `[NODE:id:"title"]`
|
||||
|
||||
**Rendering:**
|
||||
- Clickable labels in node content
|
||||
- Hover shows preview tooltip
|
||||
- Click opens in Focus panel
|
||||
|
||||
---
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Cmd+K` | Open search |
|
||||
| `Cmd+Shift+R` | Refresh all panes |
|
||||
| `Escape` | Close modals/overlays |
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
### Colors
|
||||
|
||||
- **Background:** `#0a0a0a` (near black)
|
||||
- **Accent:** Green (`#22c55e`) for actions, selections
|
||||
- **Text:** White (primary), neutral-400 (secondary)
|
||||
|
||||
### Typography
|
||||
|
||||
- **Font:** Geist (monospace feel)
|
||||
- **Sizes:** 11-14px for UI, larger for content
|
||||
|
||||
### Buttons
|
||||
|
||||
- **Primary:** White bg, black text
|
||||
- **Secondary:** Transparent, border, white text
|
||||
- **Toggle:** 28×28px, subtle border, icon only
|
||||
- Capture must succeed when both context and dimensions are omitted.
|
||||
- Focus surfaces should emphasize title, description, source, metadata, and edges.
|
||||
- Node cards may show context when present, but should not depend on it for meaning.
|
||||
|
||||
+30
-156
@@ -1,164 +1,38 @@
|
||||
# MCP Server
|
||||
# MCP Surface
|
||||
|
||||
> Connect Claude Code and other AI assistants to your knowledge base.
|
||||
RA-H exposes MCP tools for direct graph work against the local database or app API.
|
||||
|
||||
**How it works:** RA-OS includes an MCP (Model Context Protocol) server. This lets any MCP-compatible assistant — like Claude Code — search your knowledge graph, add new knowledge, and manage your graph. Everything stays local.
|
||||
## Core MCP Contract
|
||||
|
||||
---
|
||||
- `getContext` returns graph orientation: stats, contexts, hubs, and skills.
|
||||
- `createNode` and `updateNode` accept optional `context_id` but do not require context.
|
||||
- `queryNodes` searches title, description, and source, with optional context filters.
|
||||
- `dimensions` are removed from the MCP contract.
|
||||
|
||||
## Quick Start (Recommended)
|
||||
## Main Tools
|
||||
|
||||
The easiest way is using the npm package:
|
||||
Read:
|
||||
- `getContext`
|
||||
- `queryNodes`
|
||||
- `queryContexts`
|
||||
- `getNodesById`
|
||||
- `queryEdge`
|
||||
- `listSkills`
|
||||
- `readSkill`
|
||||
- `searchContentEmbeddings`
|
||||
- `sqliteQuery`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ra-h": {
|
||||
"command": "npx",
|
||||
"args": ["ra-h-mcp-server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Write:
|
||||
- `createNode`
|
||||
- `updateNode`
|
||||
- `createEdge`
|
||||
- `updateEdge`
|
||||
- `writeSkill`
|
||||
- `deleteSkill`
|
||||
|
||||
Add this to your `~/.claude.json` (Claude Code) or Claude Desktop settings.
|
||||
## Tool Behavior
|
||||
|
||||
**Requirements:**
|
||||
- Node.js 18+ installed
|
||||
|
||||
**That's it.** The database is created automatically on first connection. No need to keep RA-OS running.
|
||||
|
||||
---
|
||||
|
||||
## Alternative: Local Development
|
||||
|
||||
If you're developing RA-OS and want to use the local server:
|
||||
|
||||
### Standalone (No Web App Required)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ra-h": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/ra-h_os/apps/mcp-server-standalone/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
First install dependencies:
|
||||
```bash
|
||||
cd apps/mcp-server-standalone
|
||||
npm install
|
||||
```
|
||||
|
||||
### HTTP Transport (Web App Required)
|
||||
|
||||
If you want real-time UI updates when nodes are created:
|
||||
|
||||
1. Start RA-OS: `npm run dev`
|
||||
2. Configure:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ra-h": {
|
||||
"url": "http://127.0.0.1:44145/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Available Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `getContext` | Get graph overview — stats, contexts, hub nodes, dimensions, recent activity. Called first automatically. |
|
||||
| `queryContexts` | List contexts, inspect a context, or search contexts by name/description. |
|
||||
| `createNode` | Create a new node (title/source/dimensions) |
|
||||
| `queryNodes` | Search existing nodes by keyword |
|
||||
| `updateNode` | Update an existing node |
|
||||
| `getNodesById` | Get nodes by ID |
|
||||
| `createEdge` | Create relationship between nodes |
|
||||
| `updateEdge` | Update an edge explanation |
|
||||
| `queryEdge` | Query existing edges |
|
||||
| `queryDimensions` | List all dimensions |
|
||||
| `createDimension` | Create a new dimension |
|
||||
| `updateDimension` | Update/rename dimension |
|
||||
| `deleteDimension` | Delete a dimension |
|
||||
| `listSkills` | List available skills |
|
||||
| `readSkill` | Read a skill by name |
|
||||
| `writeSkill` | Create or update a custom skill |
|
||||
| `deleteSkill` | Delete a custom skill |
|
||||
| `searchContentEmbeddings` | Search extracted source content |
|
||||
| `sqliteQuery` | Run read-only SQL queries |
|
||||
|
||||
---
|
||||
|
||||
## What to Expect
|
||||
|
||||
Once connected, the MCP server instructs Claude to:
|
||||
|
||||
1. **Call `getContext` first** to orient itself (contexts, hub nodes, dimensions, stats, available skills)
|
||||
2. **Proactively capture knowledge** — when a new insight, decision, person, or reference surfaces, it proposes a specific node (title, dimensions, description) so you can approve with minimal friction
|
||||
3. **Read skills for complex tasks** — skills provide reusable procedural instructions for graph operations and workflows
|
||||
4. **Search before creating** to avoid duplicates
|
||||
|
||||
You don't need to ask Claude to use your knowledge base — it will offer when it spots something worth saving.
|
||||
|
||||
---
|
||||
|
||||
## Example Usage
|
||||
|
||||
Once connected, you can ask your AI assistant:
|
||||
|
||||
```
|
||||
"What's in my knowledge graph?"
|
||||
"Search RA-H for what I wrote about product strategy"
|
||||
"Add this conversation summary to RA-H as a new node"
|
||||
"Find all nodes with the 'research' dimension"
|
||||
"Create an edge between node 123 and node 456"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `apps/mcp-server-standalone/` | **Standalone server (direct SQLite, recommended)** |
|
||||
| `apps/mcp-server/server.js` | HTTP MCP server |
|
||||
| `apps/mcp-server/stdio-server.js` | STDIO bridge to HTTP server |
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- The MCP server only binds to `127.0.0.1` — localhost only
|
||||
- No authentication required (local access only)
|
||||
- All data persisted to `~/Library/Application Support/RA-H/db/rah.sqlite`
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Database not found"
|
||||
|
||||
The MCP server auto-creates the database on first connection (v1.1.0+). If you're on an older version, run RA-OS once to create it:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### "Tools not showing" (npm package)
|
||||
|
||||
1. Make sure Node.js 18+ is installed: `node --version`
|
||||
2. Try running manually: `npx ra-h-mcp-server`
|
||||
3. Restart Claude Code
|
||||
|
||||
### "Connection refused" (HTTP method)
|
||||
|
||||
1. Make sure RA-OS is running: `npm run dev`
|
||||
2. Check the port: `lsof -i :44145`
|
||||
- Always search before creating.
|
||||
- Prefer explicit context assignment when the primary scope is clear.
|
||||
- Do not expect automatic context assignment.
|
||||
- Judge graph quality by node quality and edges, not taxonomy completeness.
|
||||
|
||||
+16
-33
@@ -1,40 +1,23 @@
|
||||
# RA-OS
|
||||
# Open Source Surface
|
||||
|
||||
This is **RA-OS** — a minimal, local-first knowledge graph UI with MCP server integration.
|
||||
The open-source RA-H surface should match the main app contract:
|
||||
|
||||
## What is RA-OS?
|
||||
- no runtime `dimensions` model,
|
||||
- optional soft `contexts`,
|
||||
- no automatic context assignment on write,
|
||||
- node quality driven by title, description, source, metadata, and edges.
|
||||
|
||||
RA-OS is a stripped-down version of [RA-H](https://ra-h.app) focused on:
|
||||
## Important App Routes
|
||||
|
||||
- **2-panel UI** for browsing and editing your knowledge graph
|
||||
- **MCP server** so external AI agents (like Claude Code) can access your notes
|
||||
- **Local SQLite** database with vector search
|
||||
- **BYO API keys** — no cloud dependencies
|
||||
- `app/api/nodes/`
|
||||
- `app/api/contexts/`
|
||||
- `app/api/edges/`
|
||||
- `app/api/rah/chat/`
|
||||
- extraction routes
|
||||
- eval / verification helpers
|
||||
|
||||
## What's NOT Included
|
||||
## Porting Rule
|
||||
|
||||
RA-OS intentionally excludes:
|
||||
Main `ra-h` ships first.
|
||||
|
||||
- Chat interface (use external agents via MCP)
|
||||
- Voice features
|
||||
- Built-in AI agents
|
||||
- Auth/subscription system
|
||||
- Desktop packaging (Tauri)
|
||||
|
||||
## Relationship to RA-H
|
||||
|
||||
This repo (`ra-h_os`) is derived from the private `ra-h` repository. Shared features (database, UI components, MCP server) are synced from private to public.
|
||||
|
||||
## Getting Started
|
||||
|
||||
See [README.md](../README.md) for installation.
|
||||
|
||||
## Contributing
|
||||
|
||||
- **Bug reports** — Open an issue
|
||||
- **Feature requests** — Open an issue
|
||||
- **Pull requests** — Welcome for bug fixes and improvements
|
||||
|
||||
## License
|
||||
|
||||
MIT — See [LICENSE](../LICENSE)
|
||||
`ra-h_os` is a required follow-up port of the same contract, not a place to preserve older taxonomy behavior.
|
||||
|
||||
+3
-3
@@ -13,7 +13,7 @@ scripts/
|
||||
│ ├── duplicate-helper.js # Create new helpers
|
||||
│ └── delete-helper.js # Remove helpers
|
||||
└── migrations/ # Completed one-time migrations
|
||||
└── cleanup-dimensions.sql
|
||||
└── (historical migration artifacts)
|
||||
```
|
||||
|
||||
## Database Scripts
|
||||
@@ -24,7 +24,7 @@ npm run backup
|
||||
```
|
||||
- Creates timestamped backup in `/backups/` folder
|
||||
- Example: `rah_backup_20250902_102846.sql`
|
||||
- Includes all nodes, chunks, edges, dimensions
|
||||
- Includes all nodes, chunks, edges, contexts, and migration snapshots
|
||||
|
||||
### Restore from Backup
|
||||
```bash
|
||||
@@ -59,7 +59,7 @@ node scripts/helpers/delete-helper.js "helper-id"
|
||||
- All nodes (42,000+ knowledge items)
|
||||
- Content chunks and embeddings
|
||||
- Node connections/edges
|
||||
- Dimensions and metadata
|
||||
- Contexts, metadata, and migration snapshots
|
||||
- Database schema and indexes
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -110,7 +110,7 @@ VALUES (
|
||||
SQL
|
||||
fi
|
||||
|
||||
echo "Ensuring core tables exist (nodes, chunks, edges, chats, node_dimensions)..."
|
||||
echo "Ensuring core tables exist (nodes, chunks, edges, chats, contexts)..."
|
||||
|
||||
if ! has_table nodes; then
|
||||
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
|
||||
@@ -132,6 +132,20 @@ CREATE TABLE nodes (
|
||||
SQL
|
||||
fi
|
||||
|
||||
if ! has_table contexts; then
|
||||
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
|
||||
CREATE TABLE contexts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_contexts_name_normalized ON contexts(LOWER(TRIM(name)));
|
||||
SQL
|
||||
fi
|
||||
|
||||
if ! has_table chunks; then
|
||||
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
|
||||
CREATE TABLE chunks (
|
||||
@@ -158,6 +172,7 @@ CREATE TABLE edges (
|
||||
source TEXT,
|
||||
created_at TEXT,
|
||||
context TEXT,
|
||||
explanation TEXT,
|
||||
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -166,14 +181,186 @@ CREATE INDEX idx_edges_to ON edges(to_node_id);
|
||||
SQL
|
||||
fi
|
||||
|
||||
if has_table edges; then
|
||||
NEEDS_EDGE_REWRITE=0
|
||||
if ! has_col edges from_node_id || ! has_col edges to_node_id || ! has_col edges source || ! has_col edges created_at || ! has_col edges context; then
|
||||
NEEDS_EDGE_REWRITE=1
|
||||
fi
|
||||
if has_col edges from_id || has_col edges to_id || has_col edges description || has_col edges updated_at; then
|
||||
NEEDS_EDGE_REWRITE=1
|
||||
fi
|
||||
|
||||
if [ "$NEEDS_EDGE_REWRITE" = "1" ]; then
|
||||
echo "Migrating legacy edges table to canonical schema"
|
||||
|
||||
FROM_EXPR="NULL"
|
||||
if has_col edges from_node_id; then
|
||||
FROM_EXPR="from_node_id"
|
||||
elif has_col edges from_id; then
|
||||
FROM_EXPR="from_id"
|
||||
fi
|
||||
|
||||
TO_EXPR="NULL"
|
||||
if has_col edges to_node_id; then
|
||||
TO_EXPR="to_node_id"
|
||||
elif has_col edges to_id; then
|
||||
TO_EXPR="to_id"
|
||||
fi
|
||||
|
||||
SOURCE_EXPR="'legacy'"
|
||||
if has_col edges source; then
|
||||
SOURCE_EXPR="source"
|
||||
fi
|
||||
|
||||
CREATED_AT_EXPR="CURRENT_TIMESTAMP"
|
||||
if has_col edges created_at; then
|
||||
CREATED_AT_EXPR="created_at"
|
||||
fi
|
||||
|
||||
CONTEXT_EXPR="NULL"
|
||||
if has_col edges context; then
|
||||
CONTEXT_EXPR="context"
|
||||
fi
|
||||
|
||||
EXPLANATION_EXPR="NULL"
|
||||
if has_col edges explanation; then
|
||||
EXPLANATION_EXPR="explanation"
|
||||
elif has_col edges description; then
|
||||
EXPLANATION_EXPR="description"
|
||||
elif has_col edges context; then
|
||||
EXPLANATION_EXPR="CASE WHEN json_valid(context) THEN json_extract(context, '\$.explanation') ELSE NULL END"
|
||||
fi
|
||||
|
||||
"$SQLITE_BIN" "$DB_PATH" <<SQL
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN TRANSACTION;
|
||||
DROP INDEX IF EXISTS idx_edges_from;
|
||||
DROP INDEX IF EXISTS idx_edges_to;
|
||||
ALTER TABLE edges RENAME TO edges_legacy_migration;
|
||||
CREATE TABLE edges (
|
||||
id INTEGER PRIMARY KEY,
|
||||
from_node_id INTEGER NOT NULL,
|
||||
to_node_id INTEGER NOT NULL,
|
||||
source TEXT,
|
||||
created_at TEXT,
|
||||
context TEXT,
|
||||
explanation TEXT,
|
||||
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO edges (id, from_node_id, to_node_id, source, created_at, context, explanation)
|
||||
SELECT
|
||||
id,
|
||||
${FROM_EXPR},
|
||||
${TO_EXPR},
|
||||
${SOURCE_EXPR},
|
||||
COALESCE(${CREATED_AT_EXPR}, CURRENT_TIMESTAMP),
|
||||
${CONTEXT_EXPR},
|
||||
${EXPLANATION_EXPR}
|
||||
FROM edges_legacy_migration
|
||||
WHERE ${FROM_EXPR} IS NOT NULL
|
||||
AND ${TO_EXPR} IS NOT NULL;
|
||||
DROP TABLE edges_legacy_migration;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
SQL
|
||||
fi
|
||||
|
||||
if ! has_col edges explanation; then
|
||||
echo "Adding edges.explanation"
|
||||
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE edges ADD COLUMN explanation TEXT;"
|
||||
if has_col edges context; then
|
||||
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
|
||||
UPDATE edges
|
||||
SET explanation = CASE
|
||||
WHEN json_valid(context) THEN json_extract(context, '$.explanation')
|
||||
ELSE explanation
|
||||
END
|
||||
WHERE explanation IS NULL
|
||||
AND context IS NOT NULL;
|
||||
SQL
|
||||
fi
|
||||
fi
|
||||
|
||||
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node_id);
|
||||
SQL
|
||||
fi
|
||||
|
||||
echo "Dropping legacy episodic/semantic memory tables if they exist..."
|
||||
|
||||
if has_table chats; then
|
||||
if "$SQLITE_BIN" "$DB_PATH" -json "PRAGMA table_info(chats);" | grep -q 'focused_memory_id'; then
|
||||
echo " Removing legacy chats.focused_memory_id column"
|
||||
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
|
||||
NEEDS_CHAT_REWRITE=0
|
||||
if has_col chats focused_memory_id; then
|
||||
NEEDS_CHAT_REWRITE=1
|
||||
fi
|
||||
for required_col in chat_type helper_name agent_type delegation_id user_message assistant_message thread_id focused_node_id created_at metadata; do
|
||||
if ! has_col chats "$required_col"; then
|
||||
NEEDS_CHAT_REWRITE=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$NEEDS_CHAT_REWRITE" = "1" ]; then
|
||||
echo " Migrating legacy chats table to canonical schema"
|
||||
|
||||
CHAT_TYPE_EXPR="NULL"
|
||||
if has_col chats chat_type; then
|
||||
CHAT_TYPE_EXPR="chat_type"
|
||||
fi
|
||||
|
||||
HELPER_NAME_EXPR="NULL"
|
||||
if has_col chats helper_name; then
|
||||
HELPER_NAME_EXPR="helper_name"
|
||||
elif has_col chats title; then
|
||||
HELPER_NAME_EXPR="title"
|
||||
fi
|
||||
|
||||
AGENT_TYPE_EXPR="'orchestrator'"
|
||||
if has_col chats agent_type; then
|
||||
AGENT_TYPE_EXPR="COALESCE(agent_type, 'orchestrator')"
|
||||
fi
|
||||
|
||||
DELEGATION_ID_EXPR="NULL"
|
||||
if has_col chats delegation_id; then
|
||||
DELEGATION_ID_EXPR="delegation_id"
|
||||
fi
|
||||
|
||||
USER_MESSAGE_EXPR="NULL"
|
||||
if has_col chats user_message; then
|
||||
USER_MESSAGE_EXPR="user_message"
|
||||
fi
|
||||
|
||||
ASSISTANT_MESSAGE_EXPR="NULL"
|
||||
if has_col chats assistant_message; then
|
||||
ASSISTANT_MESSAGE_EXPR="assistant_message"
|
||||
fi
|
||||
|
||||
THREAD_ID_EXPR="NULL"
|
||||
if has_col chats thread_id; then
|
||||
THREAD_ID_EXPR="thread_id"
|
||||
fi
|
||||
|
||||
FOCUSED_NODE_ID_EXPR="NULL"
|
||||
if has_col chats focused_node_id; then
|
||||
FOCUSED_NODE_ID_EXPR="focused_node_id"
|
||||
fi
|
||||
|
||||
CREATED_AT_CHAT_EXPR="CURRENT_TIMESTAMP"
|
||||
if has_col chats created_at; then
|
||||
CREATED_AT_CHAT_EXPR="created_at"
|
||||
fi
|
||||
|
||||
METADATA_CHAT_EXPR="NULL"
|
||||
if has_col chats metadata; then
|
||||
METADATA_CHAT_EXPR="metadata"
|
||||
fi
|
||||
|
||||
"$SQLITE_BIN" "$DB_PATH" <<SQL
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN TRANSACTION;
|
||||
DROP INDEX IF EXISTS idx_chats_thread;
|
||||
ALTER TABLE chats RENAME TO chats_legacy_cleanup;
|
||||
CREATE TABLE chats (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -194,9 +381,17 @@ INSERT INTO chats (
|
||||
user_message, assistant_message, thread_id, focused_node_id,
|
||||
created_at, metadata
|
||||
)
|
||||
SELECT id, chat_type, helper_name, agent_type, delegation_id,
|
||||
user_message, assistant_message, thread_id, focused_node_id,
|
||||
created_at, metadata
|
||||
SELECT id,
|
||||
${CHAT_TYPE_EXPR},
|
||||
${HELPER_NAME_EXPR},
|
||||
${AGENT_TYPE_EXPR},
|
||||
${DELEGATION_ID_EXPR},
|
||||
${USER_MESSAGE_EXPR},
|
||||
${ASSISTANT_MESSAGE_EXPR},
|
||||
${THREAD_ID_EXPR},
|
||||
${FOCUSED_NODE_ID_EXPR},
|
||||
COALESCE(${CREATED_AT_CHAT_EXPR}, CURRENT_TIMESTAMP),
|
||||
${METADATA_CHAT_EXPR}
|
||||
FROM chats_legacy_cleanup;
|
||||
DROP TABLE chats_legacy_cleanup;
|
||||
CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);
|
||||
@@ -250,25 +445,11 @@ if has_table chats && ! has_col chats delegation_id; then
|
||||
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE chats ADD COLUMN delegation_id INTEGER;"
|
||||
fi
|
||||
|
||||
# chat_memory_state removed in final schema pass
|
||||
# Drop dead chat_memory_state table (orphaned from removed memory pipeline)
|
||||
echo "Dropping dead chat_memory_state table if present..."
|
||||
"$SQLITE_BIN" "$DB_PATH" "DROP INDEX IF EXISTS idx_chat_memory_thread;"
|
||||
"$SQLITE_BIN" "$DB_PATH" "DROP TABLE IF EXISTS chat_memory_state;"
|
||||
|
||||
echo "Dropping legacy agent_delegations table if present..."
|
||||
"$SQLITE_BIN" "$DB_PATH" "DROP TABLE IF EXISTS agent_delegations;"
|
||||
|
||||
if ! has_table node_dimensions; then
|
||||
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
|
||||
CREATE TABLE node_dimensions (
|
||||
node_id INTEGER NOT NULL,
|
||||
dimension TEXT NOT NULL,
|
||||
PRIMARY KEY (node_id, dimension),
|
||||
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX idx_dim_by_dimension ON node_dimensions(dimension, node_id);
|
||||
CREATE INDEX idx_dim_by_node ON node_dimensions(node_id, dimension);
|
||||
SQL
|
||||
fi
|
||||
|
||||
echo "Checking/adding missing columns..."
|
||||
|
||||
if has_table nodes; then
|
||||
@@ -284,7 +465,50 @@ if has_table nodes; then
|
||||
echo "Adding nodes.source"
|
||||
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN source TEXT;"
|
||||
fi
|
||||
# is_pinned removed in final schema pass
|
||||
if ! has_col nodes chunk_status; then
|
||||
echo "Adding nodes.chunk_status"
|
||||
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN chunk_status TEXT DEFAULT 'not_chunked';"
|
||||
fi
|
||||
if ! has_col nodes context_id; then
|
||||
echo "Adding nodes.context_id"
|
||||
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;"
|
||||
fi
|
||||
fi
|
||||
|
||||
if has_table contexts; then
|
||||
if ! has_col contexts description; then
|
||||
echo "Adding contexts.description"
|
||||
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE contexts ADD COLUMN description TEXT NOT NULL DEFAULT '';"
|
||||
fi
|
||||
if ! has_col contexts icon; then
|
||||
echo "Adding contexts.icon"
|
||||
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE contexts ADD COLUMN icon TEXT;"
|
||||
fi
|
||||
if ! has_col contexts created_at; then
|
||||
echo "Adding contexts.created_at"
|
||||
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE contexts ADD COLUMN created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;"
|
||||
fi
|
||||
if ! has_col contexts updated_at; then
|
||||
echo "Adding contexts.updated_at"
|
||||
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE contexts ADD COLUMN updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;"
|
||||
fi
|
||||
|
||||
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
|
||||
UPDATE contexts
|
||||
SET description = COALESCE(NULLIF(TRIM(description), ''), name)
|
||||
WHERE description IS NULL OR LENGTH(TRIM(description)) = 0;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized ON contexts(LOWER(TRIM(name)));
|
||||
SQL
|
||||
fi
|
||||
|
||||
"$SQLITE_BIN" "$DB_PATH" "CREATE INDEX IF NOT EXISTS idx_nodes_context_id ON nodes(context_id);"
|
||||
|
||||
# --- Additive migrations (do first) ---
|
||||
|
||||
# Add event_date column (2026-02-15)
|
||||
if has_table nodes && ! has_col nodes event_date; then
|
||||
echo "Adding nodes.event_date"
|
||||
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN event_date TEXT;"
|
||||
fi
|
||||
|
||||
if has_table nodes && has_col nodes source; then
|
||||
@@ -346,6 +570,38 @@ WHERE source IS NOT NULL
|
||||
SQL
|
||||
fi
|
||||
|
||||
# Backfill event_date from metadata.published_date where available
|
||||
if has_table nodes && has_col nodes event_date; then
|
||||
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
|
||||
UPDATE nodes
|
||||
SET event_date = json_extract(metadata, '$.published_date')
|
||||
WHERE event_date IS NULL
|
||||
AND json_extract(metadata, '$.published_date') IS NOT NULL;
|
||||
SQL
|
||||
fi
|
||||
|
||||
# --- Destructive migrations (do last, SQLite 3.35+ required) ---
|
||||
|
||||
# Drop deprecated nodes.type column
|
||||
if has_table nodes && has_col nodes type; then
|
||||
echo "Dropping deprecated nodes.type"
|
||||
"$SQLITE_BIN" "$DB_PATH" "DROP INDEX IF EXISTS idx_nodes_type;"
|
||||
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes DROP COLUMN type;"
|
||||
fi
|
||||
|
||||
# Drop deprecated nodes.is_pinned column
|
||||
if has_table nodes && has_col nodes is_pinned; then
|
||||
echo "Dropping deprecated nodes.is_pinned"
|
||||
"$SQLITE_BIN" "$DB_PATH" "DROP INDEX IF EXISTS idx_nodes_pinned;"
|
||||
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes DROP COLUMN is_pinned;"
|
||||
fi
|
||||
|
||||
# Drop dead edges.user_feedback column
|
||||
if has_table edges && has_col edges user_feedback; then
|
||||
echo "Dropping dead edges.user_feedback"
|
||||
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE edges DROP COLUMN user_feedback;"
|
||||
fi
|
||||
|
||||
if has_table chunks; then
|
||||
if ! has_col chunks embedding_type; then
|
||||
echo "Adding chunks.embedding_type"
|
||||
@@ -357,28 +613,44 @@ if has_table chunks; then
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Ensuring dimensions table exists..."
|
||||
if ! has_table dimensions; then
|
||||
echo "Dropping legacy dimension tables after snapshot..."
|
||||
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
|
||||
CREATE TABLE dimensions (
|
||||
name TEXT PRIMARY KEY,
|
||||
description TEXT,
|
||||
icon TEXT,
|
||||
is_priority INTEGER DEFAULT 0,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
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
|
||||
);
|
||||
SQL
|
||||
|
||||
if has_table dimensions || has_table node_dimensions; then
|
||||
SNAPSHOT_COUNT=$("$SQLITE_BIN" -readonly "$DB_PATH" "SELECT COUNT(*) FROM dimension_migration_snapshots;" 2>/dev/null || echo 0)
|
||||
if [ "${SNAPSHOT_COUNT:-0}" = "0" ]; then
|
||||
DIM_COUNT=0
|
||||
ASSIGN_COUNT=0
|
||||
PAYLOAD='[]'
|
||||
|
||||
if has_table dimensions; then
|
||||
DIM_COUNT=$("$SQLITE_BIN" -readonly "$DB_PATH" "SELECT COUNT(*) FROM dimensions;" 2>/dev/null || echo 0)
|
||||
fi
|
||||
if has_table node_dimensions; then
|
||||
ASSIGN_COUNT=$("$SQLITE_BIN" -readonly "$DB_PATH" "SELECT COUNT(*) FROM node_dimensions;" 2>/dev/null || echo 0)
|
||||
PAYLOAD=$("$SQLITE_BIN" -readonly "$DB_PATH" "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)), '[]') FROM node_dimensions nd LEFT JOIN dimensions d ON d.name = nd.dimension;" 2>/dev/null || echo '[]')
|
||||
fi
|
||||
|
||||
"$SQLITE_BIN" "$DB_PATH" <<SQL
|
||||
INSERT INTO dimension_migration_snapshots (dimension_count, assignment_count, payload)
|
||||
VALUES (${DIM_COUNT:-0}, ${ASSIGN_COUNT:-0}, '${PAYLOAD//\'/''}');
|
||||
SQL
|
||||
fi
|
||||
|
||||
if has_table dimensions; then
|
||||
echo "Seeding default locked dimensions..."
|
||||
for dimension in research ideas projects memory preferences; do
|
||||
"$SQLITE_BIN" "$DB_PATH" <<SQL
|
||||
INSERT INTO dimensions (name, is_priority, updated_at)
|
||||
VALUES ('$dimension', 1, datetime('now'))
|
||||
ON CONFLICT(name) DO UPDATE SET is_priority = 1, updated_at = datetime('now');
|
||||
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
|
||||
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;
|
||||
SQL
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Refreshing helper view nodes_v..."
|
||||
@@ -393,10 +665,7 @@ SELECT n.id,
|
||||
n.event_date,
|
||||
n.metadata,
|
||||
n.created_at,
|
||||
n.updated_at,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d
|
||||
WHERE d.node_id = n.id), '[]') AS dimensions_json
|
||||
n.updated_at
|
||||
FROM nodes n;
|
||||
SQL
|
||||
|
||||
@@ -441,6 +710,13 @@ CREATE INDEX IF NOT EXISTS idx_logs_table_ts ON logs(table_name, ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_table_row ON logs(table_name, row_id);
|
||||
SQL
|
||||
|
||||
# Performance indexes for common query patterns
|
||||
echo "Ensuring performance indexes..."
|
||||
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_updated_at ON nodes(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_chats_created_at ON chats(created_at DESC);
|
||||
SQL
|
||||
|
||||
# triggers for nodes (drop/recreate to ensure enriched payloads)
|
||||
if has_table nodes; then
|
||||
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
|
||||
@@ -604,11 +880,29 @@ if has_table helpers && has_col helpers fluid_context; then
|
||||
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE helpers DROP COLUMN fluid_context;" || true
|
||||
fi
|
||||
|
||||
echo "Ensuring performance indexes exist..."
|
||||
echo "Ensuring voice_usage table exists..."
|
||||
if ! has_table voice_usage; then
|
||||
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_updated_at ON nodes(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_chats_created_at ON chats(created_at DESC);
|
||||
CREATE TABLE voice_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER,
|
||||
session_id TEXT,
|
||||
helper_name TEXT,
|
||||
request_id TEXT,
|
||||
message_id TEXT,
|
||||
voice TEXT,
|
||||
model TEXT,
|
||||
chars INTEGER,
|
||||
cost_usd REAL,
|
||||
duration_ms INTEGER,
|
||||
text_preview TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_voice_usage_session ON voice_usage(session_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_voice_usage_chat ON voice_usage(chat_id);
|
||||
SQL
|
||||
fi
|
||||
|
||||
echo "Running VACUUM and ANALYZE..."
|
||||
"$SQLITE_BIN" "$DB_PATH" "VACUUM; ANALYZE;"
|
||||
|
||||
+250
-30
@@ -133,11 +133,10 @@ function ensureCoreSchema(db) {
|
||||
source TEXT,
|
||||
created_at TEXT,
|
||||
context TEXT,
|
||||
explanation TEXT,
|
||||
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chats (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -153,24 +152,16 @@ function ensureCoreSchema(db) {
|
||||
metadata TEXT,
|
||||
FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS node_dimensions (
|
||||
node_id INTEGER NOT NULL,
|
||||
dimension TEXT NOT NULL,
|
||||
PRIMARY KEY (node_id, dimension),
|
||||
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX IF NOT EXISTS idx_dim_by_dimension ON node_dimensions(dimension, node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dim_by_node ON node_dimensions(node_id, dimension);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dimensions (
|
||||
name TEXT PRIMARY KEY,
|
||||
description TEXT,
|
||||
CREATE TABLE IF NOT EXISTS contexts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT,
|
||||
is_priority INTEGER DEFAULT 0,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized ON contexts(LOWER(TRIM(name)));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -219,15 +210,6 @@ function ensureCoreSchema(db) {
|
||||
`);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const lockedDimensions = ['research', 'ideas', 'projects', 'memory', 'preferences'];
|
||||
const insertDimension = db.prepare(`
|
||||
INSERT INTO dimensions (name, is_priority, updated_at)
|
||||
VALUES (?, 1, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET is_priority = 1, updated_at = excluded.updated_at
|
||||
`);
|
||||
for (const dimension of lockedDimensions) {
|
||||
insertDimension.run(dimension, now);
|
||||
}
|
||||
|
||||
const agentCount = Number(db.prepare('SELECT COUNT(*) as count FROM agents').get().count || 0);
|
||||
if (agentCount === 0) {
|
||||
@@ -249,6 +231,165 @@ function ensureCoreSchema(db) {
|
||||
);
|
||||
}
|
||||
|
||||
const edgeCols = db.prepare('PRAGMA table_info(edges)').all().map(col => col.name);
|
||||
const hasEdgeCol = (name) => edgeCols.includes(name);
|
||||
const needsLegacyEdgeRewrite =
|
||||
!hasEdgeCol('from_node_id') ||
|
||||
!hasEdgeCol('to_node_id') ||
|
||||
!hasEdgeCol('source') ||
|
||||
!hasEdgeCol('created_at') ||
|
||||
!hasEdgeCol('context') ||
|
||||
hasEdgeCol('from_id') ||
|
||||
hasEdgeCol('to_id') ||
|
||||
hasEdgeCol('description') ||
|
||||
hasEdgeCol('updated_at');
|
||||
|
||||
if (needsLegacyEdgeRewrite) {
|
||||
const fromExpr = hasEdgeCol('from_node_id')
|
||||
? 'from_node_id'
|
||||
: hasEdgeCol('from_id')
|
||||
? 'from_id'
|
||||
: 'NULL';
|
||||
const toExpr = hasEdgeCol('to_node_id')
|
||||
? 'to_node_id'
|
||||
: hasEdgeCol('to_id')
|
||||
? 'to_id'
|
||||
: 'NULL';
|
||||
const sourceExpr = hasEdgeCol('source') ? 'source' : "'legacy'";
|
||||
const createdAtExpr = hasEdgeCol('created_at') ? 'created_at' : 'CURRENT_TIMESTAMP';
|
||||
const contextExpr = hasEdgeCol('context') ? 'context' : 'NULL';
|
||||
const explanationExpr = hasEdgeCol('explanation')
|
||||
? 'explanation'
|
||||
: hasEdgeCol('description')
|
||||
? 'description'
|
||||
: hasEdgeCol('context')
|
||||
? "CASE WHEN json_valid(context) THEN json_extract(context, '$.explanation') ELSE NULL END"
|
||||
: 'NULL';
|
||||
|
||||
console.log('Migrating legacy edges table to canonical schema');
|
||||
db.exec('PRAGMA foreign_keys=OFF;');
|
||||
db.exec(`
|
||||
BEGIN TRANSACTION;
|
||||
DROP INDEX IF EXISTS idx_edges_from;
|
||||
DROP INDEX IF EXISTS idx_edges_to;
|
||||
ALTER TABLE edges RENAME TO edges_legacy_migration;
|
||||
CREATE TABLE edges (
|
||||
id INTEGER PRIMARY KEY,
|
||||
from_node_id INTEGER NOT NULL,
|
||||
to_node_id INTEGER NOT NULL,
|
||||
source TEXT,
|
||||
created_at TEXT,
|
||||
context TEXT,
|
||||
explanation TEXT,
|
||||
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO edges (id, from_node_id, to_node_id, source, created_at, context, explanation)
|
||||
SELECT
|
||||
id,
|
||||
${fromExpr},
|
||||
${toExpr},
|
||||
${sourceExpr},
|
||||
COALESCE(${createdAtExpr}, CURRENT_TIMESTAMP),
|
||||
${contextExpr},
|
||||
${explanationExpr}
|
||||
FROM edges_legacy_migration
|
||||
WHERE ${fromExpr} IS NOT NULL
|
||||
AND ${toExpr} IS NOT NULL;
|
||||
DROP TABLE edges_legacy_migration;
|
||||
COMMIT;
|
||||
`);
|
||||
db.exec('PRAGMA foreign_keys=ON;');
|
||||
}
|
||||
|
||||
const refreshedEdgeCols = db.prepare('PRAGMA table_info(edges)').all().map(col => col.name);
|
||||
if (!refreshedEdgeCols.includes('explanation')) {
|
||||
db.exec('ALTER TABLE edges ADD COLUMN explanation TEXT;');
|
||||
db.exec(`
|
||||
UPDATE edges
|
||||
SET explanation = CASE
|
||||
WHEN json_valid(context) THEN json_extract(context, '$.explanation')
|
||||
ELSE explanation
|
||||
END
|
||||
WHERE explanation IS NULL
|
||||
AND context IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node_id);
|
||||
`);
|
||||
|
||||
const chatCols = db.prepare('PRAGMA table_info(chats)').all().map(col => col.name);
|
||||
const hasChatCol = (name) => chatCols.includes(name);
|
||||
const needsLegacyChatRewrite =
|
||||
hasChatCol('focused_memory_id') ||
|
||||
['chat_type', 'helper_name', 'agent_type', 'delegation_id', 'user_message', 'assistant_message', 'thread_id', 'focused_node_id', 'created_at', 'metadata']
|
||||
.some((name) => !hasChatCol(name));
|
||||
|
||||
if (needsLegacyChatRewrite) {
|
||||
const chatTypeExpr = hasChatCol('chat_type') ? 'chat_type' : 'NULL';
|
||||
const helperNameExpr = hasChatCol('helper_name')
|
||||
? 'helper_name'
|
||||
: hasChatCol('title')
|
||||
? 'title'
|
||||
: 'NULL';
|
||||
const agentTypeExpr = hasChatCol('agent_type')
|
||||
? "COALESCE(agent_type, 'orchestrator')"
|
||||
: "'orchestrator'";
|
||||
const delegationIdExpr = hasChatCol('delegation_id') ? 'delegation_id' : 'NULL';
|
||||
const userMessageExpr = hasChatCol('user_message') ? 'user_message' : 'NULL';
|
||||
const assistantMessageExpr = hasChatCol('assistant_message') ? 'assistant_message' : 'NULL';
|
||||
const threadIdExpr = hasChatCol('thread_id') ? 'thread_id' : 'NULL';
|
||||
const focusedNodeIdExpr = hasChatCol('focused_node_id') ? 'focused_node_id' : 'NULL';
|
||||
const createdAtChatExpr = hasChatCol('created_at') ? 'created_at' : 'CURRENT_TIMESTAMP';
|
||||
const metadataChatExpr = hasChatCol('metadata') ? 'metadata' : 'NULL';
|
||||
|
||||
console.log('Migrating legacy chats table to canonical schema');
|
||||
db.exec('PRAGMA foreign_keys=OFF;');
|
||||
db.exec(`
|
||||
BEGIN TRANSACTION;
|
||||
DROP INDEX IF EXISTS idx_chats_thread;
|
||||
ALTER TABLE chats RENAME TO chats_legacy_cleanup;
|
||||
CREATE TABLE chats (
|
||||
id INTEGER PRIMARY KEY,
|
||||
chat_type TEXT,
|
||||
helper_name TEXT,
|
||||
agent_type TEXT DEFAULT 'orchestrator',
|
||||
delegation_id INTEGER,
|
||||
user_message TEXT,
|
||||
assistant_message TEXT,
|
||||
thread_id TEXT,
|
||||
focused_node_id INTEGER,
|
||||
created_at TEXT DEFAULT (CURRENT_TIMESTAMP),
|
||||
metadata TEXT,
|
||||
FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL
|
||||
);
|
||||
INSERT INTO chats (
|
||||
id, chat_type, helper_name, agent_type, delegation_id,
|
||||
user_message, assistant_message, thread_id, focused_node_id,
|
||||
created_at, metadata
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
${chatTypeExpr},
|
||||
${helperNameExpr},
|
||||
${agentTypeExpr},
|
||||
${delegationIdExpr},
|
||||
${userMessageExpr},
|
||||
${assistantMessageExpr},
|
||||
${threadIdExpr},
|
||||
${focusedNodeIdExpr},
|
||||
COALESCE(${createdAtChatExpr}, CURRENT_TIMESTAMP),
|
||||
${metadataChatExpr}
|
||||
FROM chats_legacy_cleanup;
|
||||
DROP TABLE chats_legacy_cleanup;
|
||||
COMMIT;
|
||||
`);
|
||||
db.exec('PRAGMA foreign_keys=ON;');
|
||||
}
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);");
|
||||
|
||||
const nodeCols = db.prepare('PRAGMA table_info(nodes)').all().map(col => col.name);
|
||||
const hasNodeCol = (name) => nodeCols.includes(name);
|
||||
|
||||
@@ -264,6 +405,34 @@ function ensureCoreSchema(db) {
|
||||
if (!hasNodeCol('event_date')) {
|
||||
db.exec('ALTER TABLE nodes ADD COLUMN event_date TEXT;');
|
||||
}
|
||||
if (!hasNodeCol('chunk_status')) {
|
||||
db.exec("ALTER TABLE nodes ADD COLUMN chunk_status TEXT DEFAULT 'not_chunked';");
|
||||
}
|
||||
if (!hasNodeCol('context_id')) {
|
||||
db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;');
|
||||
}
|
||||
|
||||
const contextCols = db.prepare('PRAGMA table_info(contexts)').all().map(col => col.name);
|
||||
const hasContextCol = (name) => contextCols.includes(name);
|
||||
if (!hasContextCol('description')) {
|
||||
db.exec("ALTER TABLE contexts ADD COLUMN description TEXT NOT NULL DEFAULT '';");
|
||||
}
|
||||
if (!hasContextCol('icon')) {
|
||||
db.exec('ALTER TABLE contexts ADD COLUMN icon TEXT;');
|
||||
}
|
||||
if (!hasContextCol('created_at')) {
|
||||
db.exec("ALTER TABLE contexts ADD COLUMN created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
|
||||
}
|
||||
if (!hasContextCol('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;
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_context_id ON nodes(context_id);
|
||||
`);
|
||||
|
||||
if (hasNodeCol('content')) {
|
||||
db.exec(`
|
||||
@@ -315,6 +484,60 @@ function ensureCoreSchema(db) {
|
||||
AND (chunk_status IS NULL OR chunk_status != 'chunked');
|
||||
`);
|
||||
|
||||
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 existingSnapshotCount = Number(db.prepare('SELECT COUNT(*) as count FROM dimension_migration_snapshots').get().count || 0);
|
||||
if (existingSnapshotCount === 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;
|
||||
`);
|
||||
}
|
||||
|
||||
db.exec('DROP VIEW IF EXISTS nodes_v;');
|
||||
db.exec(`
|
||||
CREATE VIEW nodes_v AS
|
||||
@@ -326,10 +549,7 @@ function ensureCoreSchema(db) {
|
||||
n.event_date,
|
||||
n.metadata,
|
||||
n.created_at,
|
||||
n.updated_at,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d
|
||||
WHERE d.node_id = n.id), '[]') AS dimensions_json
|
||||
n.updated_at
|
||||
FROM nodes n;
|
||||
`);
|
||||
}
|
||||
|
||||
+135
-133
@@ -4,9 +4,7 @@ import { useEffect, useRef, useState, type DragEvent } from 'react';
|
||||
import { Trash2, Loader, Database, RefreshCw, Pencil, X, Save, Plus, Link2, Tag, Share2, AlignLeft, ChevronDown, ChevronRight, Check, Folder } from 'lucide-react';
|
||||
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
|
||||
import { Node, NodeConnection } from '@/types/database';
|
||||
import DimensionTags from './dimensions/DimensionTags';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
import ConfirmDialog from '../common/ConfirmDialog';
|
||||
import { SourceReader } from './source';
|
||||
import SourceEditor from './source/SourceEditor';
|
||||
@@ -39,7 +37,6 @@ export default function FocusPanel({
|
||||
onTextSelect,
|
||||
highlightedPassage,
|
||||
}: FocusPanelProps) {
|
||||
const { dimensionIcons } = useDimensionIcons();
|
||||
const [nodesData, setNodesData] = useState<Record<number, Node>>({});
|
||||
const [edgesData, setEdgesData] = useState<Record<number, NodeConnection[]>>({});
|
||||
const [loadingNodes, setLoadingNodes] = useState<Set<number>>(new Set());
|
||||
@@ -837,7 +834,7 @@ export default function FocusPanel({
|
||||
{isOutgoing ? '↗' : '↙'}
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
{getNodeIcon(connection.connected_node, dimensionIcons, 12)}
|
||||
{getNodeIcon(connection.connected_node, 12)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1150,7 +1147,7 @@ export default function FocusPanel({
|
||||
<div className="prop-label">node</div>
|
||||
<div className="prop-value">
|
||||
<span className="node-meta-value">
|
||||
<span className="node-meta-icon">{getNodeIcon(currentNode, dimensionIcons, 12)}</span>
|
||||
<span className="node-meta-icon">{getNodeIcon(currentNode, 12)}</span>
|
||||
<span className="node-id-pill">#{currentNode.id}</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -1235,21 +1232,24 @@ export default function FocusPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="prop-row prop-row-top">
|
||||
<div className="prop-label">ctx</div>
|
||||
<div className="prop-value" style={{ overflow: 'visible' }}>
|
||||
<div ref={contextMenuRef} style={{ position: 'relative', width: '100%' }}>
|
||||
<div className="prop-row">
|
||||
<div className="prop-label">context</div>
|
||||
<div className="prop-value context-prop-value">
|
||||
<div className="context-select-shell" ref={contextMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setContextMenuOpen((prev) => !prev)}
|
||||
disabled={contextSaving}
|
||||
className="context-select-trigger"
|
||||
disabled={contextSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setContextMenuOpen((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<span className="context-select-main">
|
||||
<span className="context-select-icon">
|
||||
<span className="context-select-current">
|
||||
<span className="context-select-badge">
|
||||
<Folder size={13} />
|
||||
</span>
|
||||
<span className="context-select-text">
|
||||
<span className={currentNode.context ? 'context-select-name' : 'context-select-empty'}>
|
||||
{currentNode.context?.name || 'No context'}
|
||||
</span>
|
||||
</span>
|
||||
@@ -1261,7 +1261,8 @@ export default function FocusPanel({
|
||||
<button
|
||||
type="button"
|
||||
className={`context-option ${currentNode.context_id == null ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void saveContext('');
|
||||
}}
|
||||
>
|
||||
@@ -1272,7 +1273,8 @@ export default function FocusPanel({
|
||||
key={context.id}
|
||||
type="button"
|
||||
className={`context-option ${currentNode.context_id === context.id ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void saveContext(String(context.id));
|
||||
}}
|
||||
>
|
||||
@@ -1286,20 +1288,6 @@ export default function FocusPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dimensions */}
|
||||
<div className="prop-row prop-row-top">
|
||||
<div className="prop-label">dime</div>
|
||||
<div className="prop-value">
|
||||
<DimensionTags
|
||||
dimensions={currentNode.dimensions || []}
|
||||
onUpdate={async (newDimensions) => {
|
||||
try { await updateNode(activeTab, { dimensions: newDimensions }); }
|
||||
catch (error) { console.error('Error saving dimensions:', error); window.alert('Failed to save dimensions. Please try again.'); }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connections */}
|
||||
<div className="prop-row prop-row-top">
|
||||
<div className="prop-label">edge</div>
|
||||
@@ -1322,7 +1310,7 @@ export default function FocusPanel({
|
||||
onMouseLeave={() => setHoveredConnectionId(null)}
|
||||
>
|
||||
<span className={`conn-dir ${isOut ? 'out' : 'in'}`}>{isOut ? '↗' : '↙'}</span>
|
||||
<span className="conn-icon">{getNodeIcon(connection.connected_node, dimensionIcons, 12)}</span>
|
||||
<span className="conn-icon">{getNodeIcon(connection.connected_node, 12)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="conn-title-btn"
|
||||
@@ -1424,7 +1412,7 @@ export default function FocusPanel({
|
||||
const title = currentNode.title || 'Untitled';
|
||||
e.dataTransfer.effectAllowed = 'copyMove';
|
||||
e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: activeTab, title }));
|
||||
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: activeTab, title, dimensions: currentNode.dimensions || [] }));
|
||||
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: activeTab, title }));
|
||||
e.dataTransfer.setData('text/plain', `[NODE:${activeTab}:"${title}"]`);
|
||||
}}
|
||||
className="node-drag-handle"
|
||||
@@ -1703,7 +1691,7 @@ export default function FocusPanel({
|
||||
}
|
||||
|
||||
.prop-label {
|
||||
width: 44px;
|
||||
width: 58px;
|
||||
flex-shrink: 0;
|
||||
padding: 7px 8px 7px 0;
|
||||
color: var(--rah-text-muted);
|
||||
@@ -1720,6 +1708,10 @@ export default function FocusPanel({
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.context-prop-value {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.props-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1769,6 +1761,116 @@ export default function FocusPanel({
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.context-select-shell {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.context-select-trigger {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 10px 0 8px;
|
||||
border: 1px solid var(--rah-border-strong);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, var(--rah-bg-surface), var(--rah-bg-panel));
|
||||
color: var(--rah-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-select-current {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.context-select-badge {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--rah-border);
|
||||
background: var(--rah-bg-base);
|
||||
color: var(--rah-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.context-select-name,
|
||||
.context-select-empty {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.context-select-name {
|
||||
color: var(--rah-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.context-select-empty {
|
||||
color: var(--rah-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.context-select-chevron {
|
||||
color: var(--rah-text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.context-select-chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.context-select-menu {
|
||||
position: relative;
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--rah-border);
|
||||
border-radius: 12px;
|
||||
background: var(--rah-bg-surface);
|
||||
box-shadow: var(--rah-shadow-floating);
|
||||
}
|
||||
|
||||
.context-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--rah-text-secondary);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-option.active {
|
||||
background: color-mix(in srgb, var(--rah-accent-green) 10%, var(--rah-bg-panel));
|
||||
color: var(--rah-text-primary);
|
||||
}
|
||||
|
||||
.context-option-count {
|
||||
font-size: 10px;
|
||||
color: var(--rah-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prop-empty-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -1780,106 +1882,6 @@ export default function FocusPanel({
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.context-select-trigger {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--rah-border);
|
||||
background: var(--rah-bg-panel);
|
||||
color: var(--rah-text-base);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
.context-select-trigger:hover {
|
||||
border-color: var(--rah-border-strong);
|
||||
background: var(--rah-bg-hover);
|
||||
}
|
||||
|
||||
.context-select-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.context-select-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--rah-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.context-select-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.context-select-chevron {
|
||||
color: var(--rah-text-muted);
|
||||
transition: transform 120ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.context-select-chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.context-select-menu {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--rah-border);
|
||||
background: var(--rah-bg-panel);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.context-option {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--rah-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.context-option:hover,
|
||||
.context-option.active {
|
||||
background: var(--rah-bg-hover);
|
||||
color: var(--rah-text-active);
|
||||
}
|
||||
|
||||
.context-option-count {
|
||||
color: var(--rah-text-muted);
|
||||
font-size: 10px;
|
||||
background: var(--rah-bg-active);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prop-muted {
|
||||
color: var(--rah-text-muted);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface DimensionSearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDimensionSelect: (dimension: string, description?: string) => void;
|
||||
existingDimensions: string[];
|
||||
}
|
||||
|
||||
interface DimensionSuggestion {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
}
|
||||
|
||||
export default function DimensionSearchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onDimensionSelect,
|
||||
existingDimensions
|
||||
}: DimensionSearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [newDimensionDescription, setNewDimensionDescription] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Store the element that triggered the modal for return focus
|
||||
useEffect(() => {
|
||||
if (isOpen && document.activeElement instanceof HTMLElement) {
|
||||
returnFocusRef.current = document.activeElement;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Focus trap and accessibility
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Autofocus input
|
||||
inputRef.current?.focus();
|
||||
|
||||
// Lock body scroll
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Handle Escape key
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Focus trap: keep focus within modal
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const focusableElements = modalRef.current?.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
if (!focusableElements || focusableElements.length === 0) return;
|
||||
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.addEventListener('keydown', handleTab);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.removeEventListener('keydown', handleTab);
|
||||
|
||||
// Return focus to trigger element
|
||||
if (returnFocusRef.current) {
|
||||
returnFocusRef.current.focus();
|
||||
}
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Track if we're in "create new" mode to avoid re-fetching
|
||||
const isCreatingNew = searchQuery.trim() &&
|
||||
!suggestions.some(s => s.dimension.toLowerCase() === searchQuery.toLowerCase().trim());
|
||||
|
||||
// Fetch dimension suggestions
|
||||
useEffect(() => {
|
||||
const fetchSuggestions = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/dimensions/popular?limit=50');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const allDimensions: DimensionSuggestion[] = result.data;
|
||||
|
||||
// Filter based on search query and exclude existing dimensions
|
||||
const filtered = allDimensions.filter(dim => {
|
||||
const matchesQuery = !searchQuery.trim() ||
|
||||
dim.dimension.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const notExisting = !existingDimensions.includes(dim.dimension);
|
||||
return matchesQuery && notExisting;
|
||||
});
|
||||
|
||||
// Sort: priority first, then by count
|
||||
const sorted = filtered.sort((a, b) => {
|
||||
if (a.isPriority && !b.isPriority) return -1;
|
||||
if (!a.isPriority && b.isPriority) return 1;
|
||||
return b.count - a.count;
|
||||
});
|
||||
|
||||
setSuggestions(sorted.slice(0, 20));
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching dimension suggestions:', error);
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Only fetch when modal is open and we're not actively creating a new dimension
|
||||
// (user has typed description means they're committing to create)
|
||||
if (isOpen && !newDimensionDescription.trim()) {
|
||||
const timeoutId = setTimeout(fetchSuggestions, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [searchQuery, existingDimensions, isOpen, newDimensionDescription]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
||||
if (suggestions[selectedIndex]) {
|
||||
// Select existing dimension
|
||||
handleSelectDimension(suggestions[selectedIndex].dimension);
|
||||
} else if (searchQuery.trim()) {
|
||||
// Create new dimension with description
|
||||
handleSelectDimension(searchQuery.trim(), newDimensionDescription.trim() || undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectDimension = (dimension: string, description?: string) => {
|
||||
onDimensionSelect(dimension, description);
|
||||
setSearchQuery('');
|
||||
setNewDimensionDescription('');
|
||||
setSuggestions([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const canCreateNew = searchQuery.trim() &&
|
||||
!suggestions.some(s => s.dimension.toLowerCase() === searchQuery.toLowerCase());
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="search-backdrop"
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search dimensions"
|
||||
>
|
||||
<div ref={modalRef} className="search-container" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Search Input */}
|
||||
<div className="search-input-wrapper">
|
||||
<svg className="search-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search or create dimension..."
|
||||
className="search-input"
|
||||
/>
|
||||
|
||||
<div className="search-shortcut">
|
||||
<kbd>esc</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{suggestions.length > 0 && (
|
||||
<div className="search-results">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={suggestion.dimension}
|
||||
onClick={() => handleSelectDimension(suggestion.dimension)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`search-result-item ${index === selectedIndex ? 'selected' : ''}`}
|
||||
>
|
||||
<span className={`result-name ${suggestion.isPriority ? 'priority' : ''}`}>
|
||||
{suggestion.dimension}
|
||||
</span>
|
||||
<span className="result-count">{suggestion.count}</span>
|
||||
{index === selectedIndex && (
|
||||
<span className="result-hint">↵</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create New Option */}
|
||||
{canCreateNew && (
|
||||
<div
|
||||
className="search-create"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="create-header">
|
||||
<span className="create-icon">+</span>
|
||||
<span className="create-title">Create "{searchQuery.trim()}"</span>
|
||||
</div>
|
||||
<div className="description-input-wrapper">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={newDimensionDescription}
|
||||
onChange={(e) => setNewDimensionDescription(e.target.value.slice(0, 500))}
|
||||
onFocus={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="Describe what belongs in this dimension..."
|
||||
className="description-input"
|
||||
rows={2}
|
||||
/>
|
||||
<span className="description-counter">{newDimensionDescription.length}/500</span>
|
||||
</div>
|
||||
{!newDimensionDescription.trim() && (
|
||||
<div className="description-warning">
|
||||
Dimensions without descriptions may not auto-assign correctly
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelectDimension(searchQuery.trim(), newDimensionDescription.trim() || undefined);
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(suggestions.length)}
|
||||
className={`create-button ${selectedIndex === suggestions.length ? 'selected' : ''}`}
|
||||
>
|
||||
Create Dimension
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!searchQuery && suggestions.length === 0 && (
|
||||
<div className="search-empty">
|
||||
Start typing to search dimensions
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.search-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 15vh;
|
||||
z-index: 9999;
|
||||
animation: backdropIn 200ms ease-out;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 70vh;
|
||||
animation: containerIn 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: #141414;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04),
|
||||
0 24px 48px -12px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: #525252;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #fafafa;
|
||||
font-size: 18px;
|
||||
font-family: inherit;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #525252;
|
||||
}
|
||||
|
||||
.search-shortcut {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.search-shortcut kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
background: #262626;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
color: #737373;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
margin-top: 8px;
|
||||
background: #141414;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04),
|
||||
0 24px 48px -12px rgba(0, 0, 0, 0.6);
|
||||
animation: resultsIn 150ms ease-out;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid #1f1f1f;
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.search-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-result-item:hover,
|
||||
.search-result-item.selected {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
flex: 1;
|
||||
color: #e5e5e5;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.result-name.priority {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
color: #525252;
|
||||
font-size: 12px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.result-hint {
|
||||
color: #525252;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-create {
|
||||
margin-top: 8px;
|
||||
background: #141414;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
padding: 16px 20px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04),
|
||||
0 24px 48px -12px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.create-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.create-title {
|
||||
color: #22c55e;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.description-input-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.description-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
color: #e5e5e5;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
outline: none;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.description-input:focus {
|
||||
border-color: #525252;
|
||||
}
|
||||
|
||||
.description-input::placeholder {
|
||||
color: #525252;
|
||||
}
|
||||
|
||||
.description-counter {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 12px;
|
||||
font-size: 11px;
|
||||
color: #525252;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.description-warning {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
border: 1px solid rgba(234, 179, 8, 0.2);
|
||||
border-radius: 6px;
|
||||
color: #eab308;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.create-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
font-family: inherit;
|
||||
color: #22c55e;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.create-button:hover,
|
||||
.create-button.selected {
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
.create-icon {
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.search-empty {
|
||||
margin-top: 8px;
|
||||
padding: 32px 24px;
|
||||
background: #141414;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 16px;
|
||||
color: #525252;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes backdropIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes containerIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96) translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes resultsIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null;
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import DimensionSearchModal from './DimensionSearchModal';
|
||||
|
||||
interface DimensionTagsProps {
|
||||
dimensions: string[];
|
||||
onUpdate: (dimensions: string[]) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface DimensionSuggestion {
|
||||
dimension: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export default function DimensionTags({
|
||||
dimensions,
|
||||
onUpdate,
|
||||
disabled = false
|
||||
}: DimensionTagsProps) {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sortedDimensions = [...dimensions];
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdding && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isAdding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery.length > 0) {
|
||||
fetchSuggestions(searchQuery);
|
||||
} else if (isAdding) {
|
||||
// Load popular dimensions when field is empty
|
||||
fetchPopularDimensions();
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
}
|
||||
}, [searchQuery, isAdding]);
|
||||
|
||||
const fetchSuggestions = async (query: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/dimensions/search?q=${encodeURIComponent(query)}`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setSuggestions(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching dimension suggestions:', error);
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPopularDimensions = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/dimensions/popular');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setSuggestions(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching popular dimensions:', error);
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addDimension = async (dimension: string, description?: string) => {
|
||||
if (!dimension.trim() || dimensions.includes(dimension.trim())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If description is provided, create/update the dimension in the database first
|
||||
if (description) {
|
||||
try {
|
||||
await fetch('/api/dimensions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: dimension.trim(),
|
||||
description: description.trim(),
|
||||
isPriority: false
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating dimension with description:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const newDimensions = [...dimensions, dimension.trim()];
|
||||
await onUpdate(newDimensions);
|
||||
|
||||
setSearchQuery('');
|
||||
setSuggestions([]);
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const removeDimension = async (index: number) => {
|
||||
const dimension = sortedDimensions[index];
|
||||
const newDimensions = dimensions.filter(d => d !== dimension);
|
||||
await onUpdate(newDimensions);
|
||||
};
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === index) return;
|
||||
|
||||
const draggedDimension = sortedDimensions[draggedIndex];
|
||||
const targetDimension = sortedDimensions[index];
|
||||
|
||||
// Find original positions in unsorted array
|
||||
const draggedOrigIndex = dimensions.indexOf(draggedDimension);
|
||||
const targetOrigIndex = dimensions.indexOf(targetDimension);
|
||||
|
||||
const newDimensions = [...dimensions];
|
||||
newDimensions.splice(draggedOrigIndex, 1);
|
||||
newDimensions.splice(targetOrigIndex, 0, draggedDimension);
|
||||
|
||||
onUpdate(newDimensions);
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
const moveDimension = async (fromIndex: number, direction: 'up' | 'down') => {
|
||||
const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1;
|
||||
if (toIndex < 0 || toIndex >= sortedDimensions.length) return;
|
||||
|
||||
const fromDimension = sortedDimensions[fromIndex];
|
||||
const toDimension = sortedDimensions[toIndex];
|
||||
|
||||
// Find original positions in unsorted array
|
||||
const fromOrigIndex = dimensions.indexOf(fromDimension);
|
||||
const toOrigIndex = dimensions.indexOf(toDimension);
|
||||
|
||||
const newDimensions = [...dimensions];
|
||||
[newDimensions[fromOrigIndex], newDimensions[toOrigIndex]] = [newDimensions[toOrigIndex], newDimensions[fromOrigIndex]];
|
||||
await onUpdate(newDimensions);
|
||||
};
|
||||
|
||||
// Check if dimensions overflow 2 lines (approximate)
|
||||
const shouldShowExpandButton = sortedDimensions.length > 6; // Rough estimate for 2 lines
|
||||
const displayedDimensions = (!isExpanded && shouldShowExpandButton)
|
||||
? sortedDimensions.slice(0, 6)
|
||||
: sortedDimensions;
|
||||
const hiddenCount = sortedDimensions.length - 6;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={() => {
|
||||
if (shouldShowExpandButton && !isExpanded) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6px',
|
||||
marginBottom: '8px',
|
||||
cursor: (shouldShowExpandButton && !isExpanded) ? 'pointer' : 'default',
|
||||
position: 'relative',
|
||||
minHeight: dimensions.length === 0 ? '24px' : 'auto'
|
||||
}}
|
||||
>
|
||||
{/* Show placeholder when no dimensions */}
|
||||
{dimensions.length === 0 && !disabled && (
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
fontStyle: 'italic',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}>
|
||||
No dimensions
|
||||
</span>
|
||||
)}
|
||||
|
||||
{displayedDimensions.map((dimension, index) => {
|
||||
return (
|
||||
<div
|
||||
key={`${dimension}-${index}`}
|
||||
draggable={!disabled}
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
fontSize: '10px',
|
||||
color: 'var(--rah-text-base)',
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
borderRadius: '8px',
|
||||
padding: '2px 6px',
|
||||
cursor: disabled ? 'default' : 'grab',
|
||||
opacity: draggedIndex === index ? 0.5 : 1,
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||
}}
|
||||
>
|
||||
<span>{dimension}</span>
|
||||
|
||||
{/* Reorder buttons removed - no longer needed */}
|
||||
|
||||
{/* Remove button */}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeDimension(index);
|
||||
}}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
fontSize: '14px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
marginLeft: '2px'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ff6b6b'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Show "+X more" indicator */}
|
||||
{shouldShowExpandButton && !isExpanded && hiddenCount > 0 && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(true);
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
background: 'transparent',
|
||||
border: '1px dashed var(--rah-border-strong)',
|
||||
borderRadius: '12px',
|
||||
padding: '2px 8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--rah-text-soft)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--rah-text-muted)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||
}}
|
||||
>
|
||||
+{hiddenCount} more
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapse button when expanded */}
|
||||
{isExpanded && shouldShowExpandButton && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
background: 'transparent',
|
||||
border: '1px dashed var(--rah-border-strong)',
|
||||
borderRadius: '12px',
|
||||
padding: '2px 8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--rah-text-soft)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--rah-text-muted)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||
}}
|
||||
>
|
||||
show less
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add dimension button */}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsAdding(true);
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1,
|
||||
color: 'var(--rah-text-muted)',
|
||||
background: 'transparent',
|
||||
border: '1px dashed var(--rah-border-strong)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 120ms ease, border-color 120ms ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--rah-text-soft)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--rah-text-muted)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||
}}
|
||||
title="Add dimension"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dimension Search Modal */}
|
||||
<DimensionSearchModal
|
||||
isOpen={isAdding}
|
||||
onClose={() => setIsAdding(false)}
|
||||
onDimensionSelect={(dim, description) => {
|
||||
addDimension(dim, description);
|
||||
}}
|
||||
existingDimensions={dimensions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ interface NodeSearchModalProps {
|
||||
interface NodeSuggestion {
|
||||
id: number;
|
||||
title: string;
|
||||
dimensions?: string[];
|
||||
}
|
||||
|
||||
export default function NodeSearchModal({
|
||||
@@ -96,7 +95,6 @@ export default function NodeSearchModal({
|
||||
.map((node) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || [],
|
||||
}));
|
||||
setSuggestions(nextSuggestions);
|
||||
setSelectedIndex(0);
|
||||
@@ -258,17 +256,6 @@ export default function NodeSearchModal({
|
||||
<div style={{ color: '#e0e0e0', fontSize: '13px', marginBottom: '2px' }}>
|
||||
{suggestion.title}
|
||||
</div>
|
||||
{suggestion.dimensions && suggestion.dimensions.length > 0 && (
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '11px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{suggestion.dimensions.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ color: '#444', fontSize: '11px', fontFamily: 'monospace', flexShrink: 0 }}>
|
||||
#{suggestion.id}
|
||||
|
||||
@@ -6,11 +6,10 @@ import { FileText } from 'lucide-react';
|
||||
interface NodeLabelProps {
|
||||
id: string;
|
||||
title: string;
|
||||
dimensions: string[];
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
function NodeLabel({ id, title, dimensions, onNodeClick }: NodeLabelProps) {
|
||||
function NodeLabel({ id, title, onNodeClick }: NodeLabelProps) {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// Prevent bubbling into parent containers (e.g., content view onClick that toggles edit)
|
||||
e.stopPropagation();
|
||||
@@ -62,7 +61,7 @@ function NodeLabel({ id, title, dimensions, onNodeClick }: NodeLabelProps) {
|
||||
export function parseAndRenderContent(content: string, onNodeClick?: (nodeId: number) => void): React.ReactNode[] {
|
||||
if (!content) return [content];
|
||||
|
||||
// Pattern to match [NODE:id:"title"] (dimensions removed)
|
||||
// Pattern to match [NODE:id:"title"]
|
||||
// Be tolerant of spaces and curly quotes
|
||||
// Use non-greedy match (.+?) to handle quotes inside titles
|
||||
const nodePattern = /\[NODE:\s*(\d+)\s*:\s*["""'](.+?)["""']\s*\]/g;
|
||||
@@ -80,15 +79,12 @@ export function parseAndRenderContent(content: string, onNodeClick?: (nodeId: nu
|
||||
// Parse the node data
|
||||
const id = match[1];
|
||||
const title = match[2];
|
||||
const dimensions: string[] = []; // No dimensions in new format
|
||||
|
||||
// Add the node label
|
||||
parts.push(
|
||||
<NodeLabel
|
||||
key={`node-${id}-${match.index}`}
|
||||
id={id}
|
||||
title={title}
|
||||
dimensions={dimensions}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
Sun,
|
||||
Moon,
|
||||
} from 'lucide-react';
|
||||
import type { PaneType, NavigablePaneType } from '../panes/types';
|
||||
import type { PaneType } from '../panes/types';
|
||||
import type { FocusedSkill } from '@/types/skills';
|
||||
import type { Theme } from '@/hooks/useTheme';
|
||||
import type { ContextSummary } from '@/types/database';
|
||||
|
||||
@@ -27,11 +28,12 @@ interface LeftToolbarProps {
|
||||
onAddStuffClick: () => void;
|
||||
onRefreshClick: () => void;
|
||||
onSettingsClick: () => void;
|
||||
onPaneTypeClick: (paneType: NavigablePaneType) => void;
|
||||
onPaneTypeClick: (paneType: PaneType) => void;
|
||||
isExpanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
openTabTypes: Set<PaneType>;
|
||||
activeTabType: PaneType | null;
|
||||
focusedSkill?: FocusedSkill | null;
|
||||
theme: Theme;
|
||||
onThemeToggle: () => void;
|
||||
contexts?: ContextSummary[];
|
||||
@@ -41,11 +43,10 @@ interface LeftToolbarProps {
|
||||
const NAV_WIDTH_COLLAPSED = 50;
|
||||
const NAV_WIDTH_EXPANDED = 280;
|
||||
|
||||
const VIEW_ITEMS: Array<{ paneType: NavigablePaneType; label: string; icon: typeof LayoutList }> = [
|
||||
const PRIMARY_VIEW_ITEMS: Array<{ paneType: PaneType; label: string; icon: typeof LayoutList }> = [
|
||||
{ paneType: 'views', label: 'Nodes', icon: LayoutList },
|
||||
{ paneType: 'skills', label: 'Skills', icon: BookOpen },
|
||||
{ paneType: 'map', label: 'Map', icon: Map },
|
||||
{ paneType: 'dimensions', label: 'Dimension', icon: Folder },
|
||||
{ paneType: 'table', label: 'Table', icon: Table2 },
|
||||
];
|
||||
|
||||
@@ -116,6 +117,7 @@ export default function LeftToolbar({
|
||||
onToggleExpanded,
|
||||
openTabTypes,
|
||||
activeTabType,
|
||||
focusedSkill,
|
||||
theme,
|
||||
onThemeToggle,
|
||||
contexts = [],
|
||||
@@ -123,6 +125,14 @@ export default function LeftToolbar({
|
||||
}: LeftToolbarProps) {
|
||||
const [contextsExpanded, setContextsExpanded] = useState(false);
|
||||
|
||||
const renderActionButtons = () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<NavButton icon={Search} label="Search" title="Search (⌘K)" expanded={isExpanded} onClick={onSearchClick} />
|
||||
<NavButton icon={Plus} label="Add Stuff" title="New node (⌘N)" expanded={isExpanded} onClick={onAddStuffClick} />
|
||||
<NavButton icon={RefreshCw} label="Refresh" expanded={isExpanded} onClick={onRefreshClick} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -131,6 +141,7 @@ export default function LeftToolbar({
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 8px',
|
||||
flexShrink: 0,
|
||||
transition: 'width 0.2s ease',
|
||||
@@ -138,7 +149,6 @@ export default function LeftToolbar({
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', height: '100%' }}>
|
||||
{/* Top: collapse + actions */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<NavButton
|
||||
icon={isExpanded ? PanelLeftClose : PanelLeftOpen}
|
||||
@@ -146,18 +156,30 @@ export default function LeftToolbar({
|
||||
expanded={isExpanded}
|
||||
onClick={onToggleExpanded}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<NavButton icon={Search} label="Search" title="Search (⌘K)" expanded={isExpanded} onClick={onSearchClick} />
|
||||
<NavButton icon={Plus} label="Add Stuff" title="New node (⌘N)" expanded={isExpanded} onClick={onAddStuffClick} />
|
||||
<NavButton icon={RefreshCw} label="Refresh" expanded={isExpanded} onClick={onRefreshClick} />
|
||||
</div>
|
||||
|
||||
{renderActionButtons()}
|
||||
</div>
|
||||
|
||||
{/* Middle: view items, vertically centered */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', minHeight: 0 }}>
|
||||
<div style={{ borderTop: '1px solid var(--rah-border)', paddingTop: '14px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '6px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', paddingTop: '4px' }}>
|
||||
{PRIMARY_VIEW_ITEMS.map((item) => (
|
||||
<NavButton
|
||||
key={`${item.paneType}-${item.label}`}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
expanded={isExpanded}
|
||||
active={
|
||||
item.paneType === 'skills'
|
||||
? focusedSkill != null || activeTabType === 'skills' || openTabTypes.has('skills')
|
||||
: activeTabType === item.paneType || openTabTypes.has(item.paneType)
|
||||
}
|
||||
onClick={() => onPaneTypeClick(item.paneType)}
|
||||
activeTone="green"
|
||||
/>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '10px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<NavButton
|
||||
@@ -227,18 +249,6 @@ export default function LeftToolbar({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{VIEW_ITEMS.map((item) => (
|
||||
<NavButton
|
||||
key={`${item.paneType}-${item.label}`}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
expanded={isExpanded}
|
||||
active={activeTabType === item.paneType || openTabTypes.has(item.paneType)}
|
||||
onClick={() => onPaneTypeClick(item.paneType)}
|
||||
activeTone="green"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,24 +4,21 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Chip from '../common/Chip';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onNodeSelect: (nodeId: number) => void;
|
||||
existingFilters: {type: 'dimension' | 'title', value: string}[];
|
||||
existingFilters: {type: 'context' | 'title' | 'tag', value: string}[];
|
||||
}
|
||||
|
||||
interface NodeSuggestion {
|
||||
id: number;
|
||||
title: string;
|
||||
dimensions?: string[];
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFilters }: SearchModalProps) {
|
||||
const { dimensionIcons } = useDimensionIcons();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<NodeSuggestion[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
@@ -111,7 +108,6 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
const nodeSuggestions: NodeSuggestion[] = result.data.map((node: any) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || [],
|
||||
link: node.link || undefined,
|
||||
}));
|
||||
|
||||
@@ -208,7 +204,7 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
className={`search-result-item ${index === selectedIndex ? 'selected' : ''}`}
|
||||
>
|
||||
<span className="result-id">{suggestion.id}</span>
|
||||
<span className="result-icon">{getNodeIcon(suggestion as any, dimensionIcons, 14)}</span>
|
||||
<span className="result-icon">{getNodeIcon(suggestion as any, 14)}</span>
|
||||
<span className="result-title">{suggestion.title}</span>
|
||||
{index === selectedIndex && (
|
||||
<span className="result-hint">↵</span>
|
||||
@@ -250,7 +246,7 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: var(--rah-bg-panel);
|
||||
background: var(--rah-bg-modal);
|
||||
border: 1px solid var(--rah-border-strong);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
@@ -262,7 +258,7 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
.search-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: #525252;
|
||||
color: var(--rah-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -271,14 +267,14 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #fafafa;
|
||||
color: var(--rah-text-active);
|
||||
font-size: 18px;
|
||||
font-family: inherit;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #525252;
|
||||
color: var(--rah-text-muted);
|
||||
}
|
||||
|
||||
.search-shortcut {
|
||||
@@ -292,17 +288,17 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
background: var(--rah-border-strong);
|
||||
background: var(--rah-bg-active);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
color: #737373;
|
||||
border: 1px solid var(--rah-border-stronger);
|
||||
color: var(--rah-text-muted);
|
||||
border: 1px solid var(--rah-border-strong);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
margin-top: 8px;
|
||||
background: var(--rah-bg-panel);
|
||||
background: var(--rah-bg-modal);
|
||||
border: 1px solid var(--rah-border-strong);
|
||||
border-radius: 16px;
|
||||
overflow-x: hidden;
|
||||
@@ -322,7 +318,7 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
padding: 16px 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--rah-bg-active);
|
||||
border-bottom: 1px solid var(--rah-border);
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
text-align: left;
|
||||
@@ -345,8 +341,8 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--rah-bg-base);
|
||||
background: #22c55e;
|
||||
color: var(--rah-text-inverse);
|
||||
background: var(--rah-accent-green);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
min-width: 28px;
|
||||
@@ -369,17 +365,17 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
}
|
||||
|
||||
.result-hint {
|
||||
color: #525252;
|
||||
color: var(--rah-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-empty {
|
||||
margin-top: 8px;
|
||||
padding: 32px 24px;
|
||||
background: var(--rah-bg-panel);
|
||||
background: var(--rah-bg-modal);
|
||||
border: 1px solid var(--rah-border-strong);
|
||||
border-radius: 16px;
|
||||
color: #525252;
|
||||
color: var(--rah-text-muted);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import FolderViewOverlay from '@/components/nodes/FolderViewOverlay';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import { DimensionsPaneProps, PaneType } from './types';
|
||||
|
||||
export default function DimensionsPane({
|
||||
slot,
|
||||
isActive,
|
||||
onPaneAction,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
tabBar,
|
||||
onNodeOpen,
|
||||
refreshToken,
|
||||
onDataChanged,
|
||||
onDimensionSelect,
|
||||
}: DimensionsPaneProps) {
|
||||
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
|
||||
const handleTypeChange = (type: PaneType) => {
|
||||
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
|
||||
};
|
||||
|
||||
// When used as a pane, "close" means switch back to node view
|
||||
const handleClose = () => {
|
||||
onPaneAction?.({ type: 'switch-pane-type', paneType: 'node' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<PaneHeader
|
||||
slot={slot}
|
||||
onCollapse={onCollapse}
|
||||
onSwapPanes={onSwapPanes}
|
||||
tabBar={tabBar}
|
||||
toolbarHostRef={setToolbarHost}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', position: 'relative' }}>
|
||||
{/* FolderViewOverlay expects to be an overlay, so we wrap it in a container */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'transparent',
|
||||
}}>
|
||||
<FolderViewOverlay
|
||||
onClose={handleClose}
|
||||
onNodeOpen={onNodeOpen}
|
||||
refreshToken={refreshToken}
|
||||
onDataChanged={onDataChanged}
|
||||
onDimensionSelect={onDimensionSelect}
|
||||
replaceWithViewsOnDimensionSelect={true}
|
||||
toolbarHost={toolbarHost}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+149
-298
@@ -6,40 +6,29 @@ import {
|
||||
Background,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge as rfAddEdge,
|
||||
type Connection,
|
||||
type NodeMouseHandler,
|
||||
type Node as RFNode,
|
||||
type Edge as RFEdge,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
MiniMap,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import type { Edge as DbEdge, Node as DbNode } from '@/types/database';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import type { MapPaneProps } from './types';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { MiniMap } from '@xyflow/react';
|
||||
import { RahNode } from './map/RahNode';
|
||||
import { RahEdge } from './map/RahEdge';
|
||||
import EdgeExplanationModal from './map/EdgeExplanationModal';
|
||||
import { getPrimaryDimension, toRFNodes, toRFEdges, NODE_LIMIT, type MapViewMode, type RahNodeData } from './map/utils';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
import { toRFNodes, toRFEdges, NODE_LIMIT, type MapViewMode, type RahNodeData } from './map/utils';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import './map/map-styles.css';
|
||||
|
||||
interface DimensionInfo {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
const nodeTypes = { rahNode: RahNode };
|
||||
const edgeTypes = { rahEdge: RahEdge };
|
||||
|
||||
// Debounce helper
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
@@ -52,8 +41,6 @@ function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
||||
|
||||
function MapPaneInner({
|
||||
slot,
|
||||
isActive,
|
||||
onPaneAction,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
tabBar,
|
||||
@@ -61,111 +48,75 @@ function MapPaneInner({
|
||||
activeTabId,
|
||||
}: MapPaneProps) {
|
||||
const reactFlowInstance = useReactFlow();
|
||||
const { dimensionIcons } = useDimensionIcons();
|
||||
const [theme] = useTheme();
|
||||
|
||||
// --- Data state (DB-level) ---
|
||||
const [baseNodes, setBaseNodes] = useState<DbNode[]>([]);
|
||||
const [expandedNodes, setExpandedNodes] = useState<DbNode[]>([]);
|
||||
const [dbEdges, setDbEdges] = useState<DbEdge[]>([]);
|
||||
const [lockedDimensions, setLockedDimensions] = useState<DimensionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// --- UI state ---
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
|
||||
const [selectedDimension, setSelectedDimension] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<MapViewMode>('dimension');
|
||||
const [dimensionDropdownOpen, setDimensionDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [viewMode, setViewMode] = useState<MapViewMode>('context');
|
||||
|
||||
// --- React Flow state ---
|
||||
const [rfNodes, setRfNodes, onNodesChange] = useNodesState<RFNode<RahNodeData>>([]);
|
||||
const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState<RFEdge>([]);
|
||||
|
||||
// --- Edge creation modal ---
|
||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null);
|
||||
|
||||
// Track current RF positions so we can preserve them across data refreshes
|
||||
const rfPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
|
||||
const hasInitialFitRef = useRef(false);
|
||||
|
||||
// Combine base + expanded
|
||||
const allDbNodes = useMemo(() => {
|
||||
const baseIds = new Set(baseNodes.map(n => n.id));
|
||||
return [...baseNodes, ...expandedNodes.filter(n => !baseIds.has(n.id))];
|
||||
const baseIds = new Set(baseNodes.map((node) => node.id));
|
||||
return [...baseNodes, ...expandedNodes.filter((node) => !baseIds.has(node.id))];
|
||||
}, [baseNodes, expandedNodes]);
|
||||
|
||||
// Selected DB node
|
||||
const selectedDbNode = useMemo(
|
||||
() => allDbNodes.find(n => n.id === selectedNodeId) ?? null,
|
||||
() => allDbNodes.find((node) => node.id === selectedNodeId) ?? null,
|
||||
[allDbNodes, selectedNodeId],
|
||||
);
|
||||
|
||||
// Connected node IDs for info panel
|
||||
const connectedNodeIds = useMemo(() => {
|
||||
if (!selectedNodeId) return new Set<number>();
|
||||
const connected = new Set<number>();
|
||||
dbEdges.forEach(e => {
|
||||
if (e.from_node_id === selectedNodeId) connected.add(e.to_node_id);
|
||||
if (e.to_node_id === selectedNodeId) connected.add(e.from_node_id);
|
||||
dbEdges.forEach((edge) => {
|
||||
if (edge.from_node_id === selectedNodeId) connected.add(edge.to_node_id);
|
||||
if (edge.to_node_id === selectedNodeId) connected.add(edge.from_node_id);
|
||||
});
|
||||
return connected;
|
||||
}, [selectedNodeId, dbEdges]);
|
||||
|
||||
const lockedDimensionNames = useMemo(
|
||||
() => new Set(lockedDimensions.map(d => d.dimension)),
|
||||
[lockedDimensions],
|
||||
);
|
||||
|
||||
const clusterLabels = useMemo(() => {
|
||||
if (viewMode !== 'dimension') {
|
||||
return [];
|
||||
}
|
||||
if (viewMode !== 'context') return [];
|
||||
|
||||
const grouped = new Map<string, { x: number; y: number; count: number }>();
|
||||
for (const node of rfNodes) {
|
||||
const dimension = getPrimaryDimension((node.data as RahNodeData).dimensions);
|
||||
const current = grouped.get(dimension) || { x: 0, y: 0, count: 0 };
|
||||
const clusterLabel = (node.data as RahNodeData).clusterLabel;
|
||||
const current = grouped.get(clusterLabel) || { x: 0, y: 0, count: 0 };
|
||||
current.x += node.position.x;
|
||||
current.y += node.position.y;
|
||||
current.count += 1;
|
||||
grouped.set(dimension, current);
|
||||
grouped.set(clusterLabel, current);
|
||||
}
|
||||
return [...grouped.entries()].map(([dimension, totals]) => ({
|
||||
dimension,
|
||||
|
||||
return [...grouped.entries()].map(([clusterLabel, totals]) => ({
|
||||
clusterLabel,
|
||||
x: totals.x / totals.count,
|
||||
y: totals.y / totals.count - 84,
|
||||
}));
|
||||
}, [rfNodes, viewMode]);
|
||||
|
||||
// ----- Close dropdown on outside click -----
|
||||
useEffect(() => {
|
||||
if (!dimensionDropdownOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as HTMLElement)) {
|
||||
setDimensionDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [dimensionDropdownOpen]);
|
||||
|
||||
// ----- Fetch base data -----
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nodesUrl = selectedDimension
|
||||
? `/api/nodes?limit=${NODE_LIMIT}&sortBy=edges&dimensions=${encodeURIComponent(selectedDimension)}`
|
||||
: `/api/nodes?limit=${NODE_LIMIT}&sortBy=edges`;
|
||||
|
||||
const [nodesRes, edgesRes, dimsRes] = await Promise.all([
|
||||
fetch(nodesUrl),
|
||||
const [nodesRes, edgesRes] = await Promise.all([
|
||||
fetch(`/api/nodes?limit=${NODE_LIMIT}&sortBy=edges`),
|
||||
fetch('/api/edges'),
|
||||
fetch('/api/dimensions/popular'),
|
||||
]);
|
||||
|
||||
if (!nodesRes.ok || !edgesRes.ok) throw new Error('Failed to load data');
|
||||
if (!nodesRes.ok || !edgesRes.ok) throw new Error('Failed to load map data');
|
||||
|
||||
const nodesPayload = await nodesRes.json();
|
||||
const edgesPayload = await edgesRes.json();
|
||||
@@ -175,23 +126,16 @@ function MapPaneInner({
|
||||
setExpandedNodes([]);
|
||||
setSelectedNodeId(null);
|
||||
rfPositionsRef.current.clear();
|
||||
|
||||
if (dimsRes.ok) {
|
||||
const dimsPayload = await dimsRes.json();
|
||||
if (dimsPayload.success && dimsPayload.data) {
|
||||
setLockedDimensions(dimsPayload.data as DimensionInfo[]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [selectedDimension]);
|
||||
|
||||
// ----- Sync DB data → React Flow nodes/edges -----
|
||||
void fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (allDbNodes.length === 0) {
|
||||
setRfNodes([]);
|
||||
@@ -199,9 +143,8 @@ function MapPaneInner({
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture current RF positions before rebuild
|
||||
rfNodes.forEach(n => {
|
||||
rfPositionsRef.current.set(n.id, n.position);
|
||||
rfNodes.forEach((node) => {
|
||||
rfPositionsRef.current.set(node.id, node.position);
|
||||
});
|
||||
|
||||
const centerX = 600;
|
||||
@@ -215,29 +158,30 @@ function MapPaneInner({
|
||||
selectedNodeId,
|
||||
connectedNodeIds,
|
||||
rfPositionsRef.current,
|
||||
dimensionIcons,
|
||||
viewMode,
|
||||
dbEdges,
|
||||
);
|
||||
|
||||
const nodeIdSet = new Set(newRfNodes.map(n => n.id));
|
||||
const nodeIdSet = new Set(newRfNodes.map((node) => node.id));
|
||||
const newRfEdges = toRFEdges(dbEdges, nodeIdSet, selectedNodeId);
|
||||
|
||||
setRfNodes(newRfNodes);
|
||||
setRfEdges(newRfEdges);
|
||||
}, [allDbNodes, baseNodes, expandedNodes, dbEdges, selectedNodeId, connectedNodeIds, dimensionIcons, viewMode]);
|
||||
}, [allDbNodes, baseNodes, expandedNodes, dbEdges, selectedNodeId, connectedNodeIds, viewMode, rfNodes, setRfEdges, setRfNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInitialFitRef.current || rfNodes.length === 0 || loading) return;
|
||||
hasInitialFitRef.current = true;
|
||||
const hubNodeIds = baseNodes
|
||||
|
||||
const hubNodeIds = [...baseNodes]
|
||||
.sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0))
|
||||
.slice(0, 25)
|
||||
.map(n => String(n.id));
|
||||
.map((node) => String(node.id));
|
||||
|
||||
setTimeout(() => {
|
||||
if (hubNodeIds.length > 0) {
|
||||
reactFlowInstance.fitView({
|
||||
nodes: hubNodeIds.map(id => ({ id })),
|
||||
nodes: hubNodeIds.map((id) => ({ id })),
|
||||
padding: 0.3,
|
||||
duration: 300,
|
||||
});
|
||||
@@ -247,27 +191,26 @@ function MapPaneInner({
|
||||
|
||||
useEffect(() => {
|
||||
hasInitialFitRef.current = false;
|
||||
}, [selectedDimension, viewMode]);
|
||||
}, [viewMode]);
|
||||
|
||||
const fitAllNodes = useCallback(() => {
|
||||
reactFlowInstance.fitView({ padding: 0.2, duration: 300 });
|
||||
}, [reactFlowInstance]);
|
||||
|
||||
const fitHubNodes = useCallback(() => {
|
||||
const hubNodeIds = baseNodes
|
||||
const hubNodeIds = [...baseNodes]
|
||||
.sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0))
|
||||
.slice(0, 25)
|
||||
.map(n => String(n.id));
|
||||
.map((node) => String(node.id));
|
||||
if (hubNodeIds.length > 0) {
|
||||
reactFlowInstance.fitView({
|
||||
nodes: hubNodeIds.map(id => ({ id })),
|
||||
nodes: hubNodeIds.map((id) => ({ id })),
|
||||
padding: 0.3,
|
||||
duration: 300,
|
||||
});
|
||||
}
|
||||
}, [baseNodes, reactFlowInstance]);
|
||||
|
||||
// ----- Node traversal: fetch connected nodes -----
|
||||
const fetchConnectedNodes = useCallback(async (nodeId: number) => {
|
||||
try {
|
||||
const edgesRes = await fetch(`/api/nodes/${nodeId}/edges`);
|
||||
@@ -278,48 +221,49 @@ function MapPaneInner({
|
||||
nodeEdges = edgesData.data || [];
|
||||
|
||||
if (nodeEdges.length > 0) {
|
||||
setDbEdges(prev => {
|
||||
const existing = new Set(prev.map(e => e.id));
|
||||
const fresh = nodeEdges.filter(e => !existing.has(e.id));
|
||||
setDbEdges((prev) => {
|
||||
const existing = new Set(prev.map((edge) => edge.id));
|
||||
const fresh = nodeEdges.filter((edge) => !existing.has(edge.id));
|
||||
return fresh.length > 0 ? [...prev, ...fresh] : prev;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find missing connected node IDs
|
||||
const connectedIds = new Set<number>();
|
||||
dbEdges.forEach(e => {
|
||||
if (e.from_node_id === nodeId) connectedIds.add(e.to_node_id);
|
||||
if (e.to_node_id === nodeId) connectedIds.add(e.from_node_id);
|
||||
dbEdges.forEach((edge) => {
|
||||
if (edge.from_node_id === nodeId) connectedIds.add(edge.to_node_id);
|
||||
if (edge.to_node_id === nodeId) connectedIds.add(edge.from_node_id);
|
||||
});
|
||||
nodeEdges.forEach(e => {
|
||||
if (e.from_node_id === nodeId) connectedIds.add(e.to_node_id);
|
||||
if (e.to_node_id === nodeId) connectedIds.add(e.from_node_id);
|
||||
nodeEdges.forEach((edge) => {
|
||||
if (edge.from_node_id === nodeId) connectedIds.add(edge.to_node_id);
|
||||
if (edge.to_node_id === nodeId) connectedIds.add(edge.from_node_id);
|
||||
});
|
||||
|
||||
const existingIds = new Set(allDbNodes.map(n => n.id));
|
||||
const missingIds = Array.from(connectedIds).filter(id => !existingIds.has(id));
|
||||
const existingIds = new Set(allDbNodes.map((node) => node.id));
|
||||
const missingIds = Array.from(connectedIds).filter((id) => !existingIds.has(id));
|
||||
if (missingIds.length === 0) return;
|
||||
|
||||
const fetched = (
|
||||
await Promise.all(
|
||||
missingIds.slice(0, 50).map(async id => {
|
||||
missingIds.slice(0, 50).map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/nodes/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return data.node as DbNode;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
)
|
||||
).filter((n): n is DbNode => n !== null);
|
||||
).filter((node): node is DbNode => node !== null);
|
||||
|
||||
if (fetched.length > 0) {
|
||||
setExpandedNodes(prev => {
|
||||
const ids = new Set(prev.map(n => n.id));
|
||||
const fresh = fetched.filter(n => !ids.has(n.id));
|
||||
setExpandedNodes((prev) => {
|
||||
const ids = new Set(prev.map((node) => node.id));
|
||||
const fresh = fetched.filter((node) => !ids.has(node.id));
|
||||
return fresh.length > 0 ? [...prev, ...fresh] : prev;
|
||||
});
|
||||
}
|
||||
@@ -329,44 +273,41 @@ function MapPaneInner({
|
||||
}, [dbEdges, allDbNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNodeId) fetchConnectedNodes(selectedNodeId);
|
||||
if (selectedNodeId) {
|
||||
void fetchConnectedNodes(selectedNodeId);
|
||||
}
|
||||
}, [selectedNodeId, fetchConnectedNodes]);
|
||||
|
||||
// ----- Focused node awareness -----
|
||||
useEffect(() => {
|
||||
if (!activeTabId) return;
|
||||
const existing = allDbNodes.find(n => n.id === activeTabId);
|
||||
const existing = allDbNodes.find((node) => node.id === activeTabId);
|
||||
if (existing) {
|
||||
setSelectedNodeId(activeTabId);
|
||||
// Pan + zoom closer to the focused node
|
||||
const rfNode = rfNodes.find(n => n.id === String(activeTabId));
|
||||
const rfNode = rfNodes.find((node) => node.id === String(activeTabId));
|
||||
if (rfNode) {
|
||||
reactFlowInstance.setCenter(rfNode.position.x, rfNode.position.y, { duration: 400, zoom: 1.5 });
|
||||
}
|
||||
} else {
|
||||
(async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/nodes/${activeTabId}`);
|
||||
if (res.ok) {
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const node = data.node as DbNode;
|
||||
if (node) {
|
||||
setExpandedNodes(prev => prev.some(n => n.id === node.id) ? prev : [...prev, node]);
|
||||
if (!node) return;
|
||||
setExpandedNodes((prev) => (prev.some((existingNode) => existingNode.id === node.id) ? prev : [...prev, node]));
|
||||
setSelectedNodeId(node.id);
|
||||
// After the next render cycle, zoom to the newly added node
|
||||
setTimeout(() => {
|
||||
reactFlowInstance.setCenter(600, 400, { duration: 400, zoom: 1.5 });
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch focused node:', err);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [activeTabId]);
|
||||
}, [activeTabId, allDbNodes, rfNodes, reactFlowInstance]);
|
||||
|
||||
// ----- SSE real-time sync -----
|
||||
useEffect(() => {
|
||||
let eventSource: EventSource | null = null;
|
||||
|
||||
@@ -382,7 +323,7 @@ function MapPaneInner({
|
||||
const node = payload.data?.node as DbNode | undefined;
|
||||
if (node?.id) {
|
||||
const updater = (prev: DbNode[]) =>
|
||||
prev.map(n => n.id === node.id ? { ...n, ...node } : n);
|
||||
prev.map((existingNode) => (existingNode.id === node.id ? { ...existingNode, ...node } : existingNode));
|
||||
setBaseNodes(updater);
|
||||
setExpandedNodes(updater);
|
||||
}
|
||||
@@ -391,43 +332,36 @@ function MapPaneInner({
|
||||
case 'NODE_DELETED': {
|
||||
const deletedId = payload.data?.nodeId;
|
||||
if (deletedId) {
|
||||
setBaseNodes(prev => prev.filter(n => n.id !== deletedId));
|
||||
setExpandedNodes(prev => prev.filter(n => n.id !== deletedId));
|
||||
setDbEdges(prev => prev.filter(e => e.from_node_id !== deletedId && e.to_node_id !== deletedId));
|
||||
setSelectedNodeId(prev => prev === deletedId ? null : prev);
|
||||
setBaseNodes((prev) => prev.filter((node) => node.id !== deletedId));
|
||||
setExpandedNodes((prev) => prev.filter((node) => node.id !== deletedId));
|
||||
setDbEdges((prev) => prev.filter((edge) => edge.from_node_id !== deletedId && edge.to_node_id !== deletedId));
|
||||
setSelectedNodeId((prev) => (prev === deletedId ? null : prev));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'NODE_CREATED': {
|
||||
// If filtering by dimension and new node matches, could add it
|
||||
// For now, just note it happened — user can refresh
|
||||
break;
|
||||
}
|
||||
case 'EDGE_CREATED': {
|
||||
const edge = payload.data?.edge as DbEdge | undefined;
|
||||
if (edge?.id) {
|
||||
setDbEdges(prev => {
|
||||
if (prev.some(e => e.id === edge.id)) return prev;
|
||||
return [...prev, edge];
|
||||
});
|
||||
setDbEdges((prev) => (prev.some((existingEdge) => existingEdge.id === edge.id) ? prev : [...prev, edge]));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'EDGE_DELETED': {
|
||||
const edgeId = payload.data?.edgeId;
|
||||
if (edgeId) {
|
||||
setDbEdges(prev => prev.filter(e => e.id !== edgeId));
|
||||
setDbEdges((prev) => prev.filter((edge) => edge.id !== edgeId));
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors (keep-alive pings, etc.)
|
||||
// Ignore keep-alives and malformed payloads.
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
// EventSource auto-reconnects, just log
|
||||
console.error('Map SSE connection error');
|
||||
};
|
||||
} catch {
|
||||
@@ -439,7 +373,6 @@ function MapPaneInner({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ----- Node drag → save position to metadata (debounced) -----
|
||||
const savePositionRef = useRef(
|
||||
debounce(async (nodeId: number, x: number, y: number, mode: MapViewMode) => {
|
||||
try {
|
||||
@@ -448,7 +381,13 @@ function MapPaneInner({
|
||||
const { node: existing } = await res.json();
|
||||
const existingMetadata = existing?.metadata ?? {};
|
||||
const mergedMeta = typeof existingMetadata === 'string'
|
||||
? (() => { try { return JSON.parse(existingMetadata); } catch { return {}; } })()
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(existingMetadata);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})()
|
||||
: existingMetadata;
|
||||
|
||||
await fetch(`/api/nodes/${nodeId}`, {
|
||||
@@ -472,8 +411,8 @@ function MapPaneInner({
|
||||
);
|
||||
|
||||
const onNodeDragStop: NodeMouseHandler<RFNode<RahNodeData>> = useCallback((_event, node) => {
|
||||
const nodeId = parseInt(node.id);
|
||||
if (!isNaN(nodeId)) {
|
||||
const nodeId = parseInt(node.id, 10);
|
||||
if (!Number.isNaN(nodeId)) {
|
||||
rfPositionsRef.current.set(node.id, node.position);
|
||||
savePositionRef.current(nodeId, node.position.x, node.position.y, viewMode);
|
||||
}
|
||||
@@ -485,36 +424,38 @@ function MapPaneInner({
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || rfNodes.length === 0) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
reactFlowInstance.fitView({ padding: 0.22, duration: 300 });
|
||||
}, 80);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [viewMode, selectedDimension, loading, rfNodes, reactFlowInstance]);
|
||||
|
||||
// ----- Node click → select + traverse -----
|
||||
return () => clearTimeout(timeout);
|
||||
}, [viewMode, loading, rfNodes, reactFlowInstance]);
|
||||
|
||||
const onNodeClickHandler: NodeMouseHandler<RFNode<RahNodeData>> = useCallback((_event, node) => {
|
||||
const nodeId = parseInt(node.id);
|
||||
if (isNaN(nodeId)) return;
|
||||
setSelectedNodeId(prev => prev === nodeId ? null : nodeId);
|
||||
const nodeId = parseInt(node.id, 10);
|
||||
if (!Number.isNaN(nodeId)) {
|
||||
setSelectedNodeId((prev) => (prev === nodeId ? null : nodeId));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ----- Node double-click → open in other pane -----
|
||||
const onNodeDoubleClick: NodeMouseHandler<RFNode<RahNodeData>> = useCallback((_event, node) => {
|
||||
const nodeId = parseInt(node.id);
|
||||
if (!isNaN(nodeId)) onNodeClick?.(nodeId);
|
||||
const nodeId = parseInt(node.id, 10);
|
||||
if (!Number.isNaN(nodeId)) {
|
||||
onNodeClick?.(nodeId);
|
||||
}
|
||||
}, [onNodeClick]);
|
||||
|
||||
// ----- Edge creation via drag -----
|
||||
const onConnect = useCallback((connection: Connection) => {
|
||||
if (connection.source === connection.target) return; // No self-connections
|
||||
if (connection.source === connection.target) return;
|
||||
setPendingConnection(connection);
|
||||
}, []);
|
||||
|
||||
const handleEdgeCreate = useCallback(async (explanation: string) => {
|
||||
if (!pendingConnection?.source || !pendingConnection?.target) return;
|
||||
|
||||
const fromId = parseInt(pendingConnection.source);
|
||||
const toId = parseInt(pendingConnection.target);
|
||||
const fromId = parseInt(pendingConnection.source, 10);
|
||||
const toId = parseInt(pendingConnection.target, 10);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/edges', {
|
||||
@@ -533,11 +474,7 @@ function MapPaneInner({
|
||||
const payload = await res.json();
|
||||
const edge = payload.data;
|
||||
if (edge?.id) {
|
||||
// Add to DB edges (SSE may also add it, dedup in handler)
|
||||
setDbEdges(prev => {
|
||||
if (prev.some(e => e.id === edge.id)) return prev;
|
||||
return [...prev, edge];
|
||||
});
|
||||
setDbEdges((prev) => (prev.some((existingEdge) => existingEdge.id === edge.id) ? prev : [...prev, edge]));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -551,12 +488,11 @@ function MapPaneInner({
|
||||
setPendingConnection(null);
|
||||
}, []);
|
||||
|
||||
// Get source/target titles for modal
|
||||
const pendingSourceTitle = pendingConnection?.source
|
||||
? allDbNodes.find(n => n.id === parseInt(pendingConnection.source!))?.title || 'Unknown'
|
||||
? allDbNodes.find((node) => node.id === parseInt(pendingConnection.source, 10))?.title || 'Unknown'
|
||||
: '';
|
||||
const pendingTargetTitle = pendingConnection?.target
|
||||
? allDbNodes.find(n => n.id === parseInt(pendingConnection.target!))?.title || 'Unknown'
|
||||
? allDbNodes.find((node) => node.id === parseInt(pendingConnection.target, 10))?.title || 'Unknown'
|
||||
: '';
|
||||
|
||||
return (
|
||||
@@ -564,29 +500,29 @@ function MapPaneInner({
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => setViewMode('dimension')}
|
||||
onClick={() => setViewMode('context')}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: viewMode === 'dimension' ? 'rgba(34, 197, 94, 0.12)' : 'transparent',
|
||||
background: viewMode === 'context' ? 'var(--rah-accent-green-soft)' : 'transparent',
|
||||
border: '1px solid',
|
||||
borderColor: viewMode === 'dimension' ? 'rgba(34, 197, 94, 0.35)' : 'var(--rah-border-strong)',
|
||||
borderColor: viewMode === 'context' ? 'var(--rah-accent-green-soft-strong)' : 'var(--rah-border-strong)',
|
||||
borderRadius: '6px',
|
||||
color: viewMode === 'dimension' ? '#a7f3b8' : 'var(--rah-text-muted)',
|
||||
color: viewMode === 'context' ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Dimension View
|
||||
Context View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('hub')}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: viewMode === 'hub' ? 'rgba(34, 197, 94, 0.12)' : 'transparent',
|
||||
background: viewMode === 'hub' ? 'var(--rah-accent-green-soft)' : 'transparent',
|
||||
border: '1px solid',
|
||||
borderColor: viewMode === 'hub' ? 'rgba(34, 197, 94, 0.35)' : 'var(--rah-border-strong)',
|
||||
borderColor: viewMode === 'hub' ? 'var(--rah-accent-green-soft-strong)' : 'var(--rah-border-strong)',
|
||||
borderRadius: '6px',
|
||||
color: viewMode === 'hub' ? '#a7f3b8' : 'var(--rah-text-muted)',
|
||||
color: viewMode === 'hub' ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
@@ -594,97 +530,8 @@ function MapPaneInner({
|
||||
Hub View
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dimension filter dropdown */}
|
||||
<div ref={dropdownRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setDimensionDropdownOpen(!dimensionDropdownOpen)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '6px 10px',
|
||||
background: selectedDimension ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
|
||||
border: '1px solid',
|
||||
borderColor: selectedDimension ? 'rgba(34, 197, 94, 0.3)' : 'var(--rah-border-strong)',
|
||||
borderRadius: '6px',
|
||||
color: selectedDimension ? '#22c55e' : 'var(--rah-text-muted)',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<span>{selectedDimension || 'All dimensions'}</span>
|
||||
<ChevronDown size={12} style={{
|
||||
transform: dimensionDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.15s ease',
|
||||
}} />
|
||||
</button>
|
||||
|
||||
{dimensionDropdownOpen && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
marginTop: '4px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
borderRadius: '8px',
|
||||
padding: '4px',
|
||||
minWidth: '180px',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => { setSelectedDimension(null); setDimensionDropdownOpen(false); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', width: '100%', padding: '8px 12px',
|
||||
background: !selectedDimension ? 'var(--rah-bg-active)' : 'transparent',
|
||||
border: 'none', borderRadius: '4px',
|
||||
color: !selectedDimension ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
|
||||
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
All dimensions
|
||||
{!selectedDimension && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>✓</span>}
|
||||
</button>
|
||||
|
||||
{lockedDimensions.map(dim => (
|
||||
<button
|
||||
key={dim.dimension}
|
||||
onClick={() => { setSelectedDimension(dim.dimension); setDimensionDropdownOpen(false); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', width: '100%', padding: '8px 12px',
|
||||
background: selectedDimension === dim.dimension ? 'var(--rah-bg-active)' : 'transparent',
|
||||
border: 'none', borderRadius: '4px',
|
||||
color: selectedDimension === dim.dimension ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
|
||||
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
if (selectedDimension !== dim.dimension) {
|
||||
e.currentTarget.style.background = 'var(--rah-bg-active)';
|
||||
e.currentTarget.style.color = 'var(--rah-text-secondary)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
if (selectedDimension !== dim.dimension) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--rah-text-muted)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{dim.dimension}
|
||||
{selectedDimension === dim.dimension && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PaneHeader>
|
||||
|
||||
{/* Map content */}
|
||||
<div style={{ position: 'relative', flex: 1, background: 'var(--rah-bg-base)' }}>
|
||||
{loading ? (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--rah-text-muted)' }}>
|
||||
@@ -715,24 +562,24 @@ function MapPaneInner({
|
||||
maxZoom={3}
|
||||
defaultEdgeOptions={{ type: 'rahEdge' }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
colorMode="dark"
|
||||
colorMode={theme}
|
||||
>
|
||||
<Background color="var(--rah-border)" gap={40} size={1} />
|
||||
<MiniMap
|
||||
style={{ background: 'var(--rah-bg-base)', border: '1px solid var(--rah-bg-active)', borderRadius: 6 }}
|
||||
maskColor="rgba(0, 0, 0, 0.7)"
|
||||
style={{ background: 'var(--rah-bg-panel)', border: '1px solid var(--rah-border)', borderRadius: 6 }}
|
||||
maskColor={theme === 'light' ? 'rgba(255, 255, 255, 0.72)' : 'rgba(0, 0, 0, 0.7)'}
|
||||
nodeColor={(node) => {
|
||||
const data = node.data as RahNodeData | undefined;
|
||||
return data?.primaryDimensionColor || '#2a2a2a';
|
||||
return data?.clusterColor || '#2a2a2a';
|
||||
}}
|
||||
pannable
|
||||
zoomable
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{viewMode === 'dimension' && clusterLabels.map((label) => (
|
||||
{viewMode === 'context' && clusterLabels.map((label) => (
|
||||
<div
|
||||
key={label.dimension}
|
||||
key={label.clusterLabel}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(${label.x}px, ${label.y}px)`,
|
||||
@@ -741,27 +588,29 @@ function MapPaneInner({
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
textShadow: '0 1px 6px rgba(0,0,0,0.45)',
|
||||
textShadow: theme === 'light' ? '0 1px 3px rgba(255,255,255,0.95)' : '0 1px 6px rgba(0,0,0,0.45)',
|
||||
}}
|
||||
>
|
||||
{label.dimension}
|
||||
{label.clusterLabel}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
zIndex: 10,
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={fitAllNodes}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: 10,
|
||||
background: 'var(--rah-bg-active)',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
borderRadius: 4,
|
||||
color: 'var(--rah-text-muted)',
|
||||
@@ -777,7 +626,7 @@ function MapPaneInner({
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: 10,
|
||||
background: 'var(--rah-bg-active)',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
borderRadius: 4,
|
||||
color: 'var(--rah-text-muted)',
|
||||
@@ -790,7 +639,6 @@ function MapPaneInner({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected node info panel */}
|
||||
{selectedDbNode && (
|
||||
<div style={infoPanel}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: 8 }}>
|
||||
@@ -807,31 +655,35 @@ function MapPaneInner({
|
||||
<div style={{ fontSize: 12, color: 'var(--rah-text-muted)', marginBottom: 8 }}>
|
||||
{connectedNodeIds.size} connected nodes
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#22c55e', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--rah-accent-green)', marginBottom: 8 }}>
|
||||
Click a connected node to traverse · Double-click to open
|
||||
</div>
|
||||
{selectedDbNode.dimensions && selectedDbNode.dimensions.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
|
||||
{selectedDbNode.dimensions.slice(0, 5).map(dim => (
|
||||
<span
|
||||
key={dim}
|
||||
style={{
|
||||
padding: '2px 8px', borderRadius: 999, fontSize: 11,
|
||||
background: lockedDimensionNames.has(dim) ? '#132018' : 'var(--rah-bg-active)',
|
||||
color: lockedDimensionNames.has(dim) ? '#86efac' : 'var(--rah-text-muted)',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
background: selectedDbNode.context?.name ? 'var(--rah-accent-green-soft)' : 'var(--rah-bg-active)',
|
||||
color: selectedDbNode.context?.name ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)',
|
||||
}}
|
||||
>
|
||||
{dim}
|
||||
{selectedDbNode.context?.name || 'Unscoped'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onNodeClick?.(selectedDbNode.id)}
|
||||
style={{
|
||||
marginTop: 4, padding: '8px 12px', background: '#22c55e', color: '#052e16',
|
||||
border: 'none', borderRadius: '6px', fontSize: 12, fontWeight: 500,
|
||||
cursor: 'pointer', width: '100%',
|
||||
marginTop: 4,
|
||||
padding: '8px 12px',
|
||||
background: 'var(--rah-accent-green)',
|
||||
color: 'var(--rah-text-inverse)',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
Open Node
|
||||
@@ -842,7 +694,6 @@ function MapPaneInner({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edge creation explanation modal */}
|
||||
{pendingConnection && (
|
||||
<EdgeExplanationModal
|
||||
sourceTitle={pendingSourceTitle}
|
||||
@@ -855,7 +706,6 @@ function MapPaneInner({
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap with ReactFlowProvider
|
||||
export default function MapPane(props: MapPaneProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
@@ -869,9 +719,10 @@ const infoPanel: CSSProperties = {
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
width: 260,
|
||||
background: 'var(--rah-bg-base)',
|
||||
border: '1px solid var(--rah-bg-active)',
|
||||
background: 'var(--rah-bg-modal)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
zIndex: 10,
|
||||
boxShadow: 'var(--rah-shadow-floating)',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { X, LayoutList, PanelsTopLeft, Map, FileText, Table2, BookOpen } from 'lucide-react';
|
||||
import type { SlotTab, PaneType, SlotId } from './types';
|
||||
|
||||
const TAB_TYPE_ICONS: Record<PaneType, typeof LayoutList> = {
|
||||
views: LayoutList,
|
||||
contexts: PanelsTopLeft,
|
||||
map: Map,
|
||||
node: FileText,
|
||||
table: Table2,
|
||||
skills: BookOpen,
|
||||
};
|
||||
|
||||
const TAB_TYPE_LABELS: Record<PaneType, string> = {
|
||||
views: 'Feed',
|
||||
contexts: 'Contexts',
|
||||
map: 'Map',
|
||||
node: 'Node',
|
||||
table: 'Table',
|
||||
skills: 'Skills',
|
||||
};
|
||||
|
||||
interface SlotTabBarProps {
|
||||
tabs: SlotTab[];
|
||||
activeTabId: string;
|
||||
slot: SlotId;
|
||||
onTabSelect: (tabId: string) => void;
|
||||
onTabClose: (tabId: string) => void;
|
||||
onReorderTabs?: (fromIndex: number, toIndex: number) => void;
|
||||
onCrossSlotDrop?: (tab: SlotTab, fromSlot: SlotId) => void;
|
||||
}
|
||||
|
||||
function truncateTitle(title: string, maxLength = 18): string {
|
||||
if (title.length <= maxLength) return title;
|
||||
return `${title.slice(0, maxLength - 1)}\u2026`;
|
||||
}
|
||||
|
||||
export default function SlotTabBar({
|
||||
tabs,
|
||||
activeTabId,
|
||||
slot,
|
||||
onTabSelect,
|
||||
onTabClose,
|
||||
onReorderTabs,
|
||||
onCrossSlotDrop,
|
||||
}: SlotTabBarProps) {
|
||||
const [nodeTitles, setNodeTitles] = useState<Record<string, string>>({});
|
||||
const fetchedRef = useRef<Set<string>>(new Set());
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nodeTabs = tabs.filter((tab) => tab.type === 'node' && tab.nodeId != null);
|
||||
for (const tab of nodeTabs) {
|
||||
if (fetchedRef.current.has(tab.id)) continue;
|
||||
fetchedRef.current.add(tab.id);
|
||||
|
||||
fetch(`/api/nodes/${tab.nodeId}`)
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((payload) => {
|
||||
if (payload?.success && payload.node) {
|
||||
setNodeTitles((prev) => ({ ...prev, [tab.id]: payload.node.title || 'Untitled' }));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
fetchedRef.current.delete(tab.id);
|
||||
});
|
||||
}
|
||||
}, [tabs]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentIds = new Set(tabs.map((tab) => tab.id));
|
||||
fetchedRef.current.forEach((id) => {
|
||||
if (!currentIds.has(id)) {
|
||||
fetchedRef.current.delete(id);
|
||||
}
|
||||
});
|
||||
}, [tabs]);
|
||||
|
||||
const handleDragStart = useCallback((event: React.DragEvent, index: number, tab: SlotTab) => {
|
||||
event.dataTransfer.setData(
|
||||
'application/x-rah-tab',
|
||||
JSON.stringify({
|
||||
sourceSlot: slot,
|
||||
sourceIndex: index,
|
||||
tab,
|
||||
})
|
||||
);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}, [slot]);
|
||||
|
||||
const handleDrop = useCallback((event: React.DragEvent, targetIndex: number) => {
|
||||
event.preventDefault();
|
||||
setDragOverIndex(null);
|
||||
|
||||
const raw = event.dataTransfer.getData('application/x-rah-tab');
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
sourceSlot: SlotId;
|
||||
sourceIndex: number;
|
||||
tab: SlotTab;
|
||||
};
|
||||
|
||||
if (parsed.sourceSlot === slot) {
|
||||
onReorderTabs?.(parsed.sourceIndex, targetIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
onCrossSlotDrop?.(parsed.tab, parsed.sourceSlot);
|
||||
} catch {
|
||||
// Ignore malformed drag payloads.
|
||||
}
|
||||
}, [onCrossSlotDrop, onReorderTabs, slot]);
|
||||
|
||||
const handleBarDrop = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
setDragOverIndex(null);
|
||||
|
||||
const raw = event.dataTransfer.getData('application/x-rah-tab');
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
sourceSlot: SlotId;
|
||||
tab: SlotTab;
|
||||
};
|
||||
|
||||
if (parsed.sourceSlot !== slot) {
|
||||
onCrossSlotDrop?.(parsed.tab, parsed.sourceSlot);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed drag payloads.
|
||||
}
|
||||
}, [onCrossSlotDrop, slot]);
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragOver={(event) => {
|
||||
if (event.dataTransfer.types.includes('application/x-rah-tab')) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}}
|
||||
onDrop={handleBarDrop}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0, overflow: 'hidden' }}
|
||||
>
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const Icon = TAB_TYPE_ICONS[tab.type];
|
||||
const label = tab.type === 'node'
|
||||
? truncateTitle(nodeTitles[tab.id] || 'Loading...')
|
||||
: TAB_TYPE_LABELS[tab.type];
|
||||
const isDragOver = dragOverIndex === index;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
draggable={tab.type === 'node'}
|
||||
onDragStart={(event) => handleDragStart(event, index, tab)}
|
||||
onDragOver={(event) => {
|
||||
if (!event.dataTransfer.types.includes('application/x-rah-tab')) return;
|
||||
event.preventDefault();
|
||||
setDragOverIndex(index);
|
||||
}}
|
||||
onDragLeave={() => setDragOverIndex(null)}
|
||||
onDrop={(event) => handleDrop(event, index)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
minWidth: 0,
|
||||
padding: '6px 10px',
|
||||
borderRadius: '10px',
|
||||
border: isDragOver ? '1px dashed var(--rah-accent-green)' : '1px solid var(--rah-border)',
|
||||
background: isActive ? 'var(--rah-bg-hover)' : 'var(--rah-bg-panel)',
|
||||
color: isActive ? 'var(--rah-text-primary)' : 'var(--rah-text-secondary)',
|
||||
cursor: tab.type === 'node' ? 'grab' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabSelect(tab.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
minWidth: 0,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span style={{ fontSize: '12px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{tabs.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabClose(tab.id)}
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,10 +13,8 @@ export interface ViewsPaneProps extends BasePaneProps {
|
||||
pendingNodes?: PendingNode[];
|
||||
onDismissPending?: (id: string) => void;
|
||||
externalContextFilterId?: number | null;
|
||||
externalDimensionFilter?: string | null;
|
||||
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
onClearExternalContextFilter?: () => void;
|
||||
onClearExternalDimensionFilter?: () => void;
|
||||
}
|
||||
|
||||
export default function ViewsPane({
|
||||
@@ -32,12 +30,11 @@ export default function ViewsPane({
|
||||
pendingNodes,
|
||||
onDismissPending,
|
||||
externalContextFilterId,
|
||||
externalDimensionFilter,
|
||||
onContextFilterSelect,
|
||||
onClearExternalContextFilter,
|
||||
onClearExternalDimensionFilter,
|
||||
}: ViewsPaneProps) {
|
||||
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const handleTypeChange = (type: PaneType) => {
|
||||
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
|
||||
};
|
||||
@@ -65,10 +62,8 @@ export default function ViewsPane({
|
||||
pendingNodes={pendingNodes}
|
||||
onDismissPending={onDismissPending}
|
||||
externalContextFilterId={externalContextFilterId}
|
||||
externalDimensionFilter={externalDimensionFilter}
|
||||
onContextFilterSelect={onContextFilterSelect}
|
||||
onClearExternalContextFilter={onClearExternalContextFilter}
|
||||
onClearExternalDimensionFilter={onClearExternalDimensionFilter}
|
||||
toolbarHost={toolbarHost}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export { default as NodePane } from './NodePane';
|
||||
export { default as ContextsPane } from './ContextsPane';
|
||||
export { default as DimensionsPane } from './DimensionsPane';
|
||||
export { default as MapPane } from './MapPane';
|
||||
export { default as ViewsPane } from './ViewsPane';
|
||||
export { default as TablePane } from './TablePane';
|
||||
export { default as SkillsPane } from './SkillsPane';
|
||||
export { default as PaneHeader } from './PaneHeader';
|
||||
export { default as SlotTabBar } from './SlotTabBar';
|
||||
export * from './types';
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
|
||||
import type { RahNodeData } from './utils';
|
||||
import { LABEL_THRESHOLD } from './utils';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
|
||||
type RahNodeType = Node<RahNodeData, 'rahNode'>;
|
||||
|
||||
function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
|
||||
const { label, dimensions, edgeCount, isExpanded, dbNode, dimensionIcons, primaryDimensionColor } = data;
|
||||
const { label, clusterLabel, edgeCount, isExpanded, dbNode, clusterColor } = data;
|
||||
const isTop = !isExpanded && edgeCount > 3;
|
||||
|
||||
return (
|
||||
@@ -20,7 +19,7 @@ function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
|
||||
isTop && 'rah-map-node--top',
|
||||
selected && 'rah-map-node--selected',
|
||||
].filter(Boolean).join(' ')}
|
||||
style={primaryDimensionColor ? { borderLeftColor: primaryDimensionColor, borderLeftWidth: 3 } : undefined}
|
||||
style={clusterColor ? { borderLeftColor: clusterColor, borderLeftWidth: 3 } : undefined}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
@@ -29,13 +28,13 @@ function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
|
||||
/>
|
||||
<div className="rah-map-node__title">
|
||||
<span className="rah-map-node__icon">
|
||||
{getNodeIcon(dbNode, dimensionIcons, 14)}
|
||||
{getNodeIcon(dbNode, 14)}
|
||||
</span>
|
||||
{label.length > 26 ? label.slice(0, 24) + '\u2026' : label}
|
||||
</div>
|
||||
{(isTop || isExpanded) && dimensions.length > 0 && (
|
||||
{(isTop || isExpanded) && clusterLabel && (
|
||||
<div className="rah-map-node__dims">
|
||||
{dimensions.slice(0, 3).map(d => d.length > 12 ? d.slice(0, 11) + '\u2026' : d).join(' \u00b7 ')}
|
||||
{clusterLabel.length > 24 ? `${clusterLabel.slice(0, 23)}\u2026` : clusterLabel}
|
||||
</div>
|
||||
)}
|
||||
<Handle
|
||||
|
||||
@@ -1,55 +1,44 @@
|
||||
import type { Node as DbNode, Edge as DbEdge } from '@/types/database';
|
||||
import type { Node as RFNode, Edge as RFEdge } from '@xyflow/react';
|
||||
|
||||
// Fixed palette for dimension border colors (muted, dark-theme-friendly)
|
||||
const DIMENSION_COLORS = [
|
||||
'#22c55e', // green
|
||||
'#3b82f6', // blue
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#8b5cf6', // violet
|
||||
'#ec4899', // pink
|
||||
'#06b6d4', // cyan
|
||||
'#f97316', // orange
|
||||
'#14b8a6', // teal
|
||||
'#a855f7', // purple
|
||||
const CLUSTER_COLORS = [
|
||||
'#22c55e',
|
||||
'#3b82f6',
|
||||
'#f59e0b',
|
||||
'#ef4444',
|
||||
'#8b5cf6',
|
||||
'#ec4899',
|
||||
'#06b6d4',
|
||||
'#f97316',
|
||||
'#14b8a6',
|
||||
'#a855f7',
|
||||
];
|
||||
|
||||
export type MapViewMode = 'dimension' | 'hub';
|
||||
export type MapViewMode = 'context' | 'hub';
|
||||
|
||||
function hashDimensionColor(dimension: string): string {
|
||||
function hashClusterColor(label: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < dimension.length; i++) {
|
||||
hash = ((hash << 5) - hash + dimension.charCodeAt(i)) | 0;
|
||||
for (let i = 0; i < label.length; i++) {
|
||||
hash = ((hash << 5) - hash + label.charCodeAt(i)) | 0;
|
||||
}
|
||||
return DIMENSION_COLORS[Math.abs(hash) % DIMENSION_COLORS.length];
|
||||
return CLUSTER_COLORS[Math.abs(hash) % CLUSTER_COLORS.length];
|
||||
}
|
||||
|
||||
export function getOrderedDimensions(dimensions: string[] | undefined): string[] {
|
||||
if (!dimensions || dimensions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [...dimensions].sort((a, b) => a.localeCompare(b));
|
||||
export function getNodeClusterLabel(node: DbNode): string {
|
||||
return node.context?.name?.trim() || 'Unscoped';
|
||||
}
|
||||
|
||||
export function getPrimaryDimension(dimensions: string[] | undefined): string {
|
||||
const ordered = getOrderedDimensions(dimensions);
|
||||
return ordered[0] || 'Unsorted';
|
||||
}
|
||||
|
||||
export function getDimensionColor(dimensions: string[] | undefined): string | undefined {
|
||||
const primary = getPrimaryDimension(dimensions);
|
||||
return primary === 'Unsorted' ? '#4b5563' : hashDimensionColor(primary);
|
||||
export function getClusterColor(label: string): string {
|
||||
return label === 'Unscoped' ? '#4b5563' : hashClusterColor(label);
|
||||
}
|
||||
|
||||
export interface RahNodeData {
|
||||
label: string;
|
||||
dimensions: string[];
|
||||
clusterLabel: string;
|
||||
edgeCount: number;
|
||||
isExpanded: boolean;
|
||||
dbNode: DbNode;
|
||||
dimensionIcons?: Record<string, string>;
|
||||
primaryDimensionColor?: string;
|
||||
clusterColor?: string;
|
||||
clusterKey?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -64,12 +53,15 @@ export function getSavedMapPosition(
|
||||
const metadata = typeof node.metadata === 'string'
|
||||
? safeParseJSON(node.metadata)
|
||||
: node.metadata;
|
||||
|
||||
const nested = metadata?.map_positions as Record<string, { x?: number; y?: number }> | undefined;
|
||||
const scoped = metadata?.[`map_position_${viewMode}`] as { x?: number; y?: number } | undefined;
|
||||
const saved = nested?.[viewMode] || scoped;
|
||||
|
||||
if (saved?.x !== undefined && saved?.y !== undefined) {
|
||||
return { x: saved.x, y: saved.y };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -78,11 +70,12 @@ function getAllNodes(baseNodes: DbNode[], expandedNodes: DbNode[]): DbNode[] {
|
||||
return [...baseNodes, ...expandedNodes.filter((node) => !baseIds.has(node.id))];
|
||||
}
|
||||
|
||||
function buildDimensionLayout(nodes: DbNode[], centerX: number, centerY: number): Map<string, { x: number; y: number }> {
|
||||
function buildContextLayout(nodes: DbNode[], centerX: number, centerY: number): Map<string, { x: number; y: number }> {
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const groups = new Map<string, DbNode[]>();
|
||||
|
||||
for (const node of nodes) {
|
||||
const key = getPrimaryDimension(node.dimensions);
|
||||
const key = getNodeClusterLabel(node);
|
||||
const existing = groups.get(key) || [];
|
||||
existing.push(node);
|
||||
groups.set(key, existing);
|
||||
@@ -97,7 +90,10 @@ function buildDimensionLayout(nodes: DbNode[], centerX: number, centerY: number)
|
||||
const originY = centerY - ((rows - 1) * clusterGapY) / 2;
|
||||
|
||||
clusterKeys.forEach((clusterKey, clusterIndex) => {
|
||||
const clusterNodes = (groups.get(clusterKey) || []).sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0));
|
||||
const clusterNodes = (groups.get(clusterKey) || []).sort((a, b) => {
|
||||
return (b.edge_count ?? 0) - (a.edge_count ?? 0);
|
||||
});
|
||||
|
||||
const clusterColumn = clusterIndex % columns;
|
||||
const clusterRow = Math.floor(clusterIndex / columns);
|
||||
const clusterCenterX = originX + clusterColumn * clusterGapX;
|
||||
@@ -147,6 +143,7 @@ function buildHubLayout(
|
||||
const orphanNodes: DbNode[] = [];
|
||||
for (const node of sortedNodes) {
|
||||
if (hubIds.has(node.id)) continue;
|
||||
|
||||
const neighbours = adjacency.get(node.id) || [];
|
||||
const connectedHub = hubs
|
||||
.filter((hub) => neighbours.includes(hub.id))
|
||||
@@ -193,9 +190,10 @@ function buildHubLayout(
|
||||
}
|
||||
|
||||
export function getClusterKey(node: DbNode, viewMode: MapViewMode, dbEdges: DbEdge[]): string {
|
||||
if (viewMode === 'dimension') {
|
||||
return getPrimaryDimension(node.dimensions);
|
||||
if (viewMode === 'context') {
|
||||
return getNodeClusterLabel(node);
|
||||
}
|
||||
|
||||
return `hub:${node.id}`;
|
||||
}
|
||||
|
||||
@@ -207,14 +205,13 @@ export function toRFNodes(
|
||||
selectedNodeId: number | null,
|
||||
connectedNodeIds: Set<number>,
|
||||
existingPositions: Map<string, { x: number; y: number }>,
|
||||
dimensionIcons: Record<string, string> | undefined,
|
||||
viewMode: MapViewMode,
|
||||
dbEdges: DbEdge[],
|
||||
): RFNode<RahNodeData>[] {
|
||||
const allNodes = getAllNodes(baseNodes, expandedNodes);
|
||||
const hasSelection = selectedNodeId !== null;
|
||||
const clusterLayout = viewMode === 'dimension'
|
||||
? buildDimensionLayout(allNodes, centerX, centerY)
|
||||
const clusterLayout = viewMode === 'context'
|
||||
? buildContextLayout(allNodes, centerX, centerY)
|
||||
: buildHubLayout(allNodes, dbEdges, centerX, centerY);
|
||||
const baseNodeIds = new Set(baseNodes.map((node) => node.id));
|
||||
|
||||
@@ -225,6 +222,7 @@ export function toRFNodes(
|
||||
const fallbackPos = clusterLayout.get(id) || { x: centerX, y: centerY };
|
||||
const pos = existingPos || savedPos || fallbackPos;
|
||||
const isDimmed = hasSelection && node.id !== selectedNodeId && !connectedNodeIds.has(node.id);
|
||||
const clusterLabel = getNodeClusterLabel(node);
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -233,23 +231,17 @@ export function toRFNodes(
|
||||
className: isDimmed ? 'dimmed' : undefined,
|
||||
data: {
|
||||
label: node.title || 'Untitled',
|
||||
dimensions: getOrderedDimensions(node.dimensions),
|
||||
clusterLabel,
|
||||
edgeCount: node.edge_count ?? 0,
|
||||
isExpanded: !baseNodeIds.has(node.id),
|
||||
dbNode: node,
|
||||
dimensionIcons,
|
||||
primaryDimensionColor: getDimensionColor(node.dimensions),
|
||||
clusterKey: viewMode === 'dimension' ? getPrimaryDimension(node.dimensions) : undefined,
|
||||
clusterColor: getClusterColor(clusterLabel),
|
||||
clusterKey: viewMode === 'context' ? clusterLabel : undefined,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform DB edges into React Flow edges, filtering to only those
|
||||
* connecting nodes currently in the graph.
|
||||
* When a node is selected, connected edges are highlighted and others dimmed.
|
||||
*/
|
||||
export function toRFEdges(
|
||||
dbEdges: DbEdge[],
|
||||
nodeIds: Set<string>,
|
||||
@@ -258,19 +250,18 @@ export function toRFEdges(
|
||||
const hasSelection = selectedNodeId !== null;
|
||||
|
||||
return dbEdges
|
||||
.filter(e => nodeIds.has(String(e.from_node_id)) && nodeIds.has(String(e.to_node_id)))
|
||||
.map(e => {
|
||||
.filter((edge) => nodeIds.has(String(edge.from_node_id)) && nodeIds.has(String(edge.to_node_id)))
|
||||
.map((edge) => {
|
||||
const isConnected = hasSelection && (
|
||||
e.from_node_id === selectedNodeId || e.to_node_id === selectedNodeId
|
||||
edge.from_node_id === selectedNodeId || edge.to_node_id === selectedNodeId
|
||||
);
|
||||
const isDimmed = hasSelection && !isConnected;
|
||||
|
||||
const explanation = typeof e.context?.explanation === 'string' ? e.context.explanation : '';
|
||||
const explanation = typeof edge.context?.explanation === 'string' ? edge.context.explanation : '';
|
||||
|
||||
return {
|
||||
id: String(e.id),
|
||||
source: String(e.from_node_id),
|
||||
target: String(e.to_node_id),
|
||||
id: String(edge.id),
|
||||
source: String(edge.from_node_id),
|
||||
target: String(edge.to_node_id),
|
||||
type: 'rahEdge',
|
||||
animated: isConnected,
|
||||
data: { explanation },
|
||||
@@ -284,7 +275,7 @@ export function toRFEdges(
|
||||
});
|
||||
}
|
||||
|
||||
function safeParseJSON(str: string | null | undefined): Record<string, unknown> | null {
|
||||
function safeParseJSON(str: string | null | undefined): Record<string, any> | null {
|
||||
if (!str || str === 'null') return null;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
|
||||
@@ -1,41 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Node } from '@/types/database';
|
||||
import type { FocusedSkill } from '@/types/skills';
|
||||
|
||||
export type SlotId = 'A' | 'B' | 'C';
|
||||
export type NavigablePaneType = Exclude<PaneType, 'node'>;
|
||||
|
||||
// Stub type for delegation (delegation system removed in rah-light)
|
||||
export type AgentDelegation = {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
task: string;
|
||||
context: string[];
|
||||
status: 'queued' | 'in_progress' | 'completed' | 'failed';
|
||||
summary?: string | null;
|
||||
agentType: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
// The pane types
|
||||
export type PaneType = 'node' | 'contexts' | 'map' | 'views' | 'table' | 'skills';
|
||||
|
||||
// Pane types (chat removed in rah-light)
|
||||
export type PaneType = 'node' | 'contexts' | 'dimensions' | 'map' | 'views' | 'table' | 'skills';
|
||||
|
||||
// State for each slot
|
||||
export interface SlotState {
|
||||
// A single tab within a slot
|
||||
export interface SlotTab {
|
||||
id: string; // 'views', 'map', or 'node-{nodeId}'
|
||||
type: PaneType;
|
||||
// NodePane state
|
||||
nodeTabs?: number[];
|
||||
activeNodeTab?: number | null;
|
||||
// DimensionsPane state
|
||||
selectedDimension?: string | null;
|
||||
viewMode?: 'grid' | 'list' | 'kanban';
|
||||
nodeId?: number; // Only for type === 'node'
|
||||
}
|
||||
|
||||
// State for each slot — persistent tabs
|
||||
export interface SlotState {
|
||||
tabs: SlotTab[];
|
||||
activeTabId: string;
|
||||
}
|
||||
|
||||
// Helper to create a consistent tab ID
|
||||
export function createTabId(type: PaneType, nodeId?: number): string {
|
||||
return type === 'node' && nodeId != null ? `node-${nodeId}` : type;
|
||||
}
|
||||
|
||||
// Helper to get the active tab from a slot state
|
||||
export function getActiveTab(state: SlotState): SlotTab | undefined {
|
||||
return state.tabs.find(t => t.id === state.activeTabId);
|
||||
}
|
||||
|
||||
// Actions panes can emit to the layout
|
||||
export type PaneAction =
|
||||
| { type: 'open-node'; nodeId: number; targetSlot?: SlotId }
|
||||
| { type: 'open-context'; contextId: number | null; contextName?: string | null; targetSlot?: SlotId }
|
||||
| { type: 'open-dimension'; dimension: string; targetSlot?: SlotId }
|
||||
| { type: 'switch-pane-type'; paneType: PaneType }
|
||||
| { type: 'close-pane' };
|
||||
|
||||
@@ -70,14 +67,24 @@ export interface HighlightedPassage {
|
||||
selectedText: string;
|
||||
}
|
||||
|
||||
// ChatPaneProps removed in rah-light
|
||||
|
||||
// DimensionsPane specific props
|
||||
export interface DimensionsPaneProps extends BasePaneProps {
|
||||
onNodeOpen: (nodeId: number) => void;
|
||||
refreshToken: number;
|
||||
onDataChanged?: () => void;
|
||||
onDimensionSelect?: (dimensionName: string | null) => void;
|
||||
export interface ChatPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpen: () => void;
|
||||
openTabsData: Node[];
|
||||
activeTabId: number | null;
|
||||
activeContextId?: number | null;
|
||||
activeContextName?: string | null;
|
||||
onClearContext?: () => void;
|
||||
focusedSkill?: FocusedSkill | null;
|
||||
onClearFocusedSkill?: () => void;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
chatMessages?: unknown[];
|
||||
setChatMessages?: React.Dispatch<React.SetStateAction<unknown[]>>;
|
||||
highlightedPassage?: HighlightedPassage | null;
|
||||
onClearPassage?: () => void;
|
||||
onboardingHint?: string | null;
|
||||
onDismissOnboardingHint?: () => void;
|
||||
}
|
||||
|
||||
// MapPane specific props
|
||||
@@ -92,13 +99,12 @@ export interface ViewsPaneProps extends BasePaneProps {
|
||||
onNodeOpenInOtherPane?: (nodeId: number) => void;
|
||||
refreshToken?: number;
|
||||
externalContextFilterId?: number | null;
|
||||
externalDimensionFilter?: string | null;
|
||||
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
onClearExternalContextFilter?: () => void;
|
||||
onClearExternalDimensionFilter?: () => void;
|
||||
}
|
||||
|
||||
export interface ContextsPaneProps extends BasePaneProps {
|
||||
onNodeOpen: (nodeId: number) => void;
|
||||
onContextSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
}
|
||||
|
||||
@@ -122,7 +128,6 @@ export interface PaneHeaderProps {
|
||||
export const PANE_LABELS: Record<PaneType, string> = {
|
||||
node: 'Nodes',
|
||||
contexts: 'Contexts',
|
||||
dimensions: 'Dimensions',
|
||||
map: 'Map',
|
||||
views: 'Feed',
|
||||
table: 'Table',
|
||||
@@ -131,11 +136,8 @@ export const PANE_LABELS: Record<PaneType, string> = {
|
||||
|
||||
// Default slot states
|
||||
export const DEFAULT_SLOT_A: SlotState = {
|
||||
type: 'node',
|
||||
nodeTabs: [],
|
||||
activeNodeTab: null,
|
||||
tabs: [{ id: 'views', type: 'views' }],
|
||||
activeTabId: 'views',
|
||||
};
|
||||
|
||||
export const DEFAULT_SLOT_B: SlotState = {
|
||||
type: 'views',
|
||||
};
|
||||
export const DEFAULT_SLOT_B: SlotState | null = null;
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function ApiKeysViewer() {
|
||||
<div style={featuresHeaderStyle}>OpenAI API Key enables:</div>
|
||||
<ul style={featuresListStyle}>
|
||||
<li>Auto-generated descriptions for new nodes</li>
|
||||
<li>Smart dimension assignment</li>
|
||||
<li>Edge explanation inference</li>
|
||||
<li>Semantic search via embeddings</li>
|
||||
</ul>
|
||||
<div style={noteStyle}>
|
||||
|
||||
@@ -3,123 +3,126 @@
|
||||
import { useEffect, useState, type CSSProperties } from 'react';
|
||||
import type { Node } from '@/types/database';
|
||||
|
||||
interface AutoContextSettings {
|
||||
autoContextEnabled: boolean;
|
||||
lastPinnedMigration?: string;
|
||||
}
|
||||
|
||||
interface NodeWithMetrics extends Node {
|
||||
edge_count?: number;
|
||||
}
|
||||
|
||||
interface CapsuleData {
|
||||
userProfile: string;
|
||||
agentProfile: string;
|
||||
lastUpdatedAt: string;
|
||||
}
|
||||
|
||||
export default function ContextViewer() {
|
||||
const [nodes, setNodes] = useState<NodeWithMetrics[]>([]);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [capsule, setCapsule] = useState<CapsuleData | null>(null);
|
||||
const [loadingNodes, setLoadingNodes] = useState(true);
|
||||
const [loadingSettings, setLoadingSettings] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadingCapsule, setLoadingCapsule] = useState(true);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadNodes = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/nodes?sortBy=edges&limit=10');
|
||||
const res = await fetch('/api/nodes?sortBy=edges&limit=5');
|
||||
const payload = await res.json();
|
||||
setNodes(payload.data || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setNodes([]);
|
||||
} finally {
|
||||
setLoadingNodes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
const loadCapsule = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/system/auto-context');
|
||||
const payload = await res.json() as { success: boolean; data?: AutoContextSettings };
|
||||
if (payload.success && payload.data) {
|
||||
setEnabled(payload.data.autoContextEnabled);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const res = await fetch('/api/rah/memory');
|
||||
const payload = await res.json();
|
||||
setCapsule(payload.data?.capsule ?? null);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setCapsule(null);
|
||||
} finally {
|
||||
setLoadingSettings(false);
|
||||
setLoadingCapsule(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadNodes();
|
||||
loadSettings();
|
||||
useEffect(() => {
|
||||
void loadNodes();
|
||||
void loadCapsule();
|
||||
}, []);
|
||||
|
||||
const handleToggle = async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
const handleReset = async () => {
|
||||
if (!confirm('Reset the context capsule to neutral defaults?')) return;
|
||||
try {
|
||||
const res = await fetch('/api/system/auto-context', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoContextEnabled: !enabled }),
|
||||
});
|
||||
const payload = await res.json() as { success: boolean; data?: AutoContextSettings };
|
||||
if (payload.success && payload.data) {
|
||||
setEnabled(payload.data.autoContextEnabled);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setResetting(true);
|
||||
await fetch('/api/rah/memory', { method: 'DELETE' });
|
||||
await loadCapsule();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<p style={descStyle}>
|
||||
Context summaries and anchor nodes are used first. Global hub nodes remain secondary diagnostics in background context.
|
||||
RA-H now carries one compact context capsule into every conversation. It stays neutral by default,
|
||||
updates only on meaningful changes, and sits alongside hub nodes rather than replacing them.
|
||||
</p>
|
||||
|
||||
{/* Toggle */}
|
||||
<div style={cardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={capsuleHeaderStyle}>
|
||||
<div>
|
||||
<div style={labelStyle}>Auto-Context</div>
|
||||
<div style={subLabelStyle}>
|
||||
{loadingSettings ? 'Loading...' : enabled ? 'Enabled' : 'Disabled'}
|
||||
</div>
|
||||
<div style={labelStyle}>Context Capsule</div>
|
||||
<div style={subLabelStyle}>Always injected into the system prompt. Max 200 words total.</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={loadingSettings || saving}
|
||||
style={{
|
||||
...toggleStyle,
|
||||
background: enabled ? 'var(--settings-active-bg)' : 'var(--settings-code-bg)',
|
||||
}}
|
||||
onClick={handleReset}
|
||||
disabled={resetting}
|
||||
style={resetButtonStyle}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--settings-button-hover-bg)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<span style={{
|
||||
...toggleKnobStyle,
|
||||
left: enabled ? 26 : 4,
|
||||
}} />
|
||||
{resetting ? 'Resetting...' : 'Reset Capsule'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingCapsule ? (
|
||||
<div style={mutedStyle}>Loading capsule...</div>
|
||||
) : capsule ? (
|
||||
<div style={capsuleGridStyle}>
|
||||
<CapsuleSection
|
||||
title="User"
|
||||
value={capsule.userProfile}
|
||||
footer={capsule.lastUpdatedAt ? `Updated ${new Date(capsule.lastUpdatedAt).toLocaleString()}` : 'Never updated'}
|
||||
/>
|
||||
<CapsuleSection title="Agent" value={capsule.agentProfile} />
|
||||
</div>
|
||||
) : (
|
||||
<div style={mutedStyle}>Capsule unavailable.</div>
|
||||
)}
|
||||
|
||||
<div style={{ ...labelStyle, marginTop: 28 }}>Hub Nodes</div>
|
||||
<div style={subLabelStyle}>
|
||||
Your 5 most-connected nodes remain the raw graph grounding and are included in every conversation.
|
||||
</div>
|
||||
|
||||
{/* Nodes List */}
|
||||
<div style={labelStyle}>Top Nodes</div>
|
||||
{loadingNodes ? (
|
||||
<div style={mutedStyle}>Loading...</div>
|
||||
<div style={mutedStyle}>Loading hub nodes...</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div style={mutedStyle}>No connected nodes yet.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 12 }}>
|
||||
{nodes.map((node) => (
|
||||
<div key={node.id} style={nodeCardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={nodeTitleStyle}>{node.title || 'Untitled'}</span>
|
||||
<span style={edgeCountStyle}>{node.edge_count ?? 0}</span>
|
||||
</div>
|
||||
{node.dimensions && node.dimensions.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 6, flexWrap: 'wrap' }}>
|
||||
{node.dimensions.slice(0, 3).map((dim) => (
|
||||
<span key={dim} style={dimTagStyle}>{dim}</span>
|
||||
))}
|
||||
{node.description && (
|
||||
<div style={nodeDescriptionStyle}>{node.description}</div>
|
||||
)}
|
||||
{node.context?.name && (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 8, flexWrap: 'wrap' }}>
|
||||
<span style={contextTagStyle}>{node.context.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -130,52 +133,62 @@ export default function ContextViewer() {
|
||||
);
|
||||
}
|
||||
|
||||
const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' };
|
||||
const descStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginBottom: 20, lineHeight: 1.5 };
|
||||
function CapsuleSection({
|
||||
title,
|
||||
value,
|
||||
footer,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
footer?: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<div style={labelStyle}>{title}</div>
|
||||
<div style={capsuleBodyStyle}>{value}</div>
|
||||
{footer && <div style={capsuleFooterStyle}>{footer}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' };
|
||||
const descStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginBottom: 20, lineHeight: 1.5, maxWidth: 780 };
|
||||
const capsuleHeaderStyle: CSSProperties = { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 12 };
|
||||
const capsuleGridStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 };
|
||||
const cardStyle: CSSProperties = {
|
||||
background: 'var(--settings-card-bg)',
|
||||
border: '1px solid var(--settings-border)',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
minHeight: 140,
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: 'var(--settings-text)', marginBottom: 8 };
|
||||
const subLabelStyle: CSSProperties = { fontSize: 12, color: 'var(--settings-muted)' };
|
||||
const mutedStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)' };
|
||||
|
||||
const toggleStyle: CSSProperties = {
|
||||
width: 48,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
border: '1px solid var(--settings-border)',
|
||||
const mutedStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginTop: 10 };
|
||||
const capsuleBodyStyle: CSSProperties = { fontSize: 13, lineHeight: 1.6, color: 'var(--settings-subtext)', whiteSpace: 'pre-wrap' };
|
||||
const capsuleFooterStyle: CSSProperties = { fontSize: 11, color: 'var(--settings-muted)', marginTop: 12 };
|
||||
const resetButtonStyle: CSSProperties = {
|
||||
padding: '8px 14px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--settings-border-strong)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--settings-text)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
transition: 'background 0.15s',
|
||||
transition: 'all 0.15s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
const toggleKnobStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--settings-text)',
|
||||
transition: 'left 0.15s',
|
||||
};
|
||||
|
||||
const nodeCardStyle: CSSProperties = {
|
||||
padding: '12px 14px',
|
||||
background: 'var(--settings-card-bg)',
|
||||
border: '1px solid var(--settings-border)',
|
||||
borderRadius: 6,
|
||||
};
|
||||
|
||||
const nodeTitleStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: 'var(--settings-text)' };
|
||||
const nodeDescriptionStyle: CSSProperties = { fontSize: 12, lineHeight: 1.5, color: 'var(--settings-subtext)', marginTop: 8 };
|
||||
const edgeCountStyle: CSSProperties = { fontSize: 12, color: 'var(--settings-muted)' };
|
||||
|
||||
const dimTagStyle: CSSProperties = {
|
||||
const contextTagStyle: CSSProperties = {
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react';
|
||||
import { Folder, Link as LinkIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react';
|
||||
import { Link as LinkIcon } from 'lucide-react';
|
||||
import { Node } from '@/types/database';
|
||||
import { openExternalUrl } from '@/utils/openExternalUrl';
|
||||
|
||||
@@ -11,16 +11,9 @@ interface NodeWithMetrics extends Node {
|
||||
|
||||
interface AppliedFilters {
|
||||
search?: string;
|
||||
dimensions?: string[];
|
||||
sortBy: 'updated' | 'edges';
|
||||
}
|
||||
|
||||
interface PopularDimension {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
}
|
||||
|
||||
const LIMIT = 50;
|
||||
|
||||
export default function DatabaseViewer() {
|
||||
@@ -30,16 +23,12 @@ export default function DatabaseViewer() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [dimensionInput, setDimensionInput] = useState('');
|
||||
const [dimensionFilters, setDimensionFilters] = useState<string[]>([]);
|
||||
const [sortBy, setSortBy] = useState<'updated' | 'edges'>('updated');
|
||||
|
||||
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>({ sortBy: 'updated' });
|
||||
const [lockedDimensionSet, setLockedDimensionSet] = useState<Set<string>>(new Set());
|
||||
const [contextHubIds, setContextHubIds] = useState<Set<number>>(new Set());
|
||||
const [hubNodeIds, setHubNodeIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const filtersActive = useMemo(
|
||||
() => Boolean(appliedFilters.search || (appliedFilters.dimensions && appliedFilters.dimensions.length > 0)),
|
||||
() => Boolean(appliedFilters.search),
|
||||
[appliedFilters]
|
||||
);
|
||||
|
||||
@@ -53,9 +42,6 @@ export default function DatabaseViewer() {
|
||||
params.set('offset', ((pageNumber - 1) * LIMIT).toString());
|
||||
params.set('sortBy', filters.sortBy);
|
||||
if (filters.search) params.set('search', filters.search);
|
||||
if (filters.dimensions && filters.dimensions.length > 0) {
|
||||
params.set('dimensions', filters.dimensions.join(','));
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/nodes?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
@@ -74,82 +60,39 @@ export default function DatabaseViewer() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes(page, appliedFilters);
|
||||
void fetchNodes(page, appliedFilters);
|
||||
}, [page, appliedFilters, fetchNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadLockedDimensions = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/dimensions/popular');
|
||||
if (!response.ok) return;
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const priorityDimensions: PopularDimension[] = result.data;
|
||||
setLockedDimensionSet(new Set(priorityDimensions.filter((d) => d.isPriority).map((d) => d.dimension)));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load locked dimensions', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadLockedDimensions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadContextHubs = async () => {
|
||||
const loadHubNodes = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/nodes?sortBy=edges&limit=10');
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
const ids = new Set<number>((payload.data || []).map((node: Node) => node.id));
|
||||
setContextHubIds(ids);
|
||||
setHubNodeIds(ids);
|
||||
} catch (err) {
|
||||
console.warn('Failed to load auto-context hubs', err);
|
||||
console.warn('Failed to load graph hubs', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadContextHubs();
|
||||
void loadHubNodes();
|
||||
}, []);
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
const payload: AppliedFilters = {
|
||||
sortBy,
|
||||
};
|
||||
|
||||
const payload: AppliedFilters = { sortBy };
|
||||
if (searchInput.trim()) payload.search = searchInput.trim();
|
||||
if (dimensionFilters.length > 0) payload.dimensions = dimensionFilters;
|
||||
|
||||
setAppliedFilters(payload);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchInput('');
|
||||
setDimensionInput('');
|
||||
setDimensionFilters([]);
|
||||
setSortBy('updated');
|
||||
setAppliedFilters({ sortBy: 'updated' });
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleAddDimension = () => {
|
||||
const next = dimensionInput.trim();
|
||||
if (!next) return;
|
||||
setDimensionFilters((prev) => (prev.includes(next) ? prev : [...prev, next]));
|
||||
setDimensionInput('');
|
||||
};
|
||||
|
||||
const handleDimensionKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleAddDimension();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveDimension = (dimension: string) => {
|
||||
setDimensionFilters((prev) => prev.filter((dim) => dim !== dimension));
|
||||
};
|
||||
|
||||
const handleSortChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = event.target.value === 'edges' ? 'edges' : 'updated';
|
||||
setSortBy(value);
|
||||
@@ -195,27 +138,15 @@ export default function DatabaseViewer() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: 'var(--rah-text-muted)' }}>
|
||||
Loading database...
|
||||
</div>
|
||||
);
|
||||
return <div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>Loading database...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>
|
||||
Error: {error}
|
||||
</div>
|
||||
);
|
||||
return <div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: 'var(--rah-text-muted)' }}>
|
||||
No nodes found
|
||||
</div>
|
||||
);
|
||||
return <div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>No nodes found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -223,71 +154,38 @@ export default function DatabaseViewer() {
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid var(--rah-border-strong)',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: 'var(--rah-text-muted)', gap: '4px' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
Search
|
||||
<input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="title or content"
|
||||
style={{
|
||||
background: 'var(--rah-bg-surface)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
color: 'var(--rah-text-base)',
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
minWidth: '220px',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: 'var(--rah-text-muted)', gap: '4px' }}>
|
||||
Dimension
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
value={dimensionInput}
|
||||
onChange={(e) => setDimensionInput(e.target.value)}
|
||||
onKeyDown={handleDimensionKeyDown}
|
||||
placeholder="e.g. research"
|
||||
style={{
|
||||
background: 'var(var(--rah-bg-surface))',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
color: 'var(var(--rah-text-base))',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
minWidth: '180px',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddDimension}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: '#1f3529',
|
||||
border: '1px solid #264333',
|
||||
borderRadius: '4px',
|
||||
color: '#c4f5d2',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: 'var(--rah-text-muted)', gap: '4px' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
Sort by
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={handleSortChange}
|
||||
style={{
|
||||
background: 'var(--rah-bg-surface)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
color: 'var(--rah-text-base)',
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
@@ -298,46 +196,8 @@ export default function DatabaseViewer() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{dimensionFilters.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{dimensionFilters.map((dimension) => (
|
||||
<span
|
||||
key={dimension}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '3px 10px',
|
||||
borderRadius: '999px',
|
||||
background: '#142817',
|
||||
color: '#c4f5d2',
|
||||
fontSize: '11px',
|
||||
border: '1px solid #1f3b23',
|
||||
}}
|
||||
>
|
||||
<Folder size={12} />
|
||||
{dimension}
|
||||
<button
|
||||
onClick={() => handleRemoveDimension(dimension)}
|
||||
style={{
|
||||
marginLeft: '2px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#7de8a5',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
aria-label={`Remove ${dimension}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: '12px', color: 'var(--rah-text-muted)' }}>{filterStatus}</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>{filterStatus}</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handleApplyFilters}
|
||||
@@ -357,10 +217,10 @@ export default function DatabaseViewer() {
|
||||
onClick={handleClearFilters}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-stronger)',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--rah-text-secondary)',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
@@ -372,10 +232,10 @@ export default function DatabaseViewer() {
|
||||
disabled={isFirstPage || filtersActive}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: isFirstPage || filtersActive ? 'var(--rah-bg-active)' : 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-stronger)',
|
||||
background: isFirstPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: isFirstPage || filtersActive ? 'var(--rah-text-muted)' : 'var(--rah-text-secondary)',
|
||||
color: isFirstPage || filtersActive ? '#555' : '#ccc',
|
||||
cursor: isFirstPage || filtersActive ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
@@ -387,10 +247,10 @@ export default function DatabaseViewer() {
|
||||
disabled={isLastPage || filtersActive}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: isLastPage || filtersActive ? 'var(--rah-bg-active)' : 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-stronger)',
|
||||
background: isLastPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: isLastPage || filtersActive ? 'var(--rah-text-muted)' : 'var(--rah-text-secondary)',
|
||||
color: isLastPage || filtersActive ? '#555' : '#ccc',
|
||||
cursor: isLastPage || filtersActive ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
@@ -403,20 +263,20 @@ export default function DatabaseViewer() {
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead style={{ position: 'sticky', top: 0, background: 'var(--rah-bg-active)', zIndex: 1 }}>
|
||||
<thead style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
|
||||
<tr>
|
||||
{['Node', 'Categories', 'Edges', 'Highlights', 'Updated', 'Created'].map((column) => (
|
||||
{['Node', 'Context', 'Edges', 'Highlights', 'Updated', 'Created'].map((column) => (
|
||||
<th
|
||||
key={column}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
textAlign: 'left',
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
color: '#888',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontWeight: 'normal',
|
||||
borderBottom: '1px solid var(--rah-border-strong)',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
}}
|
||||
>
|
||||
{column}
|
||||
@@ -425,25 +285,23 @@ export default function DatabaseViewer() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes.map((node, index) => {
|
||||
const belongsToLocked = node.dimensions?.some((dimension) => lockedDimensionSet.has(dimension));
|
||||
return (
|
||||
{nodes.map((node, index) => (
|
||||
<tr
|
||||
key={node.id}
|
||||
style={{
|
||||
background: index % 2 === 0 ? 'var(--rah-bg-base)' : 'var(--rah-bg-surface)',
|
||||
borderBottom: '1px solid var(--rah-bg-surface)',
|
||||
background: index % 2 === 0 ? '#080808' : '#0d0d0d',
|
||||
borderBottom: '1px solid #111',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '28%' }}>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '34%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ fontWeight: 600, color: 'var(--rah-text-base)', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ fontWeight: 600, color: '#f5f5f5', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{node.title || 'Untitled node'}
|
||||
{node.link && (
|
||||
<button
|
||||
onClick={() => {
|
||||
void openExternalUrl(node.link!).catch((error) => {
|
||||
console.error('[DatabaseViewer] Failed to open node link', error);
|
||||
void openExternalUrl(node.link!).catch((openError) => {
|
||||
console.error('[DatabaseViewer] Failed to open original link', openError);
|
||||
window.alert(`Unable to open ${node.link}`);
|
||||
});
|
||||
}}
|
||||
@@ -462,68 +320,56 @@ export default function DatabaseViewer() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)', fontFamily: 'JetBrains Mono, monospace' }}>ID: {node.id}</span>
|
||||
<span style={{ fontSize: '11px', color: '#666', fontFamily: 'JetBrains Mono, monospace' }}>ID: {node.id}</span>
|
||||
{node.description && <span style={{ fontSize: '12px', color: '#9ca3af' }}>{node.description}</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '24%' }}>
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
{node.dimensions && node.dimensions.length > 0 ? (
|
||||
node.dimensions.slice(0, 3).map((dimension) => (
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '18%' }}>
|
||||
{node.context?.name ? (
|
||||
<span
|
||||
key={`${node.id}-${dimension}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '2px 8px',
|
||||
padding: '3px 10px',
|
||||
borderRadius: '999px',
|
||||
background: '#111914',
|
||||
border: '1px solid #1f2f24',
|
||||
background: '#142817',
|
||||
color: '#c4f5d2',
|
||||
fontSize: '11px',
|
||||
color: '#bbf7d0',
|
||||
border: '1px solid #1f3b23',
|
||||
}}
|
||||
>
|
||||
<Folder size={11} />
|
||||
{dimension}
|
||||
{node.context.name}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>No categories</span>
|
||||
<span style={{ fontSize: '11px', color: '#555' }}>Unscoped</span>
|
||||
)}
|
||||
{node.dimensions && node.dimensions.length > 3 && (
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>+{node.dimensions.length - 3} more</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '10%' }}>
|
||||
<div style={{ fontWeight: 600, color: 'var(--rah-text-base)' }}>{node.edge_count ?? 0}</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>connections</div>
|
||||
<div style={{ fontWeight: 600, color: '#e2e8f0' }}>{node.edge_count ?? 0}</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>connections</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '14%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{contextHubIds.has(node.id) ? (
|
||||
<span style={{ fontSize: '11px', color: '#facc15', fontWeight: 600 }}>
|
||||
Auto-context hub
|
||||
</span>
|
||||
{hubNodeIds.has(node.id) ? (
|
||||
<span style={{ fontSize: '11px', color: '#facc15', fontWeight: 600 }}>Graph hub</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: '#475569' }}>—</span>
|
||||
)}
|
||||
{belongsToLocked && (
|
||||
<span style={{ fontSize: '11px', color: '#7de8a5' }}>Priority dimension</span>
|
||||
{node.metadata?.state && (
|
||||
<span style={{ fontSize: '11px', color: '#7de8a5' }}>{String(node.metadata.state)}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
|
||||
<div style={{ fontSize: '12px', color: 'var(--rah-text-base)' }}>{formatTimestamp(node.updated_at)}</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>{formatRelative(node.updated_at)}</div>
|
||||
<div style={{ fontSize: '12px', color: '#e2e8f0' }}>{formatTimestamp(node.updated_at)}</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>{formatRelative(node.updated_at)}</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
|
||||
<div style={{ fontSize: '12px', color: 'var(--rah-text-base)' }}>{formatTimestamp(node.created_at)}</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>{formatRelative(node.created_at)}</div>
|
||||
<div style={{ fontSize: '12px', color: '#cbd5f5' }}>{formatTimestamp(node.created_at)}</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>{formatRelative(node.created_at)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function ExternalAgentsPanel() {
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
<HelperCard
|
||||
title="Add to RA-H"
|
||||
body={`"Summarize our meeting and add it to RA-H under dimensions Strategy, Q1 Execution."`}
|
||||
body={`"Summarize our meeting and add it to RA-H. If a context is obvious, use it. If not, leave context blank."`}
|
||||
/>
|
||||
<HelperCard
|
||||
title="Search RA-H"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"use client";
|
||||
|
||||
export default function AutoUpdateManager() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Filter, ChevronDown, ChevronLeft, ChevronRight, X, ArrowUpDown, Search, ExternalLink } from 'lucide-react';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, X, ArrowUpDown, Search, ExternalLink } from 'lucide-react';
|
||||
import type { Node } from '@/types/database';
|
||||
import { formatRelativeDate } from '@/utils/formatDate';
|
||||
|
||||
@@ -17,12 +17,6 @@ const SORT_LABELS: Record<SortOrder, string> = {
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface DimensionSummary {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
}
|
||||
|
||||
interface DatabaseTableViewProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
refreshToken?: number;
|
||||
@@ -46,41 +40,11 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('updated');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeSearch, setActiveSearch] = useState('');
|
||||
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
|
||||
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
|
||||
const [showFilterPicker, setShowFilterPicker] = useState(false);
|
||||
const [filterSearchQuery, setFilterSearchQuery] = useState('');
|
||||
const [showSortDropdown, setShowSortDropdown] = useState(false);
|
||||
const [hoveredRow, setHoveredRow] = useState<number | null>(null);
|
||||
|
||||
const filterPickerRef = useRef<HTMLDivElement>(null);
|
||||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sortedDimensions = useMemo(() => {
|
||||
return [...dimensions].sort((a, b) => {
|
||||
if (a.isPriority && !b.isPriority) return -1;
|
||||
if (!a.isPriority && b.isPriority) return 1;
|
||||
return a.dimension.localeCompare(b.dimension);
|
||||
});
|
||||
}, [dimensions]);
|
||||
|
||||
const filterPickerDimensions = sortedDimensions.filter(d =>
|
||||
d.dimension.toLowerCase().includes(filterSearchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Fetch dimensions
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/dimensions');
|
||||
const data = await res.json();
|
||||
if (data.success) setDimensions(data.data || []);
|
||||
} catch (e) {
|
||||
console.error('Error fetching dimensions:', e);
|
||||
}
|
||||
})();
|
||||
}, [refreshToken]);
|
||||
|
||||
// Fetch nodes
|
||||
const fetchNodes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -91,10 +55,6 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
sortBy: sortOrder,
|
||||
});
|
||||
if (activeSearch) params.set('search', activeSearch);
|
||||
if (selectedFilters.length > 0) {
|
||||
params.set('dimensions', selectedFilters.join(','));
|
||||
params.set('dimensionsMatch', 'all');
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/nodes?${params}`);
|
||||
const data = await res.json();
|
||||
@@ -107,32 +67,27 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, sortOrder, activeSearch, selectedFilters]);
|
||||
}, [page, sortOrder, activeSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes();
|
||||
}, [fetchNodes, refreshToken]);
|
||||
|
||||
// Reset to page 1 when filters/sort/search change
|
||||
const filtersKey = selectedFilters.join(',');
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [sortOrder, activeSearch, filtersKey]);
|
||||
}, [sortOrder, activeSearch]);
|
||||
|
||||
// Close dropdowns on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (showFilterPicker && filterPickerRef.current && !filterPickerRef.current.contains(e.target as HTMLElement)) {
|
||||
setShowFilterPicker(false);
|
||||
setFilterSearchQuery('');
|
||||
}
|
||||
if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) {
|
||||
setShowSortDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showFilterPicker, showSortDropdown]);
|
||||
}, [showSortDropdown]);
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -189,108 +144,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Filter chips + button */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flex: 1, flexWrap: 'wrap' }}>
|
||||
{selectedFilters.map(f => (
|
||||
<div key={f} style={{
|
||||
display: 'flex', alignItems: 'center', gap: '4px',
|
||||
padding: '2px 7px',
|
||||
background: 'rgba(34, 197, 94, 0.06)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.12)',
|
||||
borderRadius: '4px', fontSize: '11px', color: '#5a9'
|
||||
}}>
|
||||
{f}
|
||||
<button
|
||||
onClick={() => setSelectedFilters(selectedFilters.filter(x => x !== f))}
|
||||
style={{ background: 'transparent', border: 'none', color: '#5a9', cursor: 'pointer', padding: 0, display: 'flex' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#5a9'; }}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ position: 'relative' }} ref={filterPickerRef}>
|
||||
<button
|
||||
onClick={() => setShowFilterPicker(!showFilterPicker)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '4px',
|
||||
padding: '4px 7px', background: 'transparent',
|
||||
border: '1px solid var(--rah-border)', borderRadius: '5px',
|
||||
color: 'var(--rah-text-soft)', fontSize: '11px', cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<Filter size={11} />
|
||||
Filter
|
||||
</button>
|
||||
|
||||
{showFilterPicker && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', left: 0, marginTop: '4px',
|
||||
background: 'var(--rah-bg-panel)', border: '1px solid var(--rah-border)', borderRadius: '10px',
|
||||
padding: '6px', minWidth: '220px', maxHeight: '320px', overflowY: 'auto',
|
||||
zIndex: 1000, boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
value={filterSearchQuery}
|
||||
onChange={(e) => setFilterSearchQuery(e.target.value)}
|
||||
placeholder="Search dimensions..."
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%', padding: '7px 10px', background: 'var(--rah-bg-base)',
|
||||
border: '1px solid transparent', borderRadius: '6px',
|
||||
color: 'var(--rah-text-active)', fontSize: '12px', marginBottom: '4px', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
{filterPickerDimensions.length === 0 ? (
|
||||
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
|
||||
No matching dimensions
|
||||
</div>
|
||||
) : (
|
||||
filterPickerDimensions.map(d => (
|
||||
<button
|
||||
key={d.dimension}
|
||||
onClick={() => {
|
||||
if (!selectedFilters.includes(d.dimension)) {
|
||||
setSelectedFilters([...selectedFilters, d.dimension]);
|
||||
}
|
||||
setShowFilterPicker(false);
|
||||
setFilterSearchQuery('');
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
width: '100%', padding: '7px 10px', background: 'transparent',
|
||||
border: 'none', borderRadius: '5px', color: 'var(--rah-text-secondary)',
|
||||
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<span>{d.dimension}</span>
|
||||
<span style={{ color: 'var(--rah-text-muted)', fontSize: '10px', background: 'var(--rah-bg-active)', padding: '1px 6px', borderRadius: '10px' }}>
|
||||
{d.count}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedFilters.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSelectedFilters([])}
|
||||
style={{ padding: '4px 6px', background: 'transparent', border: 'none', color: 'var(--rah-text-muted)', fontSize: '11px', cursor: 'pointer' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
|
||||
@@ -393,7 +247,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
<div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>Loading...</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>
|
||||
{activeSearch || selectedFilters.length > 0 ? 'No nodes match your filters.' : 'No nodes yet.'}
|
||||
{activeSearch ? 'No nodes match your search.' : 'No nodes yet.'}
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ minWidth: '1600px', width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
@@ -402,9 +256,9 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
<th style={thStyle({ width: '240px' })}>TITLE</th>
|
||||
<th style={thStyle({ width: '55px', textAlign: 'right' })}>ID</th>
|
||||
<th style={thStyle({ width: '200px' })}>DESCRIPTION</th>
|
||||
<th style={thStyle({ width: '160px' })}>NOTES</th>
|
||||
<th style={thStyle({ width: '160px' })}>SOURCE</th>
|
||||
<th style={thStyle({ width: '180px' })}>LINK</th>
|
||||
<th style={thStyle({ width: '160px' })}>DIMENSIONS</th>
|
||||
<th style={thStyle({ width: '160px' })}>CONTEXT</th>
|
||||
<th style={thStyle({ width: '50px', textAlign: 'right' })}>EDGES</th>
|
||||
<th style={thStyle({ width: '90px' })}>EVENT</th>
|
||||
<th style={thStyle({ width: '85px' })}>UPDATED</th>
|
||||
@@ -488,26 +342,19 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Dimensions */}
|
||||
{/* Context */}
|
||||
<td style={tdStyle()}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px', overflow: 'hidden', maxHeight: '36px' }}>
|
||||
{node.dimensions && node.dimensions.length > 0 ? (
|
||||
{node.context?.name ? (
|
||||
<>
|
||||
{node.dimensions.slice(0, 3).map(d => (
|
||||
<span key={d} style={{
|
||||
<span style={{
|
||||
fontSize: '9px', padding: '1px 5px',
|
||||
background: 'var(--rah-accent-green-soft)', border: '1px solid var(--rah-accent-green-soft-strong)',
|
||||
color: 'var(--rah-accent-green)', borderRadius: '3px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{d}
|
||||
{node.context.name}
|
||||
</span>
|
||||
))}
|
||||
{node.dimensions.length > 3 && (
|
||||
<span style={{ fontSize: '9px', color: 'var(--rah-text-muted)' }}>
|
||||
+{node.dimensions.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span style={{ fontSize: '10px', color: 'var(--rah-text-muted)' }}>{'\u2014'}</span>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Node } from '@/types/database';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
import { getNodeProcessedState } from '@/services/nodes/metadata';
|
||||
|
||||
interface GridViewProps {
|
||||
@@ -11,7 +10,6 @@ interface GridViewProps {
|
||||
}
|
||||
|
||||
export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
||||
const { dimensionIcons } = useDimensionIcons();
|
||||
const truncateContent = (content?: string, maxLength: number = 120) => {
|
||||
if (!content) return '';
|
||||
if (content.length <= maxLength) return content;
|
||||
@@ -93,7 +91,7 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
||||
borderRadius: '6px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{getNodeIcon(node, dimensionIcons, 14)}
|
||||
{getNodeIcon(node, 14)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
@@ -135,17 +133,15 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer with Dimensions */}
|
||||
{node.dimensions && node.dimensions.length > 0 && (
|
||||
{/* Footer with Context */}
|
||||
{node.context?.name && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: 'auto'
|
||||
}}>
|
||||
{node.dimensions.slice(0, 3).map(dim => (
|
||||
<span
|
||||
key={dim}
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
background: '#1a1a1a',
|
||||
@@ -154,18 +150,8 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
||||
color: '#888'
|
||||
}}
|
||||
>
|
||||
{dim}
|
||||
{node.context.name}
|
||||
</span>
|
||||
))}
|
||||
{node.dimensions.length > 3 && (
|
||||
<span style={{
|
||||
padding: '2px 6px',
|
||||
fontSize: '10px',
|
||||
color: '#555'
|
||||
}}>
|
||||
+{node.dimensions.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -1,536 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, DragEvent } from 'react';
|
||||
import { Plus, GripVertical, X } from 'lucide-react';
|
||||
import { Node } from '@/types/database';
|
||||
import { KanbanColumn } from '@/types/views';
|
||||
|
||||
interface KanbanViewProps {
|
||||
nodes: Node[];
|
||||
columns: KanbanColumn[];
|
||||
dimensions: string[];
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
onColumnChange: (columns: KanbanColumn[]) => void;
|
||||
onNodeDimensionUpdate: (nodeId: number, newDimension: string, oldDimension?: string) => void;
|
||||
}
|
||||
|
||||
export default function KanbanView({
|
||||
nodes,
|
||||
columns,
|
||||
dimensions,
|
||||
onNodeClick,
|
||||
onColumnChange,
|
||||
onNodeDimensionUpdate
|
||||
}: KanbanViewProps) {
|
||||
const [showColumnPicker, setShowColumnPicker] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [draggedNodeId, setDraggedNodeId] = useState<number | null>(null);
|
||||
const [draggedFromColumn, setDraggedFromColumn] = useState<string | null>(null);
|
||||
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null);
|
||||
const [draggingColumnId, setDraggingColumnId] = useState<string | null>(null);
|
||||
const [dragOverColumnId, setDragOverColumnId] = useState<string | null>(null);
|
||||
|
||||
// Get nodes for a specific column (dimension)
|
||||
const getNodesForColumn = (dimension: string) => {
|
||||
return nodes.filter(node => node.dimensions?.includes(dimension));
|
||||
};
|
||||
|
||||
// Get uncategorized nodes (not in any column)
|
||||
const getUncategorizedNodes = () => {
|
||||
const columnDimensions = columns.map(c => c.dimension);
|
||||
return nodes.filter(node =>
|
||||
!node.dimensions || !node.dimensions.some(d => columnDimensions.includes(d))
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddColumn = (dimension: string) => {
|
||||
const newColumn: KanbanColumn = {
|
||||
id: `col-${Date.now()}`,
|
||||
dimension,
|
||||
order: columns.length
|
||||
};
|
||||
onColumnChange([...columns, newColumn]);
|
||||
setShowColumnPicker(false);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const handleRemoveColumn = (columnId: string) => {
|
||||
onColumnChange(columns.filter(c => c.id !== columnId));
|
||||
};
|
||||
|
||||
// Node drag handlers
|
||||
const handleNodeDragStart = (e: DragEvent, nodeId: number, fromColumn: string) => {
|
||||
setDraggedNodeId(nodeId);
|
||||
setDraggedFromColumn(fromColumn);
|
||||
e.dataTransfer.effectAllowed = 'copyMove';
|
||||
|
||||
// Find the node to get its title for chat drop
|
||||
const node = nodes.find(n => n.id === nodeId);
|
||||
const title = node?.title || 'Untitled';
|
||||
|
||||
// Set MIME types for chat input drops
|
||||
e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: nodeId, title }));
|
||||
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: nodeId, title, dimensions: node?.dimensions || [] }));
|
||||
e.dataTransfer.setData('text/plain', `[NODE:${nodeId}:"${title}"]`);
|
||||
};
|
||||
|
||||
const handleNodeDragEnd = () => {
|
||||
setDraggedNodeId(null);
|
||||
setDraggedFromColumn(null);
|
||||
setDragOverColumn(null);
|
||||
};
|
||||
|
||||
const handleColumnDragOver = (e: DragEvent, columnDimension: string) => {
|
||||
e.preventDefault();
|
||||
if (draggedNodeId !== null) {
|
||||
setDragOverColumn(columnDimension);
|
||||
}
|
||||
};
|
||||
|
||||
const handleColumnDragLeave = () => {
|
||||
setDragOverColumn(null);
|
||||
};
|
||||
|
||||
const handleColumnDrop = (e: DragEvent, targetDimension: string) => {
|
||||
e.preventDefault();
|
||||
if (draggedNodeId !== null && draggedFromColumn !== targetDimension) {
|
||||
onNodeDimensionUpdate(
|
||||
draggedNodeId,
|
||||
targetDimension,
|
||||
draggedFromColumn || undefined
|
||||
);
|
||||
}
|
||||
handleNodeDragEnd();
|
||||
};
|
||||
|
||||
// Column reorder drag handlers
|
||||
const handleColumnDragStart = (e: DragEvent, columnId: string) => {
|
||||
setDraggingColumnId(columnId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleColumnDragEnd = () => {
|
||||
setDraggingColumnId(null);
|
||||
setDragOverColumnId(null);
|
||||
};
|
||||
|
||||
const handleColumnReorderDragOver = (e: DragEvent, columnId: string) => {
|
||||
e.preventDefault();
|
||||
if (draggingColumnId && draggingColumnId !== columnId) {
|
||||
setDragOverColumnId(columnId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleColumnReorderDrop = (e: DragEvent, targetColumnId: string) => {
|
||||
e.preventDefault();
|
||||
if (!draggingColumnId || draggingColumnId === targetColumnId) return;
|
||||
|
||||
const newColumns = [...columns];
|
||||
const dragIndex = newColumns.findIndex(c => c.id === draggingColumnId);
|
||||
const dropIndex = newColumns.findIndex(c => c.id === targetColumnId);
|
||||
|
||||
if (dragIndex !== -1 && dropIndex !== -1) {
|
||||
const [removed] = newColumns.splice(dragIndex, 1);
|
||||
newColumns.splice(dropIndex, 0, removed);
|
||||
// Update order values
|
||||
newColumns.forEach((col, idx) => { col.order = idx; });
|
||||
onColumnChange(newColumns);
|
||||
}
|
||||
|
||||
handleColumnDragEnd();
|
||||
};
|
||||
|
||||
const filteredDimensions = dimensions.filter(d =>
|
||||
d.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
!columns.some(c => c.dimension === d)
|
||||
);
|
||||
|
||||
const sortedColumns = [...columns].sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#000'
|
||||
}}>
|
||||
{/* Column Setup Bar */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid #222',
|
||||
background: '#0a0a0a',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<span style={{ fontSize: '11px', color: '#666', fontWeight: 500 }}>
|
||||
Columns:
|
||||
</span>
|
||||
|
||||
{columns.length === 0 && (
|
||||
<span style={{ fontSize: '11px', color: '#555', fontStyle: 'italic' }}>
|
||||
Add dimensions to create columns
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Add Column Button */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowColumnPicker(!showColumnPicker)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
color: '#888',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add Column
|
||||
</button>
|
||||
|
||||
{/* Dimension Picker */}
|
||||
{showColumnPicker && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
marginTop: '4px',
|
||||
width: '200px',
|
||||
maxHeight: '300px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
zIndex: 100,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.5)'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search dimensions..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
background: '#0a0a0a',
|
||||
border: 'none',
|
||||
borderBottom: '1px solid #333',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
outline: 'none'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ maxHeight: '250px', overflowY: 'auto' }}>
|
||||
{filteredDimensions.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
No dimensions available
|
||||
</div>
|
||||
) : (
|
||||
filteredDimensions.map(dim => (
|
||||
<button
|
||||
key={dim}
|
||||
onClick={() => handleAddColumn(dim)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#ccc',
|
||||
fontSize: '12px',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#2a2a2a'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{dim}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Click outside to close */}
|
||||
{showColumnPicker && (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
|
||||
onClick={() => setShowColumnPicker(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kanban Board */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
padding: '12px',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden'
|
||||
}}>
|
||||
{sortedColumns.map(column => {
|
||||
const columnNodes = getNodesForColumn(column.dimension);
|
||||
const isDropTarget = dragOverColumn === column.dimension && draggedFromColumn !== column.dimension;
|
||||
const isReorderTarget = dragOverColumnId === column.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.id}
|
||||
style={{
|
||||
width: '280px',
|
||||
minWidth: '280px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: isDropTarget ? '#0f2417' : '#0a0a0a',
|
||||
border: isReorderTarget ? '2px dashed #22c55e' : '1px solid #1a1a1a',
|
||||
borderRadius: '8px',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onDragOver={(e) => handleColumnDragOver(e, column.dimension)}
|
||||
onDragLeave={handleColumnDragLeave}
|
||||
onDrop={(e) => handleColumnDrop(e, column.dimension)}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
cursor: 'grab'
|
||||
}}
|
||||
draggable
|
||||
onDragStart={(e) => handleColumnDragStart(e, column.id)}
|
||||
onDragEnd={handleColumnDragEnd}
|
||||
onDragOver={(e) => handleColumnReorderDragOver(e, column.id)}
|
||||
onDrop={(e) => handleColumnReorderDrop(e, column.id)}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<GripVertical size={14} color="#444" />
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#e5e5e5',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
{column.dimension}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
background: '#1a1a1a',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '10px'
|
||||
}}>
|
||||
{columnNodes.length}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveColumn(column.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '4px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Column Content */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '8px'
|
||||
}}>
|
||||
{columnNodes.map(node => (
|
||||
<div
|
||||
key={node.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleNodeDragStart(e, node.id, column.dimension)}
|
||||
onDragEnd={handleNodeDragEnd}
|
||||
onClick={() => onNodeClick(node.id)}
|
||||
style={{
|
||||
padding: '10px',
|
||||
marginBottom: '6px',
|
||||
background: draggedNodeId === node.id ? '#1a1a1a' : '#111',
|
||||
border: '1px solid #222',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
opacity: draggedNodeId === node.id ? 0.5 : 1,
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (draggedNodeId !== node.id) {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (draggedNodeId !== node.id) {
|
||||
e.currentTarget.style.background = '#111';
|
||||
e.currentTarget.style.borderColor = '#222';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#e5e5e5',
|
||||
marginBottom: '4px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{node.title || 'Untitled'}
|
||||
</div>
|
||||
{node.dimensions && node.dimensions.length > 1 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: '6px'
|
||||
}}>
|
||||
{node.dimensions
|
||||
.filter(d => d !== column.dimension)
|
||||
.slice(0, 2)
|
||||
.map(dim => (
|
||||
<span
|
||||
key={dim}
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
background: '#1a1a1a',
|
||||
borderRadius: '3px',
|
||||
fontSize: '10px',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
{dim}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{columnNodes.length === 0 && (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
color: '#444',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
Drop nodes here
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Uncategorized Column (if nodes exist without column dimensions) */}
|
||||
{columns.length > 0 && getUncategorizedNodes().length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
width: '280px',
|
||||
minWidth: '280px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0a0a0a',
|
||||
border: '1px dashed #333',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid #1a1a1a'
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#666',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
Uncategorized ({getUncategorizedNodes().length})
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '8px'
|
||||
}}>
|
||||
{getUncategorizedNodes().map(node => (
|
||||
<div
|
||||
key={node.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleNodeDragStart(e, node.id, '__uncategorized__')}
|
||||
onDragEnd={handleNodeDragEnd}
|
||||
onClick={() => onNodeClick(node.id)}
|
||||
style={{
|
||||
padding: '10px',
|
||||
marginBottom: '6px',
|
||||
background: '#111',
|
||||
border: '1px solid #222',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#888',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{node.title || 'Untitled'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{columns.length === 0 && (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#666',
|
||||
fontSize: '13px'
|
||||
}}>
|
||||
Add dimension columns to organize your nodes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import { Inbox } from 'lucide-react';
|
||||
import { Node } from '@/types/database';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
import { formatRelativeDate } from '@/utils/formatDate';
|
||||
import { getNodeProcessedState } from '@/services/nodes/metadata';
|
||||
|
||||
@@ -13,7 +12,6 @@ interface ListViewProps {
|
||||
}
|
||||
|
||||
export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
const { dimensionIcons } = useDimensionIcons();
|
||||
|
||||
const truncateContent = (content?: string, maxLength: number = 100) => {
|
||||
if (!content) return '';
|
||||
@@ -97,7 +95,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
borderRadius: '6px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{getNodeIcon(node, dimensionIcons, 16)}
|
||||
{getNodeIcon(node, 16)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -148,16 +146,8 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
}}>
|
||||
{processedState}
|
||||
</span>
|
||||
{/* Dimensions */}
|
||||
{node.dimensions && node.dimensions.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
{node.dimensions.slice(0, 3).map(dim => (
|
||||
{node.context?.name && (
|
||||
<span
|
||||
key={dim}
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
@@ -167,19 +157,8 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
color: 'var(--rah-text-base)'
|
||||
}}
|
||||
>
|
||||
{dim}
|
||||
{node.context.name}
|
||||
</span>
|
||||
))}
|
||||
{node.dimensions.length > 3 && (
|
||||
<span style={{
|
||||
padding: '2px 6px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-muted)'
|
||||
}}>
|
||||
+{node.dimensions.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date */}
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X, Plus, ChevronDown } from 'lucide-react';
|
||||
import { ViewFilter } from '@/types/views';
|
||||
|
||||
interface ViewFiltersProps {
|
||||
filters: ViewFilter[];
|
||||
filterLogic: 'and' | 'or';
|
||||
dimensions: string[];
|
||||
onFilterChange: (filters: ViewFilter[]) => void;
|
||||
onFilterLogicChange: (logic: 'and' | 'or') => void;
|
||||
}
|
||||
|
||||
export default function ViewFilters({
|
||||
filters,
|
||||
filterLogic,
|
||||
dimensions,
|
||||
onFilterChange,
|
||||
onFilterLogicChange
|
||||
}: ViewFiltersProps) {
|
||||
const [showDimensionPicker, setShowDimensionPicker] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const handleAddFilter = (dimension: string) => {
|
||||
if (!filters.some(f => f.dimension === dimension)) {
|
||||
onFilterChange([...filters, { dimension, operator: 'includes' }]);
|
||||
}
|
||||
setShowDimensionPicker(false);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const handleRemoveFilter = (dimension: string) => {
|
||||
onFilterChange(filters.filter(f => f.dimension !== dimension));
|
||||
};
|
||||
|
||||
const handleToggleOperator = (dimension: string) => {
|
||||
onFilterChange(filters.map(f =>
|
||||
f.dimension === dimension
|
||||
? { ...f, operator: f.operator === 'includes' ? 'excludes' : 'includes' }
|
||||
: f
|
||||
));
|
||||
};
|
||||
|
||||
const filteredDimensions = dimensions.filter(d =>
|
||||
d.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
!filters.some(f => f.dimension === d)
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid #222',
|
||||
background: '#0a0a0a',
|
||||
flexWrap: 'wrap',
|
||||
minHeight: '40px'
|
||||
}}>
|
||||
<span style={{ fontSize: '11px', color: '#666', fontWeight: 500 }}>
|
||||
Filters:
|
||||
</span>
|
||||
|
||||
{/* Filter Chips */}
|
||||
{filters.map(filter => (
|
||||
<div
|
||||
key={filter.dimension}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
background: filter.operator === 'includes' ? '#0f2417' : '#2a1515',
|
||||
border: `1px solid ${filter.operator === 'includes' ? '#166534' : '#7f1d1d'}`,
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
color: filter.operator === 'includes' ? '#22c55e' : '#ef4444'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleToggleOperator(filter.dimension)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '0 2px',
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
fontSize: '10px',
|
||||
opacity: 0.7
|
||||
}}
|
||||
title={filter.operator === 'includes' ? 'Click to exclude' : 'Click to include'}
|
||||
>
|
||||
{filter.operator === 'includes' ? '+' : '-'}
|
||||
</button>
|
||||
<span>{filter.dimension}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveFilter(filter.dimension)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '0',
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add Filter Button */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowDimensionPicker(!showDimensionPicker)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add
|
||||
</button>
|
||||
|
||||
{/* Dimension Picker Dropdown */}
|
||||
{showDimensionPicker && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
marginTop: '4px',
|
||||
width: '200px',
|
||||
maxHeight: '300px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
zIndex: 100,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.5)'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search dimensions..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
background: '#0a0a0a',
|
||||
border: 'none',
|
||||
borderBottom: '1px solid #333',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
outline: 'none'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ maxHeight: '250px', overflowY: 'auto' }}>
|
||||
{filteredDimensions.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
No dimensions found
|
||||
</div>
|
||||
) : (
|
||||
filteredDimensions.map(dim => (
|
||||
<button
|
||||
key={dim}
|
||||
onClick={() => handleAddFilter(dim)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#ccc',
|
||||
fontSize: '12px',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#2a2a2a'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{dim}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logic Toggle */}
|
||||
{filters.length > 1 && (
|
||||
<button
|
||||
onClick={() => onFilterLogicChange(filterLogic === 'and' ? 'or' : 'and')}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
color: filterLogic === 'and' ? '#22c55e' : '#3b82f6',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}
|
||||
title={filterLogic === 'and' ? 'Match ALL filters' : 'Match ANY filter'}
|
||||
>
|
||||
{filterLogic}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Click outside to close */}
|
||||
{showDimensionPicker && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 99
|
||||
}}
|
||||
onClick={() => setShowDimensionPicker(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Filter, ChevronDown, X, ArrowUpDown, GripVertical, Inbox, Check } from 'lucide-react';
|
||||
import type { ContextSummary, Node } from '@/types/database';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
import { usePersistentState } from '@/hooks/usePersistentState';
|
||||
import type { PendingNode } from '@/components/layout/ThreePanelLayout';
|
||||
import { getNodeProcessedState } from '@/services/nodes/metadata';
|
||||
@@ -24,41 +23,6 @@ const SORT_LABELS: Record<SortOrder, string> = {
|
||||
|
||||
const DOCUMENT_MAX_WIDTH = '980px';
|
||||
|
||||
const pickerRowStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: '7px 10px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
color: 'var(--rah-text-secondary)',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
const pickerCountStyle: React.CSSProperties = {
|
||||
color: 'var(--rah-text-muted)',
|
||||
fontSize: '10px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
padding: '1px 6px',
|
||||
borderRadius: '10px',
|
||||
};
|
||||
|
||||
interface ColumnFilter {
|
||||
id: string;
|
||||
dimension: string;
|
||||
}
|
||||
|
||||
interface DimensionSummary {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
interface ViewsOverlayProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
onNodeOpenInOtherPane?: (nodeId: number) => void;
|
||||
@@ -66,10 +30,8 @@ interface ViewsOverlayProps {
|
||||
pendingNodes?: PendingNode[];
|
||||
onDismissPending?: (id: string) => void;
|
||||
externalContextFilterId?: number | null;
|
||||
externalDimensionFilter?: string | null;
|
||||
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
onClearExternalContextFilter?: () => void;
|
||||
onClearExternalDimensionFilter?: () => void;
|
||||
toolbarHost?: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
@@ -225,6 +187,29 @@ function SkeletonCard() {
|
||||
);
|
||||
}
|
||||
|
||||
const pickerRowStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: '7px 10px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
color: 'var(--rah-text-secondary)',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
const pickerCountStyle: React.CSSProperties = {
|
||||
color: 'var(--rah-text-muted)',
|
||||
fontSize: '10px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
padding: '1px 6px',
|
||||
borderRadius: '10px',
|
||||
};
|
||||
|
||||
export default function ViewsOverlay({
|
||||
onNodeClick,
|
||||
onNodeOpenInOtherPane,
|
||||
@@ -232,17 +217,11 @@ export default function ViewsOverlay({
|
||||
pendingNodes,
|
||||
onDismissPending,
|
||||
externalContextFilterId = null,
|
||||
externalDimensionFilter = null,
|
||||
onContextFilterSelect,
|
||||
onClearExternalContextFilter,
|
||||
onClearExternalDimensionFilter,
|
||||
toolbarHost,
|
||||
}: ViewsOverlayProps) {
|
||||
const { dimensionIcons } = useDimensionIcons();
|
||||
|
||||
// Dimensions for filter picker
|
||||
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
|
||||
const [dimensionsLoading, setDimensionsLoading] = useState(true);
|
||||
const [contexts, setContexts] = useState<ContextSummary[]>([]);
|
||||
const [contextsLoading, setContextsLoading] = useState(true);
|
||||
|
||||
@@ -256,13 +235,8 @@ export default function ViewsOverlay({
|
||||
const [reorderDragIndex, setReorderDragIndex] = useState<number | null>(null);
|
||||
const [reorderDropIndex, setReorderDropIndex] = useState<number | null>(null);
|
||||
|
||||
// Filter system state
|
||||
const [columns, setColumns] = useState<ColumnFilter[]>([]);
|
||||
const [filteredNodes, setFilteredNodes] = useState<Node[]>([]);
|
||||
const [filteredNodesLoading, setFilteredNodesLoading] = useState(false);
|
||||
const [showFilterPicker, setShowFilterPicker] = useState(false);
|
||||
const [showDimensionPicker, setShowDimensionPicker] = useState(false);
|
||||
const [filterSearchQuery, setFilterSearchQuery] = useState('');
|
||||
const [showSortDropdown, setShowSortDropdown] = useState(false);
|
||||
|
||||
const processedFilter: ProcessedFilter = sortOrder === 'processed'
|
||||
@@ -271,41 +245,6 @@ export default function ViewsOverlay({
|
||||
? 'not_processed'
|
||||
: 'all';
|
||||
|
||||
// Derive selectedFilters for backward compatibility (unique dimensions)
|
||||
const selectedFilters = useMemo(() => {
|
||||
if (externalDimensionFilter) {
|
||||
return [externalDimensionFilter];
|
||||
}
|
||||
|
||||
return [...new Set(columns.map(c => c.dimension))];
|
||||
}, [columns, externalDimensionFilter]);
|
||||
|
||||
// Sorted dimensions (locked first)
|
||||
const sortedDimensions = useMemo(() => {
|
||||
return [...dimensions].sort((a, b) => {
|
||||
if (a.isPriority && !b.isPriority) return -1;
|
||||
if (!a.isPriority && b.isPriority) return 1;
|
||||
return a.dimension.localeCompare(b.dimension);
|
||||
});
|
||||
}, [dimensions]);
|
||||
|
||||
// Fetch functions
|
||||
const fetchDimensions = useCallback(async () => {
|
||||
setDimensionsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/dimensions');
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Failed to fetch dimensions');
|
||||
}
|
||||
setDimensions(data.data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching dimensions:', error);
|
||||
} finally {
|
||||
setDimensionsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchContexts = useCallback(async () => {
|
||||
setContextsLoading(true);
|
||||
try {
|
||||
@@ -364,135 +303,35 @@ export default function ViewsOverlay({
|
||||
}
|
||||
}, [sortOrder, customOrder, applyProcessedFilter, externalContextFilterId]);
|
||||
|
||||
const fetchFilteredNodes = useCallback(async (filters: string[]) => {
|
||||
if (filters.length === 0) {
|
||||
fetchAllNodes();
|
||||
return;
|
||||
}
|
||||
setFilteredNodesLoading(true);
|
||||
try {
|
||||
const apiSort = sortOrder === 'custom' || sortOrder === 'processed' || sortOrder === 'not_processed'
|
||||
? 'updated'
|
||||
: sortOrder;
|
||||
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}&dimensions=${encodeURIComponent(filters.join(','))}&dimensionsMatch=all${externalContextFilterId ? `&contextId=${externalContextFilterId}` : ''}`);
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Failed to fetch nodes');
|
||||
}
|
||||
const nodes: Node[] = data.data || [];
|
||||
if (sortOrder === 'custom' && customOrder.length > 0) {
|
||||
const orderMap = new Map(customOrder.map((id, idx) => [id, idx]));
|
||||
const ordered: Node[] = [];
|
||||
const unordered: Node[] = [];
|
||||
for (const node of nodes) {
|
||||
if (orderMap.has(node.id)) {
|
||||
ordered.push(node);
|
||||
} else {
|
||||
unordered.push(node);
|
||||
}
|
||||
}
|
||||
ordered.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
||||
setFilteredNodes(applyProcessedFilter([...ordered, ...unordered]));
|
||||
} else {
|
||||
setFilteredNodes(applyProcessedFilter(nodes));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching filtered nodes:', error);
|
||||
} finally {
|
||||
setFilteredNodesLoading(false);
|
||||
}
|
||||
}, [fetchAllNodes, sortOrder, customOrder, applyProcessedFilter, externalContextFilterId]);
|
||||
|
||||
// Stringify filters for stable dependency
|
||||
const filtersKey = selectedFilters.join(',');
|
||||
|
||||
// Fetch dimensions on mount
|
||||
useEffect(() => {
|
||||
fetchDimensions();
|
||||
}, [fetchDimensions]);
|
||||
|
||||
// Fetch contexts on mount
|
||||
useEffect(() => {
|
||||
fetchContexts();
|
||||
}, [fetchContexts]);
|
||||
|
||||
// Fetch nodes on mount and when filters/sort/refreshToken change
|
||||
// Fetch nodes on mount and when sort/refreshToken/context filter change
|
||||
useEffect(() => {
|
||||
if (refreshToken > 0) {
|
||||
console.log('🔄 Feed refreshing due to SSE event (refreshToken:', refreshToken, ')');
|
||||
}
|
||||
if (selectedFilters.length > 0) {
|
||||
fetchFilteredNodes(selectedFilters);
|
||||
} else {
|
||||
fetchAllNodes();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filtersKey, sortOrder, refreshToken, externalContextFilterId]);
|
||||
}, [fetchAllNodes, refreshToken, sortOrder, externalContextFilterId]);
|
||||
|
||||
// Also refresh dimensions when data changes (for filter picker counts)
|
||||
// Refresh contexts when data changes
|
||||
useEffect(() => {
|
||||
if (refreshToken > 0) {
|
||||
fetchDimensions();
|
||||
fetchContexts();
|
||||
}
|
||||
}, [refreshToken, fetchDimensions, fetchContexts]);
|
||||
|
||||
// Column management
|
||||
const addColumn = (dimension: string) => {
|
||||
if (externalDimensionFilter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newColumn: ColumnFilter = {
|
||||
id: `col-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
dimension
|
||||
};
|
||||
setColumns([...columns, newColumn]);
|
||||
setShowFilterPicker(false);
|
||||
setFilterSearchQuery('');
|
||||
};
|
||||
|
||||
const removeFilter = (dimension: string) => {
|
||||
if (externalDimensionFilter && dimension === externalDimensionFilter) {
|
||||
onClearExternalDimensionFilter?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = columns.findIndex(c => c.dimension === dimension);
|
||||
if (idx !== -1) {
|
||||
setColumns(columns.filter((_, i) => i !== idx));
|
||||
}
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
if (externalContextFilterId) {
|
||||
onClearExternalContextFilter?.();
|
||||
}
|
||||
|
||||
if (externalDimensionFilter) {
|
||||
onClearExternalDimensionFilter?.();
|
||||
}
|
||||
|
||||
setColumns([]);
|
||||
};
|
||||
|
||||
// Filter dimensions for picker
|
||||
const filterPickerDimensions = sortedDimensions.filter(d =>
|
||||
d.dimension.toLowerCase().includes(filterSearchQuery.toLowerCase())
|
||||
);
|
||||
}, [refreshToken, fetchContexts]);
|
||||
|
||||
// Close dropdowns on outside click
|
||||
const filterPickerRef = useRef<HTMLDivElement>(null);
|
||||
const dimensionPickerRef = useRef<HTMLDivElement>(null);
|
||||
const contextPickerRef = useRef<HTMLDivElement>(null);
|
||||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [showContextPicker, setShowContextPicker] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (showFilterPicker && filterPickerRef.current && !filterPickerRef.current.contains(e.target as HTMLElement)) {
|
||||
setShowFilterPicker(false);
|
||||
setFilterSearchQuery('');
|
||||
}
|
||||
if (showDimensionPicker && dimensionPickerRef.current && !dimensionPickerRef.current.contains(e.target as HTMLElement)) {
|
||||
setShowDimensionPicker(false);
|
||||
if (showContextPicker && contextPickerRef.current && !contextPickerRef.current.contains(e.target as HTMLElement)) {
|
||||
setShowContextPicker(false);
|
||||
}
|
||||
if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) {
|
||||
setShowSortDropdown(false);
|
||||
@@ -500,7 +339,7 @@ export default function ViewsOverlay({
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showDimensionPicker, showFilterPicker, showSortDropdown]);
|
||||
}, [showContextPicker, showSortDropdown]);
|
||||
|
||||
// Reorder handlers
|
||||
const handleReorderDrop = useCallback((dropIdx: number) => {
|
||||
@@ -557,17 +396,13 @@ export default function ViewsOverlay({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating processed state from feed:', error);
|
||||
if (selectedFilters.length > 0) {
|
||||
void fetchFilteredNodes(selectedFilters);
|
||||
} else {
|
||||
void fetchAllNodes();
|
||||
}
|
||||
}
|
||||
}, [fetchAllNodes, fetchFilteredNodes, selectedFilters]);
|
||||
}, [fetchAllNodes]);
|
||||
|
||||
// Render node card
|
||||
const renderNodeCard = (node: Node, index: number) => {
|
||||
const nodeIcon = getNodeIcon(node, dimensionIcons, 14);
|
||||
const nodeIcon = getNodeIcon(node, 14);
|
||||
const isCustomSort = sortOrder === 'custom';
|
||||
const isDragSource = reorderDragIndex === index;
|
||||
const isDropTarget = reorderDropIndex === index;
|
||||
@@ -587,7 +422,7 @@ export default function ViewsOverlay({
|
||||
onDragStart={(e) => {
|
||||
const title = node.title || 'Untitled';
|
||||
e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: node.id, title }));
|
||||
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: node.id, title, dimensions: node.dimensions || [] }));
|
||||
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: node.id, title }));
|
||||
e.dataTransfer.setData('text/plain', `[NODE:${node.id}:"${title}"]`);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
@@ -636,8 +471,7 @@ export default function ViewsOverlay({
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
|
||||
{/* Grip handle — only in custom sort mode */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', minWidth: 0 }}>
|
||||
{isCustomSort && (
|
||||
<div
|
||||
draggable
|
||||
@@ -695,43 +529,66 @@ export default function ViewsOverlay({
|
||||
</button>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
fontWeight: 600,
|
||||
color: 'var(--rah-text-active)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
}}>
|
||||
{node.title || 'Untitled'}
|
||||
</span>
|
||||
{node.context?.name ? (
|
||||
<span style={{
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--rah-accent-green)',
|
||||
background: 'color-mix(in srgb, var(--rah-accent-green) 12%, var(--rah-bg-panel))',
|
||||
border: '1px solid color-mix(in srgb, var(--rah-accent-green) 32%, var(--rah-border))',
|
||||
padding: '3px 7px',
|
||||
borderRadius: '999px',
|
||||
background: 'rgba(34, 197, 94, 0.08)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.18)',
|
||||
color: 'var(--rah-accent-green)',
|
||||
lineHeight: 1.2,
|
||||
flexShrink: 0,
|
||||
maxWidth: '40%',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{node.context.name}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
lineHeight: '1.35',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}>
|
||||
{descPreview || 'No description'}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
@@ -757,7 +614,7 @@ export default function ViewsOverlay({
|
||||
}}>
|
||||
#{node.id}
|
||||
</span>
|
||||
{node.edge_count != null && node.edge_count > 0 && (
|
||||
{node.edge_count != null && node.edge_count > 0 ? (
|
||||
<span style={{
|
||||
minWidth: '18px',
|
||||
height: '18px',
|
||||
@@ -775,61 +632,13 @@ export default function ViewsOverlay({
|
||||
}}>
|
||||
{node.edge_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
lineHeight: '1.35',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}>
|
||||
{descPreview || 'No description'}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
minWidth: 0,
|
||||
maxWidth: '46%',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{node.dimensions && node.dimensions.length > 0 ? (
|
||||
<>
|
||||
{node.dimensions.map(d => (
|
||||
<span
|
||||
key={d}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
padding: '2px 7px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
color: 'var(--rah-text-base)',
|
||||
borderRadius: '999px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -843,87 +652,6 @@ export default function ViewsOverlay({
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', flex: 1 }}>
|
||||
<div style={{ position: 'relative' }} ref={filterPickerRef}>
|
||||
<button
|
||||
onClick={() => setShowFilterPicker(!showFilterPicker)}
|
||||
title="Context"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '5px',
|
||||
color: 'var(--rah-text-soft)',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
>
|
||||
<Filter size={11} />
|
||||
{externalContextFilterId
|
||||
? (contexts.find((context) => context.id === externalContextFilterId)?.name || 'Context')
|
||||
: 'Context'}
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
|
||||
{showFilterPicker && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
marginTop: '4px',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '10px',
|
||||
padding: '6px',
|
||||
minWidth: '220px',
|
||||
maxHeight: '320px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)'
|
||||
}}>
|
||||
{contextsLoading ? (
|
||||
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
|
||||
Loading contexts...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
onContextFilterSelect?.(null, null);
|
||||
setShowFilterPicker(false);
|
||||
}}
|
||||
style={pickerRowStyle}
|
||||
>
|
||||
<span>All contexts</span>
|
||||
</button>
|
||||
{contexts.map((context) => (
|
||||
<button
|
||||
key={context.id}
|
||||
onClick={() => {
|
||||
onContextFilterSelect?.(context.id, context.name);
|
||||
setShowFilterPicker(false);
|
||||
}}
|
||||
style={{
|
||||
...pickerRowStyle,
|
||||
background: externalContextFilterId === context.id ? 'rgba(255,255,255,0.04)' : 'transparent',
|
||||
color: externalContextFilterId === context.id ? 'var(--rah-text-active)' : 'var(--rah-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', paddingRight: '8px' }}>
|
||||
{context.name}
|
||||
</span>
|
||||
<span style={pickerCountStyle}>{context.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{externalContextFilterId ? (
|
||||
<div
|
||||
style={{
|
||||
@@ -938,7 +666,7 @@ export default function ViewsOverlay({
|
||||
color: '#5a9'
|
||||
}}
|
||||
>
|
||||
{contexts.find((context) => context.id === externalContextFilterId)?.name || 'Context'}
|
||||
{contexts.find((context) => context.id === externalContextFilterId)?.name ?? 'Context'}
|
||||
<button
|
||||
onClick={() => onClearExternalContextFilter?.()}
|
||||
style={{
|
||||
@@ -956,53 +684,16 @@ export default function ViewsOverlay({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedFilters.map(filter => (
|
||||
<div
|
||||
key={filter}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
padding: '3px 8px',
|
||||
background: 'rgba(34, 197, 94, 0.06)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.12)',
|
||||
borderRadius: '5px',
|
||||
fontSize: '11px',
|
||||
color: '#5a9'
|
||||
}}
|
||||
>
|
||||
{filter}
|
||||
<div style={{ position: 'relative' }} ref={contextPickerRef}>
|
||||
<button
|
||||
onClick={() => removeFilter(filter)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#5a9',
|
||||
cursor: 'pointer',
|
||||
padding: '0',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#5a9'; }}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ position: 'relative' }} ref={dimensionPickerRef}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDimensionPicker(!showDimensionPicker);
|
||||
}}
|
||||
title="Dimensions"
|
||||
onClick={() => setShowContextPicker(!showContextPicker)}
|
||||
title="Context filter"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
background: 'transparent',
|
||||
background: externalContextFilterId ? 'rgba(34, 197, 94, 0.06)' : 'transparent',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '5px',
|
||||
color: 'var(--rah-text-soft)',
|
||||
@@ -1010,21 +701,12 @@ export default function ViewsOverlay({
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border)';
|
||||
}}
|
||||
>
|
||||
<Filter size={11} />
|
||||
Dimension
|
||||
<ChevronDown size={10} />
|
||||
Context
|
||||
</button>
|
||||
|
||||
{showDimensionPicker && (
|
||||
{showContextPicker && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
@@ -1040,70 +722,36 @@ export default function ViewsOverlay({
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
value={filterSearchQuery}
|
||||
onChange={(e) => setFilterSearchQuery(e.target.value)}
|
||||
placeholder="Search dimensions..."
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '7px 10px',
|
||||
background: 'var(--rah-bg-base)',
|
||||
border: '1px solid transparent',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--rah-text-active)',
|
||||
fontSize: '12px',
|
||||
marginBottom: '4px',
|
||||
outline: 'none',
|
||||
}}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--rah-border-strong)'; }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
||||
/>
|
||||
{dimensionsLoading ? (
|
||||
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
|
||||
Loading dimensions...
|
||||
</div>
|
||||
) : filterPickerDimensions.length === 0 ? (
|
||||
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
|
||||
{filterSearchQuery ? 'No matching dimensions' : 'No dimensions available'}
|
||||
</div>
|
||||
) : (
|
||||
filterPickerDimensions.map(d => (
|
||||
<button
|
||||
key={d.dimension}
|
||||
onClick={() => addColumn(d.dimension)}
|
||||
onClick={() => {
|
||||
onClearExternalContextFilter?.();
|
||||
setShowContextPicker(false);
|
||||
}}
|
||||
style={pickerRowStyle}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<span>{d.dimension}</span>
|
||||
<span style={pickerCountStyle}>{d.count}</span>
|
||||
All contexts
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
{contextsLoading ? (
|
||||
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
|
||||
Loading contexts...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(selectedFilters.length > 0 || externalContextFilterId) && (
|
||||
) : contexts.map((context) => (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--rah-text-muted)',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer'
|
||||
key={context.id}
|
||||
onClick={() => {
|
||||
onContextFilterSelect?.(context.id, context.name);
|
||||
setShowContextPicker(false);
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
|
||||
style={pickerRowStyle}
|
||||
>
|
||||
Clear all
|
||||
<span>{context.name}</span>
|
||||
<span style={pickerCountStyle}>{context.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
|
||||
@@ -1234,10 +882,10 @@ export default function ViewsOverlay({
|
||||
<div style={{ width: '100%', maxWidth: DOCUMENT_MAX_WIDTH, margin: '0 auto', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
|
||||
<Inbox size={28} strokeWidth={1.5} style={{ opacity: 0.4 }} />
|
||||
<span style={{ fontSize: '14px', color: 'var(--rah-text-secondary)' }}>
|
||||
{selectedFilters.length > 0 ? 'Nothing matches these filters' : 'Nothing here yet'}
|
||||
Nothing here yet
|
||||
</span>
|
||||
<span style={{ fontSize: '12px', opacity: 0.7 }}>
|
||||
{selectedFilters.length > 0 ? 'Try adjusting your filters, or add a node with ⌘N' : 'Add a node with ⌘N to get started'}
|
||||
Add a node with ⌘N to get started
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,9 +20,10 @@ Tool strategy:
|
||||
- youtubeExtract, websiteExtract, and paperExtract when outside content is required.
|
||||
- webSearch only when the knowledge base lacks the answer.
|
||||
|
||||
Dimensions:
|
||||
- Create dimensions to organize content using createDimension, updateDimension, and deleteDimension tools.
|
||||
- Dimensions are flat. Do not invent lock, unlock, pin, or priority behavior.
|
||||
Contexts:
|
||||
- Contexts are optional. Only set one when it is explicit and useful.
|
||||
- Do not expect automatic context assignment.
|
||||
- Improve organization through title, description, source, metadata, and edges instead of dimensions.
|
||||
|
||||
Response polish:
|
||||
- Default to minimal reasoning effort for speed.
|
||||
|
||||
@@ -24,11 +24,11 @@ Tool strategy:
|
||||
- Extract content with youtubeExtract, websiteExtract, paperExtract as needed.
|
||||
- When searchContentEmbeddings highlights a chunk, hydrate the node via getNodesById (or fetch the chunk) before quoting.
|
||||
|
||||
Dimension management:
|
||||
- Create dimensions for new knowledge areas or topics using createDimension.
|
||||
- Update descriptions to help the AI understand dimension purpose.
|
||||
- Dimensions are flat; do not invent lock, unlock, pin, or priority behavior.
|
||||
- Delete unused dimensions to keep the system clean.
|
||||
Context handling:
|
||||
- Contexts are optional soft organization, not a required taxonomy.
|
||||
- Only set a context when it is explicit and genuinely helpful.
|
||||
- Never rely on inferred dimensions or automatic context assignment.
|
||||
- Node quality should come from strong title, description, source, metadata, and edges.
|
||||
|
||||
Response style:
|
||||
- Limit to one or two short sentences. Reference nodes as [NODE:id:"title"].
|
||||
|
||||
@@ -9,7 +9,7 @@ description: "Use for structured review, QA, cleanup, or governance checks acros
|
||||
|
||||
1. Node quality: duplicates, vague descriptions, missing dates, weak titles.
|
||||
2. Edge quality: missing links, weak explanations, wrong directionality.
|
||||
3. Dimension quality: drift, overlap, low-signal categories.
|
||||
3. Context quality: drift, overlap, low-signal buckets, or contexts being overused where stronger node metadata should carry the meaning.
|
||||
4. Skill quality: trigger clarity, overlap, dead/unused skills.
|
||||
|
||||
## Output Format
|
||||
|
||||
@@ -8,31 +8,71 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
|
||||
## Core Rules
|
||||
|
||||
1. Search before create to avoid duplicates.
|
||||
2. Every create/update should aim for a natural description that makes clear what the thing is, why it belongs in the graph, and workflow status.
|
||||
2. Every create/update must include a natural description that clearly says what the thing is, why it matters here, and its current workflow status.
|
||||
3. Use event dates when known (when it happened, not when saved).
|
||||
4. Apply dimensions deliberately; prefer existing dimensions over creating noisy new ones.
|
||||
4. Apply context only when it is explicit and useful. One node gets at most one primary context, and leaving context blank is valid.
|
||||
5. Create edges when relationships are meaningful; edge explanations should read as a sentence.
|
||||
6. For user-authored ideas, notes, or dictated thoughts, preserve the user's wording in `source` as fully as possible with only minimal cleanup.
|
||||
|
||||
## Write Quality Contract
|
||||
|
||||
- `title`: clear and specific.
|
||||
- `description`: natural prose, not labels. It should still make what / why / status clear when possible.
|
||||
- `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. For user-authored ideas or dictated notes, preserve the user's original wording with minimal cleanup.
|
||||
- `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.
|
||||
- For idea capture from chat, the `source` should usually be the raw user thought, not a compressed assistant summary.
|
||||
- `link`: external source URL only.
|
||||
- `metadata`: prefer canonical keys `type`, `state`, `captured_method`, `captured_by`, and `source_metadata`.
|
||||
- `context_id`: the node's primary context. Prefer setting it when the scope is explicit. Leave it null rather than guessing.
|
||||
- `metadata`: use the canonical node metadata contract when metadata is needed:
|
||||
- `type`
|
||||
- `state` (`processed` or `not_processed`)
|
||||
- `captured_method`
|
||||
- `captured_by`
|
||||
- `source_metadata`
|
||||
- `source_metadata`: factual source-specific details only. Keep it compact. No AI summaries or reasoning text.
|
||||
- metadata updates are merge-safe patches, not full-blob replacements. Do not assume `updateNode.metadata` wipes existing keys.
|
||||
- Derived analysis, briefs, and research notes should be stored in a separate linked node, not appended to the source node.
|
||||
|
||||
## Description Standard
|
||||
|
||||
Every node description should read like natural prose, not a template or checklist.
|
||||
|
||||
It must still make three things clear:
|
||||
1. What — what the artifact is in simple explicit terms (format + creator + core claim)
|
||||
2. Why — why it is in the graph; what Brad is interested in; what it connects to
|
||||
3. Status — where it sits in his workflow (queued, in progress, processed, unknown)
|
||||
|
||||
If the agent has graph context (context capsule, focused nodes, recent connected nodes, or an explicit active context), it should infer the why from that context and write it naturally. Do not let the service auto-generate a weak context-free description when you already have enough signal.
|
||||
|
||||
If the why genuinely cannot be inferred, say that naturally. Do not use labels like `WHAT:`, `WHY:`, or `STATUS:` and do not substitute vague filler like `insightful for understanding` or `relevant to Brad's work`.
|
||||
|
||||
If status is unknown, say naturally that it has not been reviewed yet.
|
||||
|
||||
Ask a clarification question only when a missing detail would materially change the node being created. If the user has already given enough substance to infer the artifact, title, and likely why, do the work instead of bouncing it back.
|
||||
|
||||
For user-authored idea capture, do not treat the inferred description as final if the "why" or status was mostly inferred. Save the node first, then tell the user what description framing you inferred and invite one short correction pass on:
|
||||
- what this is
|
||||
- why it belongs here
|
||||
- where it sits in their workflow
|
||||
|
||||
Max 500 characters.
|
||||
|
||||
## Metadata Semantics
|
||||
|
||||
- Direct user creation, quick add, and user-requested agent capture should default to `captured_by = "human"`.
|
||||
- Only autonomous/background creation without direct user instruction should use `captured_by = "agent"`.
|
||||
- Prefer leaving `type` blank over forcing a weak label.
|
||||
- `state` is the user-visible processed flag. If no state is known, default to `not_processed`.
|
||||
|
||||
## Execution Pattern
|
||||
|
||||
1. Read context (search + relevant nodes + relevant edges).
|
||||
2. Decide: create vs update vs connect.
|
||||
3. Execute minimum required writes.
|
||||
4. Verify result reflects user intent exactly.
|
||||
5. If description framing was materially inferred, complete the write first and then invite one concise user feedback pass instead of blocking creation.
|
||||
4. If the node is a user-authored idea and the contextual framing was inferred, offer one concise feedback pass after the write.
|
||||
5. Verify result reflects user intent exactly.
|
||||
|
||||
## Do Not
|
||||
|
||||
- Create duplicate nodes when an update is correct.
|
||||
- Write vague descriptions ("discusses", "explores", "is about").
|
||||
- Replace a user's raw idea/source with a thin summary.
|
||||
- Create weak or directionless edges.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: Node Context Enrichment
|
||||
description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with dimension review and edge suggestions."
|
||||
description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with context review and edge suggestions."
|
||||
---
|
||||
|
||||
# Node Context Enrichment
|
||||
@@ -17,15 +17,18 @@ Replace weak descriptions with a single clean natural description that captures:
|
||||
2. Why it is in Brad's graph
|
||||
3. Status in Brad's workflow
|
||||
|
||||
Also review whether the node needs dimension fixes or obvious edge suggestions.
|
||||
Also review whether the node needs a clearer context assignment or obvious edge suggestions.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Load the node and inspect title, description, source, link, metadata, dimensions, and nearby edges.
|
||||
2. Search for adjacent context before rewriting.
|
||||
1. Load the node and inspect title, description, source, link, metadata, context, and nearby edges.
|
||||
2. Search for adjacent context before rewriting:
|
||||
- the node's primary context and its anchor node when present
|
||||
- recently connected project or belief nodes
|
||||
- related nodes with overlapping titles, creators, or source themes
|
||||
3. Infer the best available "why" from that context.
|
||||
4. Rewrite the full description from scratch in natural prose. Do not append to the old text or use labels like WHAT:, WHY:, or STATUS:.
|
||||
5. Review dimensions.
|
||||
5. Review whether the current context is actually useful. Keep it, clear it, or suggest a better explicit context only when confidence is high.
|
||||
6. Suggest 1-3 high-signal edges when obvious.
|
||||
7. Update the node once the description is strong enough to be useful.
|
||||
8. After the update, tell the user what changed and ask whether they want to refine the important framing:
|
||||
@@ -34,3 +37,52 @@ Also review whether the node needs dimension fixes or obvious edge suggestions.
|
||||
- status / current relevance / workflow position
|
||||
|
||||
The user feedback pass is required whenever the enriched "why" or status was inferred rather than directly stated in the node/source.
|
||||
|
||||
## Description Standard
|
||||
|
||||
Every rewritten description must naturally cover:
|
||||
|
||||
1. What
|
||||
- explicit artifact type
|
||||
- creator/author/speaker when known
|
||||
- core subject, claim, or function
|
||||
2. Why
|
||||
- why Brad saved it
|
||||
- what project, belief, question, or theme it connects to
|
||||
- if genuinely unknown, say that naturally without inventing context
|
||||
3. Status
|
||||
- queued, in progress, processed, not yet reviewed, saved for later, etc.
|
||||
- if unknown, say naturally that it has not been reviewed yet
|
||||
|
||||
Max 500 characters.
|
||||
|
||||
## Batch Mode
|
||||
|
||||
Use batch enrichment when cleaning up many nodes with the same failure mode.
|
||||
|
||||
1. Pull a tight node set first.
|
||||
2. Group by pattern:
|
||||
- vague imported links
|
||||
- thin quick-add captures
|
||||
- old source nodes missing workflow state
|
||||
3. Enrich each node individually. Do not reuse boilerplate "why" text across unrelated nodes.
|
||||
4. Return a compact summary of:
|
||||
- nodes updated
|
||||
- contexts to review
|
||||
- edge suggestions not yet created
|
||||
|
||||
## Quality Bar
|
||||
|
||||
- No filler phrases like `insightful for understanding`, `relevant to`, or `important for`.
|
||||
- No generic summaries that only restate the topic.
|
||||
- No invented certainty. If context is weak, say so explicitly.
|
||||
- Prefer one compact 3-sentence description over bloated prose.
|
||||
|
||||
## Output Pattern
|
||||
|
||||
For each node:
|
||||
|
||||
- New description
|
||||
- Context recommendation: keep / clear / set
|
||||
- Edge suggestions: source -> target with explicit explanation
|
||||
- One short invitation for user feedback when contextual framing was inferred
|
||||
|
||||
+152
-21
@@ -1,35 +1,166 @@
|
||||
---
|
||||
name: Onboarding
|
||||
description: "Onboard a new user by mapping goals, projects, worldview, and preferences into a strong initial graph."
|
||||
when_to_use: "New user setup or major reset of account context."
|
||||
when_not_to_use: "User asks for a narrow tactical operation only."
|
||||
success_criteria: "User has an initial context structure, anchor candidates, and clear next steps for graph growth."
|
||||
description: "Use for new-user setup, empty or near-empty graphs, or major resets to map goals, projects, worldview, and preferences into an initial graph."
|
||||
---
|
||||
|
||||
# Onboarding
|
||||
|
||||
## Goal
|
||||
## Your Job
|
||||
|
||||
Understand the user deeply enough to bootstrap a useful externalized context graph.
|
||||
Three things: help the user understand the basic structure of the system, help them start building useful context in it, and bootstrap the context capsule when durable cross-session facts become clear.
|
||||
|
||||
Adapt to the user.
|
||||
|
||||
- If they already know what they want to add, help them add it.
|
||||
- If they want guidance, guide them with simple prompts.
|
||||
- Do not force a rigid interview if they are already giving you usable context.
|
||||
|
||||
## Start With Orientation, Not Setup Friction
|
||||
|
||||
For signed-in cloud/mac users, do not start by asking whether the app is open or whether they added API keys. The app is already open, and billing-backed cloud usage does not require the old local setup checklist.
|
||||
|
||||
Start with product orientation and goal discovery first.
|
||||
|
||||
Only bring up setup details if the user actually needs them:
|
||||
|
||||
1. If they are on local/BYO-key mode, point them to Settings → API Keys.
|
||||
2. If they ask about the database location, tell them the default macOS path is `~/Library/Application Support/RA-H/db/rah.sqlite`.
|
||||
3. If API keys are relevant, explain them plainly:
|
||||
- **OpenAI** — powers embeddings, semantic retrieval, and extraction-related AI work.
|
||||
- **Anthropic** — mainly relevant for compatible runtime paths and local/dev setups.
|
||||
4. If they are not ready to configure anything yet, keep onboarding. They can still learn the structure and add manual content.
|
||||
|
||||
## Capsule Bootstrap
|
||||
|
||||
As part of onboarding, also bootstrap the context capsule on the user's behalf.
|
||||
|
||||
When the user gives durable identity, preference, worldview, project, or agent-behavior information:
|
||||
|
||||
1. Call `readSkill('context-capsule')`
|
||||
2. Call `readCapsule`
|
||||
3. When you have enough confidence, call `writeCapsule`
|
||||
|
||||
Use the capsule for:
|
||||
- preferred name
|
||||
- agent name or greeting preference
|
||||
- interaction style
|
||||
- major projects
|
||||
- major interests
|
||||
- explicit worldview or belief signals
|
||||
- what the user is using RA-H for
|
||||
|
||||
Do not write the capsule for tentative, one-off, or low-confidence statements.
|
||||
Keep it compressed and canonical. Replace stale instructions instead of preserving contradictory history.
|
||||
|
||||
## Explain the System First
|
||||
|
||||
Before asking anything, orient the user. Be direct, not salesy:
|
||||
|
||||
> "RA-H is a context system built on a simple graph. The goal is to build context that persists and gets more useful over time."
|
||||
|
||||
Explain the structure in simple terms:
|
||||
|
||||
- **Contexts** — an optional soft organization layer. These are broad areas like health, job, life, or research. A node can belong to one context when it is explicit and useful, but context is not required.
|
||||
- **Nodes** — individual things. A project, idea, person, source, belief, decision, or topic. Each node must have a clear description of what it is and why it matters.
|
||||
- **Edges** — explicit connections between things. Each edge must clearly explain the relationship.
|
||||
- **Metadata and edges** — secondary structure that makes nodes more useful once the core artifact is clear.
|
||||
|
||||
Then say:
|
||||
|
||||
> "If you know specifically how you'd like to create your context corpus, feel free to tell me what you'd like to add and I can help you set things up. Otherwise, I can guide you through bootstrapping your context with a few suggested prompts."
|
||||
|
||||
Also explain one practical thing early:
|
||||
|
||||
> "You do not need to perfectly design this up front. We want a few concrete nodes, clear contexts when they matter, and a few clean edges so the graph becomes useful quickly."
|
||||
|
||||
## Interview Flow
|
||||
|
||||
1. Clarify outcomes: goals for this system and success horizon.
|
||||
2. Map active projects: responsibilities, timelines, decision pressure.
|
||||
3. Capture worldview: beliefs, principles, working assumptions, decision criteria.
|
||||
4. Capture identity/context anchors: roles, domains, recurring themes.
|
||||
5. Capture interaction preferences: style, rigor, speed vs depth, tone.
|
||||
Keep it conversational. Use these buckets and adapt based on what the user gives you.
|
||||
|
||||
## Graph Bootstrap
|
||||
**1. Projects and active work**
|
||||
- What are you working on right now?
|
||||
- What projects, responsibilities, or decisions should be part of your context?
|
||||
- What keeps coming up enough that it should probably live in the graph?
|
||||
|
||||
1. Propose 3-6 primary contexts with clear rationale.
|
||||
2. Identify one strong anchor candidate per context.
|
||||
3. Propose starter dimensions as secondary metadata and filters.
|
||||
4. Create initial edges between anchor nodes and active project nodes.
|
||||
5. Confirm with user before writing.
|
||||
**2. Goals, motivations, beliefs, world models**
|
||||
- What are you trying to achieve?
|
||||
- What motivations, principles, or beliefs shape how you work and make decisions?
|
||||
- Are there any mental models or recurring ways you think about things that should be captured?
|
||||
|
||||
## Output
|
||||
**3. Learning, exploration, and research**
|
||||
- What are you reading, watching, listening to, or researching lately?
|
||||
- Any podcasts, articles, papers, books, or rabbit holes that matter right now?
|
||||
- Are there specific people, thinkers, or sources you follow closely?
|
||||
|
||||
- Initial context map
|
||||
- Suggested first write actions
|
||||
- Suggested weekly maintenance rhythm
|
||||
**4. Interaction style and preferences**
|
||||
- How do you want me to work with you?
|
||||
- Do you want concise answers, deeper exploration, pushback, or straightforward execution?
|
||||
|
||||
## First-Run Teaching Points
|
||||
|
||||
Work these in naturally when they are relevant:
|
||||
|
||||
- **First node creation** — explain that a node is one concrete thing worth keeping: a project, source, person, belief, decision, or idea.
|
||||
- **Contexts** — explain that contexts are the primary folders or scopes for the graph.
|
||||
- **MCP connection** — if the user mentions Claude Code or external agents, offer a quick setup path and point them to the MCP docs/skill flow rather than reciting a giant config block immediately.
|
||||
- **What to do after setup** — once the graph has a few solid nodes, the next useful move is usually one of:
|
||||
- connect related nodes with explicit edges
|
||||
- ingest a source they care about
|
||||
- add one or two skills/preferences so future conversations stay grounded
|
||||
|
||||
## How to Work
|
||||
|
||||
Do your best to build the graph as useful context emerges.
|
||||
|
||||
- Add nodes when the user mentions concrete things worth keeping.
|
||||
- Assign a primary context when one is clear. Prefer leaving context empty over low-confidence guessing.
|
||||
- Add edges when relationships are clear enough to explain well.
|
||||
- Explain what you're adding in plain language so the user understands the structure as it develops.
|
||||
|
||||
When the graph is empty or nearly empty, bias toward creating a small, clean starter set rather than over-modeling everything.
|
||||
|
||||
## Write Standards
|
||||
|
||||
Before writing anything, call `readSkill('db-operations')` for full quality standards. Key points that matter most here:
|
||||
|
||||
- Search before creating — avoid duplicates from day one
|
||||
- Every description must be concrete: what it IS and why it matters to them, not what it "explores" or "discusses"
|
||||
- Contexts should hold the primary scope when clear, otherwise leave them empty
|
||||
- Every edge needs an explicit explanation sentence
|
||||
|
||||
## Propose Before Writing
|
||||
|
||||
When there is enough context, summarize the proposed structure before touching the database:
|
||||
|
||||
> "Here's what I'm planning to create: [list contexts with one-line rationale], [list starter nodes], [list key edges]. Does this look right? Anything to adjust?"
|
||||
|
||||
Write only after confirmation.
|
||||
|
||||
If onboarding also surfaced durable cross-session facts, include the capsule update in the proposal:
|
||||
|
||||
> "I also plan to bootstrap your context capsule with your name, interaction preferences, and current priorities so future chats start grounded. Sound right?"
|
||||
> "I also plan to bootstrap your context capsule with your name and interaction preferences so future chats start grounded. Sound right?"
|
||||
|
||||
For very early setup, include the first actionable next step too:
|
||||
|
||||
> "After this starter pass, the best next move will be [add a source / connect these nodes / capture another active project]."
|
||||
|
||||
## Completion
|
||||
|
||||
After writing, give a brief recap:
|
||||
- What was created
|
||||
- How the structure works
|
||||
- What would be useful to add next
|
||||
|
||||
If setup is still incomplete, end with the smallest next action, for example:
|
||||
- add OpenAI in Settings
|
||||
- connect Claude Code via MCP
|
||||
- add one more source node
|
||||
|
||||
## Do Not
|
||||
|
||||
- Create meta-nodes like "User Profile", "Preferences", or "Goals" — the graph IS the profile
|
||||
- Write anything before proposing the structure and getting confirmation
|
||||
- Skip the interview and go straight to writing
|
||||
- Write vague descriptions ("is about", "explores", "discusses", "touches on")
|
||||
- Ask one disconnected question at a time when a natural multi-part thread is cleaner
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
interface AuthContextValue {
|
||||
status: 'unauthenticated';
|
||||
user: null;
|
||||
session: null;
|
||||
supabase: null;
|
||||
signIn: () => Promise<{ error: Error | null }>;
|
||||
signUp: () => Promise<{ error: Error | null }>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const noopError = new Error('Authentication is not enabled in the open-source build.');
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
status: 'unauthenticated',
|
||||
user: null,
|
||||
session: null,
|
||||
supabase: null,
|
||||
signIn: async () => ({ error: noopError }),
|
||||
signUp: async () => ({ error: noopError }),
|
||||
signOut: async () => {},
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
return <AuthContext.Provider value={useContext(AuthContext)}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuthContext(): AuthContextValue {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import { usePersistentState } from '@/hooks/usePersistentState';
|
||||
|
||||
interface DimensionIconsContextValue {
|
||||
dimensionIcons: Record<string, string>;
|
||||
setDimensionIcons: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||
}
|
||||
|
||||
const DimensionIconsContext = createContext<DimensionIconsContextValue>({
|
||||
dimensionIcons: {},
|
||||
setDimensionIcons: () => {},
|
||||
});
|
||||
|
||||
export function DimensionIconsProvider({ children }: { children: ReactNode }) {
|
||||
const [dimensionIcons, setDimensionIcons] = usePersistentState<Record<string, string>>('ui.dimensionIcons', {});
|
||||
|
||||
return (
|
||||
<DimensionIconsContext.Provider value={{ dimensionIcons, setDimensionIcons }}>
|
||||
{children}
|
||||
</DimensionIconsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDimensionIcons() {
|
||||
return useContext(DimensionIconsContext);
|
||||
}
|
||||
@@ -12,9 +12,6 @@
|
||||
import { nodeService, edgeService } from '@/services/database';
|
||||
import { Node } from '@/types/database';
|
||||
|
||||
// Entity-like dimensions where we expect to find referenceable entities
|
||||
const ENTITY_DIMENSIONS = ['people', 'companies', 'organizations', 'books', 'papers', 'articles', 'podcasts', 'creators'];
|
||||
|
||||
/**
|
||||
* Clean up a candidate entity string by removing common prefixes/suffixes.
|
||||
*/
|
||||
@@ -113,7 +110,7 @@ function isGenericPhrase(phrase: string): boolean {
|
||||
|
||||
/**
|
||||
* Look up existing nodes that match candidate entity strings.
|
||||
* Uses exact title matching (case-insensitive) with optional dimension filtering.
|
||||
* Uses exact title matching (case-insensitive).
|
||||
*/
|
||||
async function findMatchingEntityNodes(candidates: string[]): Promise<Map<string, Node>> {
|
||||
const matches = new Map<string, Node>();
|
||||
@@ -131,16 +128,7 @@ async function findMatchingEntityNodes(candidates: string[]): Promise<Map<string
|
||||
const matchingNode = allNodes.find(node => {
|
||||
const normalizedTitle = (node.title || '').toLowerCase().trim();
|
||||
if (normalizedTitle !== normalizedCandidate) return false;
|
||||
|
||||
// Optionally filter to entity-like dimensions for higher confidence
|
||||
// But don't require it - some entities might not have dimensions yet
|
||||
const nodeDimensions = node.dimensions || [];
|
||||
const hasEntityDimension = nodeDimensions.some(dim =>
|
||||
ENTITY_DIMENSIONS.includes(dim.toLowerCase())
|
||||
);
|
||||
|
||||
// Accept if it has an entity dimension OR if it's a short title (likely a named entity)
|
||||
return hasEntityDimension || node.title.length < 50;
|
||||
return node.title.length < 80;
|
||||
});
|
||||
|
||||
if (matchingNode) {
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface QuickAddInput {
|
||||
rawInput: string;
|
||||
mode?: QuickAddMode;
|
||||
description?: string;
|
||||
contextId?: number | null;
|
||||
}
|
||||
|
||||
export interface QuickAddResult {
|
||||
@@ -65,7 +66,7 @@ function buildTaskPrompt(type: QuickAddInputType, input: string): string {
|
||||
case 'pdf':
|
||||
return `Quick Add: extract PDF and create node → ${input}`;
|
||||
case 'note':
|
||||
return `Quick Add note: create a node from this text with no dimensions → ${input}`;
|
||||
return `Quick Add note: create a node from this text with optional context → ${input}`;
|
||||
case 'chat':
|
||||
return `Quick Add: import chat transcript and summarize → ${input.slice(0, 120)}${input.length > 120 ? '…' : ''}`;
|
||||
}
|
||||
@@ -199,12 +200,11 @@ function isCreateNodeResponse(value: unknown): value is CreateNodeResponse {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: string, task: string): Promise<string> {
|
||||
async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: string, task: string, contextId?: number | null): Promise<string> {
|
||||
const { toolName, execute } = EXTRACTION_TOOL_MAP[type];
|
||||
if (!execute) {
|
||||
throw new Error(`Tool ${toolName} does not have an execute function`);
|
||||
}
|
||||
|
||||
try {
|
||||
const rawResult = await execute({ url }, { toolCallId: 'quickadd-extract', messages: [] });
|
||||
|
||||
@@ -263,6 +263,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
|
||||
refined_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
context_id: contextId,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -289,7 +290,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNoteQuickAdd(rawInput: string, task: string, userDescription?: string): Promise<string> {
|
||||
async function handleNoteQuickAdd(rawInput: string, task: string, userDescription?: string, contextId?: number | null): Promise<string> {
|
||||
const content = rawInput.trim();
|
||||
if (!content) {
|
||||
throw new Error('Input is required to create a note');
|
||||
@@ -299,6 +300,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
|
||||
const nodePayload: Record<string, unknown> = {
|
||||
title,
|
||||
source: content,
|
||||
context_id: contextId,
|
||||
metadata: {
|
||||
type: 'note',
|
||||
state: 'not_processed',
|
||||
@@ -310,6 +312,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
|
||||
},
|
||||
};
|
||||
|
||||
// If user provided a description, use it instead of auto-generating
|
||||
if (userDescription && userDescription.trim()) {
|
||||
nodePayload.description = userDescription.trim();
|
||||
}
|
||||
@@ -342,7 +345,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
|
||||
});
|
||||
}
|
||||
|
||||
async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Promise<string> {
|
||||
async function handleChatTranscriptQuickAdd(rawInput: string, task: string, contextId?: number | null): Promise<string> {
|
||||
const transcript = rawInput.trim();
|
||||
if (!transcript) {
|
||||
throw new Error('Input is required to import a chat transcript');
|
||||
@@ -401,8 +404,9 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
source: transcript,
|
||||
description: nodeDescription,
|
||||
source: transcript,
|
||||
context_id: contextId,
|
||||
metadata,
|
||||
}),
|
||||
});
|
||||
@@ -429,7 +433,7 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
|
||||
});
|
||||
}
|
||||
|
||||
export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput): Promise<QuickAddResult> {
|
||||
export async function enqueueQuickAdd({ rawInput, mode, description, contextId }: QuickAddInput): Promise<QuickAddResult> {
|
||||
const inputType = detectInputType(rawInput, mode);
|
||||
const task = buildTaskPrompt(inputType, rawInput);
|
||||
const id = `qa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -441,21 +445,25 @@ export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddI
|
||||
status: 'queued',
|
||||
};
|
||||
|
||||
// Run async - fire and forget
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
let summary: string;
|
||||
if (inputType === 'note') {
|
||||
await handleNoteQuickAdd(rawInput, task, description);
|
||||
summary = await handleNoteQuickAdd(rawInput, task, description, contextId);
|
||||
} else if (inputType === 'chat') {
|
||||
await handleChatTranscriptQuickAdd(rawInput, task);
|
||||
summary = await handleChatTranscriptQuickAdd(rawInput, task, contextId);
|
||||
} else {
|
||||
await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
|
||||
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task, contextId);
|
||||
}
|
||||
|
||||
console.log(`[QuickAdd] Completed: ${task}`);
|
||||
// Broadcast completion so ThreePanelLayout can remove the pending placeholder
|
||||
eventBroadcaster.broadcast({
|
||||
type: 'QUICK_ADD_COMPLETED',
|
||||
data: { quickAddId: id, source: 'quick-add' }
|
||||
});
|
||||
// Also broadcast NODE_CREATED to refresh the feed
|
||||
eventBroadcaster.broadcast({
|
||||
type: 'NODE_CREATED',
|
||||
data: { node: { title: task }, source: 'quick-add' }
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
import { getAutoContextSettings } from '@/services/settings/autoContextSettings';
|
||||
|
||||
export interface AutoContextSummary {
|
||||
id: number;
|
||||
@@ -34,7 +33,8 @@ function truncate(value: string | null | undefined, maxChars: number): string {
|
||||
|
||||
function fetchAutoContextRows(limit: number): AutoContextSummary[] {
|
||||
const db = getSQLiteClient();
|
||||
const rows = db.query<{
|
||||
const rows = db
|
||||
.query<{
|
||||
id: number;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
@@ -55,7 +55,8 @@ function fetchAutoContextRows(limit: number): AutoContextSummary[] {
|
||||
LIMIT ?
|
||||
`,
|
||||
[limit]
|
||||
).rows;
|
||||
)
|
||||
.rows;
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
@@ -150,7 +151,7 @@ export function buildContextsBlock(limit = 12): string | null {
|
||||
|
||||
const lines: string[] = [
|
||||
'User Contexts',
|
||||
'Use contexts as the primary scope layer. Dimensions are secondary metadata and filters.',
|
||||
'Contexts are optional soft hints. Use them when they are explicit and useful, but rely primarily on title, description, source, edges, and recency.',
|
||||
'',
|
||||
];
|
||||
|
||||
@@ -172,7 +173,7 @@ export function buildContextAnchorsBlock(limit = 12): string | null {
|
||||
|
||||
const lines: string[] = [
|
||||
'Context Anchors',
|
||||
'Each context anchor is the highest-edge node in that context. Use it as the starting graph waypoint for that scope.',
|
||||
'Each context anchor is the highest-edge node in that context. Use it only as an optional waypoint when that context is already clearly relevant.',
|
||||
'',
|
||||
];
|
||||
|
||||
@@ -195,7 +196,7 @@ export function buildHubNodesBlock(limit = 5): string | null {
|
||||
|
||||
const lines: string[] = [
|
||||
'Global Hub Diagnostics',
|
||||
'These are secondary graph diagnostics only. Do not use them as the primary scope layer when contexts are available.',
|
||||
'These are secondary graph diagnostics only. Do not treat them as the primary grounding mechanism.',
|
||||
'',
|
||||
];
|
||||
|
||||
@@ -210,24 +211,9 @@ export function buildHubNodesBlock(limit = 5): string | null {
|
||||
}
|
||||
|
||||
export function getAutoContextSummaries(limit = 5): AutoContextSummary[] {
|
||||
const settings = getAutoContextSettings();
|
||||
if (!settings.autoContextEnabled) {
|
||||
return [];
|
||||
}
|
||||
return getHubNodes(limit);
|
||||
}
|
||||
|
||||
export function buildAutoContextBlock(limit = 12): string | null {
|
||||
const settings = getAutoContextSettings();
|
||||
if (!settings.autoContextEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sections = [
|
||||
buildContextsBlock(limit),
|
||||
buildContextAnchorsBlock(limit),
|
||||
buildHubNodesBlock(5),
|
||||
].filter(Boolean);
|
||||
|
||||
return sections.length > 0 ? sections.join('\n\n') : null;
|
||||
export function buildAutoContextBlock(limit = 5): string | null {
|
||||
return buildContextsBlock(limit);
|
||||
}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
|
||||
type ContextCandidate = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
count: number;
|
||||
anchor_title: string | null;
|
||||
anchor_description: string | null;
|
||||
};
|
||||
|
||||
type InferContextInput = {
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
source?: string | null;
|
||||
dimensions?: string[] | null;
|
||||
metadata?: unknown;
|
||||
};
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'i',
|
||||
'in', 'is', 'it', 'its', 'of', 'on', 'or', 'that', 'the', 'their', 'this',
|
||||
'to', 'was', 'with', 'you', 'your'
|
||||
]);
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9\s]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function tokenize(value: string | null | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return normalizeText(value)
|
||||
.split(' ')
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length >= 2 && !STOP_WORDS.has(token));
|
||||
}
|
||||
|
||||
function uniqueTokens(values: Array<string | null | undefined>): string[] {
|
||||
return Array.from(new Set(values.flatMap((value) => tokenize(value))));
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value ?? {});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function fetchContextCandidates(): ContextCandidate[] {
|
||||
const sqlite = getSQLiteClient();
|
||||
return sqlite.query<ContextCandidate>(`
|
||||
WITH context_counts AS (
|
||||
SELECT c.id, c.name, c.description, COUNT(n.id) AS count
|
||||
FROM contexts c
|
||||
LEFT JOIN nodes n ON n.context_id = c.id
|
||||
GROUP BY c.id
|
||||
),
|
||||
ranked_anchors AS (
|
||||
SELECT
|
||||
c.id AS context_id,
|
||||
n.title AS anchor_title,
|
||||
n.description AS anchor_description,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY c.id
|
||||
ORDER BY COUNT(e.id) DESC, n.updated_at DESC, n.id ASC
|
||||
) AS anchor_rank
|
||||
FROM contexts c
|
||||
LEFT JOIN nodes n ON n.context_id = c.id
|
||||
LEFT JOIN edges e ON (e.from_node_id = n.id OR e.to_node_id = n.id)
|
||||
GROUP BY c.id, n.id
|
||||
)
|
||||
SELECT
|
||||
cc.id,
|
||||
cc.name,
|
||||
cc.description,
|
||||
cc.count,
|
||||
ra.anchor_title,
|
||||
ra.anchor_description
|
||||
FROM context_counts cc
|
||||
LEFT JOIN ranked_anchors ra
|
||||
ON ra.context_id = cc.id
|
||||
AND ra.anchor_rank = 1
|
||||
ORDER BY cc.name COLLATE NOCASE ASC
|
||||
`).rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
name: row.name,
|
||||
description: row.description ?? null,
|
||||
count: Number(row.count ?? 0),
|
||||
anchor_title: row.anchor_title ?? null,
|
||||
anchor_description: row.anchor_description ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function scoreContextCandidate(candidate: ContextCandidate, input: InferContextInput): number {
|
||||
const titleText = normalizeText(input.title || '');
|
||||
const descriptionText = normalizeText(input.description || '');
|
||||
const sourceText = normalizeText((input.source || '').slice(0, 4000));
|
||||
const metadataText = normalizeText(safeStringify(input.metadata));
|
||||
const dimensionTokens = uniqueTokens(input.dimensions ?? []);
|
||||
const contextName = normalizeText(candidate.name);
|
||||
const contextNameTokens = tokenize(candidate.name);
|
||||
const contextDescriptorTokens = uniqueTokens([
|
||||
candidate.description,
|
||||
candidate.anchor_title,
|
||||
candidate.anchor_description,
|
||||
]);
|
||||
|
||||
let score = 0;
|
||||
|
||||
if (contextName && (titleText.includes(contextName) || descriptionText.includes(contextName))) {
|
||||
score += 80;
|
||||
}
|
||||
if (contextName && sourceText.includes(contextName)) {
|
||||
score += 40;
|
||||
}
|
||||
|
||||
for (const token of contextNameTokens) {
|
||||
if (dimensionTokens.includes(token)) score += 30;
|
||||
if (titleText.includes(token)) score += 16;
|
||||
if (descriptionText.includes(token)) score += 12;
|
||||
if (sourceText.includes(token)) score += 6;
|
||||
if (metadataText.includes(token)) score += 4;
|
||||
}
|
||||
|
||||
for (const token of contextDescriptorTokens) {
|
||||
if (dimensionTokens.includes(token)) score += 8;
|
||||
if (titleText.includes(token)) score += 4;
|
||||
if (descriptionText.includes(token)) score += 3;
|
||||
if (sourceText.includes(token)) score += 2;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export async function inferBestContextIdForNode(input: InferContextInput): Promise<number | null> {
|
||||
const contexts = fetchContextCandidates();
|
||||
if (contexts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ranked = contexts
|
||||
.map((context) => ({
|
||||
context,
|
||||
score: scoreContextCandidate(context, input),
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
b.score - a.score ||
|
||||
(b.context.count - a.context.count) ||
|
||||
a.context.id - b.context.id
|
||||
);
|
||||
|
||||
const best = ranked[0];
|
||||
if (!best) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (best.score > 0) {
|
||||
return best.context.id;
|
||||
}
|
||||
|
||||
const research = contexts.find((context) => context.name.trim().toLowerCase() === 'research');
|
||||
if (research) {
|
||||
return research.id;
|
||||
}
|
||||
|
||||
return ranked[0].context.id;
|
||||
}
|
||||
@@ -177,7 +177,9 @@ export class ContextService {
|
||||
}
|
||||
|
||||
async resolveContextId(input: { context_id?: number | null; context_name?: string | null }): Promise<number | null | undefined> {
|
||||
const hasContextId = Object.prototype.hasOwnProperty.call(input, 'context_id');
|
||||
const hasContextId =
|
||||
Object.prototype.hasOwnProperty.call(input, 'context_id') &&
|
||||
input.context_id !== undefined;
|
||||
const hasContextName = typeof input.context_name === 'string' && input.context_name.trim().length > 0;
|
||||
|
||||
if (!hasContextId && !hasContextName) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { openai as openaiProvider } from '@ai-sdk/openai';
|
||||
import { generateText } from 'ai';
|
||||
import { openai as openaiProvider } from '@ai-sdk/openai';
|
||||
import { hasValidOpenAiKey } from '../storage/apiKeys';
|
||||
import type { CanonicalNodeMetadata } from '@/types/database';
|
||||
|
||||
@@ -16,11 +16,12 @@ export interface DescriptionInput {
|
||||
pages?: number;
|
||||
text_length?: number;
|
||||
};
|
||||
dimensions?: string[];
|
||||
}
|
||||
|
||||
export { hasValidOpenAiKey } from '../storage/apiKeys';
|
||||
|
||||
/**
|
||||
* Generate a context-rich description for a knowledge node.
|
||||
* The result must cover what the artifact is, why it is in the graph, and workflow status.
|
||||
*/
|
||||
export async function generateDescription(input: DescriptionInput): Promise<string> {
|
||||
if (!hasValidOpenAiKey()) {
|
||||
console.log(`[DescriptionService] No valid OpenAI key, using fallback for: "${input.title}"`);
|
||||
@@ -46,10 +47,13 @@ export async function generateDescription(input: DescriptionInput): Promise<stri
|
||||
return description;
|
||||
} catch (error) {
|
||||
console.error('[DescriptionService] Error generating description:', error);
|
||||
// Fallback: just use the title — more useful than a vague template
|
||||
return `${input.title}. Added via Quick Add with no further context yet, so the reason it belongs in the graph is not fully inferred. It has not been reviewed yet.`.slice(0, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export { hasValidOpenAiKey } from '../storage/apiKeys';
|
||||
|
||||
function buildDescriptionPrompt(input: DescriptionInput): string {
|
||||
const sourceMetadata = input.metadata?.source_metadata as Record<string, unknown> | undefined;
|
||||
const sourceType = typeof input.metadata?.type === 'string'
|
||||
@@ -60,6 +64,8 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
|
||||
const normalizedSource = sourceType.toLowerCase();
|
||||
const url = typeof input.link === 'string' ? input.link.trim() : '';
|
||||
|
||||
// Best-effort creator hint from structured metadata (when available),
|
||||
// but never assume a particular extraction source (YouTube vs paper vs website vs note).
|
||||
const creatorHint =
|
||||
(typeof sourceMetadata?.author === 'string' ? sourceMetadata.author.trim() : '') ||
|
||||
(typeof sourceMetadata?.channel_name === 'string' ? sourceMetadata.channel_name.trim() : '') ||
|
||||
@@ -67,6 +73,7 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
|
||||
(typeof input.metadata?.channel_name === 'string' ? input.metadata.channel_name.trim() : '') ||
|
||||
'';
|
||||
|
||||
// Best-effort publisher / container hint (less ideal than a true author, but better than nothing).
|
||||
const publisherHint =
|
||||
(typeof sourceMetadata?.site_name === 'string' ? sourceMetadata.site_name.trim() : '') ||
|
||||
(typeof input.metadata?.site_name === 'string' ? input.metadata.site_name.trim() : '') ||
|
||||
@@ -90,7 +97,6 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
|
||||
const lines: string[] = [`Title: ${input.title}`];
|
||||
|
||||
if (input.link) lines.push(`URL: ${input.link}`);
|
||||
if (input.dimensions?.length) lines.push(`Dimensions: ${input.dimensions.join(', ')}`);
|
||||
if (sourceMetadata?.channel_name || input.metadata?.channel_name) lines.push(`Channel: ${sourceMetadata?.channel_name || input.metadata?.channel_name}`);
|
||||
if (sourceMetadata?.author || input.metadata?.author) lines.push(`Author: ${sourceMetadata?.author || input.metadata?.author}`);
|
||||
if (sourceMetadata?.site_name || input.metadata?.site_name) lines.push(`Site: ${sourceMetadata?.site_name || input.metadata?.site_name}`);
|
||||
@@ -116,7 +122,7 @@ RULES:
|
||||
1) Name the format only if the context clearly supports it: "Podcast episode where…", "Blog post arguing…", "Personal note capturing…", "Research paper showing…", "Resume/CV for…", "Document likely containing…", "Idea that…"
|
||||
2) Name people by role — channel/host is the creator, title figures are guests/subjects. Use the Creator hint if available.
|
||||
3) State the actual claim, finding, or insight from the content — not a vague summary of the topic.
|
||||
4) If the reason it belongs in the graph cannot be inferred from title, source excerpt, URL, metadata, or dimensions, say that naturally rather than inventing context.
|
||||
4) If the reason it belongs in the graph cannot be inferred from title, source excerpt, URL, or metadata, say that naturally rather than inventing context.
|
||||
5) If workflow status is unknown, say that naturally, for example by noting it has not been reviewed yet.
|
||||
6) Do NOT use labels or headings like "WHAT:", "WHY:", or "STATUS:".
|
||||
7) ABSOLUTELY FORBIDDEN — these words and phrases will be rejected: "discusses", "explores", "examines", "talks about", "is about", "delves into", "emphasizing the need for", "insightful for understanding", "relevant to", "important for", "useful for understanding". State things directly instead.
|
||||
@@ -144,6 +150,7 @@ function sanitizeDescription(rawText: string, input: DescriptionInput): string {
|
||||
return `${input.title}. Added via Quick Add with no further context yet, so the reason it belongs in the graph is not fully inferred. It has not been reviewed yet.`.slice(0, 500);
|
||||
}
|
||||
|
||||
// Guard against weak generic openings from model drift.
|
||||
const noGenericPrefix = singleLine.replace(
|
||||
/^(your note|this note)\s*[—:-]\s*/i,
|
||||
'Personal note capturing '
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import { getSQLiteClient } from './sqlite-client';
|
||||
|
||||
export interface Dimension {
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_priority: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LockedDimension {
|
||||
name: string;
|
||||
description: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export class DimensionService {
|
||||
/**
|
||||
* Legacy compatibility shim. Dimensions are now flat, so there is no locked subset.
|
||||
*/
|
||||
static async getLockedDimensions(): Promise<LockedDimension[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatic special-dimension assignment has been removed. Callers must provide dimensions explicitly.
|
||||
*/
|
||||
static async assignDimensions(nodeData: {
|
||||
title: string;
|
||||
content?: string;
|
||||
link?: string;
|
||||
description?: string;
|
||||
}): Promise<{ locked: string[]; keywords: string[] }> {
|
||||
console.log(`[DimensionAssignment] Skipped for "${nodeData.title}" — flat dimensions require explicit assignment.`);
|
||||
return { locked: [], keywords: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method for backwards compatibility
|
||||
* @deprecated Use assignDimensions() instead
|
||||
*/
|
||||
static async assignLockedDimensions(nodeData: {
|
||||
title: string;
|
||||
content?: string;
|
||||
link?: string;
|
||||
}): Promise<string[]> {
|
||||
const result = await this.assignDimensions(nodeData);
|
||||
return result.locked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update dimension description
|
||||
*/
|
||||
static async updateDimensionDescription(name: string, description: string): Promise<void> {
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
sqlite.query(`
|
||||
INSERT INTO dimensions(name, description, is_priority, updated_at)
|
||||
VALUES (?, ?, 0, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
description = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, [name, description, description]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dimension by name with description
|
||||
*/
|
||||
static async getDimensionByName(name: string): Promise<Dimension | null> {
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const result = sqlite.query(`
|
||||
SELECT name, description, is_priority, updated_at
|
||||
FROM dimensions
|
||||
WHERE name = ?
|
||||
`, [name]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = result.rows[0] as any;
|
||||
return {
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
is_priority: Boolean(row.is_priority),
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy no-op prompt builder retained only for backward compatibility.
|
||||
*/
|
||||
private static buildAssignmentPrompt(
|
||||
nodeData: { title: string; notes?: string; link?: string; description?: string },
|
||||
lockedDimensions: LockedDimension[]
|
||||
): string {
|
||||
// Use description as primary context, content as fallback
|
||||
let nodeContextSection: string;
|
||||
if (nodeData.description) {
|
||||
const notesPreview = nodeData.notes?.slice(0, 500) || '';
|
||||
nodeContextSection = `DESCRIPTION: ${nodeData.description}
|
||||
|
||||
NOTES PREVIEW: ${notesPreview}${nodeData.notes && nodeData.notes.length > 500 ? '...' : ''}`;
|
||||
} else {
|
||||
const notesPreview = nodeData.notes?.slice(0, 2000) || '';
|
||||
nodeContextSection = `NOTES: ${notesPreview}${nodeData.notes && nodeData.notes.length > 2000 ? '...' : ''}`;
|
||||
}
|
||||
|
||||
// Include ALL locked dimensions, using fallback text for those without descriptions
|
||||
const dimensionsList = lockedDimensions
|
||||
.map(d => {
|
||||
const description = d.description && d.description.trim().length > 0
|
||||
? d.description
|
||||
: '(none - infer from name)';
|
||||
return `DIMENSION: "${d.name}"\nDESCRIPTION: ${description}`;
|
||||
})
|
||||
.join('\n---\n');
|
||||
|
||||
return `Dimensions are now flat categories with no locked subset.
|
||||
|
||||
=== NODE TO CATEGORIZE ===
|
||||
Title: ${nodeData.title}
|
||||
${nodeContextSection}
|
||||
URL: ${nodeData.link || 'none'}
|
||||
|
||||
=== LOCKED DIMENSIONS ===
|
||||
CRITICAL: Read each dimension's DESCRIPTION carefully.
|
||||
The description defines what belongs in that dimension.
|
||||
Only assign if the content CLEARLY matches the description.
|
||||
If unsure, skip it — better to miss than assign incorrectly.
|
||||
|
||||
AVAILABLE DIMENSIONS:
|
||||
${dimensionsList}
|
||||
|
||||
=== RESPONSE FORMAT ===
|
||||
LOCKED:
|
||||
[dimension names from the list above, one per line, or "none"]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy no-op parser retained only for backward compatibility.
|
||||
*/
|
||||
private static parseAssignmentResponse(
|
||||
response: string,
|
||||
availableDimensions: LockedDimension[]
|
||||
): { locked: string[]; keywords: string[] } {
|
||||
const lockedDimensions: string[] = [];
|
||||
|
||||
// Extract LOCKED section
|
||||
const lockedMatch = response.match(/LOCKED:\s*([\s\S]*?)$/i);
|
||||
|
||||
if (lockedMatch) {
|
||||
const lockedLines = lockedMatch[1].trim().split('\n');
|
||||
for (const line of lockedLines) {
|
||||
const dimensionName = line.trim().toLowerCase();
|
||||
|
||||
if (dimensionName === 'none' || dimensionName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find matching dimension (case-insensitive)
|
||||
const matchedDimension = availableDimensions.find(
|
||||
d => d.name.toLowerCase() === dimensionName
|
||||
);
|
||||
|
||||
if (matchedDimension && !lockedDimensions.includes(matchedDimension.name)) {
|
||||
lockedDimensions.push(matchedDimension.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { locked: lockedDimensions, keywords: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or get a keyword dimension (unlocked)
|
||||
*/
|
||||
static async ensureKeywordDimension(keyword: string): Promise<void> {
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
// INSERT OR IGNORE - if dimension exists, do nothing
|
||||
sqlite.query(`
|
||||
INSERT OR IGNORE INTO dimensions(name, description, is_priority, updated_at)
|
||||
VALUES (?, ?, 0, CURRENT_TIMESTAMP)
|
||||
`, [keyword, null]);
|
||||
}
|
||||
}
|
||||
|
||||
export const dimensionService = new DimensionService();
|
||||
@@ -1,29 +0,0 @@
|
||||
import { getSQLiteClient } from './sqlite-client';
|
||||
import { normalizeDimensionName } from './quality';
|
||||
|
||||
export function getUnknownDimensions(values: string[]): string[] {
|
||||
if (values.length === 0) return [];
|
||||
|
||||
const sqlite = getSQLiteClient();
|
||||
const placeholders = values.map(() => '?').join(', ');
|
||||
const result = sqlite.query<{ name: string }>(
|
||||
`SELECT name FROM dimensions WHERE name IN (${placeholders})`,
|
||||
values
|
||||
);
|
||||
|
||||
const existing = new Set(
|
||||
result.rows
|
||||
.map(row => (typeof row.name === 'string' ? normalizeDimensionName(row.name) : ''))
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
return values.filter(value => !existing.has(normalizeDimensionName(value)));
|
||||
}
|
||||
|
||||
export function formatUnknownDimensionsError(values: string[]): string {
|
||||
if (values.length === 1) {
|
||||
return `Unknown dimension: "${values[0]}". Create it first or use an existing dimension.`;
|
||||
}
|
||||
|
||||
return `Unknown dimensions: ${values.map(value => `"${value}"`).join(', ')}. Create them first or use existing dimensions.`;
|
||||
}
|
||||
@@ -2,11 +2,11 @@ import { getSQLiteClient } from './sqlite-client';
|
||||
import { Edge, EdgeContext, EdgeData, EdgeCreatedVia, NodeConnection, Node } from '@/types/database';
|
||||
import { eventBroadcaster } from '../events';
|
||||
import { nodeService } from './nodes';
|
||||
import { getOpenAiKey } from '../storage/apiKeys';
|
||||
import { generateText } from 'ai';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import { z } from 'zod';
|
||||
import { validateEdgeExplanation } from './quality';
|
||||
import { hasValidOpenAiKey } from '../storage/apiKeys';
|
||||
|
||||
const inferredEdgeContextSchema = z.object({
|
||||
type: z.enum(['created_by', 'part_of', 'source_of', 'related_to']),
|
||||
@@ -53,14 +53,6 @@ async function inferEdgeContext(params: {
|
||||
return { type: 'related_to', confidence: 0.8, swap_direction: false };
|
||||
}
|
||||
|
||||
// If no API key is configured, degrade gracefully.
|
||||
// We still enforce explanation, but fall back to "related_to" classification.
|
||||
const apiKey = getOpenAiKey();
|
||||
if (!apiKey) {
|
||||
return { type: 'related_to', confidence: 0.0, swap_direction: false };
|
||||
}
|
||||
|
||||
const provider = createOpenAI({ apiKey });
|
||||
const prompt = [
|
||||
`Given two nodes and an explanation, determine the relationship type and direction.`,
|
||||
``,
|
||||
@@ -83,8 +75,12 @@ async function inferEdgeContext(params: {
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
if (!hasValidOpenAiKey()) {
|
||||
return { type: 'related_to', confidence: 0.2, swap_direction: false };
|
||||
}
|
||||
|
||||
const { text } = await generateText({
|
||||
model: provider('gpt-4o-mini'),
|
||||
model: openai('gpt-4o-mini'),
|
||||
prompt,
|
||||
temperature: 0.0,
|
||||
maxOutputTokens: 120,
|
||||
@@ -120,18 +116,6 @@ async function autoInferEdge(params: {
|
||||
}): Promise<{ explanation: string; type: EdgeContext['type']; confidence: number; swap_direction: boolean }> {
|
||||
const { fromNode, toNode } = params;
|
||||
|
||||
const apiKey = getOpenAiKey();
|
||||
if (!apiKey) {
|
||||
// Fallback without AI
|
||||
return {
|
||||
explanation: `Connection to ${toNode.title}; exact relationship uncertain.`,
|
||||
type: 'related_to',
|
||||
confidence: 0.0,
|
||||
swap_direction: false,
|
||||
};
|
||||
}
|
||||
|
||||
const provider = createOpenAI({ apiKey });
|
||||
const prompt = [
|
||||
`Given two knowledge base nodes, determine how they are related.`,
|
||||
``,
|
||||
@@ -157,8 +141,17 @@ async function autoInferEdge(params: {
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
if (!hasValidOpenAiKey()) {
|
||||
return {
|
||||
explanation: `Connection to ${toNode.title}; exact relationship uncertain.`,
|
||||
type: 'related_to',
|
||||
confidence: 0.2,
|
||||
swap_direction: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { text } = await generateText({
|
||||
model: provider('gpt-4o-mini'),
|
||||
model: openai('gpt-4o-mini'),
|
||||
prompt,
|
||||
temperature: 0.0,
|
||||
maxOutputTokens: 150,
|
||||
@@ -477,8 +470,7 @@ export class EdgeService {
|
||||
WHEN e.from_node_id = ? THEN n_to.title
|
||||
ELSE n_from.title
|
||||
END as connected_node_title,
|
||||
CASE
|
||||
WHEN e.from_node_id = ? THEN n_to.link
|
||||
CASE WHEN e.from_node_id = ? THEN n_to.link
|
||||
ELSE n_from.link
|
||||
END as connected_node_link,
|
||||
CASE
|
||||
@@ -496,17 +488,7 @@ export class EdgeService {
|
||||
CASE
|
||||
WHEN e.from_node_id = ? THEN n_to.updated_at
|
||||
ELSE n_from.updated_at
|
||||
END as connected_node_updated_at,
|
||||
CASE
|
||||
WHEN e.from_node_id = ? THEN (
|
||||
SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n_to.id
|
||||
)
|
||||
ELSE (
|
||||
SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n_from.id
|
||||
)
|
||||
END as connected_node_dimensions_json
|
||||
END as connected_node_updated_at
|
||||
FROM edges e
|
||||
LEFT JOIN nodes n_from ON e.from_node_id = n_from.id
|
||||
LEFT JOIN nodes n_to ON e.to_node_id = n_to.id
|
||||
@@ -521,7 +503,6 @@ export class EdgeService {
|
||||
nodeId,
|
||||
nodeId,
|
||||
nodeId,
|
||||
nodeId,
|
||||
nodeId
|
||||
]);
|
||||
|
||||
@@ -543,7 +524,6 @@ export class EdgeService {
|
||||
id: row.connected_node_id,
|
||||
title: row.connected_node_title,
|
||||
link: row.connected_node_link,
|
||||
dimensions: row.connected_node_dimensions,
|
||||
embedding: undefined, // Not needed for display
|
||||
source: row.connected_node_source,
|
||||
metadata: row.connected_node_metadata,
|
||||
@@ -587,7 +567,6 @@ export class EdgeService {
|
||||
id: row.connected_node_id,
|
||||
title: row.connected_node_title,
|
||||
link: row.connected_node_link,
|
||||
dimensions: JSON.parse(row.connected_node_dimensions_json || '[]'),
|
||||
embedding: undefined, // Not needed for display
|
||||
source: row.connected_node_source,
|
||||
metadata: typeof row.connected_node_metadata === 'string' ? JSON.parse(row.connected_node_metadata) : row.connected_node_metadata,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
export { nodeService, NodeService } from './nodes';
|
||||
export { chunkService, ChunkService } from './chunks';
|
||||
export { edgeService, EdgeService } from './edges';
|
||||
export { dimensionService, DimensionService } from './dimensionService';
|
||||
export { contextService, ContextService } from './contextService';
|
||||
// export { HelperService } from './helpers'; // Removed - migrated to JSON-based service
|
||||
|
||||
@@ -39,7 +38,6 @@ async function checkSQLiteDatabaseHealth(): Promise<{
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const connected = await sqlite.testConnection();
|
||||
const vectorCapability = sqlite.getVectorCapability();
|
||||
if (!connected) {
|
||||
return {
|
||||
connected: false,
|
||||
@@ -49,7 +47,7 @@ async function checkSQLiteDatabaseHealth(): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
const vectorExtension = vectorCapability.available;
|
||||
const vectorExtension = await sqlite.checkVectorExtension();
|
||||
|
||||
// Check if main tables exist
|
||||
const tables = await sqlite.checkTables();
|
||||
|
||||
+62
-140
@@ -5,7 +5,9 @@ import { EmbeddingService } from '@/services/embeddings';
|
||||
import { scoreNodeSearchMatch } from './searchRanking';
|
||||
import { buildCanonicalNodeMetadata, mergeNodeMetadata } from '@/services/nodes/metadata';
|
||||
|
||||
type NodeRow = Node & { dimensions_json: string; context_json: string | null };
|
||||
type NodeRow = Node & {
|
||||
context_json: string | null;
|
||||
};
|
||||
type NodeSearchRow = NodeRow & { rank?: number; similarity?: number };
|
||||
|
||||
function sanitizeFtsQuery(input: string): string {
|
||||
@@ -81,8 +83,15 @@ export class NodeService {
|
||||
}
|
||||
|
||||
async countNodes(filters: NodeFilters = {}): Promise<number> {
|
||||
const { dimensions, search, dimensionsMatch = 'any',
|
||||
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus, contextId } = filters;
|
||||
const {
|
||||
search,
|
||||
createdAfter,
|
||||
createdBefore,
|
||||
eventAfter,
|
||||
eventBefore,
|
||||
chunkStatus,
|
||||
contextId,
|
||||
} = filters;
|
||||
|
||||
if (search?.trim()) {
|
||||
return this.countSearchNodesSQLite(filters);
|
||||
@@ -93,24 +102,6 @@ export class NodeService {
|
||||
let query = `SELECT COUNT(*) as total FROM nodes n WHERE 1=1`;
|
||||
const params: any[] = [];
|
||||
|
||||
if (dimensions && dimensions.length > 0) {
|
||||
if (dimensionsMatch === 'all' && dimensions.length > 1) {
|
||||
query += ` AND (
|
||||
SELECT COUNT(DISTINCT nd.dimension) FROM node_dimensions nd
|
||||
WHERE nd.node_id = n.id
|
||||
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
|
||||
) = ?`;
|
||||
params.push(...dimensions, dimensions.length);
|
||||
} else {
|
||||
query += ` AND EXISTS (
|
||||
SELECT 1 FROM node_dimensions nd
|
||||
WHERE nd.node_id = n.id
|
||||
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
|
||||
)`;
|
||||
params.push(...dimensions);
|
||||
}
|
||||
}
|
||||
|
||||
if (search) {
|
||||
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
|
||||
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
||||
@@ -130,8 +121,18 @@ export class NodeService {
|
||||
// PostgreSQL path removed in SQLite-only consolidation
|
||||
|
||||
private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> {
|
||||
const { dimensions, search, limit = 100, offset = 0, sortBy, dimensionsMatch = 'any',
|
||||
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus, contextId } = filters;
|
||||
const {
|
||||
search,
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
sortBy,
|
||||
createdAfter,
|
||||
createdBefore,
|
||||
eventAfter,
|
||||
eventBefore,
|
||||
chunkStatus,
|
||||
contextId,
|
||||
} = filters;
|
||||
|
||||
if (search?.trim()) {
|
||||
return this.searchNodesSQLite(filters);
|
||||
@@ -139,13 +140,10 @@ export class NodeService {
|
||||
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
// Use nodes_v view for array-like dimensions behavior (exclude embedding BLOB for performance)
|
||||
let query = `
|
||||
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||
n.created_at, n.updated_at, n.context_id,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||
CASE
|
||||
WHEN c.id IS NULL THEN NULL
|
||||
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
|
||||
@@ -157,27 +155,6 @@ export class NodeService {
|
||||
`;
|
||||
const params: any[] = [];
|
||||
|
||||
// Filter by dimensions (SQLite JOIN with node_dimensions)
|
||||
if (dimensions && dimensions.length > 0) {
|
||||
if (dimensionsMatch === 'all' && dimensions.length > 1) {
|
||||
// AND logic: node must have ALL specified dimensions
|
||||
query += ` AND (
|
||||
SELECT COUNT(DISTINCT nd.dimension) FROM node_dimensions nd
|
||||
WHERE nd.node_id = n.id
|
||||
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
|
||||
) = ?`;
|
||||
params.push(...dimensions, dimensions.length);
|
||||
} else {
|
||||
// OR logic: node must have at least one of the specified dimensions
|
||||
query += ` AND EXISTS (
|
||||
SELECT 1 FROM node_dimensions nd
|
||||
WHERE nd.node_id = n.id
|
||||
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
|
||||
)`;
|
||||
params.push(...dimensions);
|
||||
}
|
||||
}
|
||||
|
||||
// Text search in title, description, and source (SQLite LIKE with COLLATE NOCASE)
|
||||
if (search) {
|
||||
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
|
||||
@@ -251,7 +228,7 @@ export class NodeService {
|
||||
|
||||
const result = sqlite.query<NodeRow>(query, params);
|
||||
|
||||
// Parse dimensions_json and metadata back for compatibility
|
||||
// Parse metadata and normalize deprecated compatibility fields.
|
||||
return result.rows.map(row => this.mapNodeRow(row));
|
||||
}
|
||||
|
||||
@@ -267,8 +244,6 @@ export class NodeService {
|
||||
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||
n.created_at, n.updated_at, n.context_id,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||
CASE
|
||||
WHEN c.id IS NULL THEN NULL
|
||||
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
|
||||
@@ -298,7 +273,6 @@ export class NodeService {
|
||||
source,
|
||||
link,
|
||||
event_date,
|
||||
dimensions = [],
|
||||
chunk_status,
|
||||
metadata = {},
|
||||
context_id,
|
||||
@@ -327,20 +301,10 @@ export class NodeService {
|
||||
|
||||
const id = Number(nodeResult.lastInsertRowid);
|
||||
|
||||
// Insert dimensions separately with INSERT OR IGNORE for safety
|
||||
if (dimensions.length > 0) {
|
||||
const stmt = sqlite.prepare(
|
||||
"INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)"
|
||||
);
|
||||
for (const dimension of dimensions) {
|
||||
stmt.run(id, dimension);
|
||||
}
|
||||
}
|
||||
|
||||
return id; // Returns number directly
|
||||
});
|
||||
|
||||
// Get the created node with dimensions (outside transaction)
|
||||
// Re-read the created node outside the transaction so callers get the canonical shape.
|
||||
const createdNode = await this.getNodeByIdSQLite(nodeId);
|
||||
if (!createdNode) {
|
||||
throw new Error('Failed to create node');
|
||||
@@ -363,7 +327,7 @@ export class NodeService {
|
||||
// PostgreSQL path removed in SQLite-only consolidation
|
||||
|
||||
private async updateNodeSQLite(id: number, updates: Partial<Node>): Promise<Node> {
|
||||
const { title, description, source, link, event_date, dimensions, metadata } = updates;
|
||||
const { title, description, source, link, event_date, metadata } = updates;
|
||||
const now = new Date().toISOString();
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
@@ -411,14 +375,6 @@ export class NodeService {
|
||||
stmt.run(...params);
|
||||
}
|
||||
|
||||
// Handle dimensions separately
|
||||
if (Array.isArray(dimensions)) {
|
||||
sqlite.prepare('DELETE FROM node_dimensions WHERE node_id = ?').run(id);
|
||||
const dimStmt = sqlite.prepare('INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)');
|
||||
for (const dim of dimensions) {
|
||||
dimStmt.run(id, dim);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get updated node
|
||||
@@ -458,28 +414,21 @@ export class NodeService {
|
||||
});
|
||||
}
|
||||
|
||||
// Dimension-based filtering methods
|
||||
async getNodesByDimension(dimension: string): Promise<Node[]> {
|
||||
return this.getNodes({ dimensions: [dimension] });
|
||||
}
|
||||
|
||||
async searchNodes(searchTerm: string, limit = 50): Promise<Node[]> {
|
||||
return this.getNodes({ search: searchTerm, limit });
|
||||
}
|
||||
|
||||
private mapNodeRow(row: NodeRow): Node {
|
||||
const { context_json, ...baseRow } = row;
|
||||
return {
|
||||
...row,
|
||||
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
||||
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
|
||||
context: row.context_json ? JSON.parse(row.context_json) : null,
|
||||
...baseRow,
|
||||
metadata: baseRow.metadata ? (typeof baseRow.metadata === 'string' ? JSON.parse(baseRow.metadata) : baseRow.metadata) : null,
|
||||
context: context_json ? JSON.parse(context_json) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private buildNodeFilterClauses(filters: NodeFilters, alias = 'n'): { clauses: string[]; params: any[] } {
|
||||
const {
|
||||
dimensions,
|
||||
dimensionsMatch = 'any',
|
||||
createdAfter,
|
||||
createdBefore,
|
||||
eventAfter,
|
||||
@@ -490,24 +439,6 @@ export class NodeService {
|
||||
const clauses: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
if (dimensions && dimensions.length > 0) {
|
||||
if (dimensionsMatch === 'all' && dimensions.length > 1) {
|
||||
clauses.push(`(
|
||||
SELECT COUNT(DISTINCT nd.dimension) FROM node_dimensions nd
|
||||
WHERE nd.node_id = ${alias}.id
|
||||
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
|
||||
) = ?`);
|
||||
params.push(...dimensions, dimensions.length);
|
||||
} else {
|
||||
clauses.push(`EXISTS (
|
||||
SELECT 1 FROM node_dimensions nd
|
||||
WHERE nd.node_id = ${alias}.id
|
||||
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
|
||||
)`);
|
||||
params.push(...dimensions);
|
||||
}
|
||||
}
|
||||
|
||||
if (createdAfter) { clauses.push(`${alias}.created_at >= ?`); params.push(createdAfter); }
|
||||
if (createdBefore) { clauses.push(`${alias}.created_at < ?`); params.push(createdBefore); }
|
||||
if (eventAfter) { clauses.push(`${alias}.event_date >= ?`); params.push(eventAfter); }
|
||||
@@ -567,13 +498,14 @@ export class NodeService {
|
||||
if (!search) return 0;
|
||||
|
||||
const ftsQuery = sanitizeFtsQuery(search);
|
||||
const ftsExists = sqlite.prepare(
|
||||
const ftsExists = sqlite.isNodesFtsUsable() && sqlite.prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes_fts'"
|
||||
).get();
|
||||
const { clauses, params } = this.buildNodeFilterClauses(filters);
|
||||
|
||||
if (ftsExists && ftsQuery) {
|
||||
const whereClauses = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
try {
|
||||
const result = sqlite.query<{ total: number }>(`
|
||||
WITH matched_nodes AS (
|
||||
SELECT rowid
|
||||
@@ -587,6 +519,9 @@ export class NodeService {
|
||||
`, [ftsQuery, ...params]);
|
||||
|
||||
return Number(result.rows[0]?.total ?? 0);
|
||||
} catch (error) {
|
||||
sqlite.disableNodesFts('nodes_fts query failed during count search', error);
|
||||
}
|
||||
}
|
||||
|
||||
const words = search.split(/\s+/).filter(Boolean);
|
||||
@@ -615,7 +550,7 @@ export class NodeService {
|
||||
const ftsQuery = sanitizeFtsQuery(search);
|
||||
if (!ftsQuery) return [];
|
||||
|
||||
const ftsExists = sqlite.prepare(
|
||||
const ftsExists = sqlite.isNodesFtsUsable() && sqlite.prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes_fts'"
|
||||
).get();
|
||||
if (!ftsExists) return [];
|
||||
@@ -633,12 +568,15 @@ export class NodeService {
|
||||
)
|
||||
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||
n.created_at, n.updated_at,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||
n.created_at, n.updated_at, n.context_id,
|
||||
CASE
|
||||
WHEN c.id IS NULL THEN NULL
|
||||
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
|
||||
END as context_json,
|
||||
fm.rank
|
||||
FROM fts_matches fm
|
||||
JOIN nodes n ON n.id = fm.rowid
|
||||
LEFT JOIN contexts c ON c.id = n.context_id
|
||||
${whereClauses}
|
||||
ORDER BY fm.rank
|
||||
LIMIT ?
|
||||
@@ -646,7 +584,7 @@ export class NodeService {
|
||||
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
console.warn('[NodeSearch] FTS search failed, falling back to LIKE:', error);
|
||||
sqlite.disableNodesFts('nodes_fts query failed during node search', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -662,10 +600,13 @@ export class NodeService {
|
||||
let query = `
|
||||
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||
n.created_at, n.updated_at,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||
n.created_at, n.updated_at, n.context_id,
|
||||
CASE
|
||||
WHEN c.id IS NULL THEN NULL
|
||||
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
|
||||
END as context_json
|
||||
FROM nodes n
|
||||
LEFT JOIN contexts c ON c.id = n.context_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const queryParams = [...params];
|
||||
@@ -707,10 +648,13 @@ export class NodeService {
|
||||
let query = `
|
||||
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||
n.created_at, n.updated_at,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||
n.created_at, n.updated_at, n.context_id,
|
||||
CASE
|
||||
WHEN c.id IS NULL THEN NULL
|
||||
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
|
||||
END as context_json
|
||||
FROM nodes n
|
||||
LEFT JOIN contexts c ON c.id = n.context_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const queryParams = [...params];
|
||||
@@ -781,12 +725,15 @@ export class NodeService {
|
||||
)
|
||||
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||
n.created_at, n.updated_at,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||
n.created_at, n.updated_at, n.context_id,
|
||||
CASE
|
||||
WHEN c.id IS NULL THEN NULL
|
||||
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
|
||||
END as context_json,
|
||||
(1.0 / (1.0 + vm.distance)) AS similarity
|
||||
FROM vector_matches vm
|
||||
JOIN nodes n ON n.id = vm.node_id
|
||||
LEFT JOIN contexts c ON c.id = n.context_id
|
||||
${whereClauses}
|
||||
ORDER BY vm.distance
|
||||
LIMIT ?
|
||||
@@ -829,31 +776,6 @@ export class NodeService {
|
||||
return updatedNodes;
|
||||
}
|
||||
|
||||
// Get all unique dimensions for UI filtering
|
||||
async getAllDimensions(): Promise<string[]> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const query = `
|
||||
SELECT DISTINCT dimension
|
||||
FROM node_dimensions
|
||||
ORDER BY dimension
|
||||
`;
|
||||
const result = sqlite.query<{dimension: string}>(query);
|
||||
return result.rows.map(row => row.dimension);
|
||||
}
|
||||
|
||||
// Get dimension usage statistics
|
||||
async getDimensionStats(): Promise<{dimension: string, count: number}[]> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const query = `
|
||||
SELECT dimension, COUNT(*) as count
|
||||
FROM node_dimensions
|
||||
GROUP BY dimension
|
||||
ORDER BY count DESC
|
||||
`;
|
||||
const result = sqlite.query<{dimension: string, count: number}>(query);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
||||
@@ -1,34 +1,10 @@
|
||||
const WEAK_DESCRIPTION_PATTERNS = /\b(discusses|explores|examines|talks about|is about|delves into)\b/i;
|
||||
const EXPLICIT_ENTITY_PATTERNS = /\b(article|artifact|book|brief|claim|company|concept|conversation|dataset|decision|dimension|document|episode|essay|event|guide|idea|insight|interview|lesson|link|node|note|paper|person|plan|placeholder|podcast|post|presentation|project|question|record|research|resource|skill|source|status|summary|talk|target|test node|thread|tool|transcript|tweet|update|video|website|workflow)\b/i;
|
||||
const EXPLICIT_ENTITY_PATTERNS = /\b(article|artifact|book|brief|claim|company|concept|conversation|dataset|decision|document|episode|essay|event|guide|idea|insight|interview|lesson|link|node|note|paper|person|plan|placeholder|podcast|post|presentation|project|question|record|research|resource|skill|source|status|summary|talk|target|test node|thread|tool|transcript|tweet|update|video|website|workflow)\b/i;
|
||||
const UNCERTAINTY_PATTERNS = /\b(likely|probably|possibly|appears to be|seems to be|unclear|uncertain)\b/i;
|
||||
const WHY_PATTERNS = /(why added:|added (?:after|as|for|because|to)|follow-?on|queued for|saved for|relevant because|connected to|not inferred|belongs in the graph because|belongs here because|captures .* idea|ties directly into|ties into)/i;
|
||||
const STATUS_PATTERNS = /(status:|queued|not yet reviewed|in progress|processed|reviewed|saved for later|to review|to read|to watch|to listen|draft|not yet published|unpublished)/i;
|
||||
const GENERIC_EDGE_PATTERNS = /^(related|related to|connected|connected to|association|associated with)$/i;
|
||||
|
||||
export function normalizeDimensionName(value: string): string {
|
||||
return value.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function normalizeDimensions(values: unknown, max = 5): string[] {
|
||||
if (!Array.isArray(values)) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const trimmed = normalizeDimensionName(value);
|
||||
if (!trimmed) continue;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
normalized.push(trimmed);
|
||||
if (normalized.length >= max) break;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function validateExplicitDescription(description: string): string | null {
|
||||
const text = description.trim();
|
||||
if (text.length > 500) {
|
||||
@@ -84,14 +60,3 @@ export function validateEdgeExplanation(explanation: string): string | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateDimensionDescription(description: string): string | null {
|
||||
const text = description.trim();
|
||||
if (!text) {
|
||||
return 'Dimension description is required.';
|
||||
}
|
||||
if (text.length > 500) {
|
||||
return 'Description must be 500 characters or less.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ class SQLiteClient {
|
||||
private config: SQLiteConfig;
|
||||
private readonly readOnly: boolean;
|
||||
private readonly vectorCapability: VectorCapability;
|
||||
private nodesFtsUsable = true;
|
||||
private nodesFtsDisabledReason: string | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.config = this.getSQLiteConfig();
|
||||
@@ -61,6 +63,7 @@ class SQLiteClient {
|
||||
this.db.pragma('temp_store = memory');
|
||||
this.db.pragma('busy_timeout = 5000');
|
||||
|
||||
this.ensureCoreSchema();
|
||||
// Ensure vector virtual tables are present and healthy
|
||||
if (this.vectorCapability.available) {
|
||||
this.ensureVectorTables();
|
||||
@@ -162,6 +165,25 @@ class SQLiteClient {
|
||||
return this.vectorCapability;
|
||||
}
|
||||
|
||||
public isNodesFtsUsable(): boolean {
|
||||
return this.nodesFtsUsable;
|
||||
}
|
||||
|
||||
public disableNodesFts(reason: string, error?: unknown): void {
|
||||
this.nodesFtsUsable = false;
|
||||
if (this.nodesFtsDisabledReason === reason) {
|
||||
return;
|
||||
}
|
||||
this.nodesFtsDisabledReason = reason;
|
||||
|
||||
if (error && !this.isSqliteCorruptError(error)) {
|
||||
console.warn(`[SQLite] nodes_fts disabled: ${reason}`, error);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`[SQLite] nodes_fts disabled: ${reason}. Falling back to LIKE search for this database session.`);
|
||||
}
|
||||
|
||||
public async checkTables(): Promise<string[]> {
|
||||
try {
|
||||
const result = this.query(
|
||||
@@ -215,17 +237,283 @@ class SQLiteClient {
|
||||
this.ensureVectorExtensions();
|
||||
}
|
||||
|
||||
private ensureCoreSchema(): void {
|
||||
if (this.readOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
source TEXT,
|
||||
link TEXT,
|
||||
event_date TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
metadata TEXT,
|
||||
embedding BLOB,
|
||||
embedding_updated_at TEXT,
|
||||
embedding_text TEXT,
|
||||
chunk_status TEXT DEFAULT 'not_chunked',
|
||||
context_id INTEGER,
|
||||
FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contexts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized
|
||||
ON contexts(LOWER(TRIM(name)));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chunks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
node_id INTEGER NOT NULL,
|
||||
chunk_idx INTEGER,
|
||||
text TEXT NOT NULL,
|
||||
embedding BLOB,
|
||||
embedding_type TEXT DEFAULT 'openai',
|
||||
metadata TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chats (
|
||||
id INTEGER PRIMARY KEY,
|
||||
chat_type TEXT,
|
||||
helper_name TEXT,
|
||||
agent_type TEXT DEFAULT 'orchestrator',
|
||||
delegation_id INTEGER,
|
||||
user_message TEXT,
|
||||
assistant_message TEXT,
|
||||
thread_id TEXT,
|
||||
focused_node_id INTEGER,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
metadata TEXT,
|
||||
FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_updated_at ON nodes(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_node_id ON chunks(node_id);
|
||||
`);
|
||||
|
||||
this.ensureEdgesTableSchema();
|
||||
}
|
||||
|
||||
private ensureEdgesTableSchema(): void {
|
||||
const hasEdgesTable = this.db
|
||||
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='edges'")
|
||||
.get();
|
||||
|
||||
if (!hasEdgesTable) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE edges (
|
||||
id INTEGER PRIMARY KEY,
|
||||
from_node_id INTEGER NOT NULL,
|
||||
to_node_id INTEGER NOT NULL,
|
||||
source TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
context TEXT,
|
||||
explanation TEXT,
|
||||
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} else {
|
||||
const edgeCols = this.db.prepare('PRAGMA table_info(edges)').all() as Array<{ name: string }>;
|
||||
const edgeColNames = new Set(edgeCols.map((col) => col.name));
|
||||
const needsLegacyRewrite =
|
||||
!edgeColNames.has('from_node_id') ||
|
||||
!edgeColNames.has('to_node_id') ||
|
||||
!edgeColNames.has('source') ||
|
||||
!edgeColNames.has('created_at') ||
|
||||
!edgeColNames.has('context') ||
|
||||
edgeColNames.has('from_id') ||
|
||||
edgeColNames.has('to_id') ||
|
||||
edgeColNames.has('description') ||
|
||||
edgeColNames.has('updated_at');
|
||||
|
||||
if (needsLegacyRewrite) {
|
||||
this.rebuildLegacyEdgesTable(edgeColNames);
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node_id);
|
||||
`);
|
||||
}
|
||||
|
||||
private rebuildLegacyEdgesTable(edgeColNames: Set<string>): void {
|
||||
const fromExpr = edgeColNames.has('from_node_id')
|
||||
? 'from_node_id'
|
||||
: edgeColNames.has('from_id')
|
||||
? 'from_id'
|
||||
: 'NULL';
|
||||
const toExpr = edgeColNames.has('to_node_id')
|
||||
? 'to_node_id'
|
||||
: edgeColNames.has('to_id')
|
||||
? 'to_id'
|
||||
: 'NULL';
|
||||
const sourceExpr = edgeColNames.has('source') ? 'source' : "'legacy'";
|
||||
const createdAtExpr = edgeColNames.has('created_at') ? 'created_at' : 'CURRENT_TIMESTAMP';
|
||||
const contextExpr = edgeColNames.has('context') ? 'context' : 'NULL';
|
||||
const explanationExpr = edgeColNames.has('explanation')
|
||||
? 'explanation'
|
||||
: edgeColNames.has('description')
|
||||
? 'description'
|
||||
: edgeColNames.has('context')
|
||||
? "CASE WHEN json_valid(context) THEN json_extract(context, '$.explanation') ELSE NULL END"
|
||||
: 'NULL';
|
||||
|
||||
console.log('Migrating legacy edges table to canonical schema');
|
||||
|
||||
let flippedForeignKeys = false;
|
||||
try {
|
||||
this.db.exec('PRAGMA foreign_keys=OFF;');
|
||||
flippedForeignKeys = true;
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
this.db.exec('BEGIN TRANSACTION;');
|
||||
this.db.exec(`
|
||||
DROP INDEX IF EXISTS idx_edges_from;
|
||||
DROP INDEX IF EXISTS idx_edges_to;
|
||||
ALTER TABLE edges RENAME TO edges_legacy_migration;
|
||||
CREATE TABLE edges (
|
||||
id INTEGER PRIMARY KEY,
|
||||
from_node_id INTEGER NOT NULL,
|
||||
to_node_id INTEGER NOT NULL,
|
||||
source TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
context TEXT,
|
||||
explanation TEXT,
|
||||
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO edges (id, from_node_id, to_node_id, source, created_at, context, explanation)
|
||||
SELECT
|
||||
id,
|
||||
${fromExpr},
|
||||
${toExpr},
|
||||
${sourceExpr},
|
||||
COALESCE(${createdAtExpr}, CURRENT_TIMESTAMP),
|
||||
${contextExpr},
|
||||
${explanationExpr}
|
||||
FROM edges_legacy_migration
|
||||
WHERE ${fromExpr} IS NOT NULL
|
||||
AND ${toExpr} IS NOT NULL;
|
||||
DROP TABLE edges_legacy_migration;
|
||||
COMMIT;
|
||||
`);
|
||||
} catch (error) {
|
||||
try {
|
||||
this.db.exec('ROLLBACK;');
|
||||
} catch {}
|
||||
throw error;
|
||||
} finally {
|
||||
if (flippedForeignKeys) {
|
||||
try {
|
||||
this.db.exec('PRAGMA foreign_keys=ON;');
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildLegacyChatsTable(chatColNames: Set<string>): void {
|
||||
const chatTypeExpr = chatColNames.has('chat_type') ? 'chat_type' : 'NULL';
|
||||
const helperNameExpr = chatColNames.has('helper_name')
|
||||
? 'helper_name'
|
||||
: chatColNames.has('title')
|
||||
? 'title'
|
||||
: 'NULL';
|
||||
const agentTypeExpr = chatColNames.has('agent_type')
|
||||
? "COALESCE(agent_type, 'orchestrator')"
|
||||
: "'orchestrator'";
|
||||
const delegationIdExpr = chatColNames.has('delegation_id') ? 'delegation_id' : 'NULL';
|
||||
const userMessageExpr = chatColNames.has('user_message') ? 'user_message' : 'NULL';
|
||||
const assistantMessageExpr = chatColNames.has('assistant_message') ? 'assistant_message' : 'NULL';
|
||||
const threadIdExpr = chatColNames.has('thread_id') ? 'thread_id' : 'NULL';
|
||||
const focusedNodeIdExpr = chatColNames.has('focused_node_id') ? 'focused_node_id' : 'NULL';
|
||||
const createdAtExpr = chatColNames.has('created_at') ? 'created_at' : 'CURRENT_TIMESTAMP';
|
||||
const metadataExpr = chatColNames.has('metadata') ? 'metadata' : 'NULL';
|
||||
|
||||
console.log('Migrating legacy chats table to canonical schema');
|
||||
|
||||
let flippedForeignKeys = false;
|
||||
try {
|
||||
this.db.exec('PRAGMA foreign_keys=OFF;');
|
||||
flippedForeignKeys = true;
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
this.db.exec('BEGIN TRANSACTION;');
|
||||
this.db.exec(`
|
||||
DROP INDEX IF EXISTS idx_chats_thread;
|
||||
ALTER TABLE chats RENAME TO chats_legacy_cleanup;
|
||||
CREATE TABLE chats (
|
||||
id INTEGER PRIMARY KEY,
|
||||
chat_type TEXT,
|
||||
helper_name TEXT,
|
||||
agent_type TEXT DEFAULT 'orchestrator',
|
||||
delegation_id INTEGER,
|
||||
user_message TEXT,
|
||||
assistant_message TEXT,
|
||||
thread_id TEXT,
|
||||
focused_node_id INTEGER,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
metadata TEXT,
|
||||
FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL
|
||||
);
|
||||
INSERT INTO chats (
|
||||
id, chat_type, helper_name, agent_type, delegation_id,
|
||||
user_message, assistant_message, thread_id, focused_node_id,
|
||||
created_at, metadata
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
${chatTypeExpr},
|
||||
${helperNameExpr},
|
||||
${agentTypeExpr},
|
||||
${delegationIdExpr},
|
||||
${userMessageExpr},
|
||||
${assistantMessageExpr},
|
||||
${threadIdExpr},
|
||||
${focusedNodeIdExpr},
|
||||
COALESCE(${createdAtExpr}, CURRENT_TIMESTAMP),
|
||||
${metadataExpr}
|
||||
FROM chats_legacy_cleanup;
|
||||
DROP TABLE chats_legacy_cleanup;
|
||||
COMMIT;
|
||||
`);
|
||||
} catch (error) {
|
||||
try {
|
||||
this.db.exec('ROLLBACK;');
|
||||
} catch {}
|
||||
throw error;
|
||||
} finally {
|
||||
if (flippedForeignKeys) {
|
||||
try {
|
||||
this.db.exec('PRAGMA foreign_keys=ON;');
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ensureLoggingAndMemorySchema(): void {
|
||||
if (this.readOnly) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const hasReadyLogs = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='logs'").get();
|
||||
if (hasReadyLogs) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) If logs table missing but legacy memory table exists, migrate
|
||||
// Existing installs may already have logs but still need the idempotent schema pass below.
|
||||
// Only skip the legacy memory rename step when logs already exists.
|
||||
const hasLogs = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='logs'").get();
|
||||
const hasLegacyMemory = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='memory'").get();
|
||||
if (!hasLogs && hasLegacyMemory) {
|
||||
@@ -263,6 +551,11 @@ class SQLiteClient {
|
||||
}
|
||||
};
|
||||
ensureNodeCol('description', "ALTER TABLE nodes ADD COLUMN description TEXT;");
|
||||
ensureNodeCol('link', 'ALTER TABLE nodes ADD COLUMN link TEXT;');
|
||||
ensureNodeCol('source', 'ALTER TABLE nodes ADD COLUMN source TEXT;');
|
||||
ensureNodeCol('metadata', 'ALTER TABLE nodes ADD COLUMN metadata TEXT;');
|
||||
ensureNodeCol('created_at', "ALTER TABLE nodes ADD COLUMN created_at TEXT DEFAULT CURRENT_TIMESTAMP;");
|
||||
ensureNodeCol('updated_at', "ALTER TABLE nodes ADD COLUMN updated_at TEXT DEFAULT CURRENT_TIMESTAMP;");
|
||||
} catch (nodeErr) {
|
||||
console.warn('Failed to ensure nodes columns:', nodeErr);
|
||||
}
|
||||
@@ -279,6 +572,25 @@ class SQLiteClient {
|
||||
console.warn('Failed to ensure chats.created_at column:', chatErr);
|
||||
}
|
||||
|
||||
// Normalize legacy chats table before creating chat triggers or views that reference modern columns.
|
||||
try {
|
||||
const hasChatsTable = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chats'").get();
|
||||
if (hasChatsTable) {
|
||||
const chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as Array<{ name: string }>;
|
||||
const chatColNames = new Set(chatCols.map((col) => col.name));
|
||||
const needsChatRewrite =
|
||||
chatColNames.has('focused_memory_id') ||
|
||||
['chat_type', 'helper_name', 'agent_type', 'delegation_id', 'user_message', 'assistant_message', 'thread_id', 'focused_node_id', 'created_at', 'metadata']
|
||||
.some((name) => !chatColNames.has(name));
|
||||
|
||||
if (needsChatRewrite) {
|
||||
this.rebuildLegacyChatsTable(chatColNames);
|
||||
}
|
||||
}
|
||||
} catch (chatSchemaErr) {
|
||||
console.warn('Failed to normalize chats schema before log setup:', chatSchemaErr);
|
||||
}
|
||||
|
||||
// 3) Helpful indexes on logs (clean up old names first)
|
||||
this.db.exec(`
|
||||
DROP INDEX IF EXISTS idx_memory_ts;
|
||||
@@ -494,55 +806,20 @@ class SQLiteClient {
|
||||
if (hasChats) {
|
||||
try {
|
||||
let chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as any[];
|
||||
const hasFocusedMemoryId = chatCols.some((c: any) => c.name === 'focused_memory_id');
|
||||
if (hasFocusedMemoryId) {
|
||||
console.log('Removing legacy chats.focused_memory_id column');
|
||||
let flippedForeignKeys = false;
|
||||
try {
|
||||
this.db.exec('PRAGMA foreign_keys=OFF;');
|
||||
flippedForeignKeys = true;
|
||||
this.db.exec(`
|
||||
BEGIN TRANSACTION;
|
||||
ALTER TABLE chats RENAME TO chats_legacy_cleanup;
|
||||
CREATE TABLE chats (
|
||||
id INTEGER PRIMARY KEY,
|
||||
chat_type TEXT,
|
||||
helper_name TEXT,
|
||||
agent_type TEXT DEFAULT 'orchestrator',
|
||||
delegation_id INTEGER,
|
||||
user_message TEXT,
|
||||
assistant_message TEXT,
|
||||
thread_id TEXT,
|
||||
focused_node_id INTEGER,
|
||||
created_at TEXT DEFAULT (CURRENT_TIMESTAMP),
|
||||
metadata TEXT,
|
||||
FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL
|
||||
);
|
||||
INSERT INTO chats (
|
||||
id, chat_type, helper_name, agent_type, delegation_id,
|
||||
user_message, assistant_message, thread_id, focused_node_id,
|
||||
created_at, metadata
|
||||
)
|
||||
SELECT id, chat_type, helper_name, agent_type, delegation_id,
|
||||
user_message, assistant_message, thread_id, focused_node_id,
|
||||
created_at, metadata
|
||||
FROM chats_legacy_cleanup;
|
||||
DROP TABLE chats_legacy_cleanup;
|
||||
CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);
|
||||
COMMIT;
|
||||
`);
|
||||
} catch (migrationErr) {
|
||||
console.warn('Failed to migrate chats table (focused_memory_id removal):', migrationErr);
|
||||
try { this.db.exec('ROLLBACK;'); } catch {}
|
||||
} finally {
|
||||
if (flippedForeignKeys) {
|
||||
try { this.db.exec('PRAGMA foreign_keys=ON;'); } catch {}
|
||||
}
|
||||
}
|
||||
const chatColNames = new Set(chatCols.map((c: any) => c.name));
|
||||
const needsChatRewrite =
|
||||
chatColNames.has('focused_memory_id') ||
|
||||
['chat_type', 'helper_name', 'agent_type', 'delegation_id', 'user_message', 'assistant_message', 'thread_id', 'focused_node_id', 'created_at', 'metadata']
|
||||
.some((name) => !chatColNames.has(name));
|
||||
|
||||
if (needsChatRewrite) {
|
||||
this.rebuildLegacyChatsTable(chatColNames);
|
||||
chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as any[];
|
||||
}
|
||||
|
||||
if (chatCols.some((c: any) => c.name === 'thread_id')) {
|
||||
this.db.exec("CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);");
|
||||
}
|
||||
|
||||
const ensureCol = (name: string, ddl: string) => {
|
||||
if (!chatCols.some((c: any) => c.name === name)) {
|
||||
@@ -578,51 +855,7 @@ class SQLiteClient {
|
||||
console.warn('Failed to drop legacy memory pipeline tables:', dropLegacyErr);
|
||||
}
|
||||
|
||||
// 9) Ensure dimensions table exists (v0.1.16+ schema migration)
|
||||
const hasDimensions = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='dimensions'").get();
|
||||
if (!hasDimensions) {
|
||||
console.log('Creating dimensions table for v0.1.16+ features...');
|
||||
this.db.exec(`
|
||||
CREATE TABLE dimensions (
|
||||
name TEXT PRIMARY KEY,
|
||||
description TEXT,
|
||||
is_priority INTEGER DEFAULT 0,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// Seed default locked dimensions
|
||||
const defaultDimensions = ['research', 'ideas', 'projects', 'memory', 'preferences'];
|
||||
const insertDimension = this.db.prepare(`
|
||||
INSERT INTO dimensions (name, is_priority, updated_at)
|
||||
VALUES (?, 1, datetime('now'))
|
||||
ON CONFLICT(name) DO UPDATE SET is_priority = 1, updated_at = datetime('now')
|
||||
`);
|
||||
|
||||
for (const dimension of defaultDimensions) {
|
||||
try {
|
||||
insertDimension.run(dimension);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to seed dimension '${dimension}':`, e);
|
||||
}
|
||||
}
|
||||
console.log('Dimensions table created and seeded with default locked dimensions');
|
||||
} else {
|
||||
// Check if existing dimensions table has description column
|
||||
const dimensionCols = this.db.prepare('PRAGMA table_info(dimensions)').all() as Array<{ name: string }>;
|
||||
const hasDescription = dimensionCols.some(col => col.name === 'description');
|
||||
if (!hasDescription) {
|
||||
console.log('Adding description column to existing dimensions table...');
|
||||
try {
|
||||
this.db.exec('ALTER TABLE dimensions ADD COLUMN description TEXT;');
|
||||
console.log('Description column added to dimensions table');
|
||||
} catch (e) {
|
||||
console.warn('Failed to add description column to dimensions table:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 10) Final schema pass migrations (source canonicalization, event_date, dimensions.icon, drop dead columns)
|
||||
// 9) Final schema pass migrations (source-first backfill, event_date, soft contexts, drop dimensions)
|
||||
try {
|
||||
let nodeCols2 = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
|
||||
let nodeColNames = nodeCols2.map(c => c.name);
|
||||
@@ -634,6 +867,12 @@ class SQLiteClient {
|
||||
nodeColNames = nodeCols2.map(c => c.name);
|
||||
}
|
||||
|
||||
if (!nodeColNames.includes('chunk_status')) {
|
||||
this.db.exec("ALTER TABLE nodes ADD COLUMN chunk_status TEXT DEFAULT 'not_chunked';");
|
||||
nodeCols2 = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
|
||||
nodeColNames = nodeCols2.map(c => c.name);
|
||||
}
|
||||
|
||||
if (nodeColNames.includes('source')) {
|
||||
if (nodeColNames.includes('content')) {
|
||||
this.db.exec(`
|
||||
@@ -691,19 +930,121 @@ class SQLiteClient {
|
||||
// Add event_date
|
||||
if (!nodeColNames.includes('event_date')) {
|
||||
this.db.exec('ALTER TABLE nodes ADD COLUMN event_date TEXT;');
|
||||
// Backfill from metadata.published_date where available
|
||||
// Backfill from metadata.published_date or metadata.source_metadata.published_date where available
|
||||
try {
|
||||
this.db.exec(`
|
||||
UPDATE nodes SET event_date = json_extract(metadata, '$.published_date')
|
||||
WHERE event_date IS NULL AND json_extract(metadata, '$.published_date') IS NOT NULL;
|
||||
UPDATE nodes
|
||||
SET event_date = COALESCE(
|
||||
json_extract(metadata, '$.source_metadata.published_date'),
|
||||
json_extract(metadata, '$.published_date')
|
||||
)
|
||||
WHERE event_date IS NULL
|
||||
AND COALESCE(
|
||||
json_extract(metadata, '$.source_metadata.published_date'),
|
||||
json_extract(metadata, '$.published_date')
|
||||
) IS NOT NULL;
|
||||
`);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Add dimensions.icon
|
||||
const dimCols2 = this.db.prepare('PRAGMA table_info(dimensions)').all() as Array<{ name: string }>;
|
||||
if (!dimCols2.some(c => c.name === 'icon')) {
|
||||
this.db.exec('ALTER TABLE dimensions ADD COLUMN icon TEXT;');
|
||||
if (!nodeColNames.includes('context_id')) {
|
||||
this.db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;');
|
||||
}
|
||||
|
||||
const hasContexts = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='contexts'").get();
|
||||
if (!hasContexts) {
|
||||
this.db.exec(`
|
||||
CREATE TABLE contexts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
const contextCols = this.db.prepare('PRAGMA table_info(contexts)').all() as Array<{ name: string }>;
|
||||
const ensureContextCol = (name: string, ddl: string) => {
|
||||
if (!contextCols.some(col => col.name === name)) {
|
||||
this.db.exec(ddl);
|
||||
}
|
||||
};
|
||||
ensureContextCol('description', "ALTER TABLE contexts ADD COLUMN description TEXT NOT NULL DEFAULT '';");
|
||||
ensureContextCol('icon', 'ALTER TABLE contexts ADD COLUMN icon TEXT;');
|
||||
ensureContextCol('created_at', "ALTER TABLE contexts ADD COLUMN created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
|
||||
ensureContextCol('updated_at', "ALTER TABLE contexts ADD COLUMN updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
|
||||
|
||||
this.db.exec(`
|
||||
UPDATE contexts
|
||||
SET description = COALESCE(NULLIF(TRIM(description), ''), name)
|
||||
WHERE description IS NULL OR LENGTH(TRIM(description)) = 0;
|
||||
`);
|
||||
|
||||
this.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);
|
||||
`);
|
||||
|
||||
const hasLegacyDimensions = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='dimensions'").get();
|
||||
const hasLegacyNodeDimensions = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='node_dimensions'").get();
|
||||
this.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
|
||||
);
|
||||
`);
|
||||
|
||||
if (hasLegacyDimensions || hasLegacyNodeDimensions) {
|
||||
const existingSnapshotCount = Number(
|
||||
(this.db.prepare('SELECT COUNT(*) as count FROM dimension_migration_snapshots').get() as { count?: number } | undefined)?.count ?? 0
|
||||
);
|
||||
|
||||
if (existingSnapshotCount === 0) {
|
||||
const dimensionCount = hasLegacyDimensions
|
||||
? Number((this.db.prepare('SELECT COUNT(*) as count FROM dimensions').get() as { count?: number } | undefined)?.count ?? 0)
|
||||
: 0;
|
||||
const assignmentCount = hasLegacyNodeDimensions
|
||||
? Number((this.db.prepare('SELECT COUNT(*) as count FROM node_dimensions').get() as { count?: number } | undefined)?.count ?? 0)
|
||||
: 0;
|
||||
const payload = hasLegacyNodeDimensions
|
||||
? (
|
||||
this.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() as { payload?: string } | undefined
|
||||
)?.payload ?? '[]'
|
||||
: '[]';
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO dimension_migration_snapshots (dimension_count, assignment_count, payload)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(dimensionCount, assignmentCount, payload);
|
||||
}
|
||||
|
||||
this.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;
|
||||
`);
|
||||
}
|
||||
|
||||
// Drop dead columns (requires SQLite 3.35+)
|
||||
@@ -742,8 +1083,12 @@ class SQLiteClient {
|
||||
this.db.exec("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');");
|
||||
}
|
||||
} catch (ftsErr) {
|
||||
if (this.isSqliteCorruptError(ftsErr)) {
|
||||
this.disableNodesFts('existing nodes_fts is corrupt and could not be rebuilt', ftsErr);
|
||||
} else {
|
||||
console.warn('Failed to rebuild nodes_fts:', ftsErr);
|
||||
}
|
||||
}
|
||||
} catch (schemaErr) {
|
||||
console.warn('Final schema pass migration error:', schemaErr);
|
||||
}
|
||||
@@ -854,6 +1199,15 @@ class SQLiteClient {
|
||||
public close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
private isSqliteCorruptError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sqliteError = error as Error & { code?: string };
|
||||
return sqliteError.code === 'SQLITE_CORRUPT' || /database disk image is malformed/i.test(sqliteError.message || '');
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance (similar to PostgreSQL client interface)
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
|
||||
export interface AutoContextSettings {
|
||||
autoContextEnabled: boolean;
|
||||
lastPinnedMigration?: string;
|
||||
}
|
||||
|
||||
const SETTINGS_FILE = 'settings.json';
|
||||
const DEFAULT_SETTINGS: AutoContextSettings = {
|
||||
autoContextEnabled: false,
|
||||
};
|
||||
|
||||
let bootstrapAttempted = false;
|
||||
|
||||
function resolveBaseConfigDir(): string {
|
||||
const override = process.env.RAH_CONFIG_DIR;
|
||||
if (override && override.trim().length > 0) {
|
||||
return override;
|
||||
}
|
||||
|
||||
const home = os.homedir();
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(home, 'Library', 'Application Support', 'RA-H');
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const roaming = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
||||
return path.join(roaming, 'RA-H');
|
||||
}
|
||||
|
||||
const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
|
||||
return path.join(xdgConfig, 'ra-h');
|
||||
}
|
||||
|
||||
function getSettingsDir(): string {
|
||||
return path.join(resolveBaseConfigDir(), 'config');
|
||||
}
|
||||
|
||||
function getSettingsPath(): string {
|
||||
return path.join(getSettingsDir(), SETTINGS_FILE);
|
||||
}
|
||||
|
||||
function ensureSettingsDirExists(): void {
|
||||
const dir = getSettingsDir();
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function writeSettingsFile(settings: AutoContextSettings): AutoContextSettings {
|
||||
ensureSettingsDirExists();
|
||||
fs.writeFileSync(getSettingsPath(), JSON.stringify(settings, null, 2), 'utf-8');
|
||||
return settings;
|
||||
}
|
||||
|
||||
function bootstrapFromLegacyPins(): void {
|
||||
if (bootstrapAttempted) return;
|
||||
bootstrapAttempted = true;
|
||||
|
||||
const settingsPath = getSettingsPath();
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = getSQLiteClient();
|
||||
const countRow = db
|
||||
.query<{ count: number }>('SELECT COUNT(*) as count FROM nodes WHERE 1=0 /* /* is_pinned removed */ removed */')
|
||||
.rows[0];
|
||||
const pinnedCount = Number(countRow?.count ?? 0);
|
||||
if (pinnedCount > 0) {
|
||||
writeSettingsFile({
|
||||
autoContextEnabled: true,
|
||||
lastPinnedMigration: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Auto-context pin bootstrap failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAutoContextSettings(): AutoContextSettings {
|
||||
bootstrapFromLegacyPins();
|
||||
const settingsPath = getSettingsPath();
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(settingsPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...parsed,
|
||||
autoContextEnabled: Boolean(parsed?.autoContextEnabled),
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Failed to read auto-context settings, using defaults:', error);
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export function updateAutoContextSettings(
|
||||
partial: Partial<AutoContextSettings>
|
||||
): AutoContextSettings {
|
||||
const current = getAutoContextSettings();
|
||||
const next: AutoContextSettings = {
|
||||
...current,
|
||||
...partial,
|
||||
autoContextEnabled:
|
||||
typeof partial.autoContextEnabled === 'boolean'
|
||||
? partial.autoContextEnabled
|
||||
: current.autoContextEnabled,
|
||||
};
|
||||
return writeSettingsFile(next);
|
||||
}
|
||||
|
||||
export function setAutoContextEnabled(enabled: boolean): AutoContextSettings {
|
||||
return updateAutoContextSettings({ autoContextEnabled: enabled });
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Node metadata embedding service for RA-H knowledge management system
|
||||
* Embeds node metadata (title, content, dimensions, AI analysis) into nodes.embedding field
|
||||
* Embeds node metadata (title, source, context, AI analysis) into nodes.embedding field
|
||||
*/
|
||||
|
||||
import OpenAI from 'openai';
|
||||
@@ -8,19 +8,17 @@ import { generateText } from 'ai';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import {
|
||||
createDatabaseConnection,
|
||||
getDbVectorCapability,
|
||||
serializeFloat32Vector,
|
||||
formatEmbeddingText,
|
||||
batchProcess
|
||||
} from './sqlite-vec';
|
||||
import type { VectorCapability } from '@/services/database/sqlite-runtime';
|
||||
|
||||
interface NodeRecord {
|
||||
id: number;
|
||||
title: string;
|
||||
source: string | null;
|
||||
description: string | null;
|
||||
dimensions_json: string;
|
||||
context_name: string | null;
|
||||
embedding?: Buffer | null;
|
||||
embedding_updated_at?: string | null;
|
||||
embedding_text?: string | null;
|
||||
@@ -36,7 +34,6 @@ export class NodeEmbedder {
|
||||
private openaiClient: OpenAI;
|
||||
private openaiProvider: ReturnType<typeof createOpenAI>;
|
||||
private db: ReturnType<typeof createDatabaseConnection>;
|
||||
private readonly vectorCapability: VectorCapability;
|
||||
private processedCount: number = 0;
|
||||
private failedCount: number = 0;
|
||||
|
||||
@@ -49,21 +46,17 @@ export class NodeEmbedder {
|
||||
this.openaiClient = new OpenAI({ apiKey });
|
||||
this.openaiProvider = createOpenAI({ apiKey });
|
||||
this.db = createDatabaseConnection();
|
||||
this.vectorCapability = getDbVectorCapability(this.db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze node content with AI to extract insights
|
||||
*/
|
||||
private async analyzeNodeWithAI(node: NodeRecord): Promise<string> {
|
||||
const dimensions = node.dimensions_json ? JSON.parse(node.dimensions_json) : [];
|
||||
const dimensionsText = Array.isArray(dimensions) && dimensions.length ? dimensions.join(', ') : 'none';
|
||||
|
||||
const prompt = `Analyze this content and provide 2-3 key insights or themes in a concise paragraph (max 100 words):
|
||||
|
||||
Title: ${node.title}
|
||||
Source: ${node.source || 'No source'}
|
||||
Dimensions: ${dimensionsText}
|
||||
Context: ${node.context_name || 'none'}
|
||||
|
||||
Focus on the main concepts, key relationships, and practical implications.`;
|
||||
|
||||
@@ -104,15 +97,12 @@ Focus on the main concepts, key relationships, and practical implications.`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse dimensions from JSON string
|
||||
const dimensions = node.dimensions_json ? JSON.parse(node.dimensions_json) : [];
|
||||
|
||||
// Create base embedding text
|
||||
let embeddingText = formatEmbeddingText(
|
||||
node.title,
|
||||
node.source || '',
|
||||
dimensions,
|
||||
node.description
|
||||
node.description,
|
||||
node.context_name
|
||||
);
|
||||
|
||||
// Add AI analysis if source exists
|
||||
@@ -141,18 +131,23 @@ Focus on the main concepts, key relationships, and practical implications.`;
|
||||
const now = new Date().toISOString();
|
||||
updateStmt.run(embeddingBlob, now, embeddingText, node.id);
|
||||
|
||||
if (this.vectorCapability.available) {
|
||||
// Update vec_nodes virtual table
|
||||
try {
|
||||
// Determine correct column name for primary key (node_id vs id)
|
||||
// Use declared PK column from your DB schema (confirmed: node_id)
|
||||
const pkCol = 'node_id';
|
||||
|
||||
// Delete existing entry if any
|
||||
const deleteStmt = this.db.prepare(`DELETE FROM vec_nodes WHERE ${pkCol} = ?`);
|
||||
deleteStmt.run(BigInt(node.id));
|
||||
|
||||
// Insert new entry (use bracketed string format compatible with sqlite-vec)
|
||||
const vectorString = `[${embedding.join(',')}]`;
|
||||
const insertStmt = this.db.prepare(`INSERT INTO vec_nodes (${pkCol}, embedding) VALUES (?, ?)`);
|
||||
insertStmt.run(BigInt(node.id), vectorString);
|
||||
} catch (vecError) {
|
||||
console.warn(`Could not update vec_nodes for node ${node.id}:`, vecError);
|
||||
}
|
||||
// Continue - main embedding is still saved
|
||||
}
|
||||
|
||||
this.processedCount++;
|
||||
@@ -177,32 +172,29 @@ Focus on the main concepts, key relationships, and practical implications.`;
|
||||
if (nodeId) {
|
||||
// Single node
|
||||
query = `
|
||||
SELECT n.id, n.title, n.source, n.description,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||
SELECT n.id, n.title, n.source, n.description, c.name as context_name,
|
||||
n.embedding, n.embedding_updated_at
|
||||
FROM nodes n
|
||||
LEFT JOIN contexts c ON c.id = n.context_id
|
||||
WHERE n.id = ?
|
||||
`;
|
||||
params = [nodeId];
|
||||
} else if (forceReEmbed) {
|
||||
// All nodes
|
||||
query = `
|
||||
SELECT n.id, n.title, n.source, n.description,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||
SELECT n.id, n.title, n.source, n.description, c.name as context_name,
|
||||
n.embedding, n.embedding_updated_at
|
||||
FROM nodes n
|
||||
LEFT JOIN contexts c ON c.id = n.context_id
|
||||
ORDER BY n.id
|
||||
`;
|
||||
} else {
|
||||
// Only nodes without embeddings
|
||||
query = `
|
||||
SELECT n.id, n.title, n.source, n.description,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||
SELECT n.id, n.title, n.source, n.description, c.name as context_name,
|
||||
n.embedding, n.embedding_updated_at
|
||||
FROM nodes n
|
||||
LEFT JOIN contexts c ON c.id = n.context_id
|
||||
WHERE n.embedding IS NULL OR n.embedding_updated_at IS NULL
|
||||
ORDER BY n.id
|
||||
`;
|
||||
|
||||
@@ -4,13 +4,9 @@
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import {
|
||||
ensureDatabaseDirectory,
|
||||
getDatabasePath,
|
||||
getDbVectorCapability,
|
||||
getVecExtensionPath,
|
||||
loadVecExtension,
|
||||
} from '@/services/database/sqlite-runtime';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { getDbVectorCapability as getVectorCapability } from '@/services/database/sqlite-runtime';
|
||||
|
||||
/**
|
||||
* Serialize a float array to binary format for vec0 storage
|
||||
@@ -35,24 +31,56 @@ export function deserializeFloat32Vector(blob: Buffer): number[] {
|
||||
return vector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SQLite database path from environment or default location
|
||||
*/
|
||||
export function getDatabasePath(): string {
|
||||
const envPath = process.env.SQLITE_DB_PATH;
|
||||
if (envPath) {
|
||||
return envPath;
|
||||
}
|
||||
|
||||
// Default path: ~/Library/Application Support/RA-H/db/rah.sqlite
|
||||
const homeDir = os.homedir();
|
||||
return path.join(homeDir, 'Library', 'Application Support', 'RA-H', 'db', 'rah.sqlite');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vec extension path from environment or default location
|
||||
*/
|
||||
export function getVecExtensionPath(): string {
|
||||
const envPath = process.env.SQLITE_VEC_EXTENSION_PATH;
|
||||
if (envPath) {
|
||||
return envPath;
|
||||
}
|
||||
|
||||
// Default path relative to project root
|
||||
return path.join(process.cwd(), 'vendor', 'sqlite-extensions', 'vec0.dylib');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create database connection with vec0 extension loaded
|
||||
*/
|
||||
export function createDatabaseConnection(): Database.Database {
|
||||
const dbPath = getDatabasePath();
|
||||
const vecPath = getVecExtensionPath();
|
||||
ensureDatabaseDirectory(dbPath);
|
||||
|
||||
const db = new Database(dbPath);
|
||||
|
||||
const capability = loadVecExtension(db, vecPath);
|
||||
if (!capability.available) {
|
||||
console.warn(`Warning: ${capability.reason}`);
|
||||
// Load vec0 extension
|
||||
try {
|
||||
db.loadExtension(vecPath);
|
||||
} catch (error) {
|
||||
console.error('Warning: Could not load vec0 extension:', error);
|
||||
// Continue without vector support for non-vector operations
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
export { getDatabasePath, getDbVectorCapability, getVecExtensionPath };
|
||||
export function getDbVectorCapability(db: Database.Database) {
|
||||
return getVectorCapability(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format embedding text for node metadata
|
||||
@@ -60,12 +88,12 @@ export { getDatabasePath, getDbVectorCapability, getVecExtensionPath };
|
||||
export function formatEmbeddingText(
|
||||
title: string,
|
||||
content: string,
|
||||
dimensions: string[],
|
||||
description?: string | null
|
||||
description?: string | null,
|
||||
contextName?: string | null
|
||||
): string {
|
||||
const descriptionText = description && description.trim() ? description.trim() : 'none';
|
||||
const dimensionsText = dimensions.length > 0 ? dimensions.join(', ') : 'none';
|
||||
return `Title: ${title}\n\nDescription: ${descriptionText}\n\nContent: ${content}\n\nDimensions: ${dimensionsText}`;
|
||||
const contextText = contextName && contextName.trim() ? contextName.trim() : 'none';
|
||||
return `Title: ${title}\n\nDescription: ${descriptionText}\n\nContent: ${content}\n\nContext: ${contextText}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
|
||||
export const createDimensionTool = tool({
|
||||
description: 'Create a new dimension only when the user explicitly instructs you to do so. Always provide a description explaining what belongs in this category.',
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('Dimension name'),
|
||||
description: z.string().min(1).max(500).describe('Dimension description explaining what content belongs in this dimension (required, max 500 characters)')
|
||||
}),
|
||||
execute: async (params) => {
|
||||
console.log('📁 CreateDimension tool called with params:', JSON.stringify(params, null, 2));
|
||||
try {
|
||||
const trimmedName = params.name.trim();
|
||||
if (!trimmedName) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Dimension name is required',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
// Call POST /api/dimensions
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/dimensions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: trimmedName,
|
||||
description: params.description.trim()
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to create dimension';
|
||||
try {
|
||||
const errorResult = await response.json();
|
||||
errorMessage = errorResult.error || errorMessage;
|
||||
} catch {
|
||||
// If response is not JSON (e.g., HTML error page), use status text
|
||||
errorMessage = `Failed to create dimension: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
message: `Created dimension "${trimmedName}"${params.description ? ' with description' : ''}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create dimension',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -119,14 +119,12 @@ export const createEdgeTool = tool({
|
||||
|
||||
const fromLabel = formatNodeForChat({
|
||||
id: fromNode.id,
|
||||
title: fromNode.title,
|
||||
dimensions: fromNode.dimensions || []
|
||||
title: fromNode.title
|
||||
});
|
||||
|
||||
const toLabel = formatNodeForChat({
|
||||
id: toNode.id,
|
||||
title: toNode.title,
|
||||
dimensions: toNode.dimensions || []
|
||||
title: toNode.title
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
import { normalizeDimensions } from '@/services/database/quality';
|
||||
|
||||
function extractTextFromMessageContent(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
@@ -57,7 +56,7 @@ function inferSourceFromContext(params: { title: string; description?: string; s
|
||||
}
|
||||
|
||||
export const createNodeTool = tool({
|
||||
description: 'Create node. Set the primary context explicitly when it is clear; otherwise the server will infer the best-fit context automatically so the node is not left unscoped. Infer a clean title, dimensions, and natural description with best effort. When the node comes from the user\'s own idea, note, or dictated thought, preserve their actual wording in source with only minimal cleanup instead of flattening it into a summary. Do not block creation if the description is incomplete. If the description framing is materially inferred, create the node first and then invite one concise user correction pass.',
|
||||
description: 'Create a node. Set context explicitly only when it is clear and useful; otherwise leave it blank. Focus on a clean title, a strong natural description, preserved source text, and the right metadata. When the node comes from the user\'s own idea, note, or dictated thought, preserve their actual wording in source with only minimal cleanup instead of flattening it into a summary. Do not block creation if the description is incomplete. If the description framing is materially inferred, create the node first and then invite one concise user correction pass.',
|
||||
inputSchema: z.object({
|
||||
title: z.string().describe('The title of the node'),
|
||||
description: z.string().max(500).optional().describe('Optional natural description. If you have enough context, describe what this is, why it belongs in Brad\'s graph, and its current workflow status in normal prose. Do not use labels like WHAT:, WHY:, or STATUS:.'),
|
||||
@@ -66,23 +65,18 @@ export const createNodeTool = tool({
|
||||
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Use when the node clearly belongs to a known context.'),
|
||||
context_name: z.string().optional().describe('Optional convenience context name. Resolved to a stable context_id before persistence.'),
|
||||
dimensions: z
|
||||
.array(z.string())
|
||||
.max(5)
|
||||
.optional()
|
||||
.describe('Optional secondary dimension tags to apply to this node (0-5 items).'),
|
||||
metadata: z.record(z.any()).optional().describe('Optional node metadata. Use canonical keys when known: type, state, captured_method, captured_by, and source_metadata. Source-specific facts belong inside source_metadata.')
|
||||
}),
|
||||
execute: async (params, context) => {
|
||||
console.log('🎯 CreateNode tool called with params:', JSON.stringify(params, null, 2));
|
||||
try {
|
||||
const trimmedDimensions = normalizeDimensions(params.dimensions || [], 5);
|
||||
const canonicalSource = inferSourceFromContext(params, context);
|
||||
|
||||
// Call the nodes API endpoint
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...params, source: canonicalSource, dimensions: trimmedDimensions })
|
||||
body: JSON.stringify({ ...params, source: canonicalSource })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
@@ -95,10 +89,18 @@ export const createNodeTool = tool({
|
||||
};
|
||||
}
|
||||
|
||||
const formattedDisplay = formatNodeForChat({
|
||||
id: result.data.id,
|
||||
title: result.data.title,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
message: `Created: ${formatNodeForChat(result.data)}`
|
||||
data: {
|
||||
...result.data,
|
||||
formatted_display: formattedDisplay,
|
||||
},
|
||||
message: `Created: ${formattedDisplay}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const deleteDimensionTool = tool({
|
||||
description: 'Delete a dimension and remove all node associations',
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('Dimension name to delete')
|
||||
}),
|
||||
execute: async (params) => {
|
||||
console.log('🗑️ DeleteDimension tool called with params:', JSON.stringify(params, null, 2));
|
||||
try {
|
||||
const trimmedName = params.name.trim();
|
||||
if (!trimmedName) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Dimension name is required',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions?name=${encodeURIComponent(trimmedName)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to delete dimension';
|
||||
try {
|
||||
const errorResult = await response.json();
|
||||
errorMessage = errorResult.error || errorMessage;
|
||||
} catch {
|
||||
// If response is not JSON (e.g., HTML error page), use status text
|
||||
errorMessage = `Failed to delete dimension: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
message: `Deleted dimension "${trimmedName}" and removed all node associations`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete dimension',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { DimensionService } from '@/services/database/dimensionService';
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
|
||||
export const getDimensionTool = tool({
|
||||
description: 'Get dimension details: description and node count.',
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('The exact name of the dimension to retrieve')
|
||||
}),
|
||||
execute: async ({ name }) => {
|
||||
console.log('📁 GetDimension tool called for:', name);
|
||||
try {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Dimension name is required',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
// Get dimension details from service
|
||||
const dimension = await DimensionService.getDimensionByName(trimmedName);
|
||||
|
||||
// Get node count for this dimension
|
||||
const sqlite = getSQLiteClient();
|
||||
const countResult = sqlite.query(`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM node_dimensions
|
||||
WHERE dimension = ?
|
||||
`, [trimmedName]);
|
||||
|
||||
const nodeCount = countResult.rows.length > 0
|
||||
? Number((countResult.rows[0] as { count: number }).count)
|
||||
: 0;
|
||||
|
||||
if (!dimension) {
|
||||
// Dimension might exist in node_dimensions but not in dimensions table
|
||||
if (nodeCount > 0) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
name: trimmedName,
|
||||
description: null,
|
||||
nodeCount,
|
||||
exists: true,
|
||||
hasMetadata: false
|
||||
},
|
||||
message: `Dimension "${trimmedName}" exists with ${nodeCount} nodes but has no metadata (description/priority not set).`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Dimension "${trimmedName}" not found`,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
const result = {
|
||||
name: dimension.name,
|
||||
description: dimension.description,
|
||||
nodeCount,
|
||||
updatedAt: dimension.updated_at,
|
||||
exists: true,
|
||||
hasMetadata: true
|
||||
};
|
||||
|
||||
// Build descriptive message
|
||||
const parts: string[] = [];
|
||||
parts.push(`Dimension: ${result.name}`);
|
||||
parts.push(`Nodes: ${result.nodeCount}`);
|
||||
if (result.description) parts.push(`Description: ${result.description}`);
|
||||
parts.push(`Last updated: ${result.updatedAt}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
message: parts.join('\n')
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('GetDimension tool error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get dimension',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -36,7 +36,8 @@ export const getNodesByIdTool = tool({
|
||||
title: node.title,
|
||||
link: node.link,
|
||||
event_date: node.event_date ?? null,
|
||||
dimensions: node.dimensions || [],
|
||||
context_id: node.context_id ?? null,
|
||||
context: node.context ?? null,
|
||||
chunk_status: node.chunk_status || 'unknown',
|
||||
created_at: node.created_at,
|
||||
updated_at: node.updated_at,
|
||||
|
||||
@@ -19,11 +19,11 @@ export const queryContextsTool = tool({
|
||||
description: 'List and inspect contexts. Use this to discover available primary scopes before filtering nodes or assigning node context.',
|
||||
inputSchema: z.object({
|
||||
filters: z.object({
|
||||
id: z.number().int().positive().optional(),
|
||||
name: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
limit: z.number().min(1).max(100).default(50).optional(),
|
||||
includeNodes: z.boolean().default(false).optional(),
|
||||
id: z.number().int().positive().describe('Exact context ID lookup.').optional(),
|
||||
name: z.string().describe('Exact context name lookup.').optional(),
|
||||
search: z.string().describe('Case-insensitive search across context names and descriptions.').optional(),
|
||||
limit: z.number().min(1).max(100).default(50).describe('Maximum number of contexts to return.').optional(),
|
||||
includeNodes: z.boolean().default(false).describe('Include the node list for an exact single-context lookup.').optional(),
|
||||
}).optional(),
|
||||
}),
|
||||
execute: async ({ filters = {} }: { filters?: QueryContextFilters }) => {
|
||||
@@ -33,12 +33,15 @@ export const queryContextsTool = tool({
|
||||
const normalizedSearch = filters.search?.trim().toLowerCase();
|
||||
|
||||
let contexts = await contextService.listContexts();
|
||||
|
||||
if (filters.id !== undefined) {
|
||||
contexts = contexts.filter((context) => context.id === filters.id);
|
||||
}
|
||||
|
||||
if (normalizedName) {
|
||||
contexts = contexts.filter((context) => context.name.toLowerCase() === normalizedName.toLowerCase());
|
||||
}
|
||||
|
||||
if (normalizedSearch) {
|
||||
contexts = contexts.filter((context) =>
|
||||
matchesSearch(context.name, normalizedSearch) ||
|
||||
@@ -47,10 +50,15 @@ export const queryContextsTool = tool({
|
||||
}
|
||||
|
||||
const limitedContexts = contexts.slice(0, limit);
|
||||
const includeNodes = filters.includeNodes === true && limitedContexts.length === 1 && (filters.id !== undefined || Boolean(normalizedName));
|
||||
const canIncludeNodes =
|
||||
filters.includeNodes === true &&
|
||||
limitedContexts.length === 1 &&
|
||||
(filters.id !== undefined || Boolean(normalizedName));
|
||||
|
||||
const contextsWithNodes = await Promise.all(
|
||||
limitedContexts.map(async (context) => {
|
||||
if (!canIncludeNodes) return context;
|
||||
|
||||
const enriched = await Promise.all(limitedContexts.map(async (context) => {
|
||||
if (!includeNodes) return context;
|
||||
const nodes = await contextService.getNodesForContext(context.id);
|
||||
return {
|
||||
...context,
|
||||
@@ -58,24 +66,30 @@ export const queryContextsTool = tool({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
description: node.description ?? null,
|
||||
dimensions: node.dimensions || [],
|
||||
context_id: node.context_id ?? null,
|
||||
context: node.context ?? null,
|
||||
updated_at: node.updated_at,
|
||||
})),
|
||||
};
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
const message = contextsWithNodes.length === 0
|
||||
? 'No contexts found.'
|
||||
: `Found ${contextsWithNodes.length} context${contextsWithNodes.length === 1 ? '' : 's'}:\n${contextsWithNodes.map((context) => `• ${context.name} (#${context.id}, ${context.count} nodes)`).join('\n')}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
contexts: enriched,
|
||||
count: enriched.length,
|
||||
contexts: contextsWithNodes,
|
||||
count: contextsWithNodes.length,
|
||||
total_available: contexts.length,
|
||||
filters_applied: filters,
|
||||
},
|
||||
message: enriched.length === 0 ? 'No contexts found.' : `Found ${enriched.length} context(s).`,
|
||||
message,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('QueryContexts tool error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to query contexts',
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { nodeService } from '@/services/database/nodes';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
import type { Node } from '@/types/database';
|
||||
|
||||
export const queryDimensionNodesTool = tool({
|
||||
description: 'Get nodes in a dimension, sorted by connection count.',
|
||||
inputSchema: z.object({
|
||||
dimension: z.string().describe('The dimension name to query nodes from'),
|
||||
limit: z.number().optional().default(20).describe('Maximum number of nodes to return (default: 20)'),
|
||||
offset: z.number().optional().default(0).describe('Number of nodes to skip for pagination'),
|
||||
includeContent: z.boolean().optional().default(false).describe('Include truncated content preview (default: false)'),
|
||||
}),
|
||||
execute: async ({ dimension, limit = 20, offset = 0, includeContent = false }) => {
|
||||
try {
|
||||
// Query nodes with this dimension
|
||||
const nodes = await nodeService.getNodes({
|
||||
dimensions: [dimension],
|
||||
limit,
|
||||
offset,
|
||||
sortBy: 'edges',
|
||||
});
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
dimension,
|
||||
nodes: [],
|
||||
total: 0,
|
||||
message: `No nodes found in dimension "${dimension}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const formattedNodes = nodes.map((node: Node) => {
|
||||
const formatted: Record<string, unknown> = {
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
label: formatNodeForChat({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || [],
|
||||
}),
|
||||
edgeCount: node.edge_count || 0,
|
||||
dimensions: node.dimensions || [],
|
||||
created_at: node.created_at,
|
||||
updated_at: node.updated_at,
|
||||
event_date: node.event_date ?? null,
|
||||
};
|
||||
|
||||
if (includeContent && node.source) {
|
||||
const previewSource = node.source;
|
||||
// Truncate to ~100 chars
|
||||
formatted.sourcePreview = previewSource.length > 100
|
||||
? previewSource.substring(0, 100) + '...'
|
||||
: previewSource;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
dimension,
|
||||
nodes: formattedNodes,
|
||||
total: nodes.length,
|
||||
hasMore: nodes.length >= limit,
|
||||
message: `Found ${nodes.length} nodes in dimension "${dimension}"`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('queryDimensionNodes error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to query dimension nodes',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
|
||||
export const queryDimensionsTool = tool({
|
||||
description: 'List the existing canonical dimensions with node counts. Use this before assigning dimensions to nodes. Do not invent new dimensions without explicit user instruction.',
|
||||
inputSchema: z.object({
|
||||
filters: z.object({
|
||||
search: z.string().describe('Search term to match against dimension names').optional(),
|
||||
limit: z.number().min(1).max(100).default(50).describe('Maximum number of results to return')
|
||||
}).optional()
|
||||
}),
|
||||
execute: async ({ filters = {} }) => {
|
||||
console.log('📁 QueryDimensions tool called with filters:', JSON.stringify(filters, null, 2));
|
||||
try {
|
||||
const limit = filters.limit || 50;
|
||||
const baseUrl = getInternalApiBaseUrl();
|
||||
|
||||
// Use existing API endpoint for dimension listing
|
||||
const response = await fetch(`${baseUrl}/api/dimensions/popular`);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to query dimensions';
|
||||
try {
|
||||
const errorResult = await response.json();
|
||||
errorMessage = errorResult.error || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Failed to query dimensions: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
data: { dimensions: [], count: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid response from dimensions API',
|
||||
data: { dimensions: [], count: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
let dimensions = result.data as Array<{
|
||||
dimension: string;
|
||||
count: number;
|
||||
description: string | null;
|
||||
}>;
|
||||
|
||||
// Filter by search term
|
||||
if (filters.search) {
|
||||
const searchLower = filters.search.toLowerCase();
|
||||
dimensions = dimensions.filter(d =>
|
||||
d.dimension.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
const limitedDimensions = dimensions.slice(0, limit);
|
||||
|
||||
// Format for display
|
||||
const formattedDimensions = limitedDimensions.map(d => ({
|
||||
name: d.dimension,
|
||||
count: d.count,
|
||||
description: d.description
|
||||
}));
|
||||
|
||||
// Build message
|
||||
const filterParts: string[] = [];
|
||||
if (filters.search) filterParts.push(`matching "${filters.search}"`);
|
||||
const filterDesc = filterParts.length > 0 ? ` (${filterParts.join(', ')})` : '';
|
||||
|
||||
const dimensionList = formattedDimensions
|
||||
.map(d => `• ${d.name} (${d.count} nodes)${d.description ? ` - ${d.description}` : ''}`)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
dimensions: formattedDimensions,
|
||||
count: formattedDimensions.length,
|
||||
total_available: dimensions.length,
|
||||
filters_applied: filters
|
||||
},
|
||||
message: `Found ${formattedDimensions.length} dimensions${filterDesc}:\n${dimensionList || 'No dimensions found'}`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('QueryDimensions tool error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to query dimensions',
|
||||
data: { dimensions: [], count: 0 }
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -62,8 +62,7 @@ export const queryEdgeTool = tool({
|
||||
const formattedConnections = limitedConnections.map(connection => {
|
||||
const formattedNode = formatNodeForChat({
|
||||
id: connection.connected_node.id,
|
||||
title: connection.connected_node.title,
|
||||
dimensions: connection.connected_node.dimensions || []
|
||||
title: connection.connected_node.title
|
||||
});
|
||||
|
||||
const context = connection.edge.context as Record<string, unknown> | undefined;
|
||||
@@ -85,7 +84,7 @@ export const queryEdgeTool = tool({
|
||||
id: connection.connected_node.id,
|
||||
title: connection.connected_node.title,
|
||||
description: truncateText(connection.connected_node.description, 140),
|
||||
dimensions: connection.connected_node.dimensions || [],
|
||||
context: connection.connected_node.context ?? null,
|
||||
formatted_display: formattedNode
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { Node } from '@/types/database';
|
||||
import { scoreNodeSearchMatch } from '@/services/database/searchRanking';
|
||||
|
||||
type QueryNodeFilters = {
|
||||
dimensions?: string[];
|
||||
contextId?: number;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
@@ -17,10 +16,9 @@ type QueryNodeFilters = {
|
||||
};
|
||||
|
||||
export const queryNodesTool = tool({
|
||||
description: 'Search nodes across title, description, and source. For free-text lookups, search the graph broadly and prioritize title/description matches. Do not use dimensions to constrain keyword search unless the user is explicitly asking about a known dimension.',
|
||||
description: 'Search nodes across title, description, and source. For free-text lookups, search the graph broadly and prioritize title/description matches. Use context filtering only when the user is explicitly asking about a known context.',
|
||||
inputSchema: z.object({
|
||||
filters: z.object({
|
||||
dimensions: z.array(z.string()).describe('Filter by dimensions (e.g., ["research", "ai", "technology"]). Replaces old type/stage filtering.').optional(),
|
||||
contextId: z.number().int().positive().describe('Optional primary context filter.').optional(),
|
||||
search: z.string().describe('Search term to match against node title, description, or source').optional(),
|
||||
limit: z.number().min(1).max(50).default(10).describe('Maximum number of results to return'),
|
||||
@@ -54,7 +52,6 @@ export const queryNodesTool = tool({
|
||||
const formatted = formatNodeForChat({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || [],
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -63,7 +60,6 @@ export const queryNodesTool = tool({
|
||||
nodes: [{
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || [],
|
||||
created_at: node.created_at,
|
||||
updated_at: node.updated_at,
|
||||
event_date: node.event_date ?? null,
|
||||
@@ -83,7 +79,6 @@ export const queryNodesTool = tool({
|
||||
|
||||
const nodesPromise: Promise<Node[] | undefined> = nodeService.getNodes({
|
||||
limit,
|
||||
dimensions: queryFilters.dimensions,
|
||||
contextId: queryFilters.contextId,
|
||||
search: queryFilters.search,
|
||||
searchMode: searchTerm ? 'hybrid' : 'standard',
|
||||
@@ -97,9 +92,7 @@ export const queryNodesTool = tool({
|
||||
return Array.isArray(nodes) ? nodes : [];
|
||||
};
|
||||
|
||||
const effectiveFilters = searchTerm
|
||||
? { ...filters, dimensions: undefined }
|
||||
: { ...filters };
|
||||
const effectiveFilters = { ...filters };
|
||||
|
||||
let safeNodes = await runQuery(effectiveFilters);
|
||||
|
||||
@@ -118,12 +111,10 @@ export const queryNodesTool = tool({
|
||||
const formatted = formatNodeForChat({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || []
|
||||
});
|
||||
return {
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || [],
|
||||
created_at: node.created_at,
|
||||
updated_at: node.updated_at,
|
||||
event_date: node.event_date ?? null,
|
||||
@@ -133,7 +124,7 @@ export const queryNodesTool = tool({
|
||||
|
||||
// Create message with formatted node labels only (no full node payload)
|
||||
const formattedLabels = formattedNodes.map(node => node.formatted_display).join(', ');
|
||||
const message = `Found ${safeNodes.length} nodes${effectiveFilters.dimensions ? ` with dimensions: ${effectiveFilters.dimensions.join(', ')}` : ''}${effectiveFilters.search ? ` matching: "${effectiveFilters.search}"` : ''}${formattedLabels ? `:\n${formattedLabels}` : ''}`;
|
||||
const message = `Found ${safeNodes.length} nodes${effectiveFilters.search ? ` matching: "${effectiveFilters.search}"` : ''}${formattedLabels ? `:\n${formattedLabels}` : ''}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
|
||||
export const updateDimensionTool = tool({
|
||||
description: 'Update a dimension name or description.',
|
||||
inputSchema: z.object({
|
||||
currentName: z.string().describe('Current dimension name'),
|
||||
newName: z.string().optional().describe('New dimension name (if renaming)'),
|
||||
description: z.string().max(500).optional().describe('New description (max 500 characters)')
|
||||
}),
|
||||
execute: async (params) => {
|
||||
console.log('📝 UpdateDimension tool called with params:', JSON.stringify(params, null, 2));
|
||||
try {
|
||||
// Validate at least one update field
|
||||
if (!params.newName && params.description === undefined) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'At least one update field (newName or description) must be provided',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
// Handle rename + other updates
|
||||
const body: any = {
|
||||
currentName: params.currentName.trim(),
|
||||
description: params.description?.trim() || ''
|
||||
};
|
||||
|
||||
if (params.newName) {
|
||||
body.newName = params.newName.trim();
|
||||
}
|
||||
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/dimensions`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to update dimension';
|
||||
try {
|
||||
const errorResult = await response.json();
|
||||
errorMessage = errorResult.error || errorMessage;
|
||||
} catch {
|
||||
// If response is not JSON (e.g., HTML error page), use status text
|
||||
errorMessage = `Failed to update dimension: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
const updates = [];
|
||||
if (params.newName) updates.push(`renamed to "${params.newName}"`);
|
||||
if (params.description !== undefined) updates.push('description updated');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
message: `Updated dimension "${params.currentName}": ${updates.join(', ')}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update dimension',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
import { normalizeDimensions } from '@/services/database/quality';
|
||||
|
||||
export const updateNodeTool = tool({
|
||||
description: 'Update node fields. Use this to enrich or correct nodes without losing canonical source content. Context is preserved unless context_id is supplied explicitly. When fixing a user-authored idea node, source should preserve the user\'s original wording as fully as possible. Never block an update because the description is incomplete. If the new description framing is materially inferred, complete the update and then invite one concise user feedback pass.',
|
||||
@@ -14,7 +13,6 @@ export const updateNodeTool = tool({
|
||||
link: z.string().optional().describe('New link'),
|
||||
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Primary context ID. Omit to preserve the existing context. Use null only to clear it intentionally.'),
|
||||
dimensions: z.array(z.string()).optional().describe('New secondary dimension tags - completely replaces existing dimensions'),
|
||||
metadata: z.record(z.any()).optional().describe('Metadata patch. It now merges with existing metadata instead of replacing the full blob. Use canonical keys: type, state, captured_method, captured_by, source_metadata.')
|
||||
}).describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.')
|
||||
}),
|
||||
@@ -28,10 +26,7 @@ export const updateNodeTool = tool({
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(updates.dimensions)) {
|
||||
updates.dimensions = normalizeDimensions(updates.dimensions, 5);
|
||||
}
|
||||
|
||||
// Call the nodes API endpoint
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -51,7 +46,7 @@ export const updateNodeTool = tool({
|
||||
return {
|
||||
success: true,
|
||||
data: result.node,
|
||||
message: `Updated node ID ${id}${updates.dimensions ? ` with dimensions: ${updates.dimensions.join(', ')}` : ''}`
|
||||
message: `Updated node ID ${id}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -63,4 +58,5 @@ export const updateNodeTool = tool({
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy export for backwards compatibility
|
||||
export const updateItemTool = updateNodeTool;
|
||||
|
||||
@@ -18,7 +18,7 @@ export const TOOL_GROUPS: Record<string, ToolGroup> = {
|
||||
orchestration: {
|
||||
id: 'orchestration',
|
||||
name: 'Orchestration',
|
||||
description: 'Web search and reasoning tools',
|
||||
description: 'Workflows, web search, and reasoning (orchestrator only)',
|
||||
icon: '●',
|
||||
color: '#8b5cf6'
|
||||
},
|
||||
@@ -37,13 +37,10 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
|
||||
queryNodes: 'core',
|
||||
getNodesById: 'core',
|
||||
queryEdge: 'core',
|
||||
queryDimensions: 'core',
|
||||
getDimension: 'core',
|
||||
queryDimensionNodes: 'core',
|
||||
queryContexts: 'core',
|
||||
searchContentEmbeddings: 'core',
|
||||
|
||||
// Orchestration: Web search and reasoning
|
||||
// Orchestration: Web search and reasoning (orchestrator only)
|
||||
webSearch: 'orchestration',
|
||||
think: 'orchestration',
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
export interface NodeData {
|
||||
id: number;
|
||||
title: string;
|
||||
dimensions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +44,6 @@ export function parseNodeMarkers(text: string): Array<NodeData & { raw: string }
|
||||
raw,
|
||||
id: parseInt(id, 10),
|
||||
title,
|
||||
dimensions: []
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user