feat: port holistic node refinement contract

This commit is contained in:
“BeeRad”
2026-04-11 21:37:52 +10:00
parent 35f9ecf89c
commit 3ae46245ec
119 changed files with 6596 additions and 10982 deletions
+4 -8
View File
@@ -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 }
);
}
}
-76
View File
@@ -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 });
}
}
-347
View File
@@ -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 });
}
}
-51
View File
@@ -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)
}))
});
}
+472 -444
View File
File diff suppressed because it is too large Load Diff
@@ -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
View File
@@ -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
View File
@@ -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);
+1 -2
View File
@@ -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({
@@ -46,4 +45,4 @@ export async function GET(request: NextRequest) {
error: error instanceof Error ? error.message : 'Failed to search nodes'
}, { status: 500 });
}
}
}
-41
View File
@@ -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 }
);
}
}
+800 -262
View File
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -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>
);
+24 -7
View File
@@ -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.
+107 -361
View File
@@ -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,169 +345,21 @@ 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,
});
const summary = nodes.length === 0
? 'No matching RA-H nodes found in that context.'
: `Found ${nodes.length} node(s) in that context.`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: {
count: nodes.length,
nodes: nodes.map((node) => ({
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,
})),
},
};
}
// 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 nodes = nodeService.getNodes({
search: trimmedQuery,
limit: safeLimit,
contextId,
createdAfter: created_after,
createdBefore: created_before,
eventAfter: event_after,
eventBefore: event_before,
});
const summary = nodes.length === 0
? 'No nodes found matching that query.'
@@ -565,7 +369,17 @@ async function main() {
content: [{ type: 'text', text: summary }],
structuredContent: {
count: nodes.length,
nodes
nodes: nodes.map((node) => ({
id: node.id,
title: node.title,
source: node.source ?? null,
description: node.description ?? null,
link: node.link ?? null,
context_id: node.context_id ?? null,
created_at: node.created_at,
updated_at: node.updated_at,
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;
}
delete mappedUpdates.content;
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());
}
return true;
}).slice(0, Math.min(Math.max(limit, 1), 100));
contexts = contextService.listContexts();
}
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
}
};
}
);
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
},
async ({ name, description, isPriority }) => {
const dimension = dimensionService.createDimension({
name,
description,
isPriority
});
const summary = structuredContexts.length === 0
? 'No contexts found.'
: `Found ${structuredContexts.length} context(s).`;
return {
content: [{ type: 'text', text: `Created dimension: ${dimension.dimension}` }],
content: [{ type: 'text', text: summary }],
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}`
}
count: structuredContexts.length,
contexts: structuredContexts,
},
};
}
);
@@ -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;
`);
} catch {}
console.error('[RA-H] Migrated edges: added explanation column');
db.exec(`
CREATE TABLE IF NOT EXISTS dimension_migration_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
migrated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
dimension_count INTEGER NOT NULL,
assignment_count INTEGER NOT NULL,
payload TEXT
);
`);
const hasLegacyDimensions = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='dimensions'").get();
const hasLegacyNodeDimensions = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='node_dimensions'").get();
if (hasLegacyDimensions || hasLegacyNodeDimensions) {
const snapshotCount = Number((db.prepare('SELECT COUNT(*) as count FROM dimension_migration_snapshots').get() || {}).count || 0);
if (snapshotCount === 0) {
const dimensionCount = hasLegacyDimensions
? Number((db.prepare('SELECT COUNT(*) as count FROM dimensions').get() || {}).count || 0)
: 0;
const assignmentCount = hasLegacyNodeDimensions
? Number((db.prepare('SELECT COUNT(*) as count FROM node_dimensions').get() || {}).count || 0)
: 0;
const payload = hasLegacyNodeDimensions
? ((db.prepare(`
SELECT COALESCE(
json_group_array(
json_object(
'node_id', nd.node_id,
'dimension', nd.dimension,
'description', d.description,
'icon', d.icon,
'is_priority', d.is_priority
)
),
'[]'
) AS payload
FROM node_dimensions nd
LEFT JOIN dimensions d ON d.name = nd.dimension
`).get() || {}).payload || '[]')
: '[]';
db.prepare(`
INSERT INTO dimension_migration_snapshots (dimension_count, assignment_count, payload)
VALUES (?, ?, ?)
`).run(dimensionCount, assignmentCount, payload);
}
db.exec(`
DROP INDEX IF EXISTS idx_dim_by_dimension;
DROP INDEX IF EXISTS idx_dim_by_node;
DROP TABLE IF EXISTS node_dimensions;
DROP TABLE IF EXISTS dimensions;
`);
}
return db;
}
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.
+1 -1
View File
@@ -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
+152 -21
View File
@@ -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
+105 -221
View File
@@ -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;
}
if (search) {
const haystack = `${context.name || ''} ${context.description || ''}`.toLowerCase();
return haystack.includes(search.trim().toLowerCase());
}
return true;
}).slice(0, Math.min(Math.max(limit, 1), 100));
contexts = Array.isArray(result.data) ? result.data : [];
}
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 = 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 {
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: enriched.length === 0 ? 'No matching contexts found.' : `Found ${enriched.length} context(s).` }],
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 }
};
}
);
+103 -225
View File
@@ -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;
}
if (search) {
const haystack = `${context.name || ''} ${context.description || ''}`.toLowerCase();
return haystack.includes(search.trim().toLowerCase());
}
return true;
}).slice(0, Math.min(Math.max(limit, 1), 100));
contexts = Array.isArray(result.data) ? result.data : [];
}
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 = 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 {
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: enriched.length === 0 ? 'No matching contexts found.' : `Found ${enriched.length} context(s).` }],
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
View File
@@ -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
View File
@@ -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; // 01
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
+4 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+4 -4
View File
@@ -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,10 +59,10 @@ 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
- Backups typically ~250MB for 40k+ nodes
- Store backups safely - they contain your entire knowledge base
- Helper scripts are integrated with the application - modify with caution
- Helper scripts are integrated with the application - modify with caution
+344 -50
View File
@@ -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
"$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
echo "Dropping legacy dimension tables after snapshot..."
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
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
fi
if has_table dimensions; then
echo "Seeding default locked dimensions..."
for dimension in research ideas projects memory preferences; do
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 dimensions (name, is_priority, updated_at)
VALUES ('$dimension', 1, datetime('now'))
ON CONFLICT(name) DO UPDATE SET is_priority = 1, updated_at = datetime('now');
INSERT INTO dimension_migration_snapshots (dimension_count, assignment_count, payload)
VALUES (${DIM_COUNT:-0}, ${ASSIGN_COUNT:-0}, '${PAYLOAD//\'/''}');
SQL
fi
"$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..."
"$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);
echo "Ensuring voice_usage table exists..."
if ! has_table voice_usage; then
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
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
View File
@@ -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
View File
@@ -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 &quot;{searchQuery.trim()}&quot;</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}
+2 -6
View File
@@ -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}
/>
);
+35 -25
View File
@@ -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
+19 -23
View File
@@ -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;
}
-66
View File
@@ -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>
);
}
+173 -322
View File
@@ -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 () => {
try {
const res = await fetch(`/api/nodes/${activeTabId}`);
if (res.ok) {
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]);
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);
}
})();
return;
}
}, [activeTabId]);
// ----- SSE real-time sync -----
void (async () => {
try {
const res = await fetch(`/api/nodes/${activeTabId}`);
if (!res.ok) return;
const data = await res.json();
const node = data.node as DbNode;
if (!node) return;
setExpandedNodes((prev) => (prev.some((existingNode) => existingNode.id === node.id) ? prev : [...prev, node]));
setSelectedNodeId(node.id);
setTimeout(() => {
reactFlowInstance.setCenter(600, 400, { duration: 400, zoom: 1.5 });
}, 100);
} catch (err) {
console.error('Failed to fetch focused node:', err);
}
})();
}, [activeTabId, allDbNodes, rfNodes, reactFlowInstance]);
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' }}>&#10003;</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' }}>&#10003;</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={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
gap: 4,
zIndex: 10,
}}>
<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 &middot; 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)',
}}
>
{dim}
</span>
))}
</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
<span
style={{
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)',
}}
>
{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)',
};
+233
View File
@@ -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>
);
}
+1 -6
View File
@@ -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 -1
View File
@@ -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';
+5 -6
View File
@@ -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
+50 -59
View File
@@ -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);
+45 -43
View File
@@ -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;
+1 -1
View File
@@ -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}>
+123 -110
View File
@@ -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);
const loadNodes = async () => {
try {
const res = await fetch('/api/nodes?sortBy=edges&limit=5');
const payload = await res.json();
setNodes(payload.data || []);
} catch (error) {
console.error(error);
setNodes([]);
} finally {
setLoadingNodes(false);
}
};
const loadCapsule = async () => {
try {
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 {
setLoadingCapsule(false);
}
};
useEffect(() => {
const loadNodes = async () => {
try {
const res = await fetch('/api/nodes?sortBy=edges&limit=10');
const payload = await res.json();
setNodes(payload.data || []);
} catch (e) {
console.error(e);
} finally {
setLoadingNodes(false);
}
};
const loadSettings = 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);
} finally {
setLoadingSettings(false);
}
};
loadNodes();
loadSettings();
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>
<div style={labelStyle}>Auto-Context</div>
<div style={subLabelStyle}>
{loadingSettings ? 'Loading...' : enabled ? 'Enabled' : 'Disabled'}
</div>
</div>
<button
onClick={handleToggle}
disabled={loadingSettings || saving}
style={{
...toggleStyle,
background: enabled ? 'var(--settings-active-bg)' : 'var(--settings-code-bg)',
}}
>
<span style={{
...toggleKnobStyle,
left: enabled ? 26 : 4,
}} />
</button>
<div style={capsuleHeaderStyle}>
<div>
<div style={labelStyle}>Context Capsule</div>
<div style={subLabelStyle}>Always injected into the system prompt. Max 200 words total.</div>
</div>
<button
onClick={handleReset}
disabled={resetting}
style={resetButtonStyle}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--settings-button-hover-bg)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
{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,
+119 -273
View File
@@ -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,105 +285,91 @@ export default function DatabaseViewer() {
</tr>
</thead>
<tbody>
{nodes.map((node, index) => {
const belongsToLocked = node.dimensions?.some((dimension) => lockedDimensionSet.has(dimension));
return (
<tr
key={node.id}
style={{
background: index % 2 === 0 ? 'var(--rah-bg-base)' : 'var(--rah-bg-surface)',
borderBottom: '1px solid var(--rah-bg-surface)',
}}
>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '28%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ fontWeight: 600, color: 'var(--rah-text-base)', 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);
window.alert(`Unable to open ${node.link}`);
});
}}
title="Open original link"
style={{
background: 'transparent',
border: 'none',
color: '#7de8a5',
cursor: 'pointer',
padding: 0,
display: 'flex',
alignItems: 'center',
}}
>
<LinkIcon size={14} />
</button>
)}
</div>
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)', fontFamily: 'JetBrains Mono, monospace' }}>ID: {node.id}</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) => (
<span
key={`${node.id}-${dimension}`}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
borderRadius: '999px',
background: '#111914',
border: '1px solid #1f2f24',
fontSize: '11px',
color: '#bbf7d0',
}}
>
<Folder size={11} />
{dimension}
</span>
))
) : (
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>No categories</span>
)}
{node.dimensions && node.dimensions.length > 3 && (
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>+{node.dimensions.length - 3} more</span>
{nodes.map((node, index) => (
<tr
key={node.id}
style={{
background: index % 2 === 0 ? '#080808' : '#0d0d0d',
borderBottom: '1px solid #111',
}}
>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '34%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<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((openError) => {
console.error('[DatabaseViewer] Failed to open original link', openError);
window.alert(`Unable to open ${node.link}`);
});
}}
title="Open original link"
style={{
background: 'transparent',
border: 'none',
color: '#7de8a5',
cursor: 'pointer',
padding: 0,
display: 'flex',
alignItems: 'center',
}}
>
<LinkIcon size={14} />
</button>
)}
</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>
</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>
) : (
<span style={{ fontSize: '11px', color: '#475569' }}></span>
)}
{belongsToLocked && (
<span style={{ fontSize: '11px', color: '#7de8a5' }}>Priority dimension</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>
</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>
</td>
</tr>
);
})}
<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: '18%' }}>
{node.context?.name ? (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
padding: '3px 10px',
borderRadius: '999px',
background: '#142817',
color: '#c4f5d2',
fontSize: '11px',
border: '1px solid #1f3b23',
}}
>
{node.context.name}
</span>
) : (
<span style={{ fontSize: '11px', color: '#555' }}>Unscoped</span>
)}
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '10%' }}>
<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' }}>
{hubNodeIds.has(node.id) ? (
<span style={{ fontSize: '11px', color: '#facc15', fontWeight: 600 }}>Graph hub</span>
) : (
<span style={{ fontSize: '11px', color: '#475569' }}></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: '#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: '#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;
}
+19 -172
View File
@@ -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={{
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}
</span>
))}
{node.dimensions.length > 3 && (
<span style={{ fontSize: '9px', color: 'var(--rah-text-muted)' }}>
+{node.dimensions.length - 3}
</span>
)}
<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',
}}>
{node.context.name}
</span>
</>
) : (
<span style={{ fontSize: '10px', color: 'var(--rah-text-muted)' }}>{'\u2014'}</span>
+12 -26
View File
@@ -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,37 +133,25 @@ 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',
borderRadius: '3px',
fontSize: '10px',
color: '#888'
}}
>
{dim}
</span>
))}
{node.dimensions.length > 3 && (
<span style={{
<span
style={{
padding: '2px 6px',
background: '#1a1a1a',
borderRadius: '3px',
fontSize: '10px',
color: '#555'
}}>
+{node.dimensions.length - 3}
</span>
)}
color: '#888'
}}
>
{node.context.name}
</span>
</div>
)}
</button>
-536
View File
@@ -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>
);
}
+14 -35
View File
@@ -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,38 +146,19 @@ 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 => (
<span
key={dim}
style={{
padding: '2px 8px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
borderRadius: '8px',
fontSize: '11px',
color: 'var(--rah-text-base)'
}}
>
{dim}
</span>
))}
{node.dimensions.length > 3 && (
<span style={{
padding: '2px 6px',
fontSize: '11px',
color: 'var(--rah-text-muted)'
}}>
+{node.dimensions.length - 3}
</span>
)}
</div>
{node.context?.name && (
<span
style={{
padding: '2px 8px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
borderRadius: '8px',
fontSize: '11px',
color: 'var(--rah-text-base)'
}}
>
{node.context.name}
</span>
)}
{/* Date */}
-242
View File
@@ -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>
);
}
+155 -507
View File
@@ -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();
}, [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();
}
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,137 +529,112 @@ 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={{
fontSize: '10px',
lineHeight: 1.2,
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)',
flexShrink: 0,
maxWidth: '40%',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
<span
style={{
fontSize: '10px',
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',
lineHeight: 1.2,
flexShrink: 0,
}}
>
{node.context.name}
</span>
) : null}
</div>
<div style={{
width: '20px',
height: '20px',
borderRadius: '6px',
background: 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
{nodeIcon}
</div>
<span style={{
fontSize: '10px',
color: 'var(--rah-text-muted)',
background: 'var(--rah-bg-panel)',
padding: '2px 6px',
borderRadius: '999px',
fontFamily: 'monospace',
flexShrink: 0,
lineHeight: 1.2,
}}>
#{node.id}
</span>
{node.edge_count != null && node.edge_count > 0 && (
<span style={{
minWidth: '18px',
height: '18px',
padding: '0 5px',
borderRadius: '999px',
background: 'var(--rah-accent-green-soft)',
border: '1px solid var(--rah-accent-green-soft-strong)',
color: 'var(--rah-accent-green)',
<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',
justifyContent: 'center',
gap: '6px',
minWidth: 0,
overflow: 'hidden',
flexShrink: 0,
fontSize: '11px',
fontWeight: 600,
}}>
{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}
<div style={{
width: '20px',
height: '20px',
borderRadius: '6px',
background: 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
{nodeIcon}
</div>
<span style={{
fontSize: '10px',
color: 'var(--rah-text-muted)',
background: 'var(--rah-bg-panel)',
padding: '2px 6px',
borderRadius: '999px',
fontFamily: 'monospace',
flexShrink: 0,
lineHeight: 1.2,
}}>
#{node.id}
</span>
{node.edge_count != null && node.edge_count > 0 ? (
<span style={{
minWidth: '18px',
height: '18px',
padding: '0 5px',
borderRadius: '999px',
background: 'var(--rah-accent-green-soft)',
border: '1px solid var(--rah-accent-green-soft-strong)',
color: 'var(--rah-accent-green)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
fontSize: '11px',
fontWeight: 600,
}}>
{node.edge_count}
</span>
))}
</>
) : null}
) : null}
</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}
<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}>
<div style={{ position: 'relative' }} ref={contextPickerRef}>
<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,71 +722,37 @@ 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',
<button
onClick={() => {
onClearExternalContextFilter?.();
setShowContextPicker(false);
}}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--rah-border-strong)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
/>
{dimensionsLoading ? (
style={pickerRowStyle}
>
All contexts
</button>
{contextsLoading ? (
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
Loading dimensions...
Loading contexts...
</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)}
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>
</button>
))
)}
) : contexts.map((context) => (
<button
key={context.id}
onClick={() => {
onContextFilterSelect?.(context.id, context.name);
setShowContextPicker(false);
}}
style={pickerRowStyle}
>
<span>{context.name}</span>
<span style={pickerCountStyle}>{context.count}</span>
</button>
))}
</div>
)}
</div>
</div>
{(selectedFilters.length > 0 || externalContextFilterId) && (
<button
onClick={clearFilters}
style={{
padding: '4px 8px',
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 all
</button>
)}
{/* Sort dropdown */}
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
<button
@@ -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>
+4 -3
View File
@@ -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.
+5 -5
View File
@@ -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"].
+1 -1
View File
@@ -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
+48 -8
View File
@@ -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.
+57 -5
View File
@@ -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
View File
@@ -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
+33
View File
@@ -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);
}
-28
View File
@@ -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);
}
+2 -14
View File
@@ -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) {
+18 -10
View File
@@ -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' }
+29 -43
View File
@@ -1,5 +1,4 @@
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { getAutoContextSettings } from '@/services/settings/autoContextSettings';
export interface AutoContextSummary {
id: number;
@@ -34,28 +33,30 @@ function truncate(value: string | null | undefined, maxChars: number): string {
function fetchAutoContextRows(limit: number): AutoContextSummary[] {
const db = getSQLiteClient();
const rows = db.query<{
id: number;
title: string | null;
description: string | null;
updated_at: string;
edge_count: number | null;
}>(
`
SELECT n.id,
n.title,
n.description,
n.updated_at,
COUNT(DISTINCT e.id) AS edge_count
FROM nodes n
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, n.id ASC
LIMIT ?
`,
[limit]
).rows;
const rows = db
.query<{
id: number;
title: string | null;
description: string | null;
updated_at: string;
edge_count: number | null;
}>(
`
SELECT n.id,
n.title,
n.description,
n.updated_at,
COUNT(DISTINCT e.id) AS edge_count
FROM nodes n
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, n.id ASC
LIMIT ?
`,
[limit]
)
.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);
}
-168
View File
@@ -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;
}
+3 -1
View File
@@ -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) {
+13 -6
View File
@@ -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 '
-189
View File
@@ -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.`;
}
+20 -41
View File
@@ -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,15 +470,14 @@ 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
WHEN e.from_node_id = ? THEN n_to.source
ELSE n_from.source
END as connected_node_source,
CASE
CASE
WHEN e.from_node_id = ? THEN n_to.metadata
ELSE n_from.metadata
END as connected_node_metadata,
@@ -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,
+1 -3
View File
@@ -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();
+74 -152
View File
@@ -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,26 +498,30 @@ 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 ')}` : '';
const result = sqlite.query<{ total: number }>(`
WITH matched_nodes AS (
SELECT rowid
FROM nodes_fts
WHERE nodes_fts MATCH ?
)
SELECT COUNT(*) as total
FROM matched_nodes mn
JOIN nodes n ON n.id = mn.rowid
${whereClauses}
`, [ftsQuery, ...params]);
try {
const result = sqlite.query<{ total: number }>(`
WITH matched_nodes AS (
SELECT rowid
FROM nodes_fts
WHERE nodes_fts MATCH ?
)
SELECT COUNT(*) as total
FROM matched_nodes mn
JOIN nodes n ON n.id = mn.rowid
${whereClauses}
`, [ftsQuery, ...params]);
return Number(result.rows[0]?.total ?? 0);
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 -36
View File
@@ -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;
}
+459 -105
View File
@@ -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[];
}
this.db.exec("CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);");
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,7 +1083,11 @@ class SQLiteClient {
this.db.exec("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');");
}
} catch (ftsErr) {
console.warn('Failed to rebuild nodes_fts:', 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 });
}
+52 -60
View File
@@ -1,26 +1,24 @@
/**
* 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';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import {
createDatabaseConnection,
getDbVectorCapability,
import {
createDatabaseConnection,
serializeFloat32Vector,
formatEmbeddingText,
batchProcess
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;
@@ -45,25 +42,21 @@ export class NodeEmbedder {
if (!apiKey) {
throw new Error('OPENAI_API_KEY environment variable is not set');
}
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.`;
@@ -90,7 +83,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
model: 'text-embedding-3-small',
input: text,
});
return response.data[0].embedding;
}
@@ -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
@@ -128,36 +118,41 @@ Focus on the main concepts, key relationships, and practical implications.`;
// Generate embedding
const embedding = await this.generateEmbedding(embeddingText);
const embeddingBlob = serializeFloat32Vector(embedding);
// Update database
const updateStmt = this.db.prepare(`
UPDATE nodes
SET embedding = ?,
embedding_updated_at = ?,
UPDATE nodes
SET embedding = ?,
embedding_updated_at = ?,
embedding_text = ?
WHERE id = ?
`);
const now = new Date().toISOString();
updateStmt.run(embeddingBlob, now, embeddingText, node.id);
if (this.vectorCapability.available) {
try {
const pkCol = 'node_id';
const deleteStmt = this.db.prepare(`DELETE FROM vec_nodes WHERE ${pkCol} = ?`);
deleteStmt.run(BigInt(node.id));
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);
}
// 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++;
console.log(`✓ Embedded node ${node.id}: "${node.title}"`);
} catch (error) {
this.failedCount++;
console.error(`✗ Failed to embed node ${node.id}:`, error);
@@ -170,54 +165,51 @@ Focus on the main concepts, key relationships, and practical implications.`;
*/
async embedNodes(options: EmbedNodeOptions = {}): Promise<{ processed: number; failed: number }> {
const { nodeId, forceReEmbed = false, verbose = false } = options;
let query: string;
let params: any[] = [];
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
`;
}
const stmt = this.db.prepare(query);
const nodes = stmt.all(...params) as NodeRecord[];
if (nodes.length === 0) {
console.log('No nodes to process');
return { processed: 0, failed: 0 };
}
console.log(`Processing ${nodes.length} nodes...`);
// Process in batches
await batchProcess(
nodes,
@@ -233,9 +225,9 @@ Focus on the main concepts, key relationships, and practical implications.`;
console.log(`Progress: ${processed}/${total} nodes`);
} : undefined
);
console.log(`\nComplete! Processed: ${this.processedCount}, Failed: ${this.failedCount}`);
return {
processed: this.processedCount,
failed: this.failedCount
@@ -254,15 +246,15 @@ Focus on the main concepts, key relationships, and practical implications.`;
* CLI interface for direct execution
*/
export async function runCLI(args: string[]): Promise<void> {
const nodeId = args.includes('--node-id')
const nodeId = args.includes('--node-id')
? parseInt(args[args.indexOf('--node-id') + 1])
: undefined;
const forceReEmbed = args.includes('--force');
const verbose = args.includes('--verbose');
const embedder = new NodeEmbedder();
try {
await embedder.embedNodes({ nodeId, forceReEmbed, verbose });
} finally {
+47 -19
View File
@@ -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}`;
}
/**
@@ -78,16 +106,16 @@ export async function batchProcess<T, R>(
onProgress?: (processed: number, total: number) => void
): Promise<R[]> {
const results: R[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, Math.min(i + batchSize, items.length));
const batchResults = await Promise.all(batch.map(processor));
results.push(...batchResults);
if (onProgress) {
onProgress(Math.min(i + batchSize, items.length), items.length);
}
}
return results;
}
-64
View File
@@ -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
};
}
}
});
+2 -4
View File
@@ -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 {
+13 -11
View File
@@ -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 {
-58
View File
@@ -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
};
}
}
});
-91
View File
@@ -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
};
}
}
});
+2 -1
View File
@@ -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,
+38 -24
View File
@@ -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,35 +50,46 @@ 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 enriched = await Promise.all(limitedContexts.map(async (context) => {
if (!includeNodes) return context;
const nodes = await contextService.getNodesForContext(context.id);
return {
...context,
nodes: nodes.map((node) => ({
id: node.id,
title: node.title,
description: node.description ?? null,
dimensions: node.dimensions || [],
context_id: node.context_id ?? null,
updated_at: node.updated_at,
})),
};
}));
const contextsWithNodes = await Promise.all(
limitedContexts.map(async (context) => {
if (!canIncludeNodes) return context;
const nodes = await contextService.getNodesForContext(context.id);
return {
...context,
nodes: nodes.map((node) => ({
id: node.id,
title: node.title,
description: node.description ?? null,
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',
-78
View File
@@ -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',
};
}
},
});
-100
View File
@@ -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 }
};
}
}
});
+2 -3
View File
@@ -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
}
};
+3 -12
View File
@@ -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,
-75
View File
@@ -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
};
}
}
});
+4 -8
View File
@@ -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,16 +46,17 @@ 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 {
success: false,
error: error instanceof Error ? error.message : 'Failed to update node',
data: null
};
};
}
}
});
// Legacy export for backwards compatibility
export const updateItemTool = updateNodeTool;
+2 -5
View File
@@ -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