feat: port contexts layer and MCP parity
This commit is contained in:
@@ -115,13 +115,14 @@ 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, 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, 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.
|
||||||
|
|
||||||
Available tools:
|
Available tools:
|
||||||
|
|
||||||
| Tool | What it does |
|
| Tool | What it does |
|
||||||
|------|--------------|
|
|------|--------------|
|
||||||
| `getContext` | Get graph overview — stats, hub nodes, dimensions, recent activity |
|
| `getContext` | Get graph overview — stats, contexts, hub nodes, dimensions, recent activity |
|
||||||
|
| `queryContexts` | List contexts, inspect a context, or search contexts |
|
||||||
| `queryNodes` | Find nodes by keyword |
|
| `queryNodes` | Find nodes by keyword |
|
||||||
| `createNode` | Create a new node |
|
| `createNode` | Create a new node |
|
||||||
| `getNodesById` | Fetch nodes by ID |
|
| `getNodesById` | Fetch nodes by ID |
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { contextService } from '@/services/database';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const contextId = parseInt(id, 10);
|
||||||
|
if (Number.isNaN(contextId)) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Invalid context ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = await contextService.getNodesForContext(contextId);
|
||||||
|
return NextResponse.json({ success: true, data: nodes, count: nodes.length });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ success: false, error: error instanceof Error ? error.message : 'Failed to fetch context nodes' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { contextService } from '@/services/database';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const contextId = parseInt(id, 10);
|
||||||
|
if (Number.isNaN(contextId)) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Invalid context ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await contextService.getContextById(contextId);
|
||||||
|
if (!context) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Context not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: context });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ success: false, error: error instanceof Error ? error.message : 'Failed to fetch context' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { contextService } from '@/services/database';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const contexts = await contextService.listContexts();
|
||||||
|
return NextResponse.json({ success: true, data: contexts });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ success: false, error: error instanceof Error ? error.message : 'Failed to fetch contexts' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const context = await contextService.createContext({
|
||||||
|
name: body.name,
|
||||||
|
description: body.description,
|
||||||
|
icon: body.icon,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ success: true, data: context }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ success: false, error: error instanceof Error ? error.message : 'Failed to create context' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
if (typeof body.id !== 'number' || !Number.isInteger(body.id) || body.id <= 0) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Context id is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await contextService.updateContext({
|
||||||
|
id: body.id,
|
||||||
|
name: body.name,
|
||||||
|
description: body.description,
|
||||||
|
icon: body.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: context });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ success: false, error: error instanceof Error ? error.message : 'Failed to update context' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { nodeService } from '@/services/database';
|
import { contextService, nodeService } from '@/services/database';
|
||||||
import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue';
|
import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue';
|
||||||
import { hasSufficientContent } from '@/services/embedding/constants';
|
import { hasSufficientContent } from '@/services/embedding/constants';
|
||||||
import { normalizeDimensions, validateExplicitDescription } from '@/services/database/quality';
|
import { coerceDescriptionForStorage, normalizeDimensions } from '@/services/database/quality';
|
||||||
import { formatUnknownDimensionsError, getUnknownDimensions } from '@/services/database/dimensionValidation';
|
import { formatUnknownDimensionsError, getUnknownDimensions } from '@/services/database/dimensionValidation';
|
||||||
|
import { normalizeNodeLink } from '@/utils/nodeLink';
|
||||||
|
import { mergeNodeMetadata } from '@/services/nodes/metadata';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -73,13 +75,23 @@ export async function PUT(
|
|||||||
const updates: Record<string, unknown> = { ...body };
|
const updates: Record<string, unknown> = { ...body };
|
||||||
let shouldQueueEmbed = false;
|
let shouldQueueEmbed = false;
|
||||||
|
|
||||||
if (typeof body.description === 'string') {
|
if (typeof body.link === 'string') {
|
||||||
const descriptionError = validateExplicitDescription(body.description);
|
const trimmedLink = body.link.trim();
|
||||||
if (descriptionError) {
|
const normalizedLink = normalizeNodeLink(trimmedLink);
|
||||||
console.warn(
|
if (trimmedLink && !normalizedLink) {
|
||||||
`[DescriptionQuality] User-updated description failed validation for node ${nodeId}: ${descriptionError}`
|
return NextResponse.json({
|
||||||
);
|
success: false,
|
||||||
|
error: 'Invalid link. Use a full URL like https://example.com'
|
||||||
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
updates.link = normalizedLink ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body.description === 'string') {
|
||||||
|
updates.description = coerceDescriptionForStorage({
|
||||||
|
title: typeof updates.title === 'string' ? updates.title : existingNode.title,
|
||||||
|
description: body.description
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(body.dimensions)) {
|
if (Array.isArray(body.dimensions)) {
|
||||||
@@ -96,6 +108,24 @@ export async function PUT(
|
|||||||
delete updates.notes;
|
delete updates.notes;
|
||||||
delete updates.chunk;
|
delete updates.chunk;
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(body, 'context_id') || Object.prototype.hasOwnProperty.call(body, 'context_name')) {
|
||||||
|
try {
|
||||||
|
updates.context_id = await contextService.resolveContextId({
|
||||||
|
context_id: body.context_id,
|
||||||
|
context_name: body.context_name,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Invalid context input'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.metadata !== undefined) {
|
||||||
|
updates.metadata = mergeNodeMetadata(existingNode.metadata, body.metadata);
|
||||||
|
}
|
||||||
|
|
||||||
const incomingSource = typeof body.source === 'string' ? body.source : undefined;
|
const incomingSource = typeof body.source === 'string' ? body.source : undefined;
|
||||||
const existingSource = existingNode.source ?? '';
|
const existingSource = existingNode.source ?? '';
|
||||||
|
|
||||||
|
|||||||
+68
-24
@@ -1,11 +1,14 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { nodeService } from '@/services/database';
|
import { contextService, nodeService } from '@/services/database';
|
||||||
import { Node, NodeFilters } from '@/types/database';
|
import { Node, NodeFilters } from '@/types/database';
|
||||||
import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue';
|
import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue';
|
||||||
import { generateDescription } from '@/services/database/descriptionService';
|
import { generateDescription } from '@/services/database/descriptionService';
|
||||||
import { scheduleAutoEdgeCreation } from '@/services/agents/autoEdge';
|
import { scheduleAutoEdgeCreation } from '@/services/agents/autoEdge';
|
||||||
import { normalizeDimensions, validateExplicitDescription } from '@/services/database/quality';
|
import { coerceDescriptionForStorage, normalizeDimensions } from '@/services/database/quality';
|
||||||
import { formatUnknownDimensionsError, getUnknownDimensions } from '@/services/database/dimensionValidation';
|
import { formatUnknownDimensionsError, getUnknownDimensions } from '@/services/database/dimensionValidation';
|
||||||
|
import { normalizeNodeLink } from '@/utils/nodeLink';
|
||||||
|
import { buildCanonicalNodeMetadata } from '@/services/nodes/metadata';
|
||||||
|
import { inferBestContextIdForNode } from '@/services/context/contextAssignment';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -19,6 +22,14 @@ export async function GET(request: NextRequest) {
|
|||||||
offset: searchParams.get('offset') ? parseInt(searchParams.get('offset')!) : 0
|
offset: searchParams.get('offset') ? parseInt(searchParams.get('offset')!) : 0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const contextIdParam = searchParams.get('contextId');
|
||||||
|
if (contextIdParam) {
|
||||||
|
const parsed = parseInt(contextIdParam, 10);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
filters.contextId = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle dimensions parameter (comma-separated)
|
// Handle dimensions parameter (comma-separated)
|
||||||
const dimensionsParam = searchParams.get('dimensions');
|
const dimensionsParam = searchParams.get('dimensions');
|
||||||
if (dimensionsParam) {
|
if (dimensionsParam) {
|
||||||
@@ -94,6 +105,14 @@ export async function POST(request: NextRequest) {
|
|||||||
body.title = sanitizeTitle(body.title);
|
body.title = sanitizeTitle(body.title);
|
||||||
|
|
||||||
const rawSource = typeof body.source === 'string' ? body.source.trim() : null;
|
const rawSource = typeof body.source === 'string' ? body.source.trim() : null;
|
||||||
|
const rawLink = typeof body.link === 'string' ? body.link : null;
|
||||||
|
const normalizedLink = normalizeNodeLink(rawLink);
|
||||||
|
if (rawLink && !normalizedLink) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid link. Use a full URL like https://example.com'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
const eventDate = typeof body.event_date === 'string' ? body.event_date : null;
|
const eventDate = typeof body.event_date === 'string' ? body.event_date : null;
|
||||||
|
|
||||||
// Process provided dimensions first (needed for description generation)
|
// Process provided dimensions first (needed for description generation)
|
||||||
@@ -109,7 +128,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Use provided description if present, otherwise auto-generate
|
// Use provided description if present, otherwise auto-generate
|
||||||
const isUserSuppliedDescription = typeof body.description === 'string' && body.description.trim().length > 0;
|
const isUserSuppliedDescription = typeof body.description === 'string' && body.description.trim().length > 0;
|
||||||
let nodeDescription: string | undefined = isUserSuppliedDescription
|
let nodeDescription: string | undefined = isUserSuppliedDescription
|
||||||
? body.description.trim().slice(0, 280)
|
? body.description.trim().slice(0, 500)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (!nodeDescription) {
|
if (!nodeDescription) {
|
||||||
@@ -117,7 +136,7 @@ export async function POST(request: NextRequest) {
|
|||||||
nodeDescription = await generateDescription({
|
nodeDescription = await generateDescription({
|
||||||
title: body.title,
|
title: body.title,
|
||||||
source: rawSource?.slice(0, 2000) || undefined,
|
source: rawSource?.slice(0, 2000) || undefined,
|
||||||
link: body.link || undefined,
|
link: normalizedLink || undefined,
|
||||||
metadata: body.metadata,
|
metadata: body.metadata,
|
||||||
dimensions: trimmedProvidedDimensions
|
dimensions: trimmedProvidedDimensions
|
||||||
});
|
});
|
||||||
@@ -128,28 +147,16 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Final safety net — never store null/empty description
|
// Final safety net — never store null/empty description
|
||||||
if (!nodeDescription || nodeDescription.trim().length === 0) {
|
if (!nodeDescription || nodeDescription.trim().length === 0) {
|
||||||
nodeDescription = body.title.slice(0, 280);
|
nodeDescription = `${body.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
let finalDescription = nodeDescription ?? body.title.slice(0, 280);
|
const finalDescription = coerceDescriptionForStorage({
|
||||||
|
title: body.title,
|
||||||
const descriptionError = validateExplicitDescription(finalDescription);
|
description: nodeDescription
|
||||||
if (descriptionError) {
|
});
|
||||||
if (isUserSuppliedDescription) {
|
|
||||||
return NextResponse.json({
|
|
||||||
success: false,
|
|
||||||
error: descriptionError
|
|
||||||
}, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(
|
|
||||||
`[DescriptionQuality] Auto-generated description failed validation for "${body.title}": ${descriptionError}. Falling back to title.`
|
|
||||||
);
|
|
||||||
finalDescription = body.title.slice(0, 280);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Monitor description quality
|
// Monitor description quality
|
||||||
if (WEAK_PATTERNS.test(finalDescription)) {
|
if (WEAK_PATTERNS.test(nodeDescription ?? finalDescription)) {
|
||||||
console.warn(`[DescriptionQuality] Weak description for node "${body.title}": "${finalDescription}"`);
|
console.warn(`[DescriptionQuality] Weak description for node "${body.title}": "${finalDescription}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,15 +169,52 @@ export async function POST(request: NextRequest) {
|
|||||||
chunkStatus = 'not_chunked';
|
chunkStatus = 'not_chunked';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let resolvedContextId: number | null | undefined;
|
||||||
|
try {
|
||||||
|
resolvedContextId = await contextService.resolveContextId({
|
||||||
|
context_id: body.context_id,
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const node = await nodeService.createNode({
|
const node = await nodeService.createNode({
|
||||||
title: body.title,
|
title: body.title,
|
||||||
description: finalDescription,
|
description: finalDescription,
|
||||||
source: sourceToStore ?? undefined,
|
source: sourceToStore ?? undefined,
|
||||||
event_date: eventDate ?? undefined,
|
event_date: eventDate ?? undefined,
|
||||||
link: body.link,
|
link: normalizedLink ?? undefined,
|
||||||
dimensions: finalDimensions,
|
dimensions: finalDimensions,
|
||||||
chunk_status: chunkStatus,
|
chunk_status: chunkStatus,
|
||||||
metadata: body.metadata || {}
|
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,
|
||||||
|
state: body.metadata?.state === 'processed' ? 'processed' : 'not_processed',
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (chunkStatus === 'not_chunked' && node.id) {
|
if (chunkStatus === 'not_chunked' && node.id) {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ Restart Claude. Done.
|
|||||||
## What to Expect
|
## What to Expect
|
||||||
|
|
||||||
Once connected, Claude will:
|
Once connected, Claude will:
|
||||||
- **Call `getContext` first** to orient itself (stats, hub nodes, dimensions, skills)
|
- **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
|
- **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
|
||||||
- **Read skills for complex tasks** — skills are editable and shared across internal + external agents
|
- **Read skills for complex tasks** — skills are editable and shared across internal + external agents
|
||||||
- **Search before creating** to avoid duplicates
|
- **Search before creating** to avoid duplicates
|
||||||
@@ -50,9 +50,10 @@ Once connected, Claude will:
|
|||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `getContext` | Get graph overview — stats, hub nodes, dimensions, recent activity |
|
| `getContext` | Get graph overview — stats, contexts, hub nodes, dimensions, recent activity |
|
||||||
| `createNode` | Create a new node |
|
| `createNode` | Create a new node |
|
||||||
| `queryNodes` | Search nodes by keyword |
|
| `queryNodes` | Search nodes by keyword |
|
||||||
|
| `queryContexts` | List or inspect contexts |
|
||||||
| `getNodesById` | Load nodes by ID (includes chunk + metadata) |
|
| `getNodesById` | Load nodes by ID (includes chunk + metadata) |
|
||||||
| `updateNode` | Update an existing node |
|
| `updateNode` | Update an existing node |
|
||||||
| `createEdge` | Create connection between nodes |
|
| `createEdge` | Create connection between nodes |
|
||||||
|
|||||||
@@ -31,13 +31,14 @@ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio
|
|||||||
const { initDatabase, getDatabasePath, closeDatabase, getDb, query } = require('./services/sqlite-client');
|
const { initDatabase, getDatabasePath, closeDatabase, getDb, query } = require('./services/sqlite-client');
|
||||||
const nodeService = require('./services/nodeService');
|
const nodeService = require('./services/nodeService');
|
||||||
const edgeService = require('./services/edgeService');
|
const edgeService = require('./services/edgeService');
|
||||||
|
const contextService = require('./services/contextService');
|
||||||
const dimensionService = require('./services/dimensionService');
|
const dimensionService = require('./services/dimensionService');
|
||||||
const skillService = require('./services/skillService');
|
const skillService = require('./services/skillService');
|
||||||
|
|
||||||
// Server info
|
// Server info
|
||||||
const serverInfo = {
|
const serverInfo = {
|
||||||
name: 'ra-h-standalone',
|
name: 'ra-h-standalone',
|
||||||
version: '1.8.0'
|
version: '1.10.1'
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildInstructions() {
|
function buildInstructions() {
|
||||||
@@ -58,7 +59,7 @@ function buildInstructions() {
|
|||||||
return `Today's date: ${now}. RA-H is the user's personal knowledge graph — local SQLite, fully on-device.
|
return `Today's date: ${now}. RA-H is the user's personal knowledge graph — local SQLite, fully on-device.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
1. Call getContext for orientation (stats, hubs, dimensions).
|
1. Call getContext for orientation (stats, contexts, dimensions, anchors/hubs).
|
||||||
2. For simple tasks, tool descriptions have everything you need.
|
2. For simple tasks, tool descriptions have everything you need.
|
||||||
3. For complex tasks, call readSkill("db-operations").
|
3. For complex tasks, call readSkill("db-operations").
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ Always search before creating to avoid duplicates.
|
|||||||
Use only existing dimensions returned by getContext or queryDimensions.
|
Use only existing dimensions returned by getContext or queryDimensions.
|
||||||
Do not invent new dimensions from node titles, concepts, or phrasing.
|
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.
|
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
|
## Available skills
|
||||||
${skillIndex}
|
${skillIndex}
|
||||||
@@ -83,15 +85,18 @@ const addNodeInputSchema = {
|
|||||||
content: z.string().max(20000).optional().describe('Legacy content field; mapped to source'),
|
content: z.string().max(20000).optional().describe('Legacy content field; mapped to source'),
|
||||||
source: z.string().max(50000).optional().describe('Full source text'),
|
source: z.string().max(50000).optional().describe('Full source text'),
|
||||||
link: z.string().url().optional().describe('Source URL'),
|
link: z.string().url().optional().describe('Source URL'),
|
||||||
description: z.string().min(24).max(280).describe('REQUIRED. One-sentence summary: WHAT this is (explicit, concrete) + WHY it matters. No weak verbs (discusses, explores, examines). Example: "Podcast — Lex Fridman interviews Sam Altman on AGI timelines. First public comments since board drama."'),
|
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.'),
|
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('Additional metadata'),
|
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('Full source text')
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchNodesInputSchema = {
|
const searchNodesInputSchema = {
|
||||||
query: z.string().min(1).max(400).describe('Search query'),
|
query: z.string().min(1).max(400).describe('Search query'),
|
||||||
limit: z.number().min(1).max(25).optional().describe('Max results (default 10)'),
|
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'),
|
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_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.'),
|
created_before: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'),
|
||||||
@@ -107,9 +112,11 @@ const updateNodeInputSchema = {
|
|||||||
id: z.number().int().positive().describe('Node ID'),
|
id: z.number().int().positive().describe('Node ID'),
|
||||||
updates: z.object({
|
updates: z.object({
|
||||||
title: z.string().optional().describe('New title'),
|
title: z.string().optional().describe('New title'),
|
||||||
description: z.string().min(24).max(280).describe('REQUIRED. Explicitly state WHAT this is (podcast, conversation summary, user insight, etc.) + WHY it matters for context grounding. No vague verbs like "discusses/explores/examines".'),
|
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('Content to APPEND'),
|
||||||
|
source: z.string().optional().describe('Canonical source content for embedding'),
|
||||||
link: z.string().optional().describe('New link'),
|
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)'),
|
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('New metadata')
|
||||||
}).describe('Fields to update')
|
}).describe('Fields to update')
|
||||||
@@ -131,6 +138,14 @@ const queryEdgesInputSchema = {
|
|||||||
limit: z.number().min(1).max(50).optional().describe('Max edges (default 25)')
|
limit: z.number().min(1).max(50).optional().describe('Max edges (default 25)')
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const queryContextsInputSchema = {
|
||||||
|
contextId: z.number().int().positive().optional().describe('Exact context ID lookup'),
|
||||||
|
name: z.string().optional().describe('Exact context name lookup'),
|
||||||
|
search: z.string().optional().describe('Case-insensitive search across context names and descriptions'),
|
||||||
|
limit: z.number().min(1).max(100).optional().describe('Maximum number of contexts to return'),
|
||||||
|
includeNodes: z.boolean().optional().describe('Include nodes for an exact single-context lookup')
|
||||||
|
};
|
||||||
|
|
||||||
const listDimensionsInputSchema = {};
|
const listDimensionsInputSchema = {};
|
||||||
|
|
||||||
const createDimensionInputSchema = {
|
const createDimensionInputSchema = {
|
||||||
@@ -192,26 +207,6 @@ function sanitizeDimensions(raw) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateExplicitDescription(description) {
|
|
||||||
if (typeof description !== 'string') {
|
|
||||||
return 'Description is required and must be a string.';
|
|
||||||
}
|
|
||||||
const text = description.trim();
|
|
||||||
if (text.length < 24) {
|
|
||||||
return 'Description must be explicit and substantial (at least 24 characters).';
|
|
||||||
}
|
|
||||||
const weakPatterns = /\b(discusses|explores|examines|talks about|is about|delves into)\b/i;
|
|
||||||
const explicitEntityPatterns = /\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 uncertaintyPatterns = /\b(likely|probably|possibly|appears to be|seems to be|unclear|uncertain)\b/i;
|
|
||||||
if (weakPatterns.test(text)) {
|
|
||||||
return 'Description is too vague. State exactly what this is and why it matters.';
|
|
||||||
}
|
|
||||||
if (!explicitEntityPatterns.test(text) && !uncertaintyPatterns.test(text)) {
|
|
||||||
return 'Description must explicitly identify what this thing is, or state uncertainty explicitly.';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// FTS5 helpers
|
// FTS5 helpers
|
||||||
function sanitizeFtsQuery(input) {
|
function sanitizeFtsQuery(input) {
|
||||||
return input
|
return input
|
||||||
@@ -319,7 +314,7 @@ async function main() {
|
|||||||
'getContext',
|
'getContext',
|
||||||
{
|
{
|
||||||
title: 'Get RA-H context',
|
title: 'Get RA-H context',
|
||||||
description: 'Get knowledge graph overview: stats, hub nodes (most connected), 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), dimensions, recent activity, and available skills. Call this first to orient yourself. For deeper operating policy, follow up with readSkill("db-operations").',
|
||||||
inputSchema: {}
|
inputSchema: {}
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
@@ -340,7 +335,7 @@ async function main() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary = `Graph: ${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, ${context.stats.dimensionCount} dimensions, ${skills.length} skills.`;
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: summary }],
|
content: [{ type: 'text', text: summary }],
|
||||||
structuredContent: context
|
structuredContent: context
|
||||||
@@ -354,24 +349,28 @@ async function main() {
|
|||||||
'createNode',
|
'createNode',
|
||||||
{
|
{
|
||||||
title: 'Add RA-H node',
|
title: 'Add RA-H node',
|
||||||
description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. Title: max 160 chars, clear and descriptive. Description is REQUIRED and must be explicit about what the thing is and why it matters for contextual grounding. 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 existing dimensions — call queryDimensions first to use existing ones. Do not invent new dimensions.',
|
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.',
|
||||||
inputSchema: addNodeInputSchema
|
inputSchema: addNodeInputSchema
|
||||||
},
|
},
|
||||||
async ({ title, content, source, link, description, dimensions, metadata, chunk }) => {
|
async ({ title, content, source, link, description, context_id, context_name, dimensions, metadata, chunk }) => {
|
||||||
const normalizedDimensions = sanitizeDimensions(dimensions);
|
const normalizedDimensions = sanitizeDimensions(dimensions);
|
||||||
if (normalizedDimensions.length === 0) {
|
if (normalizedDimensions.length === 0) {
|
||||||
throw new Error('At least one dimension is required.');
|
throw new Error('At least one dimension is required.');
|
||||||
}
|
}
|
||||||
const descriptionError = validateExplicitDescription(description);
|
let resolvedContextId;
|
||||||
if (descriptionError) {
|
try {
|
||||||
throw new Error(descriptionError);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = nodeService.createNode({
|
const node = nodeService.createNode({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
source: source?.trim() || chunk?.trim() || content?.trim(),
|
source: source?.trim() || chunk?.trim() || content?.trim(),
|
||||||
link: link?.trim(),
|
link: link?.trim(),
|
||||||
description: description?.trim(),
|
description: typeof description === 'string' ? description.trim() : description,
|
||||||
|
context_id: resolvedContextId,
|
||||||
dimensions: normalizedDimensions,
|
dimensions: normalizedDimensions,
|
||||||
metadata: metadata || {}
|
metadata: metadata || {}
|
||||||
});
|
});
|
||||||
@@ -397,12 +396,41 @@ async function main() {
|
|||||||
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 dimensions. NOT for searching source documents (transcripts, articles) — use searchContentEmbeddings for that.',
|
||||||
inputSchema: searchNodesInputSchema
|
inputSchema: searchNodesInputSchema
|
||||||
},
|
},
|
||||||
async ({ query: searchQuery, limit = 10, dimensions, created_after, created_before, event_after, event_before }) => {
|
async ({ query: searchQuery, limit = 10, contextId, dimensions, created_after, created_before, event_after, event_before }) => {
|
||||||
const normalizedDimensions = sanitizeDimensions(dimensions || []);
|
const normalizedDimensions = sanitizeDimensions(dimensions || []);
|
||||||
const safeLimit = Math.min(Math.max(limit, 1), 25);
|
const safeLimit = Math.min(Math.max(limit, 1), 25);
|
||||||
const trimmedQuery = searchQuery.trim();
|
const trimmedQuery = searchQuery.trim();
|
||||||
const fts = checkFtsAvailability();
|
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
|
// Build temporal filter clauses
|
||||||
const temporalClauses = [];
|
const temporalClauses = [];
|
||||||
const temporalParams = [];
|
const temporalParams = [];
|
||||||
@@ -596,21 +624,13 @@ async function main() {
|
|||||||
'updateNode',
|
'updateNode',
|
||||||
{
|
{
|
||||||
title: 'Update RA-H node',
|
title: 'Update RA-H node',
|
||||||
description: 'Update an existing node. Description is REQUIRED on every update and must explicitly state WHAT this thing is + WHY it matters for contextual grounding. 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 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.',
|
||||||
inputSchema: updateNodeInputSchema
|
inputSchema: updateNodeInputSchema
|
||||||
},
|
},
|
||||||
async ({ id, updates }) => {
|
async ({ id, updates }) => {
|
||||||
if (!updates || Object.keys(updates).length === 0) {
|
if (!updates || Object.keys(updates).length === 0) {
|
||||||
throw new Error('At least one field must be provided in updates.');
|
throw new Error('At least one field must be provided in updates.');
|
||||||
}
|
}
|
||||||
if (!updates.description) {
|
|
||||||
throw new Error('Every node update requires an explicit description (WHAT this is + WHY it matters).');
|
|
||||||
}
|
|
||||||
const descriptionError = validateExplicitDescription(updates.description);
|
|
||||||
if (descriptionError) {
|
|
||||||
throw new Error(descriptionError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map MCP legacy fields to canonical source
|
// Map MCP legacy fields to canonical source
|
||||||
const mappedUpdates = { ...updates };
|
const mappedUpdates = { ...updates };
|
||||||
if (mappedUpdates.content !== undefined) {
|
if (mappedUpdates.content !== undefined) {
|
||||||
@@ -621,6 +641,11 @@ async function main() {
|
|||||||
}
|
}
|
||||||
delete mappedUpdates.content;
|
delete mappedUpdates.content;
|
||||||
delete mappedUpdates.chunk;
|
delete mappedUpdates.chunk;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'description')) {
|
||||||
|
mappedUpdates.description = typeof mappedUpdates.description === 'string'
|
||||||
|
? mappedUpdates.description.trim()
|
||||||
|
: mappedUpdates.description;
|
||||||
|
}
|
||||||
|
|
||||||
const node = nodeService.updateNode(id, mappedUpdates);
|
const node = nodeService.updateNode(id, mappedUpdates);
|
||||||
|
|
||||||
@@ -735,6 +760,55 @@ async function main() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
registerToolWithAliases(
|
||||||
|
'queryContexts',
|
||||||
|
{
|
||||||
|
title: 'Query RA-H contexts',
|
||||||
|
description: 'List contexts, inspect a specific context, or search contexts by name/description.',
|
||||||
|
inputSchema: queryContextsInputSchema
|
||||||
|
},
|
||||||
|
async ({ contextId, name, search, limit = 50, includeNodes = false }) => {
|
||||||
|
const normalizedName = typeof name === 'string' ? name.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];
|
||||||
|
}
|
||||||
|
} 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
const includeContextNodes = includeNodes && contexts.length === 1 && (contextId || normalizedName);
|
||||||
|
const enriched = contexts.map((context) => {
|
||||||
|
if (!includeContextNodes) return context;
|
||||||
|
const nodes = nodeService.getNodes({ contextId: context.id, limit: 500 });
|
||||||
|
return { ...context, nodes };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: enriched.length === 0 ? 'No matching contexts found.' : `Found ${enriched.length} context(s).` }],
|
||||||
|
structuredContent: {
|
||||||
|
count: enriched.length,
|
||||||
|
contexts: enriched
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
registerToolWithAliases(
|
registerToolWithAliases(
|
||||||
'createDimension',
|
'createDimension',
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@ra-h/mcp-server",
|
"name": "@ra-h/mcp-server",
|
||||||
"version": "1.8.0",
|
"version": "1.10.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@ra-h/mcp-server",
|
"name": "@ra-h/mcp-server",
|
||||||
"version": "1.8.0",
|
"version": "1.10.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ra-h-mcp-server",
|
"name": "ra-h-mcp-server",
|
||||||
"version": "1.8.0",
|
"version": "1.10.1",
|
||||||
"description": "Connect Claude Code/Desktop to your RA-H knowledge base. Direct SQLite access - no web app required.",
|
"description": "Connect Claude Code/Desktop to your RA-H knowledge base. Direct SQLite access - no web app required.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { getDb } = require('./sqlite-client');
|
||||||
|
|
||||||
|
function mapContext(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description ?? null,
|
||||||
|
icon: row.icon ?? null,
|
||||||
|
count: Number(row.count ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listContexts() {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT c.id, c.name, c.description, c.icon, COUNT(n.id) AS count
|
||||||
|
FROM contexts c
|
||||||
|
LEFT JOIN nodes n ON n.context_id = c.id
|
||||||
|
GROUP BY c.id
|
||||||
|
ORDER BY c.name COLLATE NOCASE ASC
|
||||||
|
`).all();
|
||||||
|
return rows.map(mapContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContextById(id) {
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT c.id, c.name, c.description, c.icon, COUNT(n.id) AS count
|
||||||
|
FROM contexts c
|
||||||
|
LEFT JOIN nodes n ON n.context_id = c.id
|
||||||
|
WHERE c.id = ?
|
||||||
|
GROUP BY c.id
|
||||||
|
`).get(id);
|
||||||
|
return mapContext(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContextByName(name) {
|
||||||
|
const trimmed = typeof name === 'string' ? name.trim() : '';
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT c.id, c.name, c.description, c.icon, COUNT(n.id) AS count
|
||||||
|
FROM contexts c
|
||||||
|
LEFT JOIN nodes n ON n.context_id = c.id
|
||||||
|
WHERE lower(c.name) = lower(?)
|
||||||
|
GROUP BY c.id
|
||||||
|
`).get(trimmed);
|
||||||
|
return mapContext(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createContext({ name, description = null, icon = null }) {
|
||||||
|
const db = getDb();
|
||||||
|
const trimmedName = typeof name === 'string' ? name.trim() : '';
|
||||||
|
if (!trimmedName) {
|
||||||
|
throw new Error('Context name is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const info = db.prepare(`
|
||||||
|
INSERT INTO contexts (name, description, icon, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(trimmedName, description ?? null, icon ?? null, now, now);
|
||||||
|
|
||||||
|
return getContextById(Number(info.lastInsertRowid));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateContext({ id, name, description, icon }) {
|
||||||
|
const db = getDb();
|
||||||
|
const existing = getContextById(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`Context ${id} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextName = typeof name === 'string' && name.trim() ? name.trim() : existing.name;
|
||||||
|
const nextDescription = description === undefined ? existing.description : description;
|
||||||
|
const nextIcon = icon === undefined ? existing.icon : icon;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE contexts
|
||||||
|
SET name = ?, description = ?, icon = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(nextName, nextDescription ?? null, nextIcon ?? null, now, id);
|
||||||
|
|
||||||
|
return getContextById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveContextId(input = {}) {
|
||||||
|
const hasContextId = Object.prototype.hasOwnProperty.call(input, 'context_id');
|
||||||
|
const hasContextName = typeof input.context_name === 'string' && input.context_name.trim().length > 0;
|
||||||
|
|
||||||
|
if (!hasContextId && !hasContextName) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasContextId && input.context_id === null) {
|
||||||
|
if (hasContextName) {
|
||||||
|
throw new Error('context_name cannot be combined with context_id: null.');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedById = null;
|
||||||
|
if (hasContextId) {
|
||||||
|
resolvedById = getContextById(input.context_id);
|
||||||
|
if (!resolvedById) {
|
||||||
|
throw new Error(`Context ${input.context_id} not found.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasContextName) {
|
||||||
|
return resolvedById ? resolvedById.id : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byName = getContextByName(input.context_name);
|
||||||
|
if (!byName) {
|
||||||
|
throw new Error(`Context "${input.context_name}" not found.`);
|
||||||
|
}
|
||||||
|
if (resolvedById && resolvedById.id !== byName.id) {
|
||||||
|
throw new Error('context_id and context_name refer to different contexts.');
|
||||||
|
}
|
||||||
|
return byName.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listContexts,
|
||||||
|
getContextById,
|
||||||
|
getContextByName,
|
||||||
|
createContext,
|
||||||
|
updateContext,
|
||||||
|
resolveContextId,
|
||||||
|
};
|
||||||
@@ -1,47 +1,83 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { query, transaction, getDb } = require('./sqlite-client');
|
const { query, transaction, getDb } = require('./sqlite-client');
|
||||||
|
const contextService = require('./contextService');
|
||||||
|
|
||||||
function normalizeDimensionName(value) {
|
function parseMetadata(metadata) {
|
||||||
return String(value || '').trim().replace(/\s+/g, ' ');
|
if (!metadata) return {};
|
||||||
|
if (typeof metadata === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(metadata);
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? { ...parsed } : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return metadata && typeof metadata === 'object' && !Array.isArray(metadata) ? { ...metadata } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUnknownDimensions(dimensions) {
|
function normalizeString(value) {
|
||||||
if (!Array.isArray(dimensions) || dimensions.length === 0) return [];
|
if (typeof value !== 'string') return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
const normalized = dimensions
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
.map(normalizeDimensionName)
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
if (normalized.length === 0) return [];
|
|
||||||
|
|
||||||
const placeholders = normalized.map(() => '?').join(', ');
|
|
||||||
const rows = query(`SELECT name FROM dimensions WHERE name IN (${placeholders})`, normalized);
|
|
||||||
const existing = new Set(rows.map(row => normalizeDimensionName(row.name)));
|
|
||||||
|
|
||||||
return normalized.filter(value => !existing.has(value));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatUnknownDimensionsError(values) {
|
function buildCanonicalMetadata({ existing, metadata }) {
|
||||||
if (values.length === 1) {
|
const prior = parseMetadata(existing);
|
||||||
return `Unknown dimension: "${values[0]}". Create it first or use an existing dimension.`;
|
const incoming = parseMetadata(metadata);
|
||||||
|
const sourceMetadata = {
|
||||||
|
...(prior.source_metadata && typeof prior.source_metadata === 'object' ? prior.source_metadata : {}),
|
||||||
|
...(incoming.source_metadata && typeof incoming.source_metadata === 'object' ? incoming.source_metadata : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const merged = {
|
||||||
|
...prior,
|
||||||
|
...incoming,
|
||||||
|
state: incoming.state === 'processed' ? 'processed' : (prior.state === 'processed' ? 'processed' : 'not_processed'),
|
||||||
|
captured_by: incoming.captured_by || prior.captured_by || 'human',
|
||||||
|
source_metadata: sourceMetadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
const type = normalizeString(incoming.type) || normalizeString(prior.type);
|
||||||
|
const capturedMethod = normalizeString(incoming.captured_method) || normalizeString(prior.captured_method);
|
||||||
|
|
||||||
|
if (type) merged.type = type;
|
||||||
|
else delete merged.type;
|
||||||
|
|
||||||
|
if (capturedMethod) merged.captured_method = capturedMethod;
|
||||||
|
else delete merged.captured_method;
|
||||||
|
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `Unknown dimensions: ${values.map(value => `"${value}"`).join(', ')}. Create them first or use existing dimensions.`;
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get nodes with optional filtering.
|
* Get nodes with optional filtering.
|
||||||
*/
|
*/
|
||||||
function getNodes(filters = {}) {
|
function getNodes(filters = {}) {
|
||||||
const { dimensions, search, limit = 100, offset = 0 } = filters;
|
const { dimensions, search, limit = 100, offset = 0, contextId } = filters;
|
||||||
|
|
||||||
let sql = `
|
let sql = `
|
||||||
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
||||||
n.created_at, n.updated_at,
|
n.created_at, n.updated_at, n.context_id,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
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)
|
||||||
|
END as context_json
|
||||||
FROM nodes n
|
FROM nodes n
|
||||||
|
LEFT JOIN contexts c ON c.id = n.context_id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const params = [];
|
const params = [];
|
||||||
@@ -61,6 +97,10 @@ function getNodes(filters = {}) {
|
|||||||
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
|
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
|
||||||
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
||||||
}
|
}
|
||||||
|
if (contextId !== undefined) {
|
||||||
|
sql += ' AND n.context_id = ?';
|
||||||
|
params.push(contextId);
|
||||||
|
}
|
||||||
|
|
||||||
// Sort by search relevance or updated_at
|
// Sort by search relevance or updated_at
|
||||||
if (search) {
|
if (search) {
|
||||||
@@ -85,12 +125,7 @@ function getNodes(filters = {}) {
|
|||||||
|
|
||||||
const rows = query(sql, params);
|
const rows = query(sql, params);
|
||||||
|
|
||||||
return rows.map(row => ({
|
return rows.map(mapNodeRow);
|
||||||
...row,
|
|
||||||
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
|
||||||
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
|
|
||||||
dimensions_json: undefined
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,10 +134,15 @@ function getNodes(filters = {}) {
|
|||||||
function getNodeById(id) {
|
function getNodeById(id) {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
||||||
n.created_at, n.updated_at,
|
n.created_at, n.updated_at, n.context_id,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
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)
|
||||||
|
END as context_json
|
||||||
FROM nodes n
|
FROM nodes n
|
||||||
|
LEFT JOIN contexts c ON c.id = n.context_id
|
||||||
WHERE n.id = ?
|
WHERE n.id = ?
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -110,12 +150,7 @@ function getNodeById(id) {
|
|||||||
if (rows.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
return {
|
return mapNodeRow(row);
|
||||||
...row,
|
|
||||||
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
|
||||||
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
|
|
||||||
dimensions_json: undefined
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,6 +164,134 @@ function sanitizeTitle(title) {
|
|||||||
return clean.slice(0, 160);
|
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.
|
* Create a new node.
|
||||||
*/
|
*/
|
||||||
@@ -140,26 +303,25 @@ function createNode(nodeData) {
|
|||||||
link,
|
link,
|
||||||
event_date,
|
event_date,
|
||||||
dimensions = [],
|
dimensions = [],
|
||||||
metadata = {}
|
metadata = {},
|
||||||
|
context_id
|
||||||
} = nodeData;
|
} = nodeData;
|
||||||
|
|
||||||
const title = sanitizeTitle(rawTitle);
|
const title = sanitizeTitle(rawTitle);
|
||||||
const unknownDimensions = getUnknownDimensions(dimensions);
|
|
||||||
if (unknownDimensions.length > 0) {
|
|
||||||
throw new Error(formatUnknownDimensionsError(unknownDimensions));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const canonicalMetadata = buildCanonicalMetadata({ metadata });
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
const sourceToStore = source && source.trim()
|
const sourceToStore = source ?? ([title, description].filter(Boolean).join('\n\n').trim() || null);
|
||||||
? source
|
const effectiveContextId = context_id == null
|
||||||
: [title, description].filter(Boolean).join('\n\n').trim() || null;
|
? inferBestContextIdForNode({ title, description, source: sourceToStore, dimensions, metadata: canonicalMetadata })
|
||||||
|
: context_id;
|
||||||
|
|
||||||
const nodeId = transaction(() => {
|
const nodeId = transaction(() => {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO nodes (title, description, source, link, event_date, metadata, created_at, updated_at)
|
INSERT INTO nodes (title, description, source, link, event_date, metadata, context_id, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const result = stmt.run(
|
const result = stmt.run(
|
||||||
@@ -168,7 +330,8 @@ function createNode(nodeData) {
|
|||||||
sourceToStore,
|
sourceToStore,
|
||||||
link ?? null,
|
link ?? null,
|
||||||
event_date ?? null,
|
event_date ?? null,
|
||||||
JSON.stringify(metadata),
|
JSON.stringify(canonicalMetadata),
|
||||||
|
effectiveContextId ?? null,
|
||||||
now,
|
now,
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
@@ -193,7 +356,6 @@ function createNode(nodeData) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing node.
|
* Update an existing node.
|
||||||
* Source-first update path.
|
|
||||||
*/
|
*/
|
||||||
function updateNode(id, updates, options = {}) {
|
function updateNode(id, updates, options = {}) {
|
||||||
const { title, description, source, link, event_date, dimensions, metadata } = updates;
|
const { title, description, source, link, event_date, dimensions, metadata } = updates;
|
||||||
@@ -206,12 +368,9 @@ function updateNode(id, updates, options = {}) {
|
|||||||
throw new Error(`Node with ID ${id} not found. Use rah_search_nodes to find nodes by keyword.`);
|
throw new Error(`Node with ID ${id} not found. Use rah_search_nodes to find nodes by keyword.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(dimensions)) {
|
const mergedMetadata = metadata !== undefined
|
||||||
const unknownDimensions = getUnknownDimensions(dimensions);
|
? buildCanonicalMetadata({ existing: existing.metadata, metadata })
|
||||||
if (unknownDimensions.length > 0) {
|
: undefined;
|
||||||
throw new Error(formatUnknownDimensionsError(unknownDimensions));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
transaction(() => {
|
transaction(() => {
|
||||||
const setFields = [];
|
const setFields = [];
|
||||||
@@ -237,9 +396,13 @@ function updateNode(id, updates, options = {}) {
|
|||||||
setFields.push('event_date = ?');
|
setFields.push('event_date = ?');
|
||||||
params.push(event_date);
|
params.push(event_date);
|
||||||
}
|
}
|
||||||
if (metadata !== undefined) {
|
if (Object.prototype.hasOwnProperty.call(updates, 'context_id')) {
|
||||||
|
setFields.push('context_id = ?');
|
||||||
|
params.push(updates.context_id ?? null);
|
||||||
|
}
|
||||||
|
if (mergedMetadata !== undefined) {
|
||||||
setFields.push('metadata = ?');
|
setFields.push('metadata = ?');
|
||||||
params.push(JSON.stringify(metadata));
|
params.push(JSON.stringify(mergedMetadata));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always update timestamp
|
// Always update timestamp
|
||||||
@@ -286,7 +449,7 @@ function getNodeCount() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get knowledge graph context overview.
|
* Get knowledge graph context overview.
|
||||||
* Returns stats, hub nodes, dimensions, and recent activity.
|
* Returns stats, contexts, hub nodes, dimensions, and recent activity.
|
||||||
*/
|
*/
|
||||||
function getContext() {
|
function getContext() {
|
||||||
const nodeCount = query('SELECT COUNT(*) as count FROM nodes')[0].count;
|
const nodeCount = query('SELECT COUNT(*) as count FROM nodes')[0].count;
|
||||||
@@ -315,7 +478,8 @@ function getContext() {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stats: { nodeCount, edgeCount, dimensionCount: dimensions.length },
|
stats: { nodeCount, edgeCount, dimensionCount: dimensions.length, contextCount: contextService.listContexts().length },
|
||||||
|
contexts: contextService.listContexts(),
|
||||||
dimensions,
|
dimensions,
|
||||||
recentNodes,
|
recentNodes,
|
||||||
hubNodes
|
hubNodes
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const SEEDED_SKILL_IDS = new Set([
|
|||||||
'persona',
|
'persona',
|
||||||
'calibration',
|
'calibration',
|
||||||
'connect',
|
'connect',
|
||||||
|
'node-context-enrichment',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const DEPRECATED_SKILL_IDS = new Set([
|
const DEPRECATED_SKILL_IDS = new Set([
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ function initDatabase() {
|
|||||||
embedding BLOB,
|
embedding BLOB,
|
||||||
embedding_updated_at TEXT,
|
embedding_updated_at TEXT,
|
||||||
embedding_text TEXT,
|
embedding_text TEXT,
|
||||||
chunk_status TEXT DEFAULT 'not_chunked'
|
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 (
|
CREATE TABLE IF NOT EXISTS edges (
|
||||||
@@ -93,6 +95,15 @@ function initDatabase() {
|
|||||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
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
|
-- Seed default dimensions
|
||||||
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('research', 1);
|
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 ('ideas', 1);
|
||||||
@@ -143,6 +154,42 @@ function initDatabase() {
|
|||||||
AND LENGTH(TRIM(chunk)) > 0;
|
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;');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
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 '';");
|
||||||
|
}
|
||||||
|
if (!contextCols.includes('icon')) {
|
||||||
|
db.exec('ALTER TABLE contexts ADD COLUMN icon TEXT;');
|
||||||
|
}
|
||||||
|
if (!contextCols.includes('created_at')) {
|
||||||
|
db.exec("ALTER TABLE contexts ADD COLUMN created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
|
||||||
|
}
|
||||||
|
if (!contextCols.includes('updated_at')) {
|
||||||
|
db.exec("ALTER TABLE contexts ADD COLUMN updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
|
||||||
|
}
|
||||||
|
db.exec(`
|
||||||
|
UPDATE contexts
|
||||||
|
SET description = COALESCE(NULLIF(TRIM(description), ''), name)
|
||||||
|
WHERE description IS NULL OR LENGTH(TRIM(description)) = 0;
|
||||||
|
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);
|
const edgeCols = db.prepare('PRAGMA table_info(edges)').all().map(c => c.name);
|
||||||
if (!edgeCols.includes('explanation')) {
|
if (!edgeCols.includes('explanation')) {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: Audit
|
name: Audit
|
||||||
description: "Run a structured audit of graph quality, skill quality, and operational consistency."
|
description: "Use for structured review, QA, cleanup, or governance checks across graph quality, skill quality, and operational consistency."
|
||||||
when_to_use: "User asks for review, QA, cleanup, or governance checks."
|
|
||||||
when_not_to_use: "Simple one-off write/read requests."
|
|
||||||
success_criteria: "Findings are prioritized, concrete, and tied to actionable fixes."
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Audit
|
# Audit
|
||||||
@@ -27,3 +24,5 @@ success_criteria: "Findings are prioritized, concrete, and tied to actionable fi
|
|||||||
- Prefer specific evidence over generic commentary.
|
- Prefer specific evidence over generic commentary.
|
||||||
- Propose the smallest high-leverage fixes first.
|
- Propose the smallest high-leverage fixes first.
|
||||||
- Separate defects from optional polish.
|
- Separate defects from optional polish.
|
||||||
|
- Node descriptions must read like natural prose while still making what / why / status clear.
|
||||||
|
- Flag any node description missing a clear why or status component as a high-priority quality issue.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name: Calibration
|
|||||||
description: "Run periodic check-ins to recalibrate goals, projects, preferences, and graph structure."
|
description: "Run periodic check-ins to recalibrate goals, projects, preferences, and graph structure."
|
||||||
when_to_use: "User asks for a check-in, reset, review, or strategic recalibration."
|
when_to_use: "User asks for a check-in, reset, review, or strategic recalibration."
|
||||||
when_not_to_use: "Single isolated question with no strategic update needed."
|
when_not_to_use: "Single isolated question with no strategic update needed."
|
||||||
success_criteria: "Graph reflects current reality: updated hubs, changed priorities, and explicit deltas."
|
success_criteria: "Graph reflects current reality: updated contexts, changed priorities, and explicit deltas."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Calibration
|
# Calibration
|
||||||
@@ -14,7 +14,7 @@ Re-anchor the graph to the user's current state.
|
|||||||
|
|
||||||
## Check-in Sequence
|
## Check-in Sequence
|
||||||
|
|
||||||
1. Review major hub nodes and active project nodes.
|
1. Review major contexts, anchor nodes, and active project nodes.
|
||||||
2. Ask what changed: goals, priorities, constraints, beliefs, preferences.
|
2. Ask what changed: goals, priorities, constraints, beliefs, preferences.
|
||||||
3. Identify stale nodes and missing nodes.
|
3. Identify stale nodes and missing nodes.
|
||||||
4. Propose precise updates: update existing vs create new.
|
4. Propose precise updates: update existing vs create new.
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: DB Operations
|
name: DB Operations
|
||||||
description: "Use this for all graph read/write operations with strict data quality standards."
|
description: "Use this for all graph read/write operations with strict data quality standards."
|
||||||
when_to_use: "Any request to read, create, update, connect, classify, or traverse graph data."
|
|
||||||
when_not_to_use: "Pure conversation with no graph interaction needed."
|
|
||||||
success_criteria: "Writes are explicit and correct; descriptions are concrete; edges and dimensions are high-signal."
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# DB Operations
|
# DB Operations
|
||||||
@@ -11,7 +8,7 @@ success_criteria: "Writes are explicit and correct; descriptions are concrete; e
|
|||||||
## Core Rules
|
## Core Rules
|
||||||
|
|
||||||
1. Search before create to avoid duplicates.
|
1. Search before create to avoid duplicates.
|
||||||
2. Every create/update must include an explicit description of WHAT the thing is and WHY it matters.
|
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.
|
||||||
3. Use event dates when known (when it happened, not when saved).
|
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 dimensions deliberately; prefer existing dimensions over creating noisy new ones.
|
||||||
5. Create edges when relationships are meaningful; edge explanations should read as a sentence.
|
5. Create edges when relationships are meaningful; edge explanations should read as a sentence.
|
||||||
@@ -19,9 +16,10 @@ success_criteria: "Writes are explicit and correct; descriptions are concrete; e
|
|||||||
## Write Quality Contract
|
## Write Quality Contract
|
||||||
|
|
||||||
- `title`: clear and specific.
|
- `title`: clear and specific.
|
||||||
- `description`: concrete object-level description, not vague summaries.
|
- `description`: natural prose, not labels. It should still make what / why / status clear when possible.
|
||||||
- `notes/content`: extra context, analysis, supporting detail.
|
- `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.
|
||||||
- `link`: external source URL only.
|
- `link`: external source URL only.
|
||||||
|
- `metadata`: prefer canonical keys `type`, `state`, `captured_method`, `captured_by`, and `source_metadata`.
|
||||||
|
|
||||||
## Execution Pattern
|
## Execution Pattern
|
||||||
|
|
||||||
@@ -29,9 +27,11 @@ success_criteria: "Writes are explicit and correct; descriptions are concrete; e
|
|||||||
2. Decide: create vs update vs connect.
|
2. Decide: create vs update vs connect.
|
||||||
3. Execute minimum required writes.
|
3. Execute minimum required writes.
|
||||||
4. Verify result reflects user intent exactly.
|
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.
|
||||||
|
|
||||||
## Do Not
|
## Do Not
|
||||||
|
|
||||||
- Create duplicate nodes when an update is correct.
|
- Create duplicate nodes when an update is correct.
|
||||||
- Write vague descriptions ("discusses", "explores", "is about").
|
- Write vague descriptions ("discusses", "explores", "is about").
|
||||||
|
- Replace a user's raw idea/source with a thin summary.
|
||||||
- Create weak or directionless edges.
|
- Create weak or directionless edges.
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
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."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Node Context Enrichment
|
||||||
|
|
||||||
|
Use this when a node already exists but its description is thin, generic, or missing personal context.
|
||||||
|
|
||||||
|
This skill should not silently rewrite and move on when contextual framing is inferred. If the enrichment depends on interpretation, update the node and then explicitly invite the user to correct or refine the contextual framing.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace weak descriptions with a single clean natural description that captures:
|
||||||
|
|
||||||
|
1. What the artifact literally is
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Load the node and inspect title, description, source, link, metadata, dimensions, and nearby edges.
|
||||||
|
2. Search for adjacent context before rewriting.
|
||||||
|
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.
|
||||||
|
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:
|
||||||
|
- what it is
|
||||||
|
- why it belongs in the graph
|
||||||
|
- 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.
|
||||||
@@ -3,7 +3,7 @@ name: Onboarding
|
|||||||
description: "Onboard a new user by mapping goals, projects, worldview, and preferences into a strong initial graph."
|
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_to_use: "New user setup or major reset of account context."
|
||||||
when_not_to_use: "User asks for a narrow tactical operation only."
|
when_not_to_use: "User asks for a narrow tactical operation only."
|
||||||
success_criteria: "User has an initial hub-node structure and clear next steps for graph growth."
|
success_criteria: "User has an initial context structure, anchor candidates, and clear next steps for graph growth."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Onboarding
|
# Onboarding
|
||||||
@@ -22,13 +22,14 @@ Understand the user deeply enough to bootstrap a useful externalized context gra
|
|||||||
|
|
||||||
## Graph Bootstrap
|
## Graph Bootstrap
|
||||||
|
|
||||||
1. Propose 3-6 hub nodes with clear rationale.
|
1. Propose 3-6 primary contexts with clear rationale.
|
||||||
2. Propose starter dimensions that reflect real domains.
|
2. Identify one strong anchor candidate per context.
|
||||||
3. Create initial edges between hubs and active projects.
|
3. Propose starter dimensions as secondary metadata and filters.
|
||||||
4. Confirm with user before writing.
|
4. Create initial edges between anchor nodes and active project nodes.
|
||||||
|
5. Confirm with user before writing.
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
- Initial hub map
|
- Initial context map
|
||||||
- Suggested first write actions
|
- Suggested first write actions
|
||||||
- Suggested weekly maintenance rhythm
|
- Suggested weekly maintenance rhythm
|
||||||
|
|||||||
+102
-33
@@ -44,8 +44,9 @@ let logger = (message) => console.log(`[mcp] ${message}`);
|
|||||||
|
|
||||||
const instructions = [
|
const instructions = [
|
||||||
'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
|
'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
|
||||||
'Core concepts: nodes (knowledge units), edges (connections with explanations), dimensions (categories).',
|
'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 hub nodes, dimensions, stats, and available guides.',
|
'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.',
|
'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.',
|
'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.',
|
'Only call rah_create_dimension when the user explicitly instructs you to create a new dimension.',
|
||||||
@@ -91,9 +92,11 @@ const addNodeInputSchema = {
|
|||||||
content: z.string().max(20000).optional(),
|
content: z.string().max(20000).optional(),
|
||||||
source: z.string().max(50000).optional(),
|
source: z.string().max(50000).optional(),
|
||||||
link: z.string().url().optional(),
|
link: z.string().url().optional(),
|
||||||
description: z.string().max(2000).optional(),
|
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),
|
dimensions: z.array(z.string()).min(1).max(5),
|
||||||
metadata: z.record(z.any()).optional(),
|
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()
|
chunk: z.string().max(50000).optional()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,7 +110,16 @@ const addNodeOutputSchema = {
|
|||||||
const searchNodesInputSchema = {
|
const searchNodesInputSchema = {
|
||||||
query: z.string().min(1).max(400),
|
query: z.string().min(1).max(400),
|
||||||
limit: z.number().min(1).max(25).optional(),
|
limit: z.number().min(1).max(25).optional(),
|
||||||
dimensions: z.array(z.string()).max(5).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 = {
|
const searchNodesOutputSchema = {
|
||||||
@@ -130,11 +142,13 @@ const updateNodeInputSchema = {
|
|||||||
id: z.number().int().positive().describe('The ID of the node to update'),
|
id: z.number().int().positive().describe('The ID of the node to update'),
|
||||||
updates: z.object({
|
updates: z.object({
|
||||||
title: z.string().optional().describe('New title'),
|
title: z.string().optional().describe('New title'),
|
||||||
description: z.string().optional().describe('New description (overwrites existing)'),
|
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".'),
|
||||||
content: z.string().optional().describe('Content to APPEND (not replace)'),
|
content: z.string().optional().describe('Legacy alias for source. Mapped to source for backward compatibility.'),
|
||||||
|
source: z.string().optional().describe('Canonical source text for embedding.'),
|
||||||
link: z.string().optional().describe('New link'),
|
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)'),
|
dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'),
|
||||||
metadata: z.record(z.any()).optional().describe('New metadata (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')
|
}).describe('Fields to update')
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -269,8 +283,7 @@ const extractUrlInputSchema = {
|
|||||||
const extractUrlOutputSchema = {
|
const extractUrlOutputSchema = {
|
||||||
success: z.boolean(),
|
success: z.boolean(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
content: z.string(),
|
source: z.string(),
|
||||||
chunk: z.string(),
|
|
||||||
metadata: z.record(z.any())
|
metadata: z.record(z.any())
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -283,7 +296,7 @@ const extractYoutubeOutputSchema = {
|
|||||||
success: z.boolean(),
|
success: z.boolean(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
channel: z.string(),
|
channel: z.string(),
|
||||||
transcript: z.string(),
|
source: z.string(),
|
||||||
metadata: z.record(z.any())
|
metadata: z.record(z.any())
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -295,8 +308,7 @@ const extractPdfInputSchema = {
|
|||||||
const extractPdfOutputSchema = {
|
const extractPdfOutputSchema = {
|
||||||
success: z.boolean(),
|
success: z.boolean(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
content: z.string(),
|
source: z.string(),
|
||||||
chunk: z.string(),
|
|
||||||
metadata: z.record(z.any())
|
metadata: z.record(z.any())
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -349,11 +361,11 @@ mcpServer.registerTool(
|
|||||||
'rah_add_node',
|
'rah_add_node',
|
||||||
{
|
{
|
||||||
title: 'Add RA-H node',
|
title: 'Add RA-H node',
|
||||||
description: 'Create a new node in the local RA-H knowledge base.',
|
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.',
|
||||||
inputSchema: addNodeInputSchema,
|
inputSchema: addNodeInputSchema,
|
||||||
outputSchema: addNodeOutputSchema
|
outputSchema: addNodeOutputSchema
|
||||||
},
|
},
|
||||||
async ({ title, content, source, link, description, dimensions, metadata, chunk }) => {
|
async ({ title, content, source, link, description, context_id, context_name, dimensions, metadata, chunk }) => {
|
||||||
const normalizedDimensions = sanitizeDimensions(dimensions);
|
const normalizedDimensions = sanitizeDimensions(dimensions);
|
||||||
if (normalizedDimensions.length === 0) {
|
if (normalizedDimensions.length === 0) {
|
||||||
throw new McpError(
|
throw new McpError(
|
||||||
@@ -364,9 +376,11 @@ mcpServer.registerTool(
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
source: source?.trim() || chunk?.trim() || content?.trim() || undefined,
|
source: source?.trim() || content?.trim() || chunk?.trim() || undefined,
|
||||||
link: link?.trim() || undefined,
|
link: link?.trim() || undefined,
|
||||||
description: description?.trim() || undefined,
|
description: description?.trim() || undefined,
|
||||||
|
context_id: context_id === null ? null : context_id,
|
||||||
|
context_name: context_name?.trim() || undefined,
|
||||||
dimensions: normalizedDimensions,
|
dimensions: normalizedDimensions,
|
||||||
metadata: metadata || {}
|
metadata: metadata || {}
|
||||||
};
|
};
|
||||||
@@ -399,7 +413,7 @@ mcpServer.registerTool(
|
|||||||
inputSchema: searchNodesInputSchema,
|
inputSchema: searchNodesInputSchema,
|
||||||
outputSchema: searchNodesOutputSchema
|
outputSchema: searchNodesOutputSchema
|
||||||
},
|
},
|
||||||
async ({ query, limit = 10, dimensions }) => {
|
async ({ query, limit = 10, dimensions, contextId }) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('search', query.trim());
|
params.set('search', query.trim());
|
||||||
params.set('limit', String(Math.min(Math.max(limit, 1), 25)));
|
params.set('limit', String(Math.min(Math.max(limit, 1), 25)));
|
||||||
@@ -408,6 +422,9 @@ mcpServer.registerTool(
|
|||||||
if (dimensionList.length > 0) {
|
if (dimensionList.length > 0) {
|
||||||
params.set('dimensions', dimensionList.join(','));
|
params.set('dimensions', dimensionList.join(','));
|
||||||
}
|
}
|
||||||
|
if (contextId) {
|
||||||
|
params.set('contextId', String(contextId));
|
||||||
|
}
|
||||||
|
|
||||||
const result = await callRaHApi(`/api/nodes?${params.toString()}`, {
|
const result = await callRaHApi(`/api/nodes?${params.toString()}`, {
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
@@ -436,11 +453,59 @@ mcpServer.registerTool(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
},
|
||||||
|
async ({ contextId, name, search, limit = 50, includeNodes = false }) => {
|
||||||
|
const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : '';
|
||||||
|
let contexts = [];
|
||||||
|
|
||||||
|
if (contextId) {
|
||||||
|
const result = await callRaHApi(`/api/contexts/${contextId}`, { method: 'GET' });
|
||||||
|
if (result?.data) {
|
||||||
|
contexts = [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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 : [] };
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: enriched.length === 0 ? 'No matching contexts found.' : `Found ${enriched.length} context(s).` }],
|
||||||
|
structuredContent: {
|
||||||
|
count: enriched.length,
|
||||||
|
contexts: enriched
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
mcpServer.registerTool(
|
mcpServer.registerTool(
|
||||||
'rah_update_node',
|
'rah_update_node',
|
||||||
{
|
{
|
||||||
title: 'Update RA-H node',
|
title: 'Update RA-H node',
|
||||||
description: 'Update an existing node. Content is APPENDED (not replaced). Dimensions are replaced.',
|
description: 'Update an existing node. Dimensions must be existing canonical dimensions; do not invent new ones.',
|
||||||
inputSchema: updateNodeInputSchema,
|
inputSchema: updateNodeInputSchema,
|
||||||
outputSchema: updateNodeOutputSchema
|
outputSchema: updateNodeOutputSchema
|
||||||
},
|
},
|
||||||
@@ -449,15 +514,15 @@ mcpServer.registerTool(
|
|||||||
throw new McpError(ErrorCode.InvalidParams, 'At least one field must be provided in updates.');
|
throw new McpError(ErrorCode.InvalidParams, '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 };
|
const mappedUpdates = { ...updates };
|
||||||
if (mappedUpdates.content !== undefined) {
|
|
||||||
mappedUpdates.source = mappedUpdates.content;
|
|
||||||
}
|
|
||||||
if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) {
|
if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) {
|
||||||
mappedUpdates.source = mappedUpdates.chunk;
|
mappedUpdates.source = mappedUpdates.chunk;
|
||||||
}
|
}
|
||||||
|
if (mappedUpdates.content !== undefined) {
|
||||||
|
mappedUpdates.source = mappedUpdates.content;
|
||||||
delete mappedUpdates.content;
|
delete mappedUpdates.content;
|
||||||
|
}
|
||||||
delete mappedUpdates.chunk;
|
delete mappedUpdates.chunk;
|
||||||
|
|
||||||
const result = await callRaHApi(`/api/nodes/${id}`, {
|
const result = await callRaHApi(`/api/nodes/${id}`, {
|
||||||
@@ -622,7 +687,7 @@ mcpServer.registerTool(
|
|||||||
'rah_create_dimension',
|
'rah_create_dimension',
|
||||||
{
|
{
|
||||||
title: 'Create RA-H dimension',
|
title: 'Create RA-H dimension',
|
||||||
description: 'Create a new dimension/tag for organizing nodes.',
|
description: 'Create a new dimension/tag for organizing nodes only when the user explicitly instructs you to do so.',
|
||||||
inputSchema: createDimensionInputSchema,
|
inputSchema: createDimensionInputSchema,
|
||||||
outputSchema: createDimensionOutputSchema
|
outputSchema: createDimensionOutputSchema
|
||||||
},
|
},
|
||||||
@@ -760,8 +825,7 @@ mcpServer.registerTool(
|
|||||||
structuredContent: {
|
structuredContent: {
|
||||||
success: true,
|
success: true,
|
||||||
title: result.title || 'Untitled',
|
title: result.title || 'Untitled',
|
||||||
content: result.content || '',
|
source: result.source || '',
|
||||||
chunk: result.chunk || '',
|
|
||||||
metadata: result.metadata || {}
|
metadata: result.metadata || {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -789,7 +853,7 @@ mcpServer.registerTool(
|
|||||||
success: true,
|
success: true,
|
||||||
title: result.title || 'Untitled',
|
title: result.title || 'Untitled',
|
||||||
channel: result.channel || 'Unknown',
|
channel: result.channel || 'Unknown',
|
||||||
transcript: result.transcript || '',
|
source: result.source || '',
|
||||||
metadata: result.metadata || {}
|
metadata: result.metadata || {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -816,8 +880,7 @@ mcpServer.registerTool(
|
|||||||
structuredContent: {
|
structuredContent: {
|
||||||
success: true,
|
success: true,
|
||||||
title: result.title || 'Untitled PDF',
|
title: result.title || 'Untitled PDF',
|
||||||
content: result.content || '',
|
source: result.source || '',
|
||||||
chunk: result.chunk || '',
|
|
||||||
metadata: result.metadata || {}
|
metadata: result.metadata || {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -829,11 +892,12 @@ mcpServer.registerTool(
|
|||||||
'rah_get_context',
|
'rah_get_context',
|
||||||
{
|
{
|
||||||
title: 'Get RA-H context',
|
title: 'Get RA-H context',
|
||||||
description: 'Get orientation context: hub nodes, dimensions, stats, and available guides. Call this first.',
|
description: 'Get orientation context: contexts, hub nodes, dimensions, stats, and available guides. Call this first.',
|
||||||
inputSchema: {},
|
inputSchema: {},
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
stats: z.object({ nodeCount: z.number(), edgeCount: z.number(), dimensionCount: z.number() }),
|
stats: z.object({ nodeCount: z.number(), edgeCount: z.number(), dimensionCount: z.number(), contextCount: z.number().optional() }),
|
||||||
hubNodes: z.array(z.object({ id: z.number(), title: z.string(), description: z.string().nullable(), edgeCount: z.number() })),
|
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() })),
|
dimensions: z.array(z.object({ name: z.string(), nodeCount: z.number(), description: z.string().nullable() })),
|
||||||
guides: z.array(z.string())
|
guides: z.array(z.string())
|
||||||
}
|
}
|
||||||
@@ -849,18 +913,23 @@ mcpServer.registerTool(
|
|||||||
name: d.name, nodeCount: d.node_count ?? 0, description: d.description ?? null
|
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
|
||||||
|
})) : [];
|
||||||
|
|
||||||
const guideResult = await callRaHApi('/api/guides', { method: 'GET' });
|
const guideResult = await callRaHApi('/api/guides', { method: 'GET' });
|
||||||
const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : [];
|
const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : [];
|
||||||
|
|
||||||
const stats = { nodeCount: 0, edgeCount: 0, dimensionCount: dimensions.length };
|
const stats = { nodeCount: 0, edgeCount: 0, dimensionCount: dimensions.length, contextCount: contexts.length };
|
||||||
try {
|
try {
|
||||||
const countResult = await callRaHApi('/api/nodes?limit=1', { method: 'GET' });
|
const countResult = await callRaHApi('/api/nodes?limit=1', { method: 'GET' });
|
||||||
if (countResult.total !== undefined) stats.nodeCount = countResult.total;
|
if (countResult.total !== undefined) stats.nodeCount = countResult.total;
|
||||||
} catch { /* use defaults */ }
|
} catch { /* use defaults */ }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: `Knowledge graph: ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.` }],
|
content: [{ type: 'text', text: `Knowledge graph: ${stats.contextCount} contexts, ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.` }],
|
||||||
structuredContent: { stats, hubNodes, dimensions, guides }
|
structuredContent: { stats, hubNodes, contexts, dimensions, guides }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
+118
-35
@@ -11,8 +11,9 @@ const packageJson = require('../../package.json');
|
|||||||
|
|
||||||
const instructions = [
|
const instructions = [
|
||||||
'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
|
'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
|
||||||
'Core concepts: nodes (knowledge units), edges (connections with explanations), dimensions (categories).',
|
'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 hub nodes, dimensions, stats, and available guides.',
|
'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.',
|
'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.',
|
'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.',
|
'Only call rah_create_dimension when the user explicitly instructs you to create a new dimension.',
|
||||||
@@ -40,9 +41,11 @@ const addNodeInputSchema = {
|
|||||||
content: z.string().max(20000).optional(),
|
content: z.string().max(20000).optional(),
|
||||||
source: z.string().max(50000).optional(),
|
source: z.string().max(50000).optional(),
|
||||||
link: z.string().url().optional(),
|
link: z.string().url().optional(),
|
||||||
description: z.string().max(2000).optional(),
|
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),
|
dimensions: z.array(z.string()).min(1).max(5),
|
||||||
metadata: z.record(z.any()).optional(),
|
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()
|
chunk: z.string().max(50000).optional()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,7 +59,16 @@ const addNodeOutputSchema = {
|
|||||||
const searchNodesInputSchema = {
|
const searchNodesInputSchema = {
|
||||||
query: z.string().min(1).max(400),
|
query: z.string().min(1).max(400),
|
||||||
limit: z.number().min(1).max(25).optional(),
|
limit: z.number().min(1).max(25).optional(),
|
||||||
dimensions: z.array(z.string()).max(5).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 = {
|
const searchNodesOutputSchema = {
|
||||||
@@ -65,7 +77,7 @@ const searchNodesOutputSchema = {
|
|||||||
z.object({
|
z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
content: z.string().nullable(),
|
source: z.string().nullable(),
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
link: z.string().nullable(),
|
link: z.string().nullable(),
|
||||||
dimensions: z.array(z.string()),
|
dimensions: z.array(z.string()),
|
||||||
@@ -79,10 +91,13 @@ const updateNodeInputSchema = {
|
|||||||
id: z.number().int().positive().describe('The ID of the node to update'),
|
id: z.number().int().positive().describe('The ID of the node to update'),
|
||||||
updates: z.object({
|
updates: z.object({
|
||||||
title: z.string().optional().describe('New title'),
|
title: z.string().optional().describe('New title'),
|
||||||
content: z.string().optional().describe('Content to APPEND (not replace)'),
|
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".'),
|
||||||
|
content: z.string().optional().describe('Legacy alias for source. Mapped to source for backward compatibility.'),
|
||||||
|
source: z.string().optional().describe('Canonical source text for embedding.'),
|
||||||
link: z.string().optional().describe('New link'),
|
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)'),
|
dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'),
|
||||||
metadata: z.record(z.any()).optional().describe('New metadata (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')
|
}).describe('Fields to update')
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,7 +118,7 @@ const getNodesOutputSchema = {
|
|||||||
z.object({
|
z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
content: z.string().nullable(),
|
source: z.string().nullable(),
|
||||||
link: z.string().nullable(),
|
link: z.string().nullable(),
|
||||||
dimensions: z.array(z.string()),
|
dimensions: z.array(z.string()),
|
||||||
updated_at: z.string()
|
updated_at: z.string()
|
||||||
@@ -217,8 +232,7 @@ const extractUrlInputSchema = {
|
|||||||
const extractUrlOutputSchema = {
|
const extractUrlOutputSchema = {
|
||||||
success: z.boolean(),
|
success: z.boolean(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
content: z.string(),
|
source: z.string(),
|
||||||
chunk: z.string(),
|
|
||||||
metadata: z.record(z.any())
|
metadata: z.record(z.any())
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -231,7 +245,7 @@ const extractYoutubeOutputSchema = {
|
|||||||
success: z.boolean(),
|
success: z.boolean(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
channel: z.string(),
|
channel: z.string(),
|
||||||
transcript: z.string(),
|
source: z.string(),
|
||||||
metadata: z.record(z.any())
|
metadata: z.record(z.any())
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -243,8 +257,7 @@ const extractPdfInputSchema = {
|
|||||||
const extractPdfOutputSchema = {
|
const extractPdfOutputSchema = {
|
||||||
success: z.boolean(),
|
success: z.boolean(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
content: z.string(),
|
source: z.string(),
|
||||||
chunk: z.string(),
|
|
||||||
metadata: z.record(z.any())
|
metadata: z.record(z.any())
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -322,11 +335,11 @@ server.registerTool(
|
|||||||
'rah_add_node',
|
'rah_add_node',
|
||||||
{
|
{
|
||||||
title: 'Add RA-H node',
|
title: 'Add RA-H node',
|
||||||
description: 'Create a new node in the local RA-H knowledge base.',
|
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.',
|
||||||
inputSchema: addNodeInputSchema,
|
inputSchema: addNodeInputSchema,
|
||||||
outputSchema: addNodeOutputSchema
|
outputSchema: addNodeOutputSchema
|
||||||
},
|
},
|
||||||
async ({ title, content, source, link, description, dimensions, metadata, chunk }) => {
|
async ({ title, content, source, link, description, context_id, context_name, dimensions, metadata, chunk }) => {
|
||||||
const normalizedDimensions = sanitizeDimensions(dimensions);
|
const normalizedDimensions = sanitizeDimensions(dimensions);
|
||||||
if (normalizedDimensions.length === 0) {
|
if (normalizedDimensions.length === 0) {
|
||||||
throw new Error('At least one dimension/tag is required when creating a node.');
|
throw new Error('At least one dimension/tag is required when creating a node.');
|
||||||
@@ -334,9 +347,11 @@ server.registerTool(
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
source: source?.trim() || chunk?.trim() || content?.trim() || undefined,
|
source: source?.trim() || content?.trim() || chunk?.trim() || undefined,
|
||||||
link: link?.trim() || undefined,
|
link: link?.trim() || undefined,
|
||||||
description: description?.trim() || undefined,
|
description: description?.trim() || undefined,
|
||||||
|
context_id: context_id === null ? null : context_id,
|
||||||
|
context_name: context_name?.trim() || undefined,
|
||||||
dimensions: normalizedDimensions,
|
dimensions: normalizedDimensions,
|
||||||
metadata: metadata || {}
|
metadata: metadata || {}
|
||||||
};
|
};
|
||||||
@@ -369,7 +384,7 @@ server.registerTool(
|
|||||||
inputSchema: searchNodesInputSchema,
|
inputSchema: searchNodesInputSchema,
|
||||||
outputSchema: searchNodesOutputSchema
|
outputSchema: searchNodesOutputSchema
|
||||||
},
|
},
|
||||||
async ({ query, limit = 10, dimensions }) => {
|
async ({ query, limit = 10, dimensions, contextId }) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('search', query.trim());
|
params.set('search', query.trim());
|
||||||
params.set('limit', String(Math.min(Math.max(limit, 1), 25)));
|
params.set('limit', String(Math.min(Math.max(limit, 1), 25)));
|
||||||
@@ -378,6 +393,9 @@ server.registerTool(
|
|||||||
if (dimensionList.length > 0) {
|
if (dimensionList.length > 0) {
|
||||||
params.set('dimensions', dimensionList.join(','));
|
params.set('dimensions', dimensionList.join(','));
|
||||||
}
|
}
|
||||||
|
if (contextId) {
|
||||||
|
params.set('contextId', String(contextId));
|
||||||
|
}
|
||||||
|
|
||||||
const result = await callRaHApi(`/api/nodes?${params.toString()}`, {
|
const result = await callRaHApi(`/api/nodes?${params.toString()}`, {
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
@@ -407,11 +425,59 @@ server.registerTool(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
},
|
||||||
|
async ({ contextId, name, search, limit = 50, includeNodes = false }) => {
|
||||||
|
const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : '';
|
||||||
|
let contexts = [];
|
||||||
|
|
||||||
|
if (contextId) {
|
||||||
|
const result = await callRaHApi(`/api/contexts/${contextId}`, { method: 'GET' });
|
||||||
|
if (result?.data) {
|
||||||
|
contexts = [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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 : [] };
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: enriched.length === 0 ? 'No matching contexts found.' : `Found ${enriched.length} context(s).` }],
|
||||||
|
structuredContent: {
|
||||||
|
count: enriched.length,
|
||||||
|
contexts: enriched
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
'rah_update_node',
|
'rah_update_node',
|
||||||
{
|
{
|
||||||
title: 'Update RA-H node',
|
title: 'Update RA-H node',
|
||||||
description: 'Update an existing node. Content is APPENDED (not replaced). Dimensions are replaced.',
|
description: 'Update an existing node. Dimensions must be existing canonical dimensions; do not invent new ones.',
|
||||||
inputSchema: updateNodeInputSchema,
|
inputSchema: updateNodeInputSchema,
|
||||||
outputSchema: updateNodeOutputSchema
|
outputSchema: updateNodeOutputSchema
|
||||||
},
|
},
|
||||||
@@ -420,15 +486,15 @@ server.registerTool(
|
|||||||
throw new Error('At least one field must be provided in updates.');
|
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 };
|
const mappedUpdates = { ...updates };
|
||||||
if (mappedUpdates.content !== undefined) {
|
|
||||||
mappedUpdates.source = mappedUpdates.content;
|
|
||||||
}
|
|
||||||
if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) {
|
if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) {
|
||||||
mappedUpdates.source = mappedUpdates.chunk;
|
mappedUpdates.source = mappedUpdates.chunk;
|
||||||
}
|
}
|
||||||
|
if (mappedUpdates.content !== undefined) {
|
||||||
|
mappedUpdates.source = mappedUpdates.content;
|
||||||
delete mappedUpdates.content;
|
delete mappedUpdates.content;
|
||||||
|
}
|
||||||
delete mappedUpdates.chunk;
|
delete mappedUpdates.chunk;
|
||||||
|
|
||||||
const result = await callRaHApi(`/api/nodes/${id}`, {
|
const result = await callRaHApi(`/api/nodes/${id}`, {
|
||||||
@@ -593,7 +659,7 @@ server.registerTool(
|
|||||||
'rah_create_dimension',
|
'rah_create_dimension',
|
||||||
{
|
{
|
||||||
title: 'Create RA-H dimension',
|
title: 'Create RA-H dimension',
|
||||||
description: 'Create a new dimension/tag for organizing nodes.',
|
description: 'Create a new dimension/tag for organizing nodes only when the user explicitly instructs you to do so.',
|
||||||
inputSchema: createDimensionInputSchema,
|
inputSchema: createDimensionInputSchema,
|
||||||
outputSchema: createDimensionOutputSchema
|
outputSchema: createDimensionOutputSchema
|
||||||
},
|
},
|
||||||
@@ -731,8 +797,7 @@ server.registerTool(
|
|||||||
structuredContent: {
|
structuredContent: {
|
||||||
success: true,
|
success: true,
|
||||||
title: result.title || 'Untitled',
|
title: result.title || 'Untitled',
|
||||||
content: result.content || '',
|
source: result.source || '',
|
||||||
chunk: result.chunk || '',
|
|
||||||
metadata: result.metadata || {}
|
metadata: result.metadata || {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -760,7 +825,7 @@ server.registerTool(
|
|||||||
success: true,
|
success: true,
|
||||||
title: result.title || 'Untitled',
|
title: result.title || 'Untitled',
|
||||||
channel: result.channel || 'Unknown',
|
channel: result.channel || 'Unknown',
|
||||||
transcript: result.transcript || '',
|
source: result.source || '',
|
||||||
metadata: result.metadata || {}
|
metadata: result.metadata || {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -787,8 +852,7 @@ server.registerTool(
|
|||||||
structuredContent: {
|
structuredContent: {
|
||||||
success: true,
|
success: true,
|
||||||
title: result.title || 'Untitled PDF',
|
title: result.title || 'Untitled PDF',
|
||||||
content: result.content || '',
|
source: result.source || '',
|
||||||
chunk: result.chunk || '',
|
|
||||||
metadata: result.metadata || {}
|
metadata: result.metadata || {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -797,10 +861,11 @@ server.registerTool(
|
|||||||
|
|
||||||
// rah_get_context — orientation tool for external agents
|
// rah_get_context — orientation tool for external agents
|
||||||
const getContextOutputSchema = {
|
const getContextOutputSchema = {
|
||||||
schema: z.object({
|
stats: z.object({
|
||||||
nodeCount: z.number(),
|
nodeCount: z.number(),
|
||||||
edgeCount: z.number(),
|
edgeCount: z.number(),
|
||||||
dimensionCount: z.number()
|
dimensionCount: z.number(),
|
||||||
|
contextCount: z.number().optional()
|
||||||
}),
|
}),
|
||||||
hubNodes: z.array(z.object({
|
hubNodes: z.array(z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
@@ -808,6 +873,13 @@ const getContextOutputSchema = {
|
|||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
edgeCount: z.number()
|
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({
|
dimensions: z.array(z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
nodeCount: z.number(),
|
nodeCount: z.number(),
|
||||||
@@ -820,7 +892,7 @@ server.registerTool(
|
|||||||
'rah_get_context',
|
'rah_get_context',
|
||||||
{
|
{
|
||||||
title: 'Get RA-H context',
|
title: 'Get RA-H context',
|
||||||
description: 'Get orientation context: hub nodes, dimensions, stats, and available guides. Call this first.',
|
description: 'Get orientation context: contexts, hub nodes, dimensions, stats, and available guides. Call this first.',
|
||||||
inputSchema: {},
|
inputSchema: {},
|
||||||
outputSchema: getContextOutputSchema
|
outputSchema: getContextOutputSchema
|
||||||
},
|
},
|
||||||
@@ -842,6 +914,15 @@ server.registerTool(
|
|||||||
description: d.description ?? null
|
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
|
||||||
|
})) : [];
|
||||||
|
|
||||||
// Fetch guides
|
// Fetch guides
|
||||||
const guideResult = await callRaHApi('/api/guides', { method: 'GET' });
|
const guideResult = await callRaHApi('/api/guides', { method: 'GET' });
|
||||||
const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : [];
|
const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : [];
|
||||||
@@ -851,7 +932,8 @@ server.registerTool(
|
|||||||
const stats = {
|
const stats = {
|
||||||
nodeCount: nodeCount ?? hubNodes.reduce((_, n) => 0, 0),
|
nodeCount: nodeCount ?? hubNodes.reduce((_, n) => 0, 0),
|
||||||
edgeCount: 0,
|
edgeCount: 0,
|
||||||
dimensionCount: dimensions.length
|
dimensionCount: dimensions.length,
|
||||||
|
contextCount: contexts.length
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try to get actual counts from a stats endpoint or compute
|
// Try to get actual counts from a stats endpoint or compute
|
||||||
@@ -862,13 +944,14 @@ server.registerTool(
|
|||||||
}
|
}
|
||||||
} catch { /* use defaults */ }
|
} catch { /* use defaults */ }
|
||||||
|
|
||||||
const summary = `Knowledge graph: ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.`;
|
const summary = `Knowledge graph: ${stats.contextCount} contexts, ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: summary }],
|
content: [{ type: 'text', text: summary }],
|
||||||
structuredContent: {
|
structuredContent: {
|
||||||
schema: stats,
|
stats,
|
||||||
hubNodes,
|
hubNodes,
|
||||||
|
contexts,
|
||||||
dimensions,
|
dimensions,
|
||||||
guides
|
guides
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ RA-OS exposes these core standalone MCP tools:
|
|||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `getContext` | Graph overview: stats, hub nodes, dimensions, recent activity, skills |
|
| `getContext` | Graph overview: stats, contexts, hub nodes, dimensions, recent activity, skills |
|
||||||
| `queryNodes` | Search nodes by keyword/dimensions/date |
|
| `queryNodes` | Search nodes by keyword/dimensions/date |
|
||||||
| `getNodesById` | Fetch full nodes by ID |
|
| `getNodesById` | Fetch full nodes by ID |
|
||||||
| `createNode` | Create a node |
|
| `createNode` | Create a node |
|
||||||
|
|||||||
+3
-2
@@ -76,7 +76,8 @@ If you want real-time UI updates when nodes are created:
|
|||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `getContext` | Get graph overview — stats, hub nodes, dimensions, recent activity. Called first automatically. |
|
| `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) |
|
| `createNode` | Create a new node (title/source/dimensions) |
|
||||||
| `queryNodes` | Search existing nodes by keyword |
|
| `queryNodes` | Search existing nodes by keyword |
|
||||||
| `updateNode` | Update an existing node |
|
| `updateNode` | Update an existing node |
|
||||||
@@ -101,7 +102,7 @@ If you want real-time UI updates when nodes are created:
|
|||||||
|
|
||||||
Once connected, the MCP server instructs Claude to:
|
Once connected, the MCP server instructs Claude to:
|
||||||
|
|
||||||
1. **Call `getContext` first** to orient itself (hub nodes, dimensions, stats, available skills)
|
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
|
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
|
3. **Read skills for complex tasks** — skills provide reusable procedural instructions for graph operations and workflows
|
||||||
4. **Search before creating** to avoid duplicates
|
4. **Search before creating** to avoid duplicates
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ import {
|
|||||||
LayoutList,
|
LayoutList,
|
||||||
Map,
|
Map,
|
||||||
Folder,
|
Folder,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
Table2,
|
Table2,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -18,6 +20,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { PaneType, NavigablePaneType } from '../panes/types';
|
import type { PaneType, NavigablePaneType } from '../panes/types';
|
||||||
import type { Theme } from '@/hooks/useTheme';
|
import type { Theme } from '@/hooks/useTheme';
|
||||||
|
import type { ContextSummary } from '@/types/database';
|
||||||
|
|
||||||
interface LeftToolbarProps {
|
interface LeftToolbarProps {
|
||||||
onSearchClick: () => void;
|
onSearchClick: () => void;
|
||||||
@@ -31,6 +34,8 @@ interface LeftToolbarProps {
|
|||||||
activeTabType: PaneType | null;
|
activeTabType: PaneType | null;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
onThemeToggle: () => void;
|
onThemeToggle: () => void;
|
||||||
|
contexts?: ContextSummary[];
|
||||||
|
onContextQuickSelect?: (contextId: number, contextName: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAV_WIDTH_COLLAPSED = 50;
|
const NAV_WIDTH_COLLAPSED = 50;
|
||||||
@@ -113,7 +118,11 @@ export default function LeftToolbar({
|
|||||||
activeTabType,
|
activeTabType,
|
||||||
theme,
|
theme,
|
||||||
onThemeToggle,
|
onThemeToggle,
|
||||||
|
contexts = [],
|
||||||
|
onContextQuickSelect,
|
||||||
}: LeftToolbarProps) {
|
}: LeftToolbarProps) {
|
||||||
|
const [contextsExpanded, setContextsExpanded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -148,6 +157,77 @@ export default function LeftToolbar({
|
|||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', minHeight: 0 }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', minHeight: 0 }}>
|
||||||
<div style={{ borderTop: '1px solid var(--rah-border)', paddingTop: '14px' }}>
|
<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' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '6px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<NavButton
|
||||||
|
icon={Folder}
|
||||||
|
label="Contexts"
|
||||||
|
expanded={isExpanded}
|
||||||
|
active={activeTabType === 'contexts' || openTabTypes.has('contexts')}
|
||||||
|
onClick={() => onPaneTypeClick('contexts')}
|
||||||
|
activeTone="green"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setContextsExpanded((prev) => !prev)}
|
||||||
|
style={{
|
||||||
|
width: '28px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: 'none',
|
||||||
|
background: contextsExpanded ? 'var(--rah-bg-hover)' : 'transparent',
|
||||||
|
color: contextsExpanded ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
title="Toggle context list"
|
||||||
|
>
|
||||||
|
{contextsExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && contextsExpanded && contexts.length > 0 ? (
|
||||||
|
<div style={{ marginLeft: '14px', paddingLeft: '12px', borderLeft: '1px solid var(--rah-border)', display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||||
|
{contexts.map((context) => (
|
||||||
|
<button
|
||||||
|
key={context.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onContextQuickSelect?.(context.id, context.name)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '8px',
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '28px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--rah-text-secondary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0 8px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '12px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{context.name}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '10px', color: 'var(--rah-text-muted)', flexShrink: 0 }}>
|
||||||
|
{context.count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{VIEW_ITEMS.map((item) => (
|
{VIEW_ITEMS.map((item) => (
|
||||||
<NavButton
|
<NavButton
|
||||||
key={`${item.paneType}-${item.label}`}
|
key={`${item.paneType}-${item.label}`}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
|||||||
import { PanelLeftOpen, GripVertical, X } from 'lucide-react';
|
import { PanelLeftOpen, GripVertical, X } from 'lucide-react';
|
||||||
import SettingsModal, { SettingsTab } from '../settings/SettingsModal';
|
import SettingsModal, { SettingsTab } from '../settings/SettingsModal';
|
||||||
import SearchModal from '../nodes/SearchModal';
|
import SearchModal from '../nodes/SearchModal';
|
||||||
import { Node } from '@/types/database';
|
import { ContextSummary, Node } from '@/types/database';
|
||||||
import { DatabaseEvent } from '@/services/events';
|
import { DatabaseEvent } from '@/services/events';
|
||||||
import { usePersistentState } from '@/hooks/usePersistentState';
|
import { usePersistentState } from '@/hooks/usePersistentState';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
@@ -12,7 +12,7 @@ import { useTheme } from '@/hooks/useTheme';
|
|||||||
import LeftToolbar from './LeftToolbar';
|
import LeftToolbar from './LeftToolbar';
|
||||||
import SplitHandle from './SplitHandle';
|
import SplitHandle from './SplitHandle';
|
||||||
|
|
||||||
import { NodePane, DimensionsPane, MapPane, ViewsPane, TablePane, SkillsPane } from '../panes';
|
import { NodePane, ContextsPane, DimensionsPane, MapPane, ViewsPane, TablePane, SkillsPane } from '../panes';
|
||||||
import QuickAddInput from '../agents/QuickAddInput';
|
import QuickAddInput from '../agents/QuickAddInput';
|
||||||
import type { PaneType, SlotState, PaneAction, SlotId } from '../panes/types';
|
import type { PaneType, SlotState, PaneAction, SlotId } from '../panes/types';
|
||||||
|
|
||||||
@@ -35,10 +35,11 @@ const PANEL_A_WEIGHT_KEY = 'ui.panelA.weight.v1';
|
|||||||
const PANEL_B_WEIGHT_KEY = 'ui.panelB.weight.v1';
|
const PANEL_B_WEIGHT_KEY = 'ui.panelB.weight.v1';
|
||||||
const PANEL_C_WEIGHT_KEY = 'ui.panelC.weight.v1';
|
const PANEL_C_WEIGHT_KEY = 'ui.panelC.weight.v1';
|
||||||
const LEFT_NAV_EXPANDED_KEY = 'ui.leftNavExpanded';
|
const LEFT_NAV_EXPANDED_KEY = 'ui.leftNavExpanded';
|
||||||
|
const ACTIVE_CONTEXT_KEY = 'ui.focus.activeContextId';
|
||||||
const ACTIVE_DIMENSION_KEY = 'ui.focus.activeDimension';
|
const ACTIVE_DIMENSION_KEY = 'ui.focus.activeDimension';
|
||||||
|
|
||||||
const DEFAULT_SLOT_A: SlotState = { type: 'views' };
|
const DEFAULT_SLOT_A: SlotState = { type: 'views' };
|
||||||
const VALID_PANE_TYPES = new Set<PaneType>(['node', 'dimensions', 'map', 'views', 'table', 'skills']);
|
const VALID_PANE_TYPES = new Set<PaneType>(['node', 'contexts', 'dimensions', 'map', 'views', 'table', 'skills']);
|
||||||
|
|
||||||
function normalizeSlotState(raw: SlotState | null): SlotState | null {
|
function normalizeSlotState(raw: SlotState | null): SlotState | null {
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -84,7 +85,14 @@ export default function ThreePanelLayout() {
|
|||||||
const [nodesPanelRefresh, setNodesPanelRefresh] = useState(0);
|
const [nodesPanelRefresh, setNodesPanelRefresh] = useState(0);
|
||||||
const [focusPanelRefresh, setFocusPanelRefresh] = useState(0);
|
const [focusPanelRefresh, setFocusPanelRefresh] = useState(0);
|
||||||
const [folderViewRefresh, setFolderViewRefresh] = useState(0);
|
const [folderViewRefresh, setFolderViewRefresh] = useState(0);
|
||||||
|
const [availableContexts, setAvailableContexts] = useState<ContextSummary[]>([]);
|
||||||
|
const [activeContextId, setActiveContextId] = usePersistentState<number | null>(ACTIVE_CONTEXT_KEY, null);
|
||||||
const [activeDimension, setActiveDimension] = usePersistentState<string | null>(ACTIVE_DIMENSION_KEY, null);
|
const [activeDimension, setActiveDimension] = usePersistentState<string | null>(ACTIVE_DIMENSION_KEY, null);
|
||||||
|
const [browseContextFilters, setBrowseContextFilters] = useState<Record<SlotId, number | null>>({
|
||||||
|
A: null,
|
||||||
|
B: null,
|
||||||
|
C: null,
|
||||||
|
});
|
||||||
const [browseDimensionFilters, setBrowseDimensionFilters] = useState<Record<SlotId, string | null>>({
|
const [browseDimensionFilters, setBrowseDimensionFilters] = useState<Record<SlotId, string | null>>({
|
||||||
A: null,
|
A: null,
|
||||||
B: null,
|
B: null,
|
||||||
@@ -104,6 +112,20 @@ export default function ThreePanelLayout() {
|
|||||||
setSlotC((prev) => normalizeSlotState(prev));
|
setSlotC((prev) => normalizeSlotState(prev));
|
||||||
}, [setSlotA, setSlotB, setSlotC]);
|
}, [setSlotA, setSlotB, setSlotC]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/contexts');
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok && data.success) {
|
||||||
|
setAvailableContexts(data.data || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch contexts:', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [nodesPanelRefresh, folderViewRefresh]);
|
||||||
|
|
||||||
const getSlotState = useCallback((slot: SlotId): SlotState | null => {
|
const getSlotState = useCallback((slot: SlotId): SlotState | null => {
|
||||||
switch (slot) {
|
switch (slot) {
|
||||||
case 'A':
|
case 'A':
|
||||||
@@ -549,6 +571,17 @@ export default function ThreePanelLayout() {
|
|||||||
openNodeFromSlot(nodeId, 'A');
|
openNodeFromSlot(nodeId, 'A');
|
||||||
}, [openNodeFromSlot]);
|
}, [openNodeFromSlot]);
|
||||||
|
|
||||||
|
const handleContextSelect = useCallback((slot: SlotId, contextId: number | null, _contextName?: string | null) => {
|
||||||
|
setBrowseContextFilters((prev) => ({ ...prev, [slot]: contextId }));
|
||||||
|
setActiveContextId(contextId);
|
||||||
|
|
||||||
|
if (contextId == null) return;
|
||||||
|
|
||||||
|
setPanelExpanded(slot, true);
|
||||||
|
getSlotSetter(slot)({ type: 'views' });
|
||||||
|
setActivePane(slot);
|
||||||
|
}, [getSlotSetter, setActiveContextId, setPanelExpanded]);
|
||||||
|
|
||||||
const handleDimensionPaneSelect = useCallback((slot: SlotId, dimensionName: string | null) => {
|
const handleDimensionPaneSelect = useCallback((slot: SlotId, dimensionName: string | null) => {
|
||||||
setBrowseDimensionFilters((prev) => ({ ...prev, [slot]: dimensionName }));
|
setBrowseDimensionFilters((prev) => ({ ...prev, [slot]: dimensionName }));
|
||||||
setActiveDimension(dimensionName);
|
setActiveDimension(dimensionName);
|
||||||
@@ -565,7 +598,7 @@ export default function ThreePanelLayout() {
|
|||||||
const response = await fetch('/api/quick-add', {
|
const response = await fetch('/api/quick-add', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ input, mode, description }),
|
body: JSON.stringify({ input, mode, description, contextId: activeContextId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -591,7 +624,7 @@ export default function ThreePanelLayout() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ThreePanelLayout] Quick Add error:', error);
|
console.error('[ThreePanelLayout] Quick Add error:', error);
|
||||||
}
|
}
|
||||||
}, [openPaneSingleton]);
|
}, [activeContextId, openPaneSingleton]);
|
||||||
|
|
||||||
const handleCloseSlotA = useCallback(() => {
|
const handleCloseSlotA = useCallback(() => {
|
||||||
setSlotA(null);
|
setSlotA(null);
|
||||||
@@ -619,11 +652,20 @@ export default function ThreePanelLayout() {
|
|||||||
setActivePane(slot);
|
setActivePane(slot);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'open-context':
|
||||||
|
handleContextSelect(action.targetSlot ?? slot, action.contextId, action.contextName);
|
||||||
|
break;
|
||||||
|
case 'open-dimension':
|
||||||
|
setBrowseDimensionFilters((prev) => ({ ...prev, [action.targetSlot ?? slot]: action.dimension }));
|
||||||
|
setActiveDimension(action.dimension);
|
||||||
|
setSingletonPaneInSlot(action.targetSlot ?? slot, 'views');
|
||||||
|
setActivePane(action.targetSlot ?? slot);
|
||||||
|
break;
|
||||||
case 'open-node':
|
case 'open-node':
|
||||||
openNodeFromSlot(action.nodeId, slot);
|
openNodeFromSlot(action.nodeId, slot);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [openNodeFromSlot, setSingletonPaneInSlot]);
|
}, [handleContextSelect, openNodeFromSlot, setActiveDimension, setSingletonPaneInSlot]);
|
||||||
|
|
||||||
const handleSearchNodeSelect = useCallback((nodeId: number) => {
|
const handleSearchNodeSelect = useCallback((nodeId: number) => {
|
||||||
handleNodeSelect(nodeId, false);
|
handleNodeSelect(nodeId, false);
|
||||||
@@ -758,6 +800,18 @@ export default function ThreePanelLayout() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'contexts':
|
||||||
|
return (
|
||||||
|
<ContextsPane
|
||||||
|
slot={slot}
|
||||||
|
isActive={isActive}
|
||||||
|
onPaneAction={(action) => handleSlotAction(slot, action)}
|
||||||
|
onCollapse={onCollapse}
|
||||||
|
onSwapPanes={handleSwapPanes}
|
||||||
|
onContextSelect={(contextId, contextName) => handleContextSelect(slot, contextId, contextName)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case 'dimensions':
|
case 'dimensions':
|
||||||
return (
|
return (
|
||||||
<DimensionsPane
|
<DimensionsPane
|
||||||
@@ -802,7 +856,16 @@ export default function ThreePanelLayout() {
|
|||||||
refreshToken={nodesPanelRefresh}
|
refreshToken={nodesPanelRefresh}
|
||||||
pendingNodes={pendingNodes}
|
pendingNodes={pendingNodes}
|
||||||
onDismissPending={(id) => setPendingNodes(prev => prev.filter(p => p.id !== id))}
|
onDismissPending={(id) => setPendingNodes(prev => prev.filter(p => p.id !== id))}
|
||||||
|
externalContextFilterId={browseContextFilters[slot]}
|
||||||
externalDimensionFilter={browseDimensionFilters[slot]}
|
externalDimensionFilter={browseDimensionFilters[slot]}
|
||||||
|
onContextFilterSelect={(contextId) => {
|
||||||
|
setBrowseContextFilters((prev) => ({ ...prev, [slot]: contextId }));
|
||||||
|
setActiveContextId(contextId);
|
||||||
|
}}
|
||||||
|
onClearExternalContextFilter={() => {
|
||||||
|
setBrowseContextFilters((prev) => ({ ...prev, [slot]: null }));
|
||||||
|
setActiveContextId(null);
|
||||||
|
}}
|
||||||
onClearExternalDimensionFilter={() => {
|
onClearExternalDimensionFilter={() => {
|
||||||
setBrowseDimensionFilters((prev) => ({ ...prev, [slot]: null }));
|
setBrowseDimensionFilters((prev) => ({ ...prev, [slot]: null }));
|
||||||
setActiveDimension(null);
|
setActiveDimension(null);
|
||||||
@@ -985,6 +1048,15 @@ export default function ThreePanelLayout() {
|
|||||||
onRefreshClick={handleRefreshAll}
|
onRefreshClick={handleRefreshAll}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onThemeToggle={toggleTheme}
|
onThemeToggle={toggleTheme}
|
||||||
|
contexts={availableContexts}
|
||||||
|
onContextQuickSelect={(contextId, contextName) => {
|
||||||
|
const target = openPaneSingleton('views', 'A');
|
||||||
|
setBrowseContextFilters((prev) => ({ ...prev, [target]: contextId }));
|
||||||
|
setBrowseDimensionFilters((prev) => ({ ...prev, [target]: null }));
|
||||||
|
setActiveContextId(contextId);
|
||||||
|
setActivePane(target);
|
||||||
|
void contextName;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Folder } from 'lucide-react';
|
||||||
|
import type { ContextSummary } from '@/types/database';
|
||||||
|
import PaneHeader from './PaneHeader';
|
||||||
|
import type { ContextsPaneProps } from './types';
|
||||||
|
|
||||||
|
export default function ContextsPane({
|
||||||
|
slot,
|
||||||
|
onCollapse,
|
||||||
|
onSwapPanes,
|
||||||
|
tabBar,
|
||||||
|
onContextSelect,
|
||||||
|
}: ContextsPaneProps) {
|
||||||
|
const [contexts, setContexts] = useState<ContextSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/contexts');
|
||||||
|
const payload = await response.json();
|
||||||
|
if (response.ok && payload.success) {
|
||||||
|
setContexts(payload.data || []);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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: 'auto', padding: '12px' }}>
|
||||||
|
<div style={{ maxWidth: '980px', margin: '0 auto', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{loading ? (
|
||||||
|
<div style={emptyStateStyle}>Loading contexts...</div>
|
||||||
|
) : contexts.length === 0 ? (
|
||||||
|
<div style={emptyStateStyle}>No contexts yet.</div>
|
||||||
|
) : (
|
||||||
|
contexts.map((context) => (
|
||||||
|
<button
|
||||||
|
key={context.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onContextSelect?.(context.id, context.name)}
|
||||||
|
style={rowStyle}
|
||||||
|
>
|
||||||
|
<div style={iconWrapStyle}>
|
||||||
|
<Folder size={17} />
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 0, flex: 1, textAlign: 'left' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--rah-text-primary)' }}>{context.name}</span>
|
||||||
|
<span style={countStyle}>{context.count}</span>
|
||||||
|
</div>
|
||||||
|
{context.description ? (
|
||||||
|
<div style={{ marginTop: '4px', fontSize: '12px', color: 'var(--rah-text-secondary)', lineHeight: 1.45 }}>
|
||||||
|
{context.description}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '12px 14px',
|
||||||
|
border: '1px solid var(--rah-border)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: 'var(--rah-bg-card)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconWrapStyle: React.CSSProperties = {
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '10px',
|
||||||
|
border: '1px solid var(--rah-border)',
|
||||||
|
background: 'var(--rah-bg-panel)',
|
||||||
|
color: 'var(--rah-text-muted)',
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const countStyle: React.CSSProperties = {
|
||||||
|
fontSize: '10px',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
padding: '3px 7px',
|
||||||
|
borderRadius: '999px',
|
||||||
|
border: '1px solid var(--rah-border)',
|
||||||
|
background: 'var(--rah-bg-panel)',
|
||||||
|
color: 'var(--rah-text-muted)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyStateStyle: React.CSSProperties = {
|
||||||
|
border: '1px dashed var(--rah-border-strong)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
color: 'var(--rah-text-muted)',
|
||||||
|
fontSize: '13px',
|
||||||
|
background: 'var(--rah-bg-card)',
|
||||||
|
};
|
||||||
@@ -17,6 +17,7 @@ export default function NodePane({
|
|||||||
onPaneAction,
|
onPaneAction,
|
||||||
onCollapse,
|
onCollapse,
|
||||||
onSwapPanes,
|
onSwapPanes,
|
||||||
|
tabBar,
|
||||||
openTabs,
|
openTabs,
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabSelect,
|
onTabSelect,
|
||||||
@@ -92,10 +93,10 @@ export default function NodePane({
|
|||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes}>
|
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar ? tabBar : (
|
||||||
{/* Tabs rendered inline */}
|
/* Legacy node tabs (fallback when no tabBar prop) */
|
||||||
{openTabs.length === 0 ? (
|
openTabs.length === 0 ? (
|
||||||
<span style={{ fontSize: '12px', color: 'var(--rah-text-muted)' }}>No tabs open</span>
|
<span style={{ fontSize: '12px', color: '#666' }}>No tabs open</span>
|
||||||
) : (
|
) : (
|
||||||
openTabs.map((tabId) => {
|
openTabs.map((tabId) => {
|
||||||
const title = nodeTitles[tabId] || 'Loading...';
|
const title = nodeTitles[tabId] || 'Loading...';
|
||||||
@@ -118,7 +119,7 @@ export default function NodePane({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '4px',
|
gap: '4px',
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
background: isActiveTab ? 'var(--rah-bg-active)' : 'transparent',
|
background: isActiveTab ? '#1f1f1f' : 'transparent',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'grab',
|
cursor: 'grab',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
@@ -128,7 +129,7 @@ export default function NodePane({
|
|||||||
onClick={() => onTabSelect(tabId)}
|
onClick={() => onTabSelect(tabId)}
|
||||||
style={{
|
style={{
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: isActiveTab ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
|
color: isActiveTab ? '#fff' : '#888',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -145,23 +146,23 @@ export default function NodePane({
|
|||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: 'var(--rah-text-muted)',
|
color: '#666',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
padding: '0 2px',
|
padding: '0 2px',
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--rah-text-active)'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)
|
||||||
</PaneHeader>
|
)} />
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||||
<FocusPanel
|
<FocusPanel
|
||||||
@@ -171,11 +172,8 @@ export default function NodePane({
|
|||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
onTabClose={onTabClose}
|
onTabClose={onTabClose}
|
||||||
refreshTrigger={refreshTrigger}
|
refreshTrigger={refreshTrigger}
|
||||||
onReorderTabs={onReorderTabs}
|
|
||||||
onOpenInOtherSlot={onOpenInOtherSlot}
|
|
||||||
onTextSelect={onTextSelect}
|
onTextSelect={onTextSelect}
|
||||||
highlightedPassage={highlightedPassage}
|
highlightedPassage={highlightedPassage}
|
||||||
hideTabBar
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -186,8 +184,8 @@ export default function NodePane({
|
|||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: contextMenu.y,
|
top: contextMenu.y,
|
||||||
left: contextMenu.x,
|
left: contextMenu.x,
|
||||||
background: 'var(--rah-bg-active)',
|
background: '#1a1a1a',
|
||||||
border: '1px solid var(--rah-border-strong)',
|
border: '1px solid #2a2a2a',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
padding: '4px',
|
padding: '4px',
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
@@ -211,18 +209,18 @@ export default function NodePane({
|
|||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
color: 'var(--rah-text-secondary)',
|
color: '#ccc',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = 'var(--rah-bg-active)';
|
e.currentTarget.style.background = '#2a2a2a';
|
||||||
e.currentTarget.style.color = 'var(--rah-text-active)';
|
e.currentTarget.style.color = '#fff';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.background = 'transparent';
|
e.currentTarget.style.background = 'transparent';
|
||||||
e.currentTarget.style.color = 'var(--rah-text-secondary)';
|
e.currentTarget.style.color = '#ccc';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: '14px' }}>↗</span>
|
<span style={{ fontSize: '14px' }}>↗</span>
|
||||||
@@ -243,18 +241,18 @@ export default function NodePane({
|
|||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
color: 'var(--rah-text-secondary)',
|
color: '#ccc',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = 'var(--rah-bg-active)';
|
e.currentTarget.style.background = '#2a2a2a';
|
||||||
e.currentTarget.style.color = 'var(--rah-text-active)';
|
e.currentTarget.style.color = '#fff';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.background = 'transparent';
|
e.currentTarget.style.background = 'transparent';
|
||||||
e.currentTarget.style.color = 'var(--rah-text-secondary)';
|
e.currentTarget.style.color = '#ccc';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: '14px' }}>×</span>
|
<span style={{ fontSize: '14px' }}>×</span>
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export interface ViewsPaneProps extends BasePaneProps {
|
|||||||
refreshToken?: number;
|
refreshToken?: number;
|
||||||
pendingNodes?: PendingNode[];
|
pendingNodes?: PendingNode[];
|
||||||
onDismissPending?: (id: string) => void;
|
onDismissPending?: (id: string) => void;
|
||||||
|
externalContextFilterId?: number | null;
|
||||||
externalDimensionFilter?: string | null;
|
externalDimensionFilter?: string | null;
|
||||||
|
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||||
|
onClearExternalContextFilter?: () => void;
|
||||||
onClearExternalDimensionFilter?: () => void;
|
onClearExternalDimensionFilter?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +31,10 @@ export default function ViewsPane({
|
|||||||
refreshToken,
|
refreshToken,
|
||||||
pendingNodes,
|
pendingNodes,
|
||||||
onDismissPending,
|
onDismissPending,
|
||||||
|
externalContextFilterId,
|
||||||
externalDimensionFilter,
|
externalDimensionFilter,
|
||||||
|
onContextFilterSelect,
|
||||||
|
onClearExternalContextFilter,
|
||||||
onClearExternalDimensionFilter,
|
onClearExternalDimensionFilter,
|
||||||
}: ViewsPaneProps) {
|
}: ViewsPaneProps) {
|
||||||
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
|
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
|
||||||
@@ -58,7 +64,10 @@ export default function ViewsPane({
|
|||||||
refreshToken={refreshToken}
|
refreshToken={refreshToken}
|
||||||
pendingNodes={pendingNodes}
|
pendingNodes={pendingNodes}
|
||||||
onDismissPending={onDismissPending}
|
onDismissPending={onDismissPending}
|
||||||
|
externalContextFilterId={externalContextFilterId}
|
||||||
externalDimensionFilter={externalDimensionFilter}
|
externalDimensionFilter={externalDimensionFilter}
|
||||||
|
onContextFilterSelect={onContextFilterSelect}
|
||||||
|
onClearExternalContextFilter={onClearExternalContextFilter}
|
||||||
onClearExternalDimensionFilter={onClearExternalDimensionFilter}
|
onClearExternalDimensionFilter={onClearExternalDimensionFilter}
|
||||||
toolbarHost={toolbarHost}
|
toolbarHost={toolbarHost}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { default as NodePane } from './NodePane';
|
export { default as NodePane } from './NodePane';
|
||||||
|
export { default as ContextsPane } from './ContextsPane';
|
||||||
export { default as DimensionsPane } from './DimensionsPane';
|
export { default as DimensionsPane } from './DimensionsPane';
|
||||||
export { default as MapPane } from './MapPane';
|
export { default as MapPane } from './MapPane';
|
||||||
export { default as ViewsPane } from './ViewsPane';
|
export { default as ViewsPane } from './ViewsPane';
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export type AgentDelegation = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Pane types (chat removed in rah-light)
|
// Pane types (chat removed in rah-light)
|
||||||
export type PaneType = 'node' | 'dimensions' | 'map' | 'views' | 'table' | 'skills';
|
export type PaneType = 'node' | 'contexts' | 'dimensions' | 'map' | 'views' | 'table' | 'skills';
|
||||||
|
|
||||||
// State for each slot
|
// State for each slot
|
||||||
export interface SlotState {
|
export interface SlotState {
|
||||||
@@ -34,6 +34,7 @@ export interface SlotState {
|
|||||||
// Actions panes can emit to the layout
|
// Actions panes can emit to the layout
|
||||||
export type PaneAction =
|
export type PaneAction =
|
||||||
| { type: 'open-node'; nodeId: number; targetSlot?: SlotId }
|
| { 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: 'open-dimension'; dimension: string; targetSlot?: SlotId }
|
||||||
| { type: 'switch-pane-type'; paneType: PaneType }
|
| { type: 'switch-pane-type'; paneType: PaneType }
|
||||||
| { type: 'close-pane' };
|
| { type: 'close-pane' };
|
||||||
@@ -90,10 +91,17 @@ export interface ViewsPaneProps extends BasePaneProps {
|
|||||||
onNodeClick: (nodeId: number) => void;
|
onNodeClick: (nodeId: number) => void;
|
||||||
onNodeOpenInOtherPane?: (nodeId: number) => void;
|
onNodeOpenInOtherPane?: (nodeId: number) => void;
|
||||||
refreshToken?: number;
|
refreshToken?: number;
|
||||||
|
externalContextFilterId?: number | null;
|
||||||
externalDimensionFilter?: string | null;
|
externalDimensionFilter?: string | null;
|
||||||
|
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||||
|
onClearExternalContextFilter?: () => void;
|
||||||
onClearExternalDimensionFilter?: () => void;
|
onClearExternalDimensionFilter?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContextsPaneProps extends BasePaneProps {
|
||||||
|
onContextSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
// TablePane specific props
|
// TablePane specific props
|
||||||
export interface TablePaneProps extends BasePaneProps {
|
export interface TablePaneProps extends BasePaneProps {
|
||||||
onNodeClick: (nodeId: number) => void;
|
onNodeClick: (nodeId: number) => void;
|
||||||
@@ -113,6 +121,7 @@ export interface PaneHeaderProps {
|
|||||||
// Labels for pane types
|
// Labels for pane types
|
||||||
export const PANE_LABELS: Record<PaneType, string> = {
|
export const PANE_LABELS: Record<PaneType, string> = {
|
||||||
node: 'Nodes',
|
node: 'Nodes',
|
||||||
|
contexts: 'Contexts',
|
||||||
dimensions: 'Dimensions',
|
dimensions: 'Dimensions',
|
||||||
map: 'Map',
|
map: 'Map',
|
||||||
views: 'Feed',
|
views: 'Feed',
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default function ContextViewer() {
|
|||||||
return (
|
return (
|
||||||
<div style={containerStyle}>
|
<div style={containerStyle}>
|
||||||
<p style={descStyle}>
|
<p style={descStyle}>
|
||||||
Top 10 most-connected nodes are added to background context for tool execution.
|
Context summaries and anchor nodes are used first. Global hub nodes remain secondary diagnostics in background context.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Toggle */}
|
{/* Toggle */}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Node } from '@/types/database';
|
import { Node } from '@/types/database';
|
||||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||||
|
import { getNodeProcessedState } from '@/services/nodes/metadata';
|
||||||
|
|
||||||
interface GridViewProps {
|
interface GridViewProps {
|
||||||
nodes: Node[];
|
nodes: Node[];
|
||||||
@@ -43,7 +44,11 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
|||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||||
gap: '12px'
|
gap: '12px'
|
||||||
}}>
|
}}>
|
||||||
{nodes.map(node => (
|
{nodes.map(node => {
|
||||||
|
const processedState = getNodeProcessedState(node.metadata);
|
||||||
|
const isProcessed = processedState === 'processed';
|
||||||
|
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={node.id}
|
key={node.id}
|
||||||
onClick={() => onNodeClick(node.id)}
|
onClick={() => onNodeClick(node.id)}
|
||||||
@@ -51,13 +56,14 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
background: '#0a0a0a',
|
background: isProcessed ? 'rgba(34, 58, 42, 0.45)' : '#0a0a0a',
|
||||||
border: '1px solid #1a1a1a',
|
border: `1px solid ${isProcessed ? 'rgba(74, 222, 128, 0.28)' : '#1a1a1a'}`,
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
minHeight: '140px'
|
minHeight: '140px',
|
||||||
|
opacity: isProcessed ? 0.84 : 1
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = '#111';
|
e.currentTarget.style.background = '#111';
|
||||||
@@ -103,6 +109,15 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '10px',
|
||||||
|
fontSize: '10px',
|
||||||
|
color: isProcessed ? '#86efac' : '#888',
|
||||||
|
textTransform: 'lowercase'
|
||||||
|
}}>
|
||||||
|
{processedState}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Description or Content Preview */}
|
{/* Description or Content Preview */}
|
||||||
{(node.description || node.source) && (
|
{(node.description || node.source) && (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -154,7 +169,8 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Node } from '@/types/database';
|
|||||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||||
import { formatRelativeDate } from '@/utils/formatDate';
|
import { formatRelativeDate } from '@/utils/formatDate';
|
||||||
|
import { getNodeProcessedState } from '@/services/nodes/metadata';
|
||||||
|
|
||||||
interface ListViewProps {
|
interface ListViewProps {
|
||||||
nodes: Node[];
|
nodes: Node[];
|
||||||
@@ -48,7 +49,11 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
|||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
padding: '8px'
|
padding: '8px'
|
||||||
}}>
|
}}>
|
||||||
{nodes.map(node => (
|
{nodes.map(node => {
|
||||||
|
const processedState = getNodeProcessedState(node.metadata);
|
||||||
|
const isProcessed = processedState === 'processed';
|
||||||
|
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={node.id}
|
key={node.id}
|
||||||
onClick={() => onNodeClick(node.id)}
|
onClick={() => onNodeClick(node.id)}
|
||||||
@@ -59,13 +64,14 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
|||||||
gap: '12px',
|
gap: '12px',
|
||||||
padding: '12px',
|
padding: '12px',
|
||||||
marginBottom: '4px',
|
marginBottom: '4px',
|
||||||
background: 'var(--rah-bg-base)',
|
background: isProcessed ? 'var(--rah-bg-subtle, var(--rah-bg-base))' : 'var(--rah-bg-base)',
|
||||||
border: '1px solid var(--rah-border)',
|
border: '1px solid var(--rah-border)',
|
||||||
borderLeft: '2px solid var(--rah-border-stronger)',
|
borderLeft: `2px solid ${isProcessed ? 'var(--rah-accent-green, #4ade80)' : 'var(--rah-border-stronger)'}`,
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
transition: 'all 0.15s ease'
|
transition: 'all 0.15s ease',
|
||||||
|
opacity: isProcessed ? 0.82 : 1
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = 'var(--rah-bg-surface)';
|
e.currentTarget.style.background = 'var(--rah-bg-surface)';
|
||||||
@@ -132,6 +138,16 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
|||||||
gap: '12px',
|
gap: '12px',
|
||||||
flexWrap: 'wrap'
|
flexWrap: 'wrap'
|
||||||
}}>
|
}}>
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
background: isProcessed ? 'rgba(74, 222, 128, 0.10)' : 'var(--rah-bg-active)',
|
||||||
|
border: `1px solid ${isProcessed ? 'rgba(74, 222, 128, 0.35)' : 'var(--rah-border-strong)'}`,
|
||||||
|
borderRadius: '999px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: isProcessed ? 'var(--rah-accent-green, #4ade80)' : 'var(--rah-text-muted)'
|
||||||
|
}}>
|
||||||
|
{processedState}
|
||||||
|
</span>
|
||||||
{/* Dimensions */}
|
{/* Dimensions */}
|
||||||
{node.dimensions && node.dimensions.length > 0 && (
|
{node.dimensions && node.dimensions.length > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -186,7 +202,8 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,25 +2,51 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Filter, ChevronDown, X, ArrowUpDown, GripVertical, Inbox } from 'lucide-react';
|
import { Filter, ChevronDown, X, ArrowUpDown, GripVertical, Inbox, Check } from 'lucide-react';
|
||||||
import type { Node } from '@/types/database';
|
import type { ContextSummary, Node } from '@/types/database';
|
||||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||||
import { usePersistentState } from '@/hooks/usePersistentState';
|
import { usePersistentState } from '@/hooks/usePersistentState';
|
||||||
import type { PendingNode } from '../layout/ThreePanelLayout';
|
import type { PendingNode } from '@/components/layout/ThreePanelLayout';
|
||||||
import { formatRelativeDate } from '@/utils/formatDate';
|
import { getNodeProcessedState } from '@/services/nodes/metadata';
|
||||||
|
|
||||||
type SortOrder = 'updated' | 'edges' | 'created' | 'custom';
|
type SortOrder = 'updated' | 'edges' | 'created' | 'custom' | 'processed' | 'not_processed';
|
||||||
|
type ProcessedFilter = 'all' | 'processed' | 'not_processed';
|
||||||
|
|
||||||
const SORT_LABELS: Record<SortOrder, string> = {
|
const SORT_LABELS: Record<SortOrder, string> = {
|
||||||
updated: 'Updated',
|
updated: 'Updated',
|
||||||
edges: 'Edges',
|
edges: 'Edges',
|
||||||
created: 'Created',
|
created: 'Created',
|
||||||
custom: 'Custom',
|
custom: 'Custom',
|
||||||
|
processed: 'Processed',
|
||||||
|
not_processed: 'Not Processed',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DOCUMENT_MAX_WIDTH = '980px';
|
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 {
|
interface ColumnFilter {
|
||||||
id: string;
|
id: string;
|
||||||
dimension: string;
|
dimension: string;
|
||||||
@@ -33,91 +59,127 @@ interface DimensionSummary {
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ViewsOverlayProps {
|
||||||
|
onNodeClick: (nodeId: number) => void;
|
||||||
|
onNodeOpenInOtherPane?: (nodeId: number) => void;
|
||||||
|
refreshToken?: number;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const INPUT_TYPE_LABELS: Record<string, string> = {
|
const INPUT_TYPE_LABELS: Record<string, string> = {
|
||||||
youtube: 'Extracting YouTube video...',
|
youtube: 'Extracting YouTube video...',
|
||||||
website: 'Extracting webpage...',
|
website: 'Extracting webpage...',
|
||||||
pdf: 'Extracting PDF...',
|
pdf: 'Processing PDF...',
|
||||||
note: 'Creating note...',
|
note: 'Creating note...',
|
||||||
chat: 'Importing transcript...',
|
chat: 'Importing transcript...',
|
||||||
};
|
};
|
||||||
|
|
||||||
function PendingNodeCard({ pending, onDismiss }: { pending: PendingNode; onDismiss?: () => void }) {
|
function PendingNodeCard({ pending, onDismiss }: { pending: PendingNode; onDismiss?: () => void }) {
|
||||||
const isError = pending.status === 'error';
|
const isError = pending.status === 'error';
|
||||||
|
const label = isError
|
||||||
|
? pending.error || 'Processing failed'
|
||||||
|
: INPUT_TYPE_LABELS[pending.inputType] || 'Processing...';
|
||||||
|
|
||||||
|
// Truncate input for display
|
||||||
|
const displayInput = pending.input.length > 80
|
||||||
|
? pending.input.slice(0, 77) + '...'
|
||||||
|
: pending.input;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
|
style={{
|
||||||
padding: '10px 12px',
|
padding: '10px 12px',
|
||||||
background: 'transparent',
|
background: isError ? 'rgba(239, 68, 68, 0.04)' : 'rgba(34, 197, 94, 0.03)',
|
||||||
borderBottom: '1px solid var(--rah-bg-panel)',
|
borderBottom: '1px solid var(--rah-border)',
|
||||||
borderLeft: `3px solid ${isError ? '#ef4444' : '#22c55e'}`,
|
borderLeft: isError ? '3px solid rgba(239, 68, 68, 0.4)' : '3px solid rgba(34, 197, 94, 0.3)',
|
||||||
opacity: 0.8,
|
borderTop: '2px solid transparent',
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '32px',
|
width: '32px',
|
||||||
height: '32px',
|
height: '32px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
background: 'var(var(--rah-bg-panel))',
|
background: isError ? 'rgba(239, 68, 68, 0.1)' : 'var(--rah-bg-panel)',
|
||||||
border: `1px solid ${isError ? 'rgba(239,68,68,0.3)' : '#1f1f1f'}`,
|
border: isError ? '1px solid rgba(239, 68, 68, 0.2)' : '1px solid var(--rah-border)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2">
|
||||||
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15" />
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<span className="pending-spinner" />
|
<span className="pending-node-spinner" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '2px',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: isError ? '#ef4444' : '#888',
|
color: isError ? '#ef4444' : 'var(--rah-text-active)',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
}}>
|
}}>
|
||||||
{isError ? 'Failed' : (INPUT_TYPE_LABELS[pending.inputType] || 'Processing...')}
|
{displayInput}
|
||||||
</div>
|
</span>
|
||||||
<div style={{
|
|
||||||
fontSize: '11px',
|
|
||||||
color: 'var(var(--rah-text-muted))',
|
|
||||||
marginTop: '2px',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
}}>
|
|
||||||
{isError && pending.error ? pending.error : pending.input}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isError && onDismiss && (
|
{isError && onDismiss && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onDismiss(); }}
|
onClick={(e) => { e.stopPropagation(); onDismiss(); }}
|
||||||
style={{
|
style={{
|
||||||
padding: '4px',
|
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
color: 'var(var(--rah-text-muted))',
|
color: 'var(--rah-text-muted)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
borderRadius: '4px',
|
padding: '2px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
fontSize: '11px',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#555'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: isError ? 'rgba(239, 68, 68, 0.7)' : 'var(--rah-accent-green)',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
.pending-spinner {
|
.pending-node-spinner {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border: 2px solid #22c55e;
|
border: 2px solid var(--rah-accent-green);
|
||||||
border-top-color: transparent;
|
border-top-color: transparent;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: pendingSpin 0.8s linear infinite;
|
animation: pendingSpin 0.8s linear infinite;
|
||||||
@@ -163,24 +225,16 @@ function SkeletonCard() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ViewsOverlayProps {
|
|
||||||
onNodeClick: (nodeId: number) => void;
|
|
||||||
onNodeOpenInOtherPane?: (nodeId: number) => void;
|
|
||||||
refreshToken?: number;
|
|
||||||
pendingNodes?: PendingNode[];
|
|
||||||
onDismissPending?: (id: string) => void;
|
|
||||||
externalDimensionFilter?: string | null;
|
|
||||||
onClearExternalDimensionFilter?: () => void;
|
|
||||||
toolbarHost?: HTMLDivElement | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ViewsOverlay({
|
export default function ViewsOverlay({
|
||||||
onNodeClick,
|
onNodeClick,
|
||||||
onNodeOpenInOtherPane,
|
onNodeOpenInOtherPane,
|
||||||
refreshToken = 0,
|
refreshToken = 0,
|
||||||
pendingNodes,
|
pendingNodes,
|
||||||
onDismissPending,
|
onDismissPending,
|
||||||
|
externalContextFilterId = null,
|
||||||
externalDimensionFilter = null,
|
externalDimensionFilter = null,
|
||||||
|
onContextFilterSelect,
|
||||||
|
onClearExternalContextFilter,
|
||||||
onClearExternalDimensionFilter,
|
onClearExternalDimensionFilter,
|
||||||
toolbarHost,
|
toolbarHost,
|
||||||
}: ViewsOverlayProps) {
|
}: ViewsOverlayProps) {
|
||||||
@@ -189,6 +243,8 @@ export default function ViewsOverlay({
|
|||||||
// Dimensions for filter picker
|
// Dimensions for filter picker
|
||||||
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
|
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
|
||||||
const [dimensionsLoading, setDimensionsLoading] = useState(true);
|
const [dimensionsLoading, setDimensionsLoading] = useState(true);
|
||||||
|
const [contexts, setContexts] = useState<ContextSummary[]>([]);
|
||||||
|
const [contextsLoading, setContextsLoading] = useState(true);
|
||||||
|
|
||||||
// Sort order (persisted)
|
// Sort order (persisted)
|
||||||
const [sortOrder, setSortOrder] = usePersistentState<SortOrder>('ui.feedSortOrder', 'updated');
|
const [sortOrder, setSortOrder] = usePersistentState<SortOrder>('ui.feedSortOrder', 'updated');
|
||||||
@@ -205,14 +261,22 @@ export default function ViewsOverlay({
|
|||||||
const [filteredNodes, setFilteredNodes] = useState<Node[]>([]);
|
const [filteredNodes, setFilteredNodes] = useState<Node[]>([]);
|
||||||
const [filteredNodesLoading, setFilteredNodesLoading] = useState(false);
|
const [filteredNodesLoading, setFilteredNodesLoading] = useState(false);
|
||||||
const [showFilterPicker, setShowFilterPicker] = useState(false);
|
const [showFilterPicker, setShowFilterPicker] = useState(false);
|
||||||
|
const [showDimensionPicker, setShowDimensionPicker] = useState(false);
|
||||||
const [filterSearchQuery, setFilterSearchQuery] = useState('');
|
const [filterSearchQuery, setFilterSearchQuery] = useState('');
|
||||||
const [showSortDropdown, setShowSortDropdown] = useState(false);
|
const [showSortDropdown, setShowSortDropdown] = useState(false);
|
||||||
|
|
||||||
|
const processedFilter: ProcessedFilter = sortOrder === 'processed'
|
||||||
|
? 'processed'
|
||||||
|
: sortOrder === 'not_processed'
|
||||||
|
? 'not_processed'
|
||||||
|
: 'all';
|
||||||
|
|
||||||
// Derive selectedFilters for backward compatibility (unique dimensions)
|
// Derive selectedFilters for backward compatibility (unique dimensions)
|
||||||
const selectedFilters = useMemo(() => {
|
const selectedFilters = useMemo(() => {
|
||||||
if (externalDimensionFilter) {
|
if (externalDimensionFilter) {
|
||||||
return [externalDimensionFilter];
|
return [externalDimensionFilter];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...new Set(columns.map(c => c.dimension))];
|
return [...new Set(columns.map(c => c.dimension))];
|
||||||
}, [columns, externalDimensionFilter]);
|
}, [columns, externalDimensionFilter]);
|
||||||
|
|
||||||
@@ -242,12 +306,35 @@ export default function ViewsOverlay({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const fetchContexts = useCallback(async () => {
|
||||||
|
setContextsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/contexts');
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
throw new Error(data.error || 'Failed to fetch contexts');
|
||||||
|
}
|
||||||
|
setContexts(data.data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching contexts:', error);
|
||||||
|
} finally {
|
||||||
|
setContextsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyProcessedFilter = useCallback((nodes: Node[]) => {
|
||||||
|
if (processedFilter === 'all') return nodes;
|
||||||
|
return nodes.filter((node) => getNodeProcessedState(node.metadata) === processedFilter);
|
||||||
|
}, [processedFilter]);
|
||||||
|
|
||||||
const fetchAllNodes = useCallback(async () => {
|
const fetchAllNodes = useCallback(async () => {
|
||||||
setFilteredNodesLoading(true);
|
setFilteredNodesLoading(true);
|
||||||
try {
|
try {
|
||||||
// Custom sort fetches with 'updated' then reorders client-side
|
// Custom sort fetches with 'updated' then reorders client-side
|
||||||
const apiSort = sortOrder === 'custom' ? 'updated' : sortOrder;
|
const apiSort = sortOrder === 'custom' || sortOrder === 'processed' || sortOrder === 'not_processed'
|
||||||
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}`);
|
? 'updated'
|
||||||
|
: sortOrder;
|
||||||
|
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}${externalContextFilterId ? `&contextId=${externalContextFilterId}` : ''}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok || !data.success) {
|
if (!response.ok || !data.success) {
|
||||||
throw new Error(data.error || 'Failed to fetch nodes');
|
throw new Error(data.error || 'Failed to fetch nodes');
|
||||||
@@ -266,16 +353,16 @@ export default function ViewsOverlay({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ordered.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
ordered.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
||||||
setFilteredNodes([...ordered, ...unordered]);
|
setFilteredNodes(applyProcessedFilter([...ordered, ...unordered]));
|
||||||
} else {
|
} else {
|
||||||
setFilteredNodes(nodes);
|
setFilteredNodes(applyProcessedFilter(nodes));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching nodes:', error);
|
console.error('Error fetching nodes:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setFilteredNodesLoading(false);
|
setFilteredNodesLoading(false);
|
||||||
}
|
}
|
||||||
}, [sortOrder, customOrder]);
|
}, [sortOrder, customOrder, applyProcessedFilter, externalContextFilterId]);
|
||||||
|
|
||||||
const fetchFilteredNodes = useCallback(async (filters: string[]) => {
|
const fetchFilteredNodes = useCallback(async (filters: string[]) => {
|
||||||
if (filters.length === 0) {
|
if (filters.length === 0) {
|
||||||
@@ -284,8 +371,10 @@ export default function ViewsOverlay({
|
|||||||
}
|
}
|
||||||
setFilteredNodesLoading(true);
|
setFilteredNodesLoading(true);
|
||||||
try {
|
try {
|
||||||
const apiSort = sortOrder === 'custom' ? 'updated' : sortOrder;
|
const apiSort = sortOrder === 'custom' || sortOrder === 'processed' || sortOrder === 'not_processed'
|
||||||
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}&dimensions=${encodeURIComponent(filters.join(','))}&dimensionsMatch=all`);
|
? '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();
|
const data = await response.json();
|
||||||
if (!response.ok || !data.success) {
|
if (!response.ok || !data.success) {
|
||||||
throw new Error(data.error || 'Failed to fetch nodes');
|
throw new Error(data.error || 'Failed to fetch nodes');
|
||||||
@@ -303,16 +392,16 @@ export default function ViewsOverlay({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ordered.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
ordered.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
||||||
setFilteredNodes([...ordered, ...unordered]);
|
setFilteredNodes(applyProcessedFilter([...ordered, ...unordered]));
|
||||||
} else {
|
} else {
|
||||||
setFilteredNodes(nodes);
|
setFilteredNodes(applyProcessedFilter(nodes));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching filtered nodes:', error);
|
console.error('Error fetching filtered nodes:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setFilteredNodesLoading(false);
|
setFilteredNodesLoading(false);
|
||||||
}
|
}
|
||||||
}, [fetchAllNodes, sortOrder, customOrder]);
|
}, [fetchAllNodes, sortOrder, customOrder, applyProcessedFilter, externalContextFilterId]);
|
||||||
|
|
||||||
// Stringify filters for stable dependency
|
// Stringify filters for stable dependency
|
||||||
const filtersKey = selectedFilters.join(',');
|
const filtersKey = selectedFilters.join(',');
|
||||||
@@ -322,6 +411,10 @@ export default function ViewsOverlay({
|
|||||||
fetchDimensions();
|
fetchDimensions();
|
||||||
}, [fetchDimensions]);
|
}, [fetchDimensions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchContexts();
|
||||||
|
}, [fetchContexts]);
|
||||||
|
|
||||||
// Fetch nodes on mount and when filters/sort/refreshToken change
|
// Fetch nodes on mount and when filters/sort/refreshToken change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (refreshToken > 0) {
|
if (refreshToken > 0) {
|
||||||
@@ -333,20 +426,22 @@ export default function ViewsOverlay({
|
|||||||
fetchAllNodes();
|
fetchAllNodes();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filtersKey, sortOrder, refreshToken]);
|
}, [filtersKey, sortOrder, refreshToken, externalContextFilterId]);
|
||||||
|
|
||||||
// Also refresh dimensions when data changes (for filter picker counts)
|
// Also refresh dimensions when data changes (for filter picker counts)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (refreshToken > 0) {
|
if (refreshToken > 0) {
|
||||||
fetchDimensions();
|
fetchDimensions();
|
||||||
|
fetchContexts();
|
||||||
}
|
}
|
||||||
}, [refreshToken, fetchDimensions]);
|
}, [refreshToken, fetchDimensions, fetchContexts]);
|
||||||
|
|
||||||
// Column management
|
// Column management
|
||||||
const addColumn = (dimension: string) => {
|
const addColumn = (dimension: string) => {
|
||||||
if (externalDimensionFilter) {
|
if (externalDimensionFilter) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newColumn: ColumnFilter = {
|
const newColumn: ColumnFilter = {
|
||||||
id: `col-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
id: `col-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
dimension
|
dimension
|
||||||
@@ -361,6 +456,7 @@ export default function ViewsOverlay({
|
|||||||
onClearExternalDimensionFilter?.();
|
onClearExternalDimensionFilter?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const idx = columns.findIndex(c => c.dimension === dimension);
|
const idx = columns.findIndex(c => c.dimension === dimension);
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
setColumns(columns.filter((_, i) => i !== idx));
|
setColumns(columns.filter((_, i) => i !== idx));
|
||||||
@@ -368,9 +464,14 @@ export default function ViewsOverlay({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
|
if (externalContextFilterId) {
|
||||||
|
onClearExternalContextFilter?.();
|
||||||
|
}
|
||||||
|
|
||||||
if (externalDimensionFilter) {
|
if (externalDimensionFilter) {
|
||||||
onClearExternalDimensionFilter?.();
|
onClearExternalDimensionFilter?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
setColumns([]);
|
setColumns([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -381,6 +482,7 @@ export default function ViewsOverlay({
|
|||||||
|
|
||||||
// Close dropdowns on outside click
|
// Close dropdowns on outside click
|
||||||
const filterPickerRef = useRef<HTMLDivElement>(null);
|
const filterPickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dimensionPickerRef = useRef<HTMLDivElement>(null);
|
||||||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -389,13 +491,16 @@ export default function ViewsOverlay({
|
|||||||
setShowFilterPicker(false);
|
setShowFilterPicker(false);
|
||||||
setFilterSearchQuery('');
|
setFilterSearchQuery('');
|
||||||
}
|
}
|
||||||
|
if (showDimensionPicker && dimensionPickerRef.current && !dimensionPickerRef.current.contains(e.target as HTMLElement)) {
|
||||||
|
setShowDimensionPicker(false);
|
||||||
|
}
|
||||||
if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) {
|
if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) {
|
||||||
setShowSortDropdown(false);
|
setShowSortDropdown(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, [showFilterPicker, showSortDropdown]);
|
}, [showDimensionPicker, showFilterPicker, showSortDropdown]);
|
||||||
|
|
||||||
// Reorder handlers
|
// Reorder handlers
|
||||||
const handleReorderDrop = useCallback((dropIdx: number) => {
|
const handleReorderDrop = useCallback((dropIdx: number) => {
|
||||||
@@ -414,12 +519,60 @@ export default function ViewsOverlay({
|
|||||||
setReorderDropIndex(null);
|
setReorderDropIndex(null);
|
||||||
}, [reorderDragIndex, filteredNodes, setCustomOrder]);
|
}, [reorderDragIndex, filteredNodes, setCustomOrder]);
|
||||||
|
|
||||||
|
const toggleNodeProcessed = useCallback(async (node: Node) => {
|
||||||
|
const nextState = getNodeProcessedState(node.metadata) === 'processed' ? 'not_processed' : 'processed';
|
||||||
|
|
||||||
|
setFilteredNodes((prev) => prev.map((candidate) => (
|
||||||
|
candidate.id === node.id
|
||||||
|
? {
|
||||||
|
...candidate,
|
||||||
|
metadata: {
|
||||||
|
...(candidate.metadata && typeof candidate.metadata === 'object' ? candidate.metadata : {}),
|
||||||
|
state: nextState,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: candidate
|
||||||
|
)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/nodes/${node.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
metadata: {
|
||||||
|
state: nextState,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update processed state');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.node) {
|
||||||
|
setFilteredNodes((prev) => prev.map((candidate) => (
|
||||||
|
candidate.id === node.id ? result.node as Node : candidate
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating processed state from feed:', error);
|
||||||
|
if (selectedFilters.length > 0) {
|
||||||
|
void fetchFilteredNodes(selectedFilters);
|
||||||
|
} else {
|
||||||
|
void fetchAllNodes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [fetchAllNodes, fetchFilteredNodes, selectedFilters]);
|
||||||
|
|
||||||
// Render node card
|
// Render node card
|
||||||
const renderNodeCard = (node: Node, index: number) => {
|
const renderNodeCard = (node: Node, index: number) => {
|
||||||
const nodeIcon = getNodeIcon(node, dimensionIcons, 18);
|
const nodeIcon = getNodeIcon(node, dimensionIcons, 14);
|
||||||
const isCustomSort = sortOrder === 'custom';
|
const isCustomSort = sortOrder === 'custom';
|
||||||
const isDragSource = reorderDragIndex === index;
|
const isDragSource = reorderDragIndex === index;
|
||||||
const isDropTarget = reorderDropIndex === index;
|
const isDropTarget = reorderDropIndex === index;
|
||||||
|
const processedState = getNodeProcessedState(node.metadata);
|
||||||
|
const isProcessed = processedState === 'processed';
|
||||||
|
|
||||||
// Description preview — first meaningful line, truncated
|
// Description preview — first meaningful line, truncated
|
||||||
const descPreview = node.description && node.description.length > 10
|
const descPreview = node.description && node.description.length > 10
|
||||||
@@ -454,11 +607,11 @@ export default function ViewsOverlay({
|
|||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 12px',
|
padding: '10px 12px',
|
||||||
background: 'var(--rah-bg-base)',
|
background: isProcessed ? 'rgba(74, 222, 128, 0.045)' : 'var(--rah-bg-base)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.15s ease',
|
transition: 'all 0.15s ease',
|
||||||
border: '1px solid var(--rah-border)',
|
border: '1px solid var(--rah-border)',
|
||||||
borderLeft: '2px solid var(--rah-border-stronger)',
|
borderLeft: `2px solid ${isProcessed ? 'rgba(74, 222, 128, 0.6)' : 'var(--rah-border-stronger)'}`,
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
opacity: isDragSource ? 0.4 : 1,
|
opacity: isDragSource ? 0.4 : 1,
|
||||||
borderTop: isDropTarget ? '2px solid var(--rah-accent-green)' : '2px solid transparent',
|
borderTop: isDropTarget ? '2px solid var(--rah-accent-green)' : '2px solid transparent',
|
||||||
@@ -482,8 +635,9 @@ export default function ViewsOverlay({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
|
||||||
{/* Grip handle — only in custom sort mode */}
|
{/* Grip handle — only in custom sort mode */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||||
{isCustomSort && (
|
{isCustomSort && (
|
||||||
<div
|
<div
|
||||||
draggable
|
draggable
|
||||||
@@ -505,7 +659,6 @@ export default function ViewsOverlay({
|
|||||||
cursor: 'grab',
|
cursor: 'grab',
|
||||||
color: 'var(--rah-text-muted)',
|
color: 'var(--rah-text-muted)',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
alignSelf: 'center',
|
|
||||||
transition: 'color 0.15s ease',
|
transition: 'color 0.15s ease',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--rah-text-soft)'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--rah-text-soft)'; }}
|
||||||
@@ -515,28 +668,41 @@ export default function ViewsOverlay({
|
|||||||
<GripVertical size={14} />
|
<GripVertical size={14} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{
|
<button
|
||||||
width: '32px',
|
type="button"
|
||||||
height: '32px',
|
onClick={(e) => {
|
||||||
borderRadius: '8px',
|
e.stopPropagation();
|
||||||
background: 'var(--rah-bg-panel)',
|
void toggleNodeProcessed(node);
|
||||||
border: '1px solid var(--rah-border)',
|
}}
|
||||||
display: 'flex',
|
title="Toggle processed"
|
||||||
|
aria-label="Toggle processed"
|
||||||
|
style={{
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
borderRadius: '999px',
|
||||||
|
border: `1px solid ${isProcessed ? 'rgba(74, 222, 128, 0.55)' : 'var(--rah-border-strong)'}`,
|
||||||
|
background: isProcessed ? 'rgba(74, 222, 128, 0.16)' : 'var(--rah-bg-panel)',
|
||||||
|
color: isProcessed ? '#86efac' : 'transparent',
|
||||||
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
flexShrink: 0
|
flexShrink: 0,
|
||||||
}}>
|
cursor: 'pointer',
|
||||||
{nodeIcon}
|
transition: 'all 0.15s ease',
|
||||||
</div>
|
}}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
>
|
||||||
|
<Check size={11} strokeWidth={2.8} />
|
||||||
|
</button>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
marginBottom: descPreview ? '2px' : '4px'
|
minWidth: 0,
|
||||||
|
flex: 1,
|
||||||
}}>
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0, flex: 1 }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '14px',
|
fontSize: '13px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: 'var(--rah-text-active)',
|
color: 'var(--rah-text-active)',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
@@ -547,6 +713,50 @@ export default function ViewsOverlay({
|
|||||||
}}>
|
}}>
|
||||||
{node.title || 'Untitled'}
|
{node.title || 'Untitled'}
|
||||||
</span>
|
</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',
|
||||||
|
}}>
|
||||||
|
{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 && (
|
{node.edge_count != null && node.edge_count > 0 && (
|
||||||
<span style={{
|
<span style={{
|
||||||
minWidth: '18px',
|
minWidth: '18px',
|
||||||
@@ -566,71 +776,56 @@ export default function ViewsOverlay({
|
|||||||
{node.edge_count}
|
{node.edge_count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span style={{
|
|
||||||
fontSize: '11px',
|
|
||||||
color: 'var(--rah-text-muted)',
|
|
||||||
background: 'var(--rah-bg-panel)',
|
|
||||||
padding: '2px 6px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
#{node.id}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{descPreview && (
|
|
||||||
<div style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
color: 'var(--rah-text-muted)',
|
|
||||||
lineHeight: '1.4',
|
|
||||||
marginBottom: '4px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}>
|
|
||||||
{descPreview}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: '3px',
|
|
||||||
}}>
|
|
||||||
{node.dimensions && node.dimensions.length > 0 ? (
|
|
||||||
<>
|
|
||||||
{node.dimensions.slice(0, 3).map(d => (
|
|
||||||
<span
|
|
||||||
key={d}
|
|
||||||
style={{
|
|
||||||
fontSize: '11px',
|
|
||||||
padding: '2px 8px',
|
|
||||||
background: 'var(--rah-bg-active)',
|
|
||||||
border: '1px solid var(--rah-border-strong)',
|
|
||||||
color: 'var(--rah-text-base)',
|
|
||||||
borderRadius: '8px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{d}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{node.dimensions.length > 3 && (
|
|
||||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
|
|
||||||
+{node.dimensions.length - 3}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
marginTop: '6px',
|
minWidth: 0,
|
||||||
fontSize: '11px',
|
|
||||||
color: 'var(--rah-text-muted)',
|
|
||||||
}}>
|
}}>
|
||||||
<span>{formatRelativeDate(node.updated_at || node.created_at)}</span>
|
<div style={{
|
||||||
{node.edge_count != null && node.edge_count > 0 ? <span>{node.edge_count} connections</span> : null}
|
fontSize: '12px',
|
||||||
|
color: 'var(--rah-text-muted)',
|
||||||
|
lineHeight: '1.35',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
}}>
|
||||||
|
{descPreview || 'No description'}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '3px',
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: '46%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexShrink: 1,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{node.dimensions && node.dimensions.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{node.dimensions.map(d => (
|
||||||
|
<span
|
||||||
|
key={d}
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '2px 7px',
|
||||||
|
background: 'var(--rah-bg-active)',
|
||||||
|
border: '1px solid var(--rah-border-strong)',
|
||||||
|
color: 'var(--rah-text-base)',
|
||||||
|
borderRadius: '999px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -648,6 +843,119 @@ export default function ViewsOverlay({
|
|||||||
flexWrap: 'wrap'
|
flexWrap: 'wrap'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', flex: 1 }}>
|
<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={{
|
||||||
|
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'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{contexts.find((context) => context.id === externalContextFilterId)?.name || 'Context'}
|
||||||
|
<button
|
||||||
|
onClick={() => onClearExternalContextFilter?.()}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#5a9',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{selectedFilters.map(filter => (
|
{selectedFilters.map(filter => (
|
||||||
<div
|
<div
|
||||||
key={filter}
|
key={filter}
|
||||||
@@ -683,21 +991,12 @@ export default function ViewsOverlay({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{externalDimensionFilter && (
|
<div style={{ position: 'relative' }} ref={dimensionPickerRef}>
|
||||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
|
|
||||||
Sidebar filter
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ position: 'relative' }} ref={filterPickerRef}>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!externalDimensionFilter) {
|
setShowDimensionPicker(!showDimensionPicker);
|
||||||
setShowFilterPicker(!showFilterPicker);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={!!externalDimensionFilter}
|
title="Dimensions"
|
||||||
title="Filter (⌘F)"
|
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -706,16 +1005,14 @@ export default function ViewsOverlay({
|
|||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: '1px solid var(--rah-border)',
|
border: '1px solid var(--rah-border)',
|
||||||
borderRadius: '5px',
|
borderRadius: '5px',
|
||||||
color: externalDimensionFilter ? 'var(--rah-text-muted)' : 'var(--rah-text-soft)',
|
color: 'var(--rah-text-soft)',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
cursor: externalDimensionFilter ? 'not-allowed' : 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.15s ease'
|
transition: 'all 0.15s ease'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!externalDimensionFilter) {
|
|
||||||
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
|
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
|
||||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.background = 'transparent';
|
e.currentTarget.style.background = 'transparent';
|
||||||
@@ -723,11 +1020,11 @@ export default function ViewsOverlay({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Filter size={11} />
|
<Filter size={11} />
|
||||||
Filter
|
Dimension
|
||||||
|
<ChevronDown size={10} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Filter picker dropdown */}
|
{showDimensionPicker && (
|
||||||
{showFilterPicker && !externalDimensionFilter && (
|
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '100%',
|
top: '100%',
|
||||||
@@ -776,41 +1073,21 @@ export default function ViewsOverlay({
|
|||||||
<button
|
<button
|
||||||
key={d.dimension}
|
key={d.dimension}
|
||||||
onClick={() => addColumn(d.dimension)}
|
onClick={() => addColumn(d.dimension)}
|
||||||
style={{
|
style={pickerRowStyle}
|
||||||
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)'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||||
>
|
>
|
||||||
<span>{d.dimension}</span>
|
<span>{d.dimension}</span>
|
||||||
<span style={{
|
<span style={pickerCountStyle}>{d.count}</span>
|
||||||
color: 'var(--rah-text-muted)',
|
|
||||||
fontSize: '10px',
|
|
||||||
background: 'var(--rah-bg-active)',
|
|
||||||
padding: '1px 6px',
|
|
||||||
borderRadius: '10px',
|
|
||||||
}}>
|
|
||||||
{d.count}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{selectedFilters.length > 0 && (
|
{(selectedFilters.length > 0 || externalContextFilterId) && (
|
||||||
<button
|
<button
|
||||||
onClick={clearFilters}
|
onClick={clearFilters}
|
||||||
style={{
|
style={{
|
||||||
@@ -827,7 +1104,6 @@ export default function ViewsOverlay({
|
|||||||
Clear all
|
Clear all
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sort dropdown */}
|
{/* Sort dropdown */}
|
||||||
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
|
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: Audit
|
name: Audit
|
||||||
description: "Run a structured audit of graph quality, skill quality, and operational consistency."
|
description: "Use for structured review, QA, cleanup, or governance checks across graph quality, skill quality, and operational consistency."
|
||||||
when_to_use: "User asks for review, QA, cleanup, or governance checks."
|
|
||||||
when_not_to_use: "Simple one-off write/read requests."
|
|
||||||
success_criteria: "Findings are prioritized, concrete, and tied to actionable fixes."
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Audit
|
# Audit
|
||||||
@@ -27,3 +24,5 @@ success_criteria: "Findings are prioritized, concrete, and tied to actionable fi
|
|||||||
- Prefer specific evidence over generic commentary.
|
- Prefer specific evidence over generic commentary.
|
||||||
- Propose the smallest high-leverage fixes first.
|
- Propose the smallest high-leverage fixes first.
|
||||||
- Separate defects from optional polish.
|
- Separate defects from optional polish.
|
||||||
|
- Node descriptions must read like natural prose while still making what / why / status clear.
|
||||||
|
- Flag any node description missing a clear why or status component as a high-priority quality issue.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name: Calibration
|
|||||||
description: "Run periodic check-ins to recalibrate goals, projects, preferences, and graph structure."
|
description: "Run periodic check-ins to recalibrate goals, projects, preferences, and graph structure."
|
||||||
when_to_use: "User asks for a check-in, reset, review, or strategic recalibration."
|
when_to_use: "User asks for a check-in, reset, review, or strategic recalibration."
|
||||||
when_not_to_use: "Single isolated question with no strategic update needed."
|
when_not_to_use: "Single isolated question with no strategic update needed."
|
||||||
success_criteria: "Graph reflects current reality: updated hubs, changed priorities, and explicit deltas."
|
success_criteria: "Graph reflects current reality: updated contexts, changed priorities, and explicit deltas."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Calibration
|
# Calibration
|
||||||
@@ -14,7 +14,7 @@ Re-anchor the graph to the user's current state.
|
|||||||
|
|
||||||
## Check-in Sequence
|
## Check-in Sequence
|
||||||
|
|
||||||
1. Review major hub nodes and active project nodes.
|
1. Review major contexts, anchor nodes, and active project nodes.
|
||||||
2. Ask what changed: goals, priorities, constraints, beliefs, preferences.
|
2. Ask what changed: goals, priorities, constraints, beliefs, preferences.
|
||||||
3. Identify stale nodes and missing nodes.
|
3. Identify stale nodes and missing nodes.
|
||||||
4. Propose precise updates: update existing vs create new.
|
4. Propose precise updates: update existing vs create new.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
|
|||||||
## Core Rules
|
## Core Rules
|
||||||
|
|
||||||
1. Search before create to avoid duplicates.
|
1. Search before create to avoid duplicates.
|
||||||
2. Every create/update must include an explicit description of WHAT the thing is and WHY it matters.
|
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.
|
||||||
3. Use event dates when known (when it happened, not when saved).
|
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 dimensions deliberately; prefer existing dimensions over creating noisy new ones.
|
||||||
5. Create edges when relationships are meaningful; edge explanations should read as a sentence.
|
5. Create edges when relationships are meaningful; edge explanations should read as a sentence.
|
||||||
@@ -16,9 +16,10 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
|
|||||||
## Write Quality Contract
|
## Write Quality Contract
|
||||||
|
|
||||||
- `title`: clear and specific.
|
- `title`: clear and specific.
|
||||||
- `description`: concrete object-level description, not vague summaries.
|
- `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.
|
- `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.
|
||||||
- `link`: external source URL only.
|
- `link`: external source URL only.
|
||||||
|
- `metadata`: prefer canonical keys `type`, `state`, `captured_method`, `captured_by`, and `source_metadata`.
|
||||||
- Derived analysis, briefs, and research notes should be stored in a separate linked node, not appended to the source node.
|
- Derived analysis, briefs, and research notes should be stored in a separate linked node, not appended to the source node.
|
||||||
|
|
||||||
## Execution Pattern
|
## Execution Pattern
|
||||||
@@ -27,9 +28,11 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
|
|||||||
2. Decide: create vs update vs connect.
|
2. Decide: create vs update vs connect.
|
||||||
3. Execute minimum required writes.
|
3. Execute minimum required writes.
|
||||||
4. Verify result reflects user intent exactly.
|
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.
|
||||||
|
|
||||||
## Do Not
|
## Do Not
|
||||||
|
|
||||||
- Create duplicate nodes when an update is correct.
|
- Create duplicate nodes when an update is correct.
|
||||||
- Write vague descriptions ("discusses", "explores", "is about").
|
- Write vague descriptions ("discusses", "explores", "is about").
|
||||||
|
- Replace a user's raw idea/source with a thin summary.
|
||||||
- Create weak or directionless edges.
|
- Create weak or directionless edges.
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
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."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Node Context Enrichment
|
||||||
|
|
||||||
|
Use this when a node already exists but its description is thin, generic, or missing personal context.
|
||||||
|
|
||||||
|
This skill should not silently rewrite and move on when contextual framing is inferred. If the enrichment depends on interpretation, update the node and then explicitly invite the user to correct or refine the contextual framing.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace weak descriptions with a single clean natural description that captures:
|
||||||
|
|
||||||
|
1. What the artifact literally is
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Load the node and inspect title, description, source, link, metadata, dimensions, and nearby edges.
|
||||||
|
2. Search for adjacent context before rewriting.
|
||||||
|
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.
|
||||||
|
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:
|
||||||
|
- what it is
|
||||||
|
- why it belongs in the graph
|
||||||
|
- 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.
|
||||||
@@ -3,7 +3,7 @@ name: Onboarding
|
|||||||
description: "Onboard a new user by mapping goals, projects, worldview, and preferences into a strong initial graph."
|
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_to_use: "New user setup or major reset of account context."
|
||||||
when_not_to_use: "User asks for a narrow tactical operation only."
|
when_not_to_use: "User asks for a narrow tactical operation only."
|
||||||
success_criteria: "User has an initial hub-node structure and clear next steps for graph growth."
|
success_criteria: "User has an initial context structure, anchor candidates, and clear next steps for graph growth."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Onboarding
|
# Onboarding
|
||||||
@@ -22,13 +22,14 @@ Understand the user deeply enough to bootstrap a useful externalized context gra
|
|||||||
|
|
||||||
## Graph Bootstrap
|
## Graph Bootstrap
|
||||||
|
|
||||||
1. Propose 3-6 hub nodes with clear rationale.
|
1. Propose 3-6 primary contexts with clear rationale.
|
||||||
2. Propose starter dimensions that reflect real domains.
|
2. Identify one strong anchor candidate per context.
|
||||||
3. Create initial edges between hubs and active projects.
|
3. Propose starter dimensions as secondary metadata and filters.
|
||||||
4. Confirm with user before writing.
|
4. Create initial edges between anchor nodes and active project nodes.
|
||||||
|
5. Confirm with user before writing.
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
- Initial hub map
|
- Initial context map
|
||||||
- Suggested first write actions
|
- Suggested first write actions
|
||||||
- Suggested weekly maintenance rhythm
|
- Suggested weekly maintenance rhythm
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { paperExtractTool } from '@/tools/other/paperExtract';
|
|||||||
import { formatNodeForChat } from '@/tools/infrastructure/nodeFormatter';
|
import { formatNodeForChat } from '@/tools/infrastructure/nodeFormatter';
|
||||||
import { summarizeTranscript } from './transcriptSummarizer';
|
import { summarizeTranscript } from './transcriptSummarizer';
|
||||||
import { eventBroadcaster } from '@/services/events';
|
import { eventBroadcaster } from '@/services/events';
|
||||||
|
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||||
|
|
||||||
export type QuickAddMode = 'link' | 'text';
|
export type QuickAddMode = 'link' | 'text';
|
||||||
|
|
||||||
@@ -242,7 +243,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
|
|||||||
`Attempted pipeline: ${type}`,
|
`Attempted pipeline: ${type}`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -251,12 +252,17 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
|
|||||||
source,
|
source,
|
||||||
link: url,
|
link: url,
|
||||||
metadata: {
|
metadata: {
|
||||||
source: 'quick-add-link-fallback',
|
type: 'website',
|
||||||
|
state: 'not_processed',
|
||||||
|
captured_method: 'quick_add_link_fallback',
|
||||||
|
captured_by: 'human',
|
||||||
|
source_metadata: {
|
||||||
attempted_pipeline: type,
|
attempted_pipeline: type,
|
||||||
extraction_failed: true,
|
extraction_failed: true,
|
||||||
extraction_error: message,
|
extraction_error: message,
|
||||||
refined_at: new Date().toISOString(),
|
refined_at: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -294,16 +300,21 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
|
|||||||
title,
|
title,
|
||||||
source: content,
|
source: content,
|
||||||
metadata: {
|
metadata: {
|
||||||
source: 'quick-add-note',
|
type: 'note',
|
||||||
|
state: 'not_processed',
|
||||||
|
captured_method: 'quick_add_note',
|
||||||
|
captured_by: 'human',
|
||||||
|
source_metadata: {
|
||||||
refined_at: new Date().toISOString(),
|
refined_at: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (userDescription && userDescription.trim()) {
|
if (userDescription && userDescription.trim()) {
|
||||||
nodePayload.description = userDescription.trim();
|
nodePayload.description = userDescription.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(nodePayload),
|
body: JSON.stringify(nodePayload),
|
||||||
@@ -356,28 +367,21 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
|
|||||||
? ['Where things felt stuck:', ...stickingPoints.map((item) => `- ${item}`)].join('\n')
|
? ['Where things felt stuck:', ...stickingPoints.map((item) => `- ${item}`)].join('\n')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const contentParts = [
|
|
||||||
`Overview:\n${baseSummary}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (intentLine) {
|
|
||||||
contentParts.push(`What you were trying to do:\n${intentLine}`);
|
|
||||||
}
|
|
||||||
if (progressLine) {
|
|
||||||
contentParts.push(`Where you made progress:\n${progressLine}`);
|
|
||||||
}
|
|
||||||
if (stickingSection) {
|
|
||||||
contentParts.push(stickingSection);
|
|
||||||
}
|
|
||||||
if (highlightSection) contentParts.push(highlightSection);
|
|
||||||
if (followUpSection) contentParts.push(followUpSection);
|
|
||||||
const content = contentParts.join('\n\n');
|
|
||||||
|
|
||||||
const title = deriveChatTitle(transcript, summaryResult.subject);
|
const title = deriveChatTitle(transcript, summaryResult.subject);
|
||||||
const wordCount = transcript.split(/\s+/).filter(Boolean).length;
|
const wordCount = transcript.split(/\s+/).filter(Boolean).length;
|
||||||
|
const compactSummary = baseSummary.replace(/\s+/g, ' ').trim();
|
||||||
|
const whyDetail = intentLine
|
||||||
|
? `It belongs in the graph because it preserves context around ${intentLine.toLowerCase()}.`
|
||||||
|
: 'It belongs in the graph because it preserves context from this conversation.';
|
||||||
|
const statusDetail = 'It has not been reviewed yet.';
|
||||||
|
const nodeDescription = `${compactSummary} ${whyDetail} ${statusDetail}`.slice(0, 500);
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
source: 'quick-add-chat',
|
type: 'chat',
|
||||||
|
state: 'not_processed',
|
||||||
|
captured_method: 'quick_add_chat',
|
||||||
|
captured_by: 'human',
|
||||||
|
source_metadata: {
|
||||||
summary_subject: summaryResult.subject,
|
summary_subject: summaryResult.subject,
|
||||||
summary_intent: summaryResult.intent,
|
summary_intent: summaryResult.intent,
|
||||||
summary_progress: summaryResult.progress,
|
summary_progress: summaryResult.progress,
|
||||||
@@ -389,15 +393,16 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
|
|||||||
transcript_length_words: wordCount,
|
transcript_length_words: wordCount,
|
||||||
transcript_truncated_for_summary: summaryResult.truncated ?? false,
|
transcript_truncated_for_summary: summaryResult.truncated ?? false,
|
||||||
summary_generated_at: new Date().toISOString(),
|
summary_generated_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title,
|
title,
|
||||||
source: transcript,
|
source: transcript,
|
||||||
description: content,
|
description: nodeDescription,
|
||||||
metadata,
|
metadata,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,72 +1,233 @@
|
|||||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||||
import { getAutoContextSettings } from '@/services/settings/autoContextSettings';
|
import { getAutoContextSettings } from '@/services/settings/autoContextSettings';
|
||||||
|
|
||||||
interface AutoContextRow {
|
|
||||||
id: number;
|
|
||||||
title: string | null;
|
|
||||||
updated_at: string;
|
|
||||||
edge_count: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AutoContextSummary {
|
export interface AutoContextSummary {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
edgeCount: number;
|
description: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
edgeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextAnchorSummary {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
updatedAt: string;
|
||||||
|
edgeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptContextSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
count: number;
|
||||||
|
anchor: ContextAnchorSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(value: string | null | undefined, maxChars: number): string {
|
||||||
|
const trimmed = (value || '').trim();
|
||||||
|
if (trimmed.length <= maxChars) return trimmed;
|
||||||
|
return `${trimmed.slice(0, maxChars)}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchAutoContextRows(limit: number): AutoContextSummary[] {
|
function fetchAutoContextRows(limit: number): AutoContextSummary[] {
|
||||||
const db = getSQLiteClient();
|
const db = getSQLiteClient();
|
||||||
const rows = db
|
const rows = db.query<{
|
||||||
.query<AutoContextRow>(
|
id: number;
|
||||||
|
title: string | null;
|
||||||
|
description: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
edge_count: number | null;
|
||||||
|
}>(
|
||||||
`
|
`
|
||||||
SELECT n.id,
|
SELECT n.id,
|
||||||
n.title,
|
n.title,
|
||||||
|
n.description,
|
||||||
n.updated_at,
|
n.updated_at,
|
||||||
COUNT(DISTINCT e.id) AS edge_count
|
COUNT(DISTINCT e.id) AS edge_count
|
||||||
FROM nodes n
|
FROM nodes n
|
||||||
LEFT JOIN edges e
|
LEFT JOIN edges e
|
||||||
ON (e.from_node_id = n.id OR e.to_node_id = n.id)
|
ON (e.from_node_id = n.id OR e.to_node_id = n.id)
|
||||||
WHERE 1=1
|
|
||||||
GROUP BY n.id
|
GROUP BY n.id
|
||||||
ORDER BY edge_count DESC, n.updated_at DESC
|
ORDER BY edge_count DESC, n.updated_at DESC, n.id ASC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`,
|
`,
|
||||||
[limit]
|
[limit]
|
||||||
)
|
).rows;
|
||||||
.rows;
|
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title || 'Untitled node',
|
title: row.title || 'Untitled node',
|
||||||
|
description: row.description || '',
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
edgeCount: Number(row.edge_count ?? 0),
|
edgeCount: Number(row.edge_count ?? 0),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAutoContextSummaries(limit = 10): AutoContextSummary[] {
|
export function getHubNodes(limit = 5): AutoContextSummary[] {
|
||||||
const settings = getAutoContextSettings();
|
|
||||||
if (!settings.autoContextEnabled) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return fetchAutoContextRows(limit);
|
return fetchAutoContextRows(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAutoContextBlock(limit = 10): string | null {
|
export function getContextSummaries(limit = 12): PromptContextSummary[] {
|
||||||
const summaries = getAutoContextSummaries(limit);
|
const db = getSQLiteClient();
|
||||||
|
const rows = db.query<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
count: number;
|
||||||
|
anchor_id: number | null;
|
||||||
|
anchor_title: string | null;
|
||||||
|
anchor_description: string | null;
|
||||||
|
anchor_updated_at: string | null;
|
||||||
|
anchor_edge_count: number | null;
|
||||||
|
}>(`
|
||||||
|
WITH context_counts AS (
|
||||||
|
SELECT c.id, c.name, c.description, c.icon, 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.id AS node_id,
|
||||||
|
n.title,
|
||||||
|
n.description,
|
||||||
|
n.updated_at,
|
||||||
|
COUNT(e.id) AS edge_count,
|
||||||
|
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.icon,
|
||||||
|
cc.count,
|
||||||
|
ra.node_id AS anchor_id,
|
||||||
|
ra.title AS anchor_title,
|
||||||
|
ra.description AS anchor_description,
|
||||||
|
ra.updated_at AS anchor_updated_at,
|
||||||
|
ra.edge_count AS anchor_edge_count
|
||||||
|
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
|
||||||
|
LIMIT ?
|
||||||
|
`, [limit]).rows;
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description ?? null,
|
||||||
|
icon: row.icon ?? null,
|
||||||
|
count: Number(row.count ?? 0),
|
||||||
|
anchor: row.anchor_id == null ? null : {
|
||||||
|
id: Number(row.anchor_id),
|
||||||
|
title: row.anchor_title || 'Untitled node',
|
||||||
|
description: row.anchor_description || '',
|
||||||
|
updatedAt: row.anchor_updated_at || '',
|
||||||
|
edgeCount: Number(row.anchor_edge_count ?? 0),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildContextsBlock(limit = 12): string | null {
|
||||||
|
const contexts = getContextSummaries(limit);
|
||||||
|
if (contexts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
'User Contexts',
|
||||||
|
'Use contexts as the primary scope layer. Dimensions are secondary metadata and filters.',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
contexts.forEach((context, index) => {
|
||||||
|
const description = truncate(context.description, 140) || 'No description.';
|
||||||
|
const iconPrefix = context.icon ? `${context.icon} ` : '';
|
||||||
|
lines.push(`${index + 1}. ${iconPrefix}${context.name} (${context.count} nodes)`);
|
||||||
|
lines.push(` ${description}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildContextAnchorsBlock(limit = 12): string | null {
|
||||||
|
const contexts = getContextSummaries(limit).filter((context) => context.anchor);
|
||||||
|
if (contexts.length === 0) {
|
||||||
|
return 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.',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
contexts.forEach((context, index) => {
|
||||||
|
const anchor = context.anchor!;
|
||||||
|
lines.push(`${index + 1}. ${context.name}: [NODE:${anchor.id}:"${anchor.title}"] (${anchor.edgeCount} edges)`);
|
||||||
|
if (anchor.description) {
|
||||||
|
lines.push(` ${truncate(anchor.description, 160)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHubNodesBlock(limit = 5): string | null {
|
||||||
|
const summaries = getHubNodes(limit);
|
||||||
if (summaries.length === 0) {
|
if (summaries.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
'=== BACKGROUND CONTEXT ===',
|
'Global Hub Diagnostics',
|
||||||
'Top 10 most-connected nodes (important knowledge hubs). Use queryNodes/getNodesById if relevant.',
|
'These are secondary graph diagnostics only. Do not use them as the primary scope layer when contexts are available.',
|
||||||
'',
|
'',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const summary of summaries) {
|
summaries.forEach((summary, i) => {
|
||||||
lines.push(`[NODE:${summary.id}:"${summary.title}"] (edges: ${summary.edgeCount})`);
|
lines.push(`${i + 1}. [NODE:${summary.id}:"${summary.title}"] (${summary.edgeCount} edges)`);
|
||||||
|
if (summary.description) {
|
||||||
|
lines.push(` ${truncate(summary.description, 140)}`);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { getSQLiteClient } from './sqlite-client';
|
||||||
|
import type { Context, ContextSummary, Node } from '@/types/database';
|
||||||
|
import { nodeService } from './nodes';
|
||||||
|
|
||||||
|
type ContextRow = Context;
|
||||||
|
|
||||||
|
function normalizeContextName(name: string): string {
|
||||||
|
return name.trim().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertContextName(name: unknown): string {
|
||||||
|
if (typeof name !== 'string') {
|
||||||
|
throw new Error('Context name is required.');
|
||||||
|
}
|
||||||
|
const normalized = normalizeContextName(name);
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error('Context name is required.');
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertContextDescription(description: unknown): string {
|
||||||
|
if (typeof description !== 'string') {
|
||||||
|
throw new Error('Context description is required.');
|
||||||
|
}
|
||||||
|
const normalized = description.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error('Context description is required.');
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapContextRow(row: ContextRow): Context {
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
name: row.name,
|
||||||
|
description: row.description ?? null,
|
||||||
|
icon: row.icon ?? null,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContextService {
|
||||||
|
async listContexts(): Promise<ContextSummary[]> {
|
||||||
|
const sqlite = getSQLiteClient();
|
||||||
|
const rows = sqlite.query<ContextSummary>(`
|
||||||
|
SELECT c.id, c.name, c.description, c.icon, COUNT(n.id) as count
|
||||||
|
FROM contexts c
|
||||||
|
LEFT JOIN nodes n ON n.context_id = c.id
|
||||||
|
GROUP BY c.id
|
||||||
|
ORDER BY c.name COLLATE NOCASE ASC
|
||||||
|
`).rows;
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: Number(row.id),
|
||||||
|
name: row.name,
|
||||||
|
description: row.description ?? null,
|
||||||
|
icon: row.icon ?? null,
|
||||||
|
count: Number(row.count ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContextById(id: number): Promise<ContextSummary | null> {
|
||||||
|
const sqlite = getSQLiteClient();
|
||||||
|
const row = sqlite.query<ContextSummary>(`
|
||||||
|
SELECT c.id, c.name, c.description, c.icon, COUNT(n.id) as count
|
||||||
|
FROM contexts c
|
||||||
|
LEFT JOIN nodes n ON n.context_id = c.id
|
||||||
|
WHERE c.id = ?
|
||||||
|
GROUP BY c.id
|
||||||
|
`, [id]).rows[0];
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
name: row.name,
|
||||||
|
description: row.description ?? null,
|
||||||
|
icon: row.icon ?? null,
|
||||||
|
count: Number(row.count ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContextByName(name: string): Promise<Context | null> {
|
||||||
|
const normalized = assertContextName(name);
|
||||||
|
const sqlite = getSQLiteClient();
|
||||||
|
const row = sqlite.query<ContextRow>(`
|
||||||
|
SELECT id, name, description, icon, created_at, updated_at
|
||||||
|
FROM contexts
|
||||||
|
WHERE LOWER(TRIM(name)) = LOWER(TRIM(?))
|
||||||
|
LIMIT 1
|
||||||
|
`, [normalized]).rows[0];
|
||||||
|
|
||||||
|
return row ? mapContextRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContext(input: { name: string; description: string; icon?: string | null }): Promise<Context> {
|
||||||
|
const name = assertContextName(input.name);
|
||||||
|
const description = assertContextDescription(input.description);
|
||||||
|
const icon = typeof input.icon === 'string' && input.icon.trim() ? input.icon.trim() : null;
|
||||||
|
const sqlite = getSQLiteClient();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const existing = await this.getContextByName(name);
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Context "${name}" already exists.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = sqlite.prepare(`
|
||||||
|
INSERT INTO contexts (name, description, icon, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(name, description, icon, now, now);
|
||||||
|
|
||||||
|
const created = await this.getContextById(Number(result.lastInsertRowid));
|
||||||
|
if (!created) {
|
||||||
|
throw new Error('Failed to create context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...created,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateContext(input: { id: number; name?: string; description?: string; icon?: string | null }): Promise<Context> {
|
||||||
|
const sqlite = getSQLiteClient();
|
||||||
|
const existing = sqlite.query<ContextRow>(`
|
||||||
|
SELECT id, name, description, icon, created_at, updated_at
|
||||||
|
FROM contexts
|
||||||
|
WHERE id = ?
|
||||||
|
`, [input.id]).rows[0];
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`Context ${input.id} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextName = input.name !== undefined ? assertContextName(input.name) : existing.name;
|
||||||
|
const nextDescription = input.description !== undefined
|
||||||
|
? assertContextDescription(input.description)
|
||||||
|
: existing.description;
|
||||||
|
const nextIcon = input.icon !== undefined
|
||||||
|
? (typeof input.icon === 'string' && input.icon.trim() ? input.icon.trim() : null)
|
||||||
|
: existing.icon;
|
||||||
|
|
||||||
|
const conflicting = sqlite.query<{ id: number }>(`
|
||||||
|
SELECT id FROM contexts
|
||||||
|
WHERE LOWER(TRIM(name)) = LOWER(TRIM(?))
|
||||||
|
AND id != ?
|
||||||
|
LIMIT 1
|
||||||
|
`, [nextName, input.id]).rows[0];
|
||||||
|
|
||||||
|
if (conflicting) {
|
||||||
|
throw new Error(`Context "${nextName}" already exists.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
sqlite.prepare(`
|
||||||
|
UPDATE contexts
|
||||||
|
SET name = ?, description = ?, icon = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(nextName, nextDescription, nextIcon, now, input.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
name: nextName,
|
||||||
|
description: nextDescription ?? null,
|
||||||
|
icon: nextIcon ?? null,
|
||||||
|
created_at: existing.created_at,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNodesForContext(id: number): Promise<Node[]> {
|
||||||
|
return nodeService.getNodes({ contextId: id, limit: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 hasContextName = typeof input.context_name === 'string' && input.context_name.trim().length > 0;
|
||||||
|
|
||||||
|
if (!hasContextId && !hasContextName) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasContextId && input.context_id === null) {
|
||||||
|
if (hasContextName) {
|
||||||
|
throw new Error('context_name cannot be combined with context_id: null.');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedById: Context | null = null;
|
||||||
|
if (hasContextId) {
|
||||||
|
if (typeof input.context_id !== 'number' || !Number.isInteger(input.context_id) || input.context_id <= 0) {
|
||||||
|
throw new Error('context_id must be a positive integer or null.');
|
||||||
|
}
|
||||||
|
const byId = await this.getContextById(input.context_id);
|
||||||
|
if (!byId) {
|
||||||
|
throw new Error(`Context ${input.context_id} not found.`);
|
||||||
|
}
|
||||||
|
resolvedById = {
|
||||||
|
...byId,
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasContextName) {
|
||||||
|
const byName = await this.getContextByName(input.context_name!);
|
||||||
|
if (!byName) {
|
||||||
|
throw new Error(`Context "${input.context_name}" not found.`);
|
||||||
|
}
|
||||||
|
if (resolvedById && resolvedById.id !== byName.id) {
|
||||||
|
throw new Error('context_id and context_name refer to different contexts.');
|
||||||
|
}
|
||||||
|
return byName.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedById?.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contextService = new ContextService();
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { openai as openaiProvider } from '@ai-sdk/openai';
|
import { openai as openaiProvider } from '@ai-sdk/openai';
|
||||||
import { generateText } from 'ai';
|
import { generateText } from 'ai';
|
||||||
import { hasValidOpenAiKey } from '../storage/apiKeys';
|
import { hasValidOpenAiKey } from '../storage/apiKeys';
|
||||||
|
import type { CanonicalNodeMetadata } from '@/types/database';
|
||||||
|
|
||||||
export interface DescriptionInput {
|
export interface DescriptionInput {
|
||||||
title: string;
|
title: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
link?: string;
|
link?: string;
|
||||||
metadata?: {
|
metadata?: CanonicalNodeMetadata & {
|
||||||
source?: string;
|
source?: string;
|
||||||
channel_name?: string;
|
channel_name?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
@@ -18,52 +19,12 @@ export interface DescriptionInput {
|
|||||||
dimensions?: string[];
|
dimensions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export for backwards compatibility — canonical source is ../storage/apiKeys
|
|
||||||
export { hasValidOpenAiKey } from '../storage/apiKeys';
|
export { hasValidOpenAiKey } from '../storage/apiKeys';
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a simple fallback description without AI.
|
|
||||||
* Used when no API key is available or for simple inputs.
|
|
||||||
*/
|
|
||||||
export function generateFallbackDescription(input: DescriptionInput): string {
|
|
||||||
const { title, metadata, dimensions } = input;
|
|
||||||
|
|
||||||
// Build a contextual fallback
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
if (metadata?.author || metadata?.channel_name) {
|
|
||||||
parts.push(`By ${metadata.author || metadata.channel_name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dimensions?.length) {
|
|
||||||
parts.push(`in ${dimensions.slice(0, 2).join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.length > 0) {
|
|
||||||
return `${parts.join(' — ')}: ${title.slice(0, 200)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return title.slice(0, 280);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a 280-character description for a knowledge node.
|
|
||||||
* Contextually grounded - adapts to node type (person, concept, article, etc.)
|
|
||||||
*
|
|
||||||
* IMPORTANT: Returns fallback immediately if no valid API key is configured.
|
|
||||||
* This prevents slow node creation (9-13s timeout) when OpenAI is unavailable.
|
|
||||||
*/
|
|
||||||
export async function generateDescription(input: DescriptionInput): Promise<string> {
|
export async function generateDescription(input: DescriptionInput): Promise<string> {
|
||||||
// Fast path: skip AI if no valid API key
|
|
||||||
if (!hasValidOpenAiKey()) {
|
if (!hasValidOpenAiKey()) {
|
||||||
console.log(`[DescriptionService] No valid OpenAI key, using fallback for: "${input.title}"`);
|
console.log(`[DescriptionService] No valid OpenAI key, using fallback for: "${input.title}"`);
|
||||||
return generateFallbackDescription(input);
|
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);
|
||||||
}
|
|
||||||
|
|
||||||
// Fast path: skip AI for very short inputs (likely just notes)
|
|
||||||
if (!input.source && !input.link && input.title.length < 30) {
|
|
||||||
console.log(`[DescriptionService] Short input, using fallback for: "${input.title}"`);
|
|
||||||
return generateFallbackDescription(input);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -78,31 +39,38 @@ export async function generateDescription(input: DescriptionInput): Promise<stri
|
|||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
});
|
});
|
||||||
|
|
||||||
const finalDescription = sanitizeDescription(response.text, input);
|
const description = sanitizeDescription(response.text, input);
|
||||||
|
|
||||||
console.log(`[DescriptionService] Generated: "${finalDescription}"`);
|
console.log(`[DescriptionService] Generated: "${description}"`);
|
||||||
|
|
||||||
return finalDescription;
|
return description;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[DescriptionService] Error generating description:', error);
|
console.error('[DescriptionService] Error generating description:', error);
|
||||||
// Return a fallback description
|
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);
|
||||||
return generateFallbackDescription(input);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDescriptionPrompt(input: DescriptionInput): string {
|
function buildDescriptionPrompt(input: DescriptionInput): string {
|
||||||
const normalizedSource = (input.metadata?.source || '').toLowerCase();
|
const sourceMetadata = input.metadata?.source_metadata as Record<string, unknown> | undefined;
|
||||||
|
const sourceType = typeof input.metadata?.type === 'string'
|
||||||
|
? input.metadata.type
|
||||||
|
: typeof input.metadata?.source === 'string'
|
||||||
|
? input.metadata.source
|
||||||
|
: '';
|
||||||
|
const normalizedSource = sourceType.toLowerCase();
|
||||||
const url = typeof input.link === 'string' ? input.link.trim() : '';
|
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 =
|
const creatorHint =
|
||||||
input.metadata?.author?.trim() ||
|
(typeof sourceMetadata?.author === 'string' ? sourceMetadata.author.trim() : '') ||
|
||||||
input.metadata?.channel_name?.trim() ||
|
(typeof sourceMetadata?.channel_name === 'string' ? sourceMetadata.channel_name.trim() : '') ||
|
||||||
|
(typeof input.metadata?.author === 'string' ? input.metadata.author.trim() : '') ||
|
||||||
|
(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 =
|
||||||
const publisherHint = input.metadata?.site_name?.trim() || '';
|
(typeof sourceMetadata?.site_name === 'string' ? sourceMetadata.site_name.trim() : '') ||
|
||||||
|
(typeof input.metadata?.site_name === 'string' ? input.metadata.site_name.trim() : '') ||
|
||||||
|
'';
|
||||||
|
|
||||||
const likelyExternal =
|
const likelyExternal =
|
||||||
Boolean(url) ||
|
Boolean(url) ||
|
||||||
@@ -123,38 +91,41 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
|
|||||||
|
|
||||||
if (input.link) lines.push(`URL: ${input.link}`);
|
if (input.link) lines.push(`URL: ${input.link}`);
|
||||||
if (input.dimensions?.length) lines.push(`Dimensions: ${input.dimensions.join(', ')}`);
|
if (input.dimensions?.length) lines.push(`Dimensions: ${input.dimensions.join(', ')}`);
|
||||||
if (input.metadata?.channel_name) lines.push(`Channel: ${input.metadata.channel_name}`);
|
if (sourceMetadata?.channel_name || input.metadata?.channel_name) lines.push(`Channel: ${sourceMetadata?.channel_name || input.metadata?.channel_name}`);
|
||||||
if (input.metadata?.author) lines.push(`Author: ${input.metadata.author}`);
|
if (sourceMetadata?.author || input.metadata?.author) lines.push(`Author: ${sourceMetadata?.author || input.metadata?.author}`);
|
||||||
if (input.metadata?.site_name) lines.push(`Site: ${input.metadata.site_name}`);
|
if (sourceMetadata?.site_name || input.metadata?.site_name) lines.push(`Site: ${sourceMetadata?.site_name || input.metadata?.site_name}`);
|
||||||
if (input.metadata?.source) lines.push(`Source type: ${input.metadata.source}`);
|
if (sourceType) lines.push(`Source type: ${sourceType}`);
|
||||||
if (input.metadata?.original_filename) lines.push(`Original filename: ${input.metadata.original_filename}`);
|
if (sourceMetadata?.original_filename || input.metadata?.original_filename) lines.push(`Original filename: ${sourceMetadata?.original_filename || input.metadata?.original_filename}`);
|
||||||
if (typeof input.metadata?.pages === 'number') lines.push(`Pages: ${input.metadata.pages}`);
|
if (typeof sourceMetadata?.pages === 'number' || typeof input.metadata?.pages === 'number') lines.push(`Pages: ${sourceMetadata?.pages || input.metadata?.pages}`);
|
||||||
if (typeof input.metadata?.text_length === 'number') lines.push(`Text length: ${input.metadata.text_length}`);
|
if (typeof sourceMetadata?.text_length === 'number' || typeof input.metadata?.text_length === 'number') lines.push(`Text length: ${sourceMetadata?.text_length || input.metadata?.text_length}`);
|
||||||
if (creatorHint) lines.push(`Creator hint: ${creatorHint}`);
|
if (creatorHint) lines.push(`Creator hint: ${creatorHint}`);
|
||||||
if (publisherHint) lines.push(`Publisher hint: ${publisherHint}`);
|
if (publisherHint) lines.push(`Publisher hint: ${publisherHint}`);
|
||||||
lines.push(`Likely user-authored: ${likelyUserAuthored ? 'yes' : 'no'}`);
|
lines.push(`Likely user-authored: ${likelyUserAuthored ? 'yes' : 'no'}`);
|
||||||
|
|
||||||
const contentPreview = input.source?.slice(0, 800) || '';
|
const sourcePreview = input.source?.slice(0, 800) || '';
|
||||||
if (contentPreview) lines.push(`Source excerpt: ${contentPreview}${input.source && input.source.length > 800 ? '...' : ''}`);
|
if (sourcePreview) lines.push(`Source excerpt: ${sourcePreview}${input.source && input.source.length > 800 ? '...' : ''}`);
|
||||||
|
|
||||||
return `Write a description for this knowledge node. Max 280 characters.
|
return `Write a natural description for this knowledge node. Max 500 characters.
|
||||||
|
|
||||||
Say WHAT this literally is and WHY it matters. Be concrete and specific — like you're telling a friend what this thing is in one breath.
|
The description should read like normal prose, not a template or checklist. In one compact paragraph or a few natural sentences, make sure it clearly conveys:
|
||||||
|
1) what this literally is
|
||||||
|
2) why it is in Brad's graph
|
||||||
|
3) its current status in Brad's workflow
|
||||||
|
|
||||||
RULES:
|
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…"
|
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.
|
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.
|
3) State the actual claim, finding, or insight from the content — not a vague summary of the topic.
|
||||||
4) End with why it's interesting or important — one concrete phrase.
|
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.
|
||||||
5) ABSOLUTELY FORBIDDEN — these words will be rejected: "discusses", "explores", "examines", "talks about", "is about", "delves into", "emphasizing the need for". State things directly instead.
|
5) If workflow status is unknown, say that naturally, for example by noting it has not been reviewed yet.
|
||||||
6) Do NOT start with "Your note —" or "This note —". Use a concrete opener tied to the actual artifact.
|
6) Do NOT use labels or headings like "WHAT:", "WHY:", or "STATUS:".
|
||||||
7) If the artifact type is unclear, say so explicitly using words like "likely", "appears to be", or "unclear" rather than guessing a confident format.
|
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.
|
||||||
|
8) Do NOT start with "Your note —" or "This note —". Use a concrete opener tied to the actual artifact.
|
||||||
|
9) If the artifact type is unclear, say so explicitly using words like "likely", "appears to be", or "unclear" rather than guessing a confident format.
|
||||||
|
|
||||||
GOOD: "Karpathy blog post — AI agents make software fluid, ripping functionality from repos instead of taking dependencies. Signals the end of monolithic libraries."
|
GOOD: "CS153 lecture by ElevenLabs co-founder Mati Staniszewski on production AI voice systems. Brad likely saved it as a follow-on to his interest in the ElevenLabs voice pipeline after CS153 ep.1, and it has not been reviewed yet."
|
||||||
GOOD: "Dwarkesh Patel interview with Anthropic CEO Dario Amodei — argues we're nearing the end of exponential AI scaling. Key signal for what comes next."
|
GOOD: "YouTube talk by Lex Fridman with Sam Altman on AGI timelines and OpenAI strategy. It was added via Quick Add and the exact reason it belongs in the graph is not yet inferred from the available context, and it has not been reviewed yet."
|
||||||
GOOD: "Personal note capturing a recurring pattern: morning optimism reverses to evening pessimism. Indicates a belief-level swing worth tracking."
|
GOOD: "Personal note capturing a recurring pattern: morning optimism reverses to evening pessimism. It belongs in the graph because it points to a belief-level pattern worth tracking against Brad's decision quality, and it has already been processed."
|
||||||
GOOD: "Resume/CV for Brad Morris outlining work in AI systems, context engineering, and RA-H. Useful as a compact record of background, projects, and expertise."
|
|
||||||
GOOD: "Document likely related to Brad Morris's work history and AI consulting, but the exact artifact type is unclear from the available context. Still useful as a reference profile."
|
|
||||||
BAD: "By Dario Amodei — discusses reaching the limits of exponential growth in AI, emphasizing the need for a critical perspective on future advancements."
|
BAD: "By Dario Amodei — discusses reaching the limits of exponential growth in AI, emphasizing the need for a critical perspective on future advancements."
|
||||||
BAD: "This article explores ideas about how software is changing."
|
BAD: "This article explores ideas about how software is changing."
|
||||||
|
|
||||||
@@ -170,7 +141,7 @@ function sanitizeDescription(rawText: string, input: DescriptionInput): string {
|
|||||||
.replace(/^["']|["']$/g, '');
|
.replace(/^["']|["']$/g, '');
|
||||||
|
|
||||||
if (!singleLine) {
|
if (!singleLine) {
|
||||||
return input.title.slice(0, 280);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
const noGenericPrefix = singleLine.replace(
|
const noGenericPrefix = singleLine.replace(
|
||||||
@@ -178,7 +149,7 @@ function sanitizeDescription(rawText: string, input: DescriptionInput): string {
|
|||||||
'Personal note capturing '
|
'Personal note capturing '
|
||||||
);
|
);
|
||||||
|
|
||||||
return noGenericPrefix.slice(0, 280);
|
return noGenericPrefix.slice(0, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const descriptionService = {
|
export const descriptionService = {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export { nodeService, NodeService } from './nodes';
|
|||||||
export { chunkService, ChunkService } from './chunks';
|
export { chunkService, ChunkService } from './chunks';
|
||||||
export { edgeService, EdgeService } from './edges';
|
export { edgeService, EdgeService } from './edges';
|
||||||
export { dimensionService, DimensionService } from './dimensionService';
|
export { dimensionService, DimensionService } from './dimensionService';
|
||||||
|
export { contextService, ContextService } from './contextService';
|
||||||
// export { HelperService } from './helpers'; // Removed - migrated to JSON-based service
|
// export { HelperService } from './helpers'; // Removed - migrated to JSON-based service
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { Node, NodeFilters } from '@/types/database';
|
|||||||
import { eventBroadcaster } from '../events';
|
import { eventBroadcaster } from '../events';
|
||||||
import { EmbeddingService } from '@/services/embeddings';
|
import { EmbeddingService } from '@/services/embeddings';
|
||||||
import { scoreNodeSearchMatch } from './searchRanking';
|
import { scoreNodeSearchMatch } from './searchRanking';
|
||||||
|
import { buildCanonicalNodeMetadata, mergeNodeMetadata } from '@/services/nodes/metadata';
|
||||||
|
|
||||||
type NodeRow = Node & { dimensions_json: string };
|
type NodeRow = Node & { dimensions_json: string; context_json: string | null };
|
||||||
type NodeSearchRow = NodeRow & { rank?: number; similarity?: number };
|
type NodeSearchRow = NodeRow & { rank?: number; similarity?: number };
|
||||||
|
|
||||||
function sanitizeFtsQuery(input: string): string {
|
function sanitizeFtsQuery(input: string): string {
|
||||||
@@ -81,7 +82,7 @@ export class NodeService {
|
|||||||
|
|
||||||
async countNodes(filters: NodeFilters = {}): Promise<number> {
|
async countNodes(filters: NodeFilters = {}): Promise<number> {
|
||||||
const { dimensions, search, dimensionsMatch = 'any',
|
const { dimensions, search, dimensionsMatch = 'any',
|
||||||
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus } = filters;
|
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus, contextId } = filters;
|
||||||
|
|
||||||
if (search?.trim()) {
|
if (search?.trim()) {
|
||||||
return this.countSearchNodesSQLite(filters);
|
return this.countSearchNodesSQLite(filters);
|
||||||
@@ -120,6 +121,7 @@ export class NodeService {
|
|||||||
if (eventAfter) { query += ` AND n.event_date >= ?`; params.push(eventAfter); }
|
if (eventAfter) { query += ` AND n.event_date >= ?`; params.push(eventAfter); }
|
||||||
if (eventBefore) { query += ` AND n.event_date < ?`; params.push(eventBefore); }
|
if (eventBefore) { query += ` AND n.event_date < ?`; params.push(eventBefore); }
|
||||||
if (chunkStatus) { query += ` AND n.chunk_status = ?`; params.push(chunkStatus); }
|
if (chunkStatus) { query += ` AND n.chunk_status = ?`; params.push(chunkStatus); }
|
||||||
|
if (contextId !== undefined) { query += ` AND n.context_id = ?`; params.push(contextId); }
|
||||||
|
|
||||||
const result = sqlite.query<{ total: number }>(query, params);
|
const result = sqlite.query<{ total: number }>(query, params);
|
||||||
return result.rows[0]?.total ?? 0;
|
return result.rows[0]?.total ?? 0;
|
||||||
@@ -129,7 +131,7 @@ export class NodeService {
|
|||||||
|
|
||||||
private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> {
|
private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> {
|
||||||
const { dimensions, search, limit = 100, offset = 0, sortBy, dimensionsMatch = 'any',
|
const { dimensions, search, limit = 100, offset = 0, sortBy, dimensionsMatch = 'any',
|
||||||
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus } = filters;
|
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus, contextId } = filters;
|
||||||
|
|
||||||
if (search?.trim()) {
|
if (search?.trim()) {
|
||||||
return this.searchNodesSQLite(filters);
|
return this.searchNodesSQLite(filters);
|
||||||
@@ -141,11 +143,16 @@ export class NodeService {
|
|||||||
let query = `
|
let query = `
|
||||||
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
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.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||||
n.created_at, n.updated_at,
|
n.created_at, n.updated_at, n.context_id,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
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)
|
||||||
|
END as context_json,
|
||||||
(SELECT COUNT(*) FROM edges WHERE from_node_id = n.id OR to_node_id = n.id) as edge_count
|
(SELECT COUNT(*) FROM edges WHERE from_node_id = n.id OR to_node_id = n.id) as edge_count
|
||||||
FROM nodes n
|
FROM nodes n
|
||||||
|
LEFT JOIN contexts c ON c.id = n.context_id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
@@ -198,6 +205,10 @@ export class NodeService {
|
|||||||
query += ` AND n.chunk_status = ?`;
|
query += ` AND n.chunk_status = ?`;
|
||||||
params.push(chunkStatus);
|
params.push(chunkStatus);
|
||||||
}
|
}
|
||||||
|
if (contextId !== undefined) {
|
||||||
|
query += ` AND n.context_id = ?`;
|
||||||
|
params.push(contextId);
|
||||||
|
}
|
||||||
|
|
||||||
// Sorting logic
|
// Sorting logic
|
||||||
if (search) {
|
if (search) {
|
||||||
@@ -255,10 +266,15 @@ export class NodeService {
|
|||||||
const query = `
|
const query = `
|
||||||
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
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.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||||
n.created_at, n.updated_at,
|
n.created_at, n.updated_at, n.context_id,
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
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)
|
||||||
|
END as context_json
|
||||||
FROM nodes n
|
FROM nodes n
|
||||||
|
LEFT JOIN contexts c ON c.id = n.context_id
|
||||||
WHERE n.id = ?
|
WHERE n.id = ?
|
||||||
`;
|
`;
|
||||||
const result = sqlite.query<NodeRow>(query, [id]);
|
const result = sqlite.query<NodeRow>(query, [id]);
|
||||||
@@ -284,24 +300,27 @@ export class NodeService {
|
|||||||
event_date,
|
event_date,
|
||||||
dimensions = [],
|
dimensions = [],
|
||||||
chunk_status,
|
chunk_status,
|
||||||
metadata = {}
|
metadata = {},
|
||||||
|
context_id,
|
||||||
} = nodeData;
|
} = nodeData;
|
||||||
|
const canonicalMetadata = buildCanonicalNodeMetadata({ metadata });
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const sqlite = getSQLiteClient();
|
const sqlite = getSQLiteClient();
|
||||||
|
|
||||||
const nodeId = sqlite.transaction(() => {
|
const nodeId = sqlite.transaction(() => {
|
||||||
// Insert node using prepare/run for lastInsertRowid access
|
// Insert node using prepare/run for lastInsertRowid access
|
||||||
const nodeResult = sqlite.prepare(`
|
const nodeResult = sqlite.prepare(`
|
||||||
INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, created_at, updated_at)
|
INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, context_id, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
title,
|
title,
|
||||||
description ?? null,
|
description ?? null,
|
||||||
source ?? null,
|
source ?? null,
|
||||||
link ?? null,
|
link ?? null,
|
||||||
event_date ?? null,
|
event_date ?? null,
|
||||||
JSON.stringify(metadata),
|
JSON.stringify(canonicalMetadata),
|
||||||
chunk_status ?? null,
|
chunk_status ?? null,
|
||||||
|
context_id ?? null,
|
||||||
now,
|
now,
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
@@ -349,13 +368,17 @@ export class NodeService {
|
|||||||
const sqlite = getSQLiteClient();
|
const sqlite = getSQLiteClient();
|
||||||
|
|
||||||
const existingRow = sqlite
|
const existingRow = sqlite
|
||||||
.query<{ id: number }>('SELECT id FROM nodes WHERE id = ?', [id])
|
.query<{ id: number; metadata: string | null }>('SELECT id, metadata FROM nodes WHERE id = ?', [id])
|
||||||
.rows[0];
|
.rows[0];
|
||||||
|
|
||||||
if (!existingRow) {
|
if (!existingRow) {
|
||||||
throw new Error(`Node with ID ${id} not found`);
|
throw new Error(`Node with ID ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mergedMetadata = metadata !== undefined
|
||||||
|
? mergeNodeMetadata(existingRow.metadata, metadata)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
sqlite.transaction(() => {
|
sqlite.transaction(() => {
|
||||||
// Update node columns (only update provided fields)
|
// Update node columns (only update provided fields)
|
||||||
const setFields: string[] = [];
|
const setFields: string[] = [];
|
||||||
@@ -366,13 +389,17 @@ export class NodeService {
|
|||||||
if (source !== undefined) { setFields.push('source = ?'); params.push(source); }
|
if (source !== undefined) { setFields.push('source = ?'); params.push(source); }
|
||||||
if (link !== undefined) { setFields.push('link = ?'); params.push(link); }
|
if (link !== undefined) { setFields.push('link = ?'); params.push(link); }
|
||||||
if (event_date !== undefined) { setFields.push('event_date = ?'); params.push(event_date); }
|
if (event_date !== undefined) { setFields.push('event_date = ?'); params.push(event_date); }
|
||||||
|
if (Object.prototype.hasOwnProperty.call(updates, 'context_id')) {
|
||||||
|
setFields.push('context_id = ?');
|
||||||
|
params.push(updates.context_id ?? null);
|
||||||
|
}
|
||||||
if (Object.prototype.hasOwnProperty.call(updates, 'chunk_status')) {
|
if (Object.prototype.hasOwnProperty.call(updates, 'chunk_status')) {
|
||||||
setFields.push('chunk_status = ?');
|
setFields.push('chunk_status = ?');
|
||||||
params.push(updates.chunk_status ?? null);
|
params.push(updates.chunk_status ?? null);
|
||||||
}
|
}
|
||||||
if (metadata !== undefined) {
|
if (mergedMetadata !== undefined) {
|
||||||
setFields.push('metadata = ?');
|
setFields.push('metadata = ?');
|
||||||
params.push(JSON.stringify(metadata));
|
params.push(JSON.stringify(mergedMetadata));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always update timestamp
|
// Always update timestamp
|
||||||
@@ -445,6 +472,7 @@ export class NodeService {
|
|||||||
...row,
|
...row,
|
||||||
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
||||||
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
|
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
|
||||||
|
context: row.context_json ? JSON.parse(row.context_json) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,6 +484,7 @@ export class NodeService {
|
|||||||
createdBefore,
|
createdBefore,
|
||||||
eventAfter,
|
eventAfter,
|
||||||
eventBefore,
|
eventBefore,
|
||||||
|
contextId,
|
||||||
} = filters;
|
} = filters;
|
||||||
|
|
||||||
const clauses: string[] = [];
|
const clauses: string[] = [];
|
||||||
@@ -483,6 +512,7 @@ export class NodeService {
|
|||||||
if (createdBefore) { clauses.push(`${alias}.created_at < ?`); params.push(createdBefore); }
|
if (createdBefore) { clauses.push(`${alias}.created_at < ?`); params.push(createdBefore); }
|
||||||
if (eventAfter) { clauses.push(`${alias}.event_date >= ?`); params.push(eventAfter); }
|
if (eventAfter) { clauses.push(`${alias}.event_date >= ?`); params.push(eventAfter); }
|
||||||
if (eventBefore) { clauses.push(`${alias}.event_date < ?`); params.push(eventBefore); }
|
if (eventBefore) { clauses.push(`${alias}.event_date < ?`); params.push(eventBefore); }
|
||||||
|
if (contextId !== undefined) { clauses.push(`${alias}.context_id = ?`); params.push(contextId); }
|
||||||
|
|
||||||
return { clauses, params };
|
return { clauses, params };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const WEAK_DESCRIPTION_PATTERNS = /\b(discusses|explores|examines|talks about|is about|delves into)\b/i;
|
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|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 UNCERTAINTY_PATTERNS = /\b(likely|probably|possibly|appears to be|seems to be|unclear|uncertain)\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;
|
const GENERIC_EDGE_PATTERNS = /^(related|related to|connected|connected to|association|associated with)$/i;
|
||||||
|
|
||||||
export function normalizeDimensionName(value: string): string {
|
export function normalizeDimensionName(value: string): string {
|
||||||
@@ -29,6 +31,9 @@ export function normalizeDimensions(values: unknown, max = 5): string[] {
|
|||||||
|
|
||||||
export function validateExplicitDescription(description: string): string | null {
|
export function validateExplicitDescription(description: string): string | null {
|
||||||
const text = description.trim();
|
const text = description.trim();
|
||||||
|
if (text.length > 500) {
|
||||||
|
return 'Description must be 500 characters or less.';
|
||||||
|
}
|
||||||
if (text.length < 24) {
|
if (text.length < 24) {
|
||||||
return 'Description must be explicit and substantial (at least 24 characters).';
|
return 'Description must be explicit and substantial (at least 24 characters).';
|
||||||
}
|
}
|
||||||
@@ -38,9 +43,37 @@ export function validateExplicitDescription(description: string): string | null
|
|||||||
if (!EXPLICIT_ENTITY_PATTERNS.test(text) && !UNCERTAINTY_PATTERNS.test(text)) {
|
if (!EXPLICIT_ENTITY_PATTERNS.test(text) && !UNCERTAINTY_PATTERNS.test(text)) {
|
||||||
return 'Description must explicitly identify what this thing is, or state uncertainty explicitly.';
|
return 'Description must explicitly identify what this thing is, or state uncertainty explicitly.';
|
||||||
}
|
}
|
||||||
|
if (!WHY_PATTERNS.test(text)) {
|
||||||
|
return 'Description must clearly indicate why this belongs in the graph. If that reason is unknown, say so naturally instead of inventing it.';
|
||||||
|
}
|
||||||
|
if (!STATUS_PATTERNS.test(text)) {
|
||||||
|
return 'Description must make the workflow status clear. If status is unknown, say naturally that it has not been reviewed yet or is still in progress.';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DescriptionFallbackInput {
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coerceDescriptionForStorage(input: DescriptionFallbackInput): string {
|
||||||
|
const candidate = typeof input.description === 'string' ? input.description.trim() : '';
|
||||||
|
const clippedCandidate = candidate.slice(0, 500);
|
||||||
|
const validationError = clippedCandidate ? validateExplicitDescription(clippedCandidate) : 'missing';
|
||||||
|
|
||||||
|
if (!validationError && clippedCandidate) {
|
||||||
|
return clippedCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = input.title.trim() || 'Untitled node';
|
||||||
|
const opening = clippedCandidate || `${title}.`;
|
||||||
|
const normalizedOpening = opening.endsWith('.') ? opening : `${opening}.`;
|
||||||
|
const fallbackTail = ' It was added to the graph with incomplete context, so the exact reason it belongs here is not yet inferred, and it has not been reviewed yet.';
|
||||||
|
|
||||||
|
return `${normalizedOpening}${fallbackTail}`.replace(/\s+/g, ' ').trim().slice(0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
export function validateEdgeExplanation(explanation: string): string | null {
|
export function validateEdgeExplanation(explanation: string): string | null {
|
||||||
const text = explanation.trim();
|
const text = explanation.trim();
|
||||||
if (text.length < 8) {
|
if (text.length < 8) {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class SQLiteClient {
|
|||||||
|
|
||||||
// Ensure logging schema (rename memory->logs if needed, create triggers/views)
|
// Ensure logging schema (rename memory->logs if needed, create triggers/views)
|
||||||
this.ensureLoggingAndMemorySchema();
|
this.ensureLoggingAndMemorySchema();
|
||||||
|
this.ensureContextsSchema();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('SQLite client initialized successfully');
|
console.log('SQLite client initialized successfully');
|
||||||
@@ -219,6 +220,11 @@ class SQLiteClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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
|
// 1) If logs table missing but legacy memory table exists, migrate
|
||||||
const hasLogs = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='logs'").get();
|
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();
|
const hasLegacyMemory = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='memory'").get();
|
||||||
@@ -748,6 +754,58 @@ class SQLiteClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensureContextsSchema(): void {
|
||||||
|
if (this.readOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.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
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const nodeCols = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
|
||||||
|
const nodeColNames = nodeCols.map((column) => column.name);
|
||||||
|
if (!nodeColNames.includes('context_id')) {
|
||||||
|
this.db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;');
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextCols = this.db.prepare('PRAGMA table_info(contexts)').all() as Array<{ name: string }>;
|
||||||
|
const contextColNames = contextCols.map((column) => column.name);
|
||||||
|
if (!contextColNames.includes('description')) {
|
||||||
|
this.db.exec("ALTER TABLE contexts ADD COLUMN description TEXT NOT NULL DEFAULT '';");
|
||||||
|
}
|
||||||
|
if (!contextColNames.includes('icon')) {
|
||||||
|
this.db.exec('ALTER TABLE contexts ADD COLUMN icon TEXT;');
|
||||||
|
}
|
||||||
|
if (!contextColNames.includes('created_at')) {
|
||||||
|
this.db.exec("ALTER TABLE contexts ADD COLUMN created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
|
||||||
|
}
|
||||||
|
if (!contextColNames.includes('updated_at')) {
|
||||||
|
this.db.exec("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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to ensure contexts schema:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private healVectorTablesIfCorrupt(): void {
|
private healVectorTablesIfCorrupt(): void {
|
||||||
if (this.readOnly || !this.vectorCapability.available) {
|
if (this.readOnly || !this.vectorCapability.available) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type {
|
||||||
|
CanonicalNodeMetadata,
|
||||||
|
NodeCapturedBy,
|
||||||
|
NodeMetadataState,
|
||||||
|
} from '@/types/database';
|
||||||
|
|
||||||
|
type MetadataLike = CanonicalNodeMetadata | Record<string, unknown> | string | null | undefined;
|
||||||
|
|
||||||
|
export interface BuildCanonicalMetadataInput {
|
||||||
|
existing?: MetadataLike;
|
||||||
|
metadata?: MetadataLike;
|
||||||
|
type?: string | null;
|
||||||
|
state?: NodeMetadataState | null;
|
||||||
|
captured_method?: string | null;
|
||||||
|
captured_by?: NodeCapturedBy | null;
|
||||||
|
source_metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNodeMetadata(metadata: MetadataLike): CanonicalNodeMetadata {
|
||||||
|
if (!metadata) return {};
|
||||||
|
|
||||||
|
if (typeof metadata === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(metadata);
|
||||||
|
return isObject(parsed) ? { ...parsed } : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isObject(metadata) ? { ...metadata } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSourceMetadata(sourceMetadata: unknown): Record<string, unknown> {
|
||||||
|
return isObject(sourceMetadata) ? { ...sourceMetadata } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedString(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string') return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCanonicalNodeMetadata(input: BuildCanonicalMetadataInput): CanonicalNodeMetadata {
|
||||||
|
const existing = parseNodeMetadata(input.existing);
|
||||||
|
const incoming = parseNodeMetadata(input.metadata);
|
||||||
|
|
||||||
|
const type = normalizedString(input.type) ?? normalizedString(incoming.type) ?? normalizedString(existing.type);
|
||||||
|
const state = input.state ?? (incoming.state as NodeMetadataState | undefined) ?? (existing.state as NodeMetadataState | undefined) ?? 'not_processed';
|
||||||
|
const capturedMethod =
|
||||||
|
normalizedString(input.captured_method)
|
||||||
|
?? normalizedString(incoming.captured_method)
|
||||||
|
?? normalizedString(existing.captured_method);
|
||||||
|
const capturedBy =
|
||||||
|
input.captured_by
|
||||||
|
?? (incoming.captured_by as NodeCapturedBy | undefined)
|
||||||
|
?? (existing.captured_by as NodeCapturedBy | undefined)
|
||||||
|
?? 'human';
|
||||||
|
|
||||||
|
const sourceMetadata = {
|
||||||
|
...normalizeSourceMetadata(existing.source_metadata),
|
||||||
|
...normalizeSourceMetadata(incoming.source_metadata),
|
||||||
|
...normalizeSourceMetadata(input.source_metadata),
|
||||||
|
};
|
||||||
|
|
||||||
|
const merged: CanonicalNodeMetadata = {
|
||||||
|
...existing,
|
||||||
|
...incoming,
|
||||||
|
source_metadata: sourceMetadata,
|
||||||
|
state,
|
||||||
|
captured_by: capturedBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
merged.type = type;
|
||||||
|
} else {
|
||||||
|
delete merged.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capturedMethod) {
|
||||||
|
merged.captured_method = capturedMethod;
|
||||||
|
} else {
|
||||||
|
delete merged.captured_method;
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeNodeMetadata(existing: MetadataLike, incoming: MetadataLike): CanonicalNodeMetadata {
|
||||||
|
return buildCanonicalNodeMetadata({ existing, metadata: incoming });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeProcessedState(metadata: MetadataLike): NodeMetadataState {
|
||||||
|
const parsed = parseNodeMetadata(metadata);
|
||||||
|
return parsed.state === 'processed' ? 'processed' : 'not_processed';
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ const SEEDED_SKILL_IDS = new Set([
|
|||||||
'persona',
|
'persona',
|
||||||
'calibration',
|
'calibration',
|
||||||
'connect',
|
'connect',
|
||||||
|
'node-context-enrichment',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const DEPRECATED_SKILL_IDS = new Set([
|
const DEPRECATED_SKILL_IDS = new Set([
|
||||||
|
|||||||
@@ -2,42 +2,87 @@ import { tool } from 'ai';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||||
import { normalizeDimensions, validateExplicitDescription } from '@/services/database/quality';
|
import { normalizeDimensions } from '@/services/database/quality';
|
||||||
|
|
||||||
|
function extractTextFromMessageContent(content: unknown): string {
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
.map((part) => {
|
||||||
|
if (!part || typeof part !== 'object') return '';
|
||||||
|
const candidate = part as Record<string, unknown>;
|
||||||
|
if (typeof candidate.text === 'string') return candidate.text;
|
||||||
|
if (candidate.type === 'text' && typeof candidate.value === 'string') return candidate.value;
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferSourceFromContext(params: { title: string; description?: string; source?: string }, context: any): string | undefined {
|
||||||
|
if (typeof params.source === 'string' && params.source.trim()) {
|
||||||
|
return params.source.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = Array.isArray(context?.messages) ? context.messages : [];
|
||||||
|
const latestUserMessage = [...messages].reverse().find((message: any) => message?.role === 'user');
|
||||||
|
if (!latestUserMessage) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawUserText = extractTextFromMessageContent(latestUserMessage.content).trim();
|
||||||
|
if (!rawUserText) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(rawUserText)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = rawUserText.replace(/\r\n/g, '\n').trim();
|
||||||
|
const descriptionLength = typeof params.description === 'string' ? params.description.trim().length : 0;
|
||||||
|
const isSubstantialCapture = normalized.length >= Math.max(280, descriptionLength + 120) || normalized.includes('\n');
|
||||||
|
|
||||||
|
if (!isSubstantialCapture) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
export const createNodeTool = tool({
|
export const createNodeTool = tool({
|
||||||
description: 'Create node. Description is REQUIRED and must be explicit about what the thing is (podcast, chat summary, idea, etc).',
|
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.',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
title: z.string().describe('The title of the node'),
|
title: z.string().describe('The title of the node'),
|
||||||
description: z.string().max(280).describe('REQUIRED. Explicitly state WHAT this is (e.g. podcast episode, conversation summary, user insight) + WHY it matters for context grounding.'),
|
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:.'),
|
||||||
source: z.string().optional().describe('Raw content for embedding: transcript, article text, book passages, or user thoughts. If omitted, falls back to title + description.'),
|
source: z.string().optional().describe('Canonical source content for embedding. For external content, store the actual transcript/article/document text. For user-authored ideas or dictated notes, store the user\'s original wording as fully as possible with only minimal cleanup such as obvious whitespace or transcription artifacts. Do not replace raw user thinking with a thin summary.'),
|
||||||
link: z.string().optional().describe('A URL link to the source'),
|
link: z.string().optional().describe('A URL link to the source'),
|
||||||
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
|
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
|
dimensions: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
.max(5)
|
.max(5)
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Optional dimension tags to apply to this node (0-5 items).'),
|
.describe('Optional secondary dimension tags to apply to this node (0-5 items).'),
|
||||||
metadata: z.record(z.any()).optional().describe('Additional metadata like source info, extraction details, etc.')
|
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) => {
|
execute: async (params, context) => {
|
||||||
console.log('🎯 CreateNode tool called with params:', JSON.stringify(params, null, 2));
|
console.log('🎯 CreateNode tool called with params:', JSON.stringify(params, null, 2));
|
||||||
try {
|
try {
|
||||||
const descriptionError = validateExplicitDescription(params.description);
|
|
||||||
if (descriptionError) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `${descriptionError} Do not retry with minor rephrasing in the same turn. Rewrite the description so it explicitly names the entity type, such as note, node, person, episode, article, project, test node, or skill, and states why it matters.`,
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedDimensions = normalizeDimensions(params.dimensions || [], 5);
|
const trimmedDimensions = normalizeDimensions(params.dimensions || [], 5);
|
||||||
|
const canonicalSource = inferSourceFromContext(params, context);
|
||||||
|
|
||||||
// Call the nodes API endpoint
|
|
||||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ...params, dimensions: trimmedDimensions })
|
body: JSON.stringify({ ...params, source: canonicalSource, dimensions: trimmedDimensions })
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -50,20 +95,10 @@ export const createNodeTool = tool({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format the created node for chat display
|
|
||||||
const formattedDisplay = formatNodeForChat({
|
|
||||||
id: result.data.id,
|
|
||||||
title: result.data.title,
|
|
||||||
dimensions: result.data.dimensions || trimmedDimensions
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: result.data,
|
||||||
...result.data,
|
message: `Created: ${formatNodeForChat(result.data)}`
|
||||||
formatted_display: formattedDisplay
|
|
||||||
},
|
|
||||||
message: `Created node ${formattedDisplay} with dimensions: ${result.data.dimensions ? result.data.dimensions.join(', ') : 'none'}`
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -75,5 +110,4 @@ export const createNodeTool = tool({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Legacy export for backwards compatibility
|
|
||||||
export const createItemTool = createNodeTool;
|
export const createItemTool = createNodeTool;
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { tool } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { contextService } from '@/services/database/contextService';
|
||||||
|
|
||||||
|
type QueryContextFilters = {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
includeNodes?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function matchesSearch(value: string | null | undefined, search: string): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
return value.toLowerCase().includes(search);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}).optional(),
|
||||||
|
}),
|
||||||
|
execute: async ({ filters = {} }: { filters?: QueryContextFilters }) => {
|
||||||
|
try {
|
||||||
|
const limit = filters.limit || 50;
|
||||||
|
const normalizedName = filters.name?.trim();
|
||||||
|
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) ||
|
||||||
|
matchesSearch(context.description, normalizedSearch)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitedContexts = contexts.slice(0, limit);
|
||||||
|
const includeNodes = 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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
contexts: enriched,
|
||||||
|
count: enriched.length,
|
||||||
|
total_available: contexts.length,
|
||||||
|
filters_applied: filters,
|
||||||
|
},
|
||||||
|
message: enriched.length === 0 ? 'No contexts found.' : `Found ${enriched.length} context(s).`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to query contexts',
|
||||||
|
data: {
|
||||||
|
contexts: [],
|
||||||
|
count: 0,
|
||||||
|
filters_applied: filters,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import { scoreNodeSearchMatch } from '@/services/database/searchRanking';
|
|||||||
|
|
||||||
type QueryNodeFilters = {
|
type QueryNodeFilters = {
|
||||||
dimensions?: string[];
|
dimensions?: string[];
|
||||||
|
contextId?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
createdAfter?: string;
|
createdAfter?: string;
|
||||||
@@ -20,6 +21,7 @@ export const queryNodesTool = tool({
|
|||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
filters: 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(),
|
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(),
|
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'),
|
limit: z.number().min(1).max(50).default(10).describe('Maximum number of results to return'),
|
||||||
createdAfter: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'),
|
createdAfter: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'),
|
||||||
@@ -82,6 +84,7 @@ export const queryNodesTool = tool({
|
|||||||
const nodesPromise: Promise<Node[] | undefined> = nodeService.getNodes({
|
const nodesPromise: Promise<Node[] | undefined> = nodeService.getNodes({
|
||||||
limit,
|
limit,
|
||||||
dimensions: queryFilters.dimensions,
|
dimensions: queryFilters.dimensions,
|
||||||
|
contextId: queryFilters.contextId,
|
||||||
search: queryFilters.search,
|
search: queryFilters.search,
|
||||||
searchMode: searchTerm ? 'hybrid' : 'standard',
|
searchMode: searchTerm ? 'hybrid' : 'standard',
|
||||||
createdAfter: queryFilters.createdAfter,
|
createdAfter: queryFilters.createdAfter,
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { tool } from 'ai';
|
import { tool } from 'ai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||||
import { normalizeDimensions, validateExplicitDescription } from '@/services/database/quality';
|
import { normalizeDimensions } from '@/services/database/quality';
|
||||||
|
|
||||||
export const updateNodeTool = tool({
|
export const updateNodeTool = tool({
|
||||||
description: 'Update node fields. Description is REQUIRED on every update and must explicitly state WHAT this is + WHY it matters.',
|
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.',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
id: z.number().describe('The ID of the node to update'),
|
id: z.number().describe('The ID of the node to update'),
|
||||||
updates: z.object({
|
updates: z.object({
|
||||||
title: z.string().optional().describe('New title'),
|
title: z.string().optional().describe('New title'),
|
||||||
description: z.string().max(280).describe('REQUIRED on every update. Explicitly state WHAT this is + WHY it matters. No "discusses/explores".'),
|
description: z.string().max(500).optional().describe('Optional natural description. Replace the whole field with one clean description when you are improving context. It should read like normal prose, not labels.'),
|
||||||
source: z.string().optional().describe('Canonical source content for embedding. Use this only to set or correct the raw source text.'),
|
source: z.string().optional().describe('Canonical source content for embedding. Use this to set or correct the raw source text. For user-authored ideas or dictated notes, preserve the user\'s original wording with only minimal cleanup rather than compressing it into a summary.'),
|
||||||
link: z.string().optional().describe('New link'),
|
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.'),
|
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
|
||||||
dimensions: z.array(z.string()).optional().describe('New dimension tags - completely replaces existing dimensions'),
|
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.'),
|
||||||
metadata: z.record(z.any()).optional().describe('New metadata - completely replaces existing metadata')
|
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.')
|
}).describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.')
|
||||||
}),
|
}),
|
||||||
execute: async ({ id, updates }) => {
|
execute: async ({ id, updates }) => {
|
||||||
@@ -27,27 +28,10 @@ export const updateNodeTool = tool({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updates.description) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Every node update requires an explicit description (WHAT this is + WHY it matters).',
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const descriptionError = validateExplicitDescription(updates.description);
|
|
||||||
if (descriptionError) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: descriptionError,
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(updates.dimensions)) {
|
if (Array.isArray(updates.dimensions)) {
|
||||||
updates.dimensions = normalizeDimensions(updates.dimensions, 5);
|
updates.dimensions = normalizeDimensions(updates.dimensions, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the nodes API endpoint
|
|
||||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`, {
|
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -79,5 +63,4 @@ export const updateNodeTool = tool({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Legacy export for backwards compatibility
|
|
||||||
export const updateItemTool = updateNodeTool;
|
export const updateItemTool = updateNodeTool;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
|
|||||||
queryDimensions: 'core',
|
queryDimensions: 'core',
|
||||||
getDimension: 'core',
|
getDimension: 'core',
|
||||||
queryDimensionNodes: 'core',
|
queryDimensionNodes: 'core',
|
||||||
|
queryContexts: 'core',
|
||||||
searchContentEmbeddings: 'core',
|
searchContentEmbeddings: 'core',
|
||||||
|
|
||||||
// Orchestration: Web search and reasoning
|
// Orchestration: Web search and reasoning
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { deleteDimensionTool } from '../database/deleteDimension';
|
|||||||
import { queryDimensionsTool } from '../database/queryDimensions';
|
import { queryDimensionsTool } from '../database/queryDimensions';
|
||||||
import { getDimensionTool } from '../database/getDimension';
|
import { getDimensionTool } from '../database/getDimension';
|
||||||
import { queryDimensionNodesTool } from '../database/queryDimensionNodes';
|
import { queryDimensionNodesTool } from '../database/queryDimensionNodes';
|
||||||
|
import { queryContextsTool } from '../database/queryContexts';
|
||||||
import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings';
|
import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings';
|
||||||
import { webSearchTool } from '../other/webSearch';
|
import { webSearchTool } from '../other/webSearch';
|
||||||
import { thinkTool } from '../other/think';
|
import { thinkTool } from '../other/think';
|
||||||
@@ -32,6 +33,7 @@ const CORE_TOOLS: Record<string, any> = {
|
|||||||
queryDimensions: queryDimensionsTool,
|
queryDimensions: queryDimensionsTool,
|
||||||
getDimension: getDimensionTool,
|
getDimension: getDimensionTool,
|
||||||
queryDimensionNodes: queryDimensionNodesTool,
|
queryDimensionNodes: queryDimensionNodesTool,
|
||||||
|
queryContexts: queryContextsTool,
|
||||||
searchContentEmbeddings: searchContentEmbeddingsTool,
|
searchContentEmbeddings: searchContentEmbeddingsTool,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,20 @@ import { z } from 'zod';
|
|||||||
import { openai } from '@ai-sdk/openai';
|
import { openai } from '@ai-sdk/openai';
|
||||||
import { generateText } from 'ai';
|
import { generateText } from 'ai';
|
||||||
import { extractPaper } from '@/services/typescript/extractors/paper';
|
import { extractPaper } from '@/services/typescript/extractors/paper';
|
||||||
|
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||||
|
import { validateExplicitDescription } from '@/services/database/quality';
|
||||||
|
|
||||||
|
function ensureNodeDescription(candidate: string | undefined, fallbackLead: string): string {
|
||||||
|
const normalizedCandidate = typeof candidate === 'string' ? candidate.trim().replace(/\s+/g, ' ') : '';
|
||||||
|
if (normalizedCandidate && !validateExplicitDescription(normalizedCandidate)) {
|
||||||
|
return normalizedCandidate.slice(0, 500);
|
||||||
|
}
|
||||||
|
const lead = normalizedCandidate || fallbackLead.trim();
|
||||||
|
const suffix = 'It was added via extraction and the exact reason it belongs in the graph is not yet inferred from the available context, and it has not been reviewed yet.';
|
||||||
|
return `${lead}${/[.!?]$/.test(lead) ? ' ' : '. '}${suffix}`.slice(0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
// AI-powered content analysis
|
|
||||||
async function analyzeContentWithAI(title: string, description: string, contentType: string) {
|
async function analyzeContentWithAI(title: string, description: string, contentType: string) {
|
||||||
try {
|
try {
|
||||||
const prompt = `Analyze this ${contentType} content and provide classification.
|
const prompt = `Analyze this ${contentType} content and provide classification.
|
||||||
@@ -13,56 +24,36 @@ async function analyzeContentWithAI(title: string, description: string, contentT
|
|||||||
Title: "${title}"
|
Title: "${title}"
|
||||||
Description: "${description}"
|
Description: "${description}"
|
||||||
|
|
||||||
CRITICAL — nodeDescription rules (max 280 chars):
|
CRITICAL — nodeDescription rules (max 500 chars):
|
||||||
1. Say WHAT this literally is: "Paper by…", "Research from…", "Preprint introducing…"
|
1. Write natural prose.
|
||||||
2. Name the authors if known from the metadata.
|
2. Make clear what this literally is.
|
||||||
3. State the actual finding, method, or contribution — not "a study on X" but what they actually found or built.
|
3. State the actual finding, method, or contribution.
|
||||||
4. End with why it matters — one concrete phrase about impact or implication.
|
4. Make clear why it belongs in the graph. If unclear, say so naturally.
|
||||||
5. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for". State things directly.
|
5. Make the workflow status clear.
|
||||||
|
|
||||||
Examples:
|
Respond with ONLY valid JSON:
|
||||||
- Title: "Attention Is All You Need" / Authors: Vaswani et al.
|
|
||||||
GOOD: "Vaswani et al. introduce the Transformer architecture — replaces recurrence with self-attention for sequence modeling. Foundation of every modern LLM."
|
|
||||||
BAD: "This paper discusses a new architecture called the Transformer and explores its applications."
|
|
||||||
|
|
||||||
- Title: "Scaling Laws for Neural Language Models" / Authors: Kaplan et al.
|
|
||||||
GOOD: "Kaplan et al. show that LLM performance scales as a power law with compute, data, and parameters — and compute matters most. The paper that launched the scaling era."
|
|
||||||
BAD: "A study examining how neural language models scale with different factors."
|
|
||||||
|
|
||||||
Respond with ONLY valid JSON (no markdown, no code blocks):
|
|
||||||
{
|
{
|
||||||
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
|
"enhancedDescription": "A comprehensive summary.",
|
||||||
"nodeDescription": "<your 280-char description following the rules above>",
|
"nodeDescription": "<your natural description>",
|
||||||
"tags": ["relevant", "semantic", "tags"],
|
"reasoning": "Brief explanation"
|
||||||
"reasoning": "Brief explanation of classification choices"
|
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
const response = await generateText({
|
const response = await generateText({
|
||||||
model: openai('gpt-4o-mini'),
|
model: openai('gpt-4o-mini'),
|
||||||
prompt,
|
prompt,
|
||||||
maxOutputTokens: 800
|
maxOutputTokens: 800
|
||||||
});
|
});
|
||||||
|
const content = (response.text || '{}').replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
|
||||||
let content = response.text || '{}';
|
|
||||||
|
|
||||||
// Clean up the response - remove markdown code blocks if present
|
|
||||||
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
|
|
||||||
|
|
||||||
const result = JSON.parse(content);
|
const result = JSON.parse(content);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enhancedDescription: result.enhancedDescription || description,
|
enhancedDescription: result.enhancedDescription || description,
|
||||||
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 280) : undefined,
|
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 500) : undefined,
|
||||||
tags: Array.isArray(result.tags) ? result.tags : [],
|
|
||||||
reasoning: result.reasoning || 'AI analysis completed'
|
reasoning: result.reasoning || 'AI analysis completed'
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'unknown error';
|
console.warn('Paper analysis fallback (using default description):', error);
|
||||||
console.warn('Paper analysis fallback (using default description):', message);
|
|
||||||
return {
|
return {
|
||||||
enhancedDescription: description,
|
enhancedDescription: description,
|
||||||
nodeDescription: undefined,
|
nodeDescription: undefined,
|
||||||
tags: [],
|
|
||||||
reasoning: 'Fallback description used'
|
reasoning: 'Fallback description used'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -73,30 +64,19 @@ export const paperExtractTool = tool({
|
|||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
url: z.string().describe('The PDF URL to add to inbox'),
|
url: z.string().describe('The PDF URL to add to inbox'),
|
||||||
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
|
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
|
||||||
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node (locked dimensions first)')
|
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node')
|
||||||
}),
|
}),
|
||||||
execute: async ({ url, title, dimensions }) => {
|
execute: async ({ url, title, dimensions }) => {
|
||||||
try {
|
try {
|
||||||
// Validate PDF URL
|
|
||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
return {
|
return { success: false, error: 'Invalid URL format - must start with http:// or https://', data: null };
|
||||||
success: false,
|
|
||||||
error: 'Invalid URL format - must start with http:// or https://',
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if URL likely points to a PDF
|
|
||||||
if (!url.toLowerCase().includes('.pdf') && !url.includes('arxiv.org')) {
|
if (!url.toLowerCase().includes('.pdf') && !url.includes('arxiv.org')) {
|
||||||
return {
|
return { success: false, error: 'URL does not appear to point to a PDF file', data: null };
|
||||||
success: false,
|
|
||||||
error: 'URL does not appear to point to a PDF file',
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: { success: boolean; source?: string; metadata?: any; error?: string };
|
let result: { success: boolean; source?: string; metadata?: any; error?: string };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const extractionResult = await extractPaper(url);
|
const extractionResult = await extractPaper(url);
|
||||||
result = {
|
result = {
|
||||||
@@ -112,91 +92,72 @@ export const paperExtractTool = tool({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
result = {
|
result = { success: false, error: error.message || 'TypeScript extraction failed' };
|
||||||
success: false,
|
|
||||||
error: error.message || 'TypeScript extraction failed'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.success || !result.source) {
|
if (!result.success || !result.source) {
|
||||||
return {
|
return { success: false, error: result.error || 'Failed to extract PDF content', data: null };
|
||||||
success: false,
|
|
||||||
error: result.error || 'Failed to extract PDF content',
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🎯 PDF extraction successful, analyzing with AI...');
|
|
||||||
|
|
||||||
// Step 2: AI Analysis for enhanced metadata
|
|
||||||
const aiAnalysis = await analyzeContentWithAI(
|
const aiAnalysis = await analyzeContentWithAI(
|
||||||
result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`,
|
result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`,
|
||||||
result.source.substring(0, 2000) || 'PDF document content',
|
result.source.substring(0, 2000) || 'PDF document content',
|
||||||
'pdf'
|
'pdf'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Step 3: Create node with extracted content and AI analysis
|
|
||||||
const nodeTitle = title || result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`;
|
const nodeTitle = title || result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`;
|
||||||
const enhancedDescription = aiAnalysis?.enhancedDescription || `PDF document from ${new URL(url).hostname}`;
|
const nodeDescription = ensureNodeDescription(
|
||||||
|
aiAnalysis?.nodeDescription,
|
||||||
|
`Research paper or PDF from ${new URL(url).hostname} titled ${nodeTitle}`
|
||||||
|
);
|
||||||
|
const trimmedDimensions = Array.isArray(dimensions) ? dimensions.slice(0, 5) : [];
|
||||||
|
|
||||||
const suppliedDimensions = Array.isArray(dimensions) ? dimensions : [];
|
const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||||
let trimmedDimensions = suppliedDimensions
|
|
||||||
.map(dim => (typeof dim === 'string' ? dim.trim() : ''))
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
trimmedDimensions = trimmedDimensions.slice(0, 5);
|
|
||||||
|
|
||||||
const createResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: nodeTitle,
|
title: nodeTitle,
|
||||||
description: aiAnalysis?.nodeDescription,
|
description: nodeDescription,
|
||||||
source: result.source,
|
source: result.source,
|
||||||
link: url,
|
link: url,
|
||||||
dimensions: trimmedDimensions,
|
dimensions: trimmedDimensions,
|
||||||
metadata: {
|
metadata: {
|
||||||
source: 'pdf',
|
type: 'pdf',
|
||||||
|
state: 'not_processed',
|
||||||
|
captured_method: 'paper_extract',
|
||||||
|
captured_by: 'agent',
|
||||||
|
source_metadata: {
|
||||||
hostname: new URL(url).hostname,
|
hostname: new URL(url).hostname,
|
||||||
author: result.metadata?.author || result.metadata?.info?.Author,
|
author: result.metadata?.author || result.metadata?.info?.Author,
|
||||||
pages: result.metadata?.pages,
|
pages: result.metadata?.pages,
|
||||||
file_size: result.metadata?.file_size,
|
|
||||||
content_length: result.source.length,
|
content_length: result.source.length,
|
||||||
extraction_method: result.metadata?.extraction_method || 'python_pdfplumber',
|
extraction_method: result.metadata?.extraction_method || 'typescript',
|
||||||
ai_analysis: aiAnalysis?.reasoning,
|
ai_analysis: aiAnalysis?.reasoning,
|
||||||
enhanced_description: enhancedDescription,
|
enhanced_description: aiAnalysis?.enhancedDescription,
|
||||||
refined_at: new Date().toISOString()
|
refined_at: new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const createResult = await createResponse.json();
|
const createResult = await createResponse.json();
|
||||||
|
|
||||||
if (!createResponse.ok) {
|
if (!createResponse.ok) {
|
||||||
return {
|
return { success: false, error: createResult.error || 'Failed to create node', data: null };
|
||||||
success: false,
|
|
||||||
error: createResult.error || 'Failed to create node',
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🎯 PaperExtract completed successfully');
|
|
||||||
|
|
||||||
// Use actual assigned dimensions from API response (includes auto-assigned locked + keywords)
|
|
||||||
const actualDimensions: string[] = createResult.data?.dimensions || trimmedDimensions || [];
|
const actualDimensions: string[] = createResult.data?.dimensions || trimmedDimensions || [];
|
||||||
const formattedNode = createResult.data?.id
|
const formattedNode = createResult.data?.id
|
||||||
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
|
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
|
||||||
: nodeTitle;
|
: nodeTitle;
|
||||||
const dimsDisplay = actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Added ${formattedNode} with dimensions: ${dimsDisplay}`,
|
message: `Added ${formattedNode} with dimensions: ${actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none'}`,
|
||||||
data: {
|
data: {
|
||||||
nodeId: createResult.data?.id,
|
nodeId: createResult.data?.id,
|
||||||
title: nodeTitle,
|
title: nodeTitle,
|
||||||
contentLength: result.source.length,
|
contentLength: result.source.length,
|
||||||
url: url,
|
url,
|
||||||
dimensions: actualDimensions
|
dimensions: actualDimensions
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,13 +3,25 @@ import { z } from 'zod';
|
|||||||
import { openai } from '@ai-sdk/openai';
|
import { openai } from '@ai-sdk/openai';
|
||||||
import { generateText } from 'ai';
|
import { generateText } from 'ai';
|
||||||
import { extractWebsite } from '@/services/typescript/extractors/website';
|
import { extractWebsite } from '@/services/typescript/extractors/website';
|
||||||
|
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||||
|
import { validateExplicitDescription } from '@/services/database/quality';
|
||||||
|
|
||||||
interface ExistingDimension {
|
interface ExistingDimension {
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureNodeDescription(candidate: string | undefined, fallbackLead: string): string {
|
||||||
|
const normalizedCandidate = typeof candidate === 'string' ? candidate.trim().replace(/\s+/g, ' ') : '';
|
||||||
|
if (normalizedCandidate && !validateExplicitDescription(normalizedCandidate)) {
|
||||||
|
return normalizedCandidate.slice(0, 500);
|
||||||
|
}
|
||||||
|
const lead = normalizedCandidate || fallbackLead.trim();
|
||||||
|
const suffix = 'It was added via extraction and the exact reason it belongs in the graph is not yet inferred from the available context, and it has not been reviewed yet.';
|
||||||
|
return `${lead}${/[.!?]$/.test(lead) ? ' ' : '. '}${suffix}`.slice(0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
function inferWebsiteContentType(url: string): 'website' | 'tweet' {
|
function inferWebsiteContentType(url: string): 'website' | 'tweet' {
|
||||||
try {
|
try {
|
||||||
const hostname = new URL(url).hostname.toLowerCase();
|
const hostname = new URL(url).hostname.toLowerCase();
|
||||||
@@ -23,12 +35,10 @@ function inferWebsiteContentType(url: string): 'website' | 'tweet' {
|
|||||||
|
|
||||||
async function fetchExistingDimensions(): Promise<ExistingDimension[]> {
|
async function fetchExistingDimensions(): Promise<ExistingDimension[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions/popular`);
|
const response = await fetch(`${getInternalApiBaseUrl()}/api/dimensions/popular`);
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (!Array.isArray(result.data)) return [];
|
if (!Array.isArray(result.data)) return [];
|
||||||
|
|
||||||
return result.data
|
return result.data
|
||||||
.map((dimension: { dimension?: unknown; description?: unknown }) => ({
|
.map((dimension: { dimension?: unknown; description?: unknown }) => ({
|
||||||
name: typeof dimension.dimension === 'string' ? dimension.dimension.trim() : '',
|
name: typeof dimension.dimension === 'string' ? dimension.dimension.trim() : '',
|
||||||
@@ -41,17 +51,11 @@ async function fetchExistingDimensions(): Promise<ExistingDimension[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectExistingDimensions(
|
function selectExistingDimensions(selected: unknown, existingDimensions: ExistingDimension[], max = 5): string[] {
|
||||||
selected: unknown,
|
|
||||||
existingDimensions: ExistingDimension[],
|
|
||||||
max = 5
|
|
||||||
): string[] {
|
|
||||||
if (!Array.isArray(selected) || existingDimensions.length === 0) return [];
|
if (!Array.isArray(selected) || existingDimensions.length === 0) return [];
|
||||||
|
|
||||||
const byLowerName = new Map(existingDimensions.map((dimension) => [dimension.name.toLowerCase(), dimension.name]));
|
const byLowerName = new Map(existingDimensions.map((dimension) => [dimension.name.toLowerCase(), dimension.name]));
|
||||||
const normalized: string[] = [];
|
const normalized: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
for (const value of selected) {
|
for (const value of selected) {
|
||||||
if (typeof value !== 'string') continue;
|
if (typeof value !== 'string') continue;
|
||||||
const matched = byLowerName.get(value.trim().toLowerCase());
|
const matched = byLowerName.get(value.trim().toLowerCase());
|
||||||
@@ -62,89 +66,53 @@ function selectExistingDimensions(
|
|||||||
normalized.push(matched);
|
normalized.push(matched);
|
||||||
if (normalized.length >= max) break;
|
if (normalized.length >= max) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI-powered content analysis
|
async function analyzeContentWithAI(title: string, description: string, contentType: string, existingDimensions: ExistingDimension[]) {
|
||||||
async function analyzeContentWithAI(
|
|
||||||
title: string,
|
|
||||||
description: string,
|
|
||||||
contentType: string,
|
|
||||||
existingDimensions: ExistingDimension[]
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
const availableDimensionsBlock = existingDimensions.length > 0
|
const availableDimensionsBlock = existingDimensions.length > 0
|
||||||
? existingDimensions
|
? existingDimensions.map((dimension) => `- ${dimension.name}${dimension.description ? `: ${dimension.description}` : ''}`).join('\n')
|
||||||
.map((dimension) => `- ${dimension.name}${dimension.description ? `: ${dimension.description}` : ''}`)
|
|
||||||
.join('\n')
|
|
||||||
: '- No existing dimensions available. Return an empty dimensions array.';
|
: '- No existing dimensions available. Return an empty dimensions array.';
|
||||||
const prompt = `Analyze this ${contentType} content and provide classification.
|
const prompt = `Analyze this ${contentType} content and provide classification.
|
||||||
|
|
||||||
Title: "${title}"
|
Title: "${title}"
|
||||||
Description: "${description}"
|
Description: "${description}"
|
||||||
|
|
||||||
CRITICAL — nodeDescription rules (max 280 chars):
|
CRITICAL — nodeDescription rules (max 500 chars):
|
||||||
1. Say WHAT this literally is using explicit entity words only: "Blog post by…", "Article from…", "Essay arguing…", "Tutorial on…", "Thread by…", "Tweet by…", "Post by…"
|
1. Write natural prose, not labels or a checklist.
|
||||||
2. Name the author/site if known from the metadata.
|
2. Make clear what this literally is using explicit entity words only.
|
||||||
3. State the actual claim or thesis — don't paraphrase into vague abstractions.
|
3. Name the author/site if known from the metadata.
|
||||||
4. End with why it's interesting or important — one concrete phrase.
|
4. State the actual claim or thesis.
|
||||||
5. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for". State things directly.
|
5. Make clear why it belongs in the graph. If that remains unclear, say so naturally.
|
||||||
|
6. Make workflow status clear.
|
||||||
DIMENSION SELECTION (critical):
|
|
||||||
You must select 0-3 dimensions from the list below.
|
|
||||||
Do NOT invent new dimension names.
|
|
||||||
Pick only dimensions that genuinely fit this content.
|
|
||||||
If nothing fits, return an empty array.
|
|
||||||
|
|
||||||
Available dimensions:
|
Available dimensions:
|
||||||
${availableDimensionsBlock}
|
${availableDimensionsBlock}
|
||||||
|
|
||||||
Examples:
|
Respond with ONLY valid JSON:
|
||||||
- Title: "Software is eating the world — again" / Author: Andrej Karpathy
|
|
||||||
GOOD: "Karpathy's blog post arguing AI agents make software fluid — they can rip functionality from repos instead of taking dependencies. Signals the end of monolithic libraries."
|
|
||||||
BAD: "By Karpathy — discusses the importance of software becoming more fluid and malleable with agents."
|
|
||||||
|
|
||||||
- Title: "The case for slowing down AI" / Site: The Atlantic
|
|
||||||
GOOD: "Atlantic article making the case that AI labs should voluntarily slow capability research until safety catches up. Notable because it cites internal lab disagreements."
|
|
||||||
BAD: "This article explores ideas about slowing down AI development and its implications."
|
|
||||||
|
|
||||||
Respond with ONLY valid JSON (no markdown, no code blocks):
|
|
||||||
{
|
{
|
||||||
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
|
"enhancedDescription": "A comprehensive summary.",
|
||||||
"nodeDescription": "<your 280-char description following the rules above>",
|
"nodeDescription": "<your natural description>",
|
||||||
"dimensions": ["existing-dimension-1", "existing-dimension-2"],
|
"dimensions": ["existing-dimension-1"],
|
||||||
"reasoning": "Brief explanation of classification choices"
|
"reasoning": "Brief explanation"
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
const response = await generateText({
|
const response = await generateText({
|
||||||
model: openai('gpt-4o-mini'),
|
model: openai('gpt-4o-mini'),
|
||||||
prompt,
|
prompt,
|
||||||
maxOutputTokens: 800
|
maxOutputTokens: 800
|
||||||
});
|
});
|
||||||
|
const content = (response.text || '{}').replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
|
||||||
let content = response.text || '{}';
|
|
||||||
|
|
||||||
// Clean up the response - remove markdown code blocks if present
|
|
||||||
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
|
|
||||||
|
|
||||||
const result = JSON.parse(content);
|
const result = JSON.parse(content);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enhancedDescription: result.enhancedDescription || description,
|
enhancedDescription: result.enhancedDescription || description,
|
||||||
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 280) : undefined,
|
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 500) : undefined,
|
||||||
dimensions: selectExistingDimensions(result.dimensions, existingDimensions, 5),
|
dimensions: selectExistingDimensions(result.dimensions, existingDimensions, 5),
|
||||||
reasoning: result.reasoning || 'AI analysis completed'
|
reasoning: result.reasoning || 'AI analysis completed'
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'unknown error';
|
console.warn('Website analysis fallback (using default description):', error);
|
||||||
console.warn('Website analysis fallback (using default description):', message);
|
return { enhancedDescription: description, nodeDescription: undefined, dimensions: [], reasoning: 'Fallback description used' };
|
||||||
return {
|
|
||||||
enhancedDescription: description,
|
|
||||||
nodeDescription: undefined,
|
|
||||||
dimensions: [],
|
|
||||||
reasoning: 'Fallback description used'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,21 +121,15 @@ export const websiteExtractTool = tool({
|
|||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
url: z.string().describe('The website URL to add to knowledge base'),
|
url: z.string().describe('The website URL to add to knowledge base'),
|
||||||
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
|
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
|
||||||
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node (locked dimensions first)')
|
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node')
|
||||||
}),
|
}),
|
||||||
execute: async ({ url, title, dimensions }) => {
|
execute: async ({ url, title, dimensions }) => {
|
||||||
try {
|
try {
|
||||||
// Validate URL format
|
|
||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
return {
|
return { success: false, error: 'Invalid URL format - must start with http:// or https://', data: null };
|
||||||
success: false,
|
|
||||||
error: 'Invalid URL format - must start with http:// or https://',
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: { success: boolean; source?: string; metadata?: any; error?: string };
|
let result: { success: boolean; source?: string; metadata?: any; error?: string };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const extractionResult = await extractWebsite(url);
|
const extractionResult = await extractWebsite(url);
|
||||||
result = {
|
result = {
|
||||||
@@ -184,97 +146,75 @@ export const websiteExtractTool = tool({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
result = {
|
result = { success: false, error: error.message || 'TypeScript extraction failed' };
|
||||||
success: false,
|
|
||||||
error: error.message || 'TypeScript extraction failed'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.success || !result.source) {
|
if (!result.success || !result.source) {
|
||||||
return {
|
return { success: false, error: result.error || 'Failed to extract website content', data: null };
|
||||||
success: false,
|
|
||||||
error: result.error || 'Failed to extract website content',
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🎯 Website extraction successful, analyzing with AI...');
|
|
||||||
|
|
||||||
// Step 2: AI Analysis for enhanced metadata
|
|
||||||
const existingDimensions = await fetchExistingDimensions();
|
const existingDimensions = await fetchExistingDimensions();
|
||||||
const contentType = inferWebsiteContentType(url);
|
|
||||||
const aiAnalysis = await analyzeContentWithAI(
|
const aiAnalysis = await analyzeContentWithAI(
|
||||||
result.metadata?.title || `Website: ${new URL(url).hostname}`,
|
result.metadata?.title || `Website: ${new URL(url).hostname}`,
|
||||||
result.source.substring(0, 2000) || 'Website content',
|
result.source.substring(0, 2000) || 'Website content',
|
||||||
contentType,
|
inferWebsiteContentType(url),
|
||||||
existingDimensions
|
existingDimensions
|
||||||
);
|
);
|
||||||
|
|
||||||
// Step 3: Create node with extracted content and AI analysis
|
|
||||||
const nodeTitle = title || result.metadata?.title || `Website: ${new URL(url).hostname}`;
|
const nodeTitle = title || result.metadata?.title || `Website: ${new URL(url).hostname}`;
|
||||||
const enhancedDescription = aiAnalysis?.enhancedDescription || `Website content from ${new URL(url).hostname}`;
|
const suppliedDimensions = Array.isArray(dimensions) ? dimensions.slice(0, 5) : [];
|
||||||
|
const finalDimensions = suppliedDimensions.length > 0 ? suppliedDimensions : (aiAnalysis?.dimensions || []).slice(0, 5);
|
||||||
|
const nodeDescription = ensureNodeDescription(
|
||||||
|
aiAnalysis?.nodeDescription,
|
||||||
|
`Website article from ${result.metadata?.site_name || new URL(url).hostname} titled ${nodeTitle}`
|
||||||
|
);
|
||||||
|
|
||||||
const suppliedDimensions = Array.isArray(dimensions) ? dimensions : [];
|
const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||||
let trimmedDimensions = suppliedDimensions
|
|
||||||
.map(dim => (typeof dim === 'string' ? dim.trim() : ''))
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
trimmedDimensions = trimmedDimensions.slice(0, 5);
|
|
||||||
const finalDimensions = trimmedDimensions.length > 0
|
|
||||||
? trimmedDimensions
|
|
||||||
: (aiAnalysis?.dimensions || []).slice(0, 5);
|
|
||||||
|
|
||||||
const createResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: nodeTitle,
|
title: nodeTitle,
|
||||||
description: aiAnalysis?.nodeDescription,
|
description: nodeDescription,
|
||||||
source: result.source,
|
source: result.source,
|
||||||
link: url,
|
link: url,
|
||||||
event_date: result.metadata?.published_date || result.metadata?.date || null,
|
|
||||||
dimensions: finalDimensions,
|
dimensions: finalDimensions,
|
||||||
metadata: {
|
metadata: {
|
||||||
source: contentType,
|
type: inferWebsiteContentType(url),
|
||||||
|
state: 'not_processed',
|
||||||
|
captured_method: 'website_extract',
|
||||||
|
captured_by: 'agent',
|
||||||
|
source_metadata: {
|
||||||
hostname: new URL(url).hostname,
|
hostname: new URL(url).hostname,
|
||||||
author: result.metadata?.author,
|
author: result.metadata?.author,
|
||||||
published_date: result.metadata?.published_date || result.metadata?.date,
|
site_name: result.metadata?.site_name,
|
||||||
content_length: result.source.length,
|
date: result.metadata?.date,
|
||||||
extraction_method: result.metadata?.extraction_method || 'python_beautifulsoup',
|
og_image: result.metadata?.og_image,
|
||||||
|
extraction_method: result.metadata?.extraction_method,
|
||||||
ai_analysis: aiAnalysis?.reasoning,
|
ai_analysis: aiAnalysis?.reasoning,
|
||||||
enhanced_description: enhancedDescription,
|
|
||||||
refined_at: new Date().toISOString()
|
refined_at: new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const createResult = await createResponse.json();
|
const createResult = await createResponse.json();
|
||||||
|
|
||||||
if (!createResponse.ok) {
|
if (!createResponse.ok) {
|
||||||
return {
|
return { success: false, error: createResult.error || 'Failed to create node', data: null };
|
||||||
success: false,
|
|
||||||
error: createResult.error || 'Failed to create node',
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🎯 WebsiteExtract completed successfully');
|
|
||||||
|
|
||||||
// Use actual assigned dimensions from API response (includes auto-assigned locked + keywords)
|
|
||||||
const actualDimensions: string[] = createResult.data?.dimensions || finalDimensions || [];
|
const actualDimensions: string[] = createResult.data?.dimensions || finalDimensions || [];
|
||||||
const formattedNode = createResult.data?.id
|
const formattedNode = createResult.data?.id
|
||||||
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
|
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
|
||||||
: nodeTitle;
|
: nodeTitle;
|
||||||
const dimsDisplay = actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Added ${formattedNode} with dimensions: ${dimsDisplay}`,
|
message: `Added ${formattedNode} with dimensions: ${actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none'}`,
|
||||||
data: {
|
data: {
|
||||||
nodeId: createResult.data?.id,
|
nodeId: createResult.data?.id,
|
||||||
title: nodeTitle,
|
title: nodeTitle,
|
||||||
contentLength: result.source.length,
|
contentLength: result.source.length,
|
||||||
url: url,
|
url,
|
||||||
dimensions: actualDimensions
|
dimensions: actualDimensions
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,16 +3,33 @@ import { z } from 'zod';
|
|||||||
import { openai } from '@ai-sdk/openai';
|
import { openai } from '@ai-sdk/openai';
|
||||||
import { generateText } from 'ai';
|
import { generateText } from 'ai';
|
||||||
import { extractYouTube } from '@/services/typescript/extractors/youtube';
|
import { extractYouTube } from '@/services/typescript/extractors/youtube';
|
||||||
|
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||||
|
import { validateExplicitDescription } from '@/services/database/quality';
|
||||||
|
|
||||||
interface ExistingDimension {
|
interface ExistingDimension {
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureNodeDescription(candidate: string | undefined, fallbackLead: string): string {
|
||||||
|
const normalizedCandidate = typeof candidate === 'string'
|
||||||
|
? candidate.trim().replace(/\s+/g, ' ')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (normalizedCandidate && !validateExplicitDescription(normalizedCandidate)) {
|
||||||
|
return normalizedCandidate.slice(0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lead = normalizedCandidate || fallbackLead.trim();
|
||||||
|
const suffix = 'It was added via extraction and the exact reason it belongs in the graph is not yet inferred from the available context, and it has not been reviewed yet.';
|
||||||
|
const joined = `${lead}${/[.!?]$/.test(lead) ? ' ' : '. '}${suffix}`;
|
||||||
|
return joined.slice(0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchExistingDimensions(): Promise<ExistingDimension[]> {
|
async function fetchExistingDimensions(): Promise<ExistingDimension[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions/popular`);
|
const response = await fetch(`${getInternalApiBaseUrl()}/api/dimensions/popular`);
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -30,11 +47,7 @@ async function fetchExistingDimensions(): Promise<ExistingDimension[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectExistingDimensions(
|
function selectExistingDimensions(selected: unknown, existingDimensions: ExistingDimension[], max = 5): string[] {
|
||||||
selected: unknown,
|
|
||||||
existingDimensions: ExistingDimension[],
|
|
||||||
max = 5
|
|
||||||
): string[] {
|
|
||||||
if (!Array.isArray(selected) || existingDimensions.length === 0) return [];
|
if (!Array.isArray(selected) || existingDimensions.length === 0) return [];
|
||||||
|
|
||||||
const byLowerName = new Map(existingDimensions.map((dimension) => [dimension.name.toLowerCase(), dimension.name]));
|
const byLowerName = new Map(existingDimensions.map((dimension) => [dimension.name.toLowerCase(), dimension.name]));
|
||||||
@@ -55,55 +68,39 @@ function selectExistingDimensions(
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI-powered content analysis
|
async function analyzeContentWithAI(title: string, description: string, contentType: string, existingDimensions: ExistingDimension[]) {
|
||||||
async function analyzeContentWithAI(
|
|
||||||
title: string,
|
|
||||||
description: string,
|
|
||||||
contentType: string,
|
|
||||||
existingDimensions: ExistingDimension[]
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
const availableDimensionsBlock = existingDimensions.length > 0
|
const availableDimensionsBlock = existingDimensions.length > 0
|
||||||
? existingDimensions
|
? existingDimensions.map((dimension) => `- ${dimension.name}${dimension.description ? `: ${dimension.description}` : ''}`).join('\n')
|
||||||
.map((dimension) => `- ${dimension.name}${dimension.description ? `: ${dimension.description}` : ''}`)
|
|
||||||
.join('\n')
|
|
||||||
: '- No existing dimensions available. Return an empty dimensions array.';
|
: '- No existing dimensions available. Return an empty dimensions array.';
|
||||||
|
|
||||||
const prompt = `Analyze this ${contentType} content and provide classification.
|
const prompt = `Analyze this ${contentType} content and provide classification.
|
||||||
|
|
||||||
Title: "${title}"
|
Title: "${title}"
|
||||||
Description: "${description}"
|
Description: "${description}"
|
||||||
|
|
||||||
CRITICAL — nodeDescription rules (max 280 chars):
|
CRITICAL — nodeDescription rules (max 500 chars):
|
||||||
1. Say WHAT this literally is: "Podcast episode where…", "Talk by…", "Interview with…", "Video essay on…"
|
1. Write natural prose, not labels or a checklist.
|
||||||
2. Name people by their role: the channel/host is the creator, anyone in the title is likely the guest or subject.
|
2. Make clear what this literally is: "Podcast episode where…", "Talk by…", "Interview with…", "Video essay on…"
|
||||||
3. State the actual claim or thesis from the title — don't paraphrase into vague abstractions.
|
3. Name people by their role: the channel/host is the creator, anyone in the title is likely the guest or subject.
|
||||||
4. End with why it's interesting or important — one concrete phrase.
|
4. State the actual claim or thesis from the title.
|
||||||
5. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for". State things directly.
|
5. Make clear why it belongs in the graph. If that cannot be inferred, say so naturally.
|
||||||
|
6. Make the workflow status clear. If unknown, say naturally that it has not been reviewed yet.
|
||||||
|
7. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for", "insightful for understanding", "relevant to".
|
||||||
|
|
||||||
DIMENSION SELECTION (critical):
|
DIMENSION SELECTION:
|
||||||
You must select 0-3 dimensions from the list below.
|
You must select 0-3 dimensions from the list below.
|
||||||
Do NOT invent new dimension names.
|
Do NOT invent new dimension names.
|
||||||
Pick only dimensions that genuinely fit this content.
|
|
||||||
If nothing fits, return an empty array.
|
|
||||||
|
|
||||||
Available dimensions:
|
Available dimensions:
|
||||||
${availableDimensionsBlock}
|
${availableDimensionsBlock}
|
||||||
|
|
||||||
Examples:
|
Respond with ONLY valid JSON:
|
||||||
- Title: "Dario Amodei — We are near the end of the exponential" / Channel: Dwarkesh Patel
|
|
||||||
GOOD: "Dwarkesh Patel interview with Anthropic CEO Dario Amodei — argues we're nearing the end of exponential AI scaling. Key signal for what the next phase of AI development looks like."
|
|
||||||
BAD: "By Dario Amodei — discusses reaching the limits of exponential growth in AI, emphasizing the need for a critical perspective."
|
|
||||||
|
|
||||||
- Title: "The spell of language models" / Channel: Andrej Karpathy
|
|
||||||
GOOD: "Karpathy talk on how LLMs work under the hood — tokenization, attention, and why they feel like magic but aren't. Essential mental model for anyone building with LLMs."
|
|
||||||
BAD: "By Andrej Karpathy — explores the nature of language models and their capabilities."
|
|
||||||
|
|
||||||
Respond with ONLY valid JSON (no markdown, no code blocks):
|
|
||||||
{
|
{
|
||||||
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
|
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars).",
|
||||||
"nodeDescription": "<your 280-char description following the rules above>",
|
"nodeDescription": "<your natural description>",
|
||||||
"dimensions": ["existing-dimension-1", "existing-dimension-2"],
|
"dimensions": ["existing-dimension-1"],
|
||||||
"reasoning": "Brief explanation of classification choices"
|
"reasoning": "Brief explanation"
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
const response = await generateText({
|
const response = await generateText({
|
||||||
@@ -112,22 +109,17 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
|
|||||||
maxOutputTokens: 800
|
maxOutputTokens: 800
|
||||||
});
|
});
|
||||||
|
|
||||||
let content = response.text || '{}';
|
const content = (response.text || '{}').replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
|
||||||
|
|
||||||
// Clean up the response - remove markdown code blocks if present
|
|
||||||
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
|
|
||||||
|
|
||||||
const result = JSON.parse(content);
|
const result = JSON.parse(content);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enhancedDescription: result.enhancedDescription || description,
|
enhancedDescription: result.enhancedDescription || description,
|
||||||
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 280) : undefined,
|
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 500) : undefined,
|
||||||
dimensions: selectExistingDimensions(result.dimensions, existingDimensions, 5),
|
dimensions: selectExistingDimensions(result.dimensions, existingDimensions, 5),
|
||||||
reasoning: result.reasoning || 'AI analysis completed'
|
reasoning: result.reasoning || 'AI analysis completed'
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'unknown error';
|
console.warn('YouTube analysis fallback (using default description):', error);
|
||||||
console.warn('YouTube analysis fallback (using default description):', message);
|
|
||||||
return {
|
return {
|
||||||
enhancedDescription: description,
|
enhancedDescription: description,
|
||||||
nodeDescription: undefined,
|
nodeDescription: undefined,
|
||||||
@@ -142,29 +134,21 @@ async function summariseTranscript(title: string, transcript: string): Promise<s
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit transcript length to keep token costs manageable
|
const excerpt = transcript.trim().length > 16000
|
||||||
const MAX_CHARS = 16000;
|
? `${transcript.trim().slice(0, 8000)}\n[...]\n${transcript.trim().slice(-8000)}`
|
||||||
let excerpt = transcript.trim();
|
: transcript.trim();
|
||||||
if (excerpt.length > MAX_CHARS) {
|
|
||||||
const head = excerpt.slice(0, MAX_CHARS / 2);
|
|
||||||
const tail = excerpt.slice(-MAX_CHARS / 2);
|
|
||||||
excerpt = `${head}\n[...]\n${tail}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prompt = `You are summarising a long-form recording for a knowledge graph entry. Title: "${title}".
|
|
||||||
|
|
||||||
Using the transcript excerpt below, write a concise 3-4 sentence summary covering the main themes, notable claims, and outcomes. If specific terms, frameworks, or memorable lines appear, mention them. Keep the tone factual (no marketing language). If the excerpt appears truncated, note that the summary is based on the portion provided.
|
|
||||||
|
|
||||||
Transcript excerpt:
|
|
||||||
"""
|
|
||||||
${excerpt}
|
|
||||||
"""
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await generateText({
|
const response = await generateText({
|
||||||
model: openai('gpt-4o-mini'),
|
model: openai('gpt-4o-mini'),
|
||||||
prompt,
|
prompt: `You are summarising a long-form recording for a knowledge graph entry. Title: "${title}".
|
||||||
|
|
||||||
|
Using the transcript excerpt below, write a concise 3-4 sentence summary covering the main themes, notable claims, and outcomes. Keep the tone factual.
|
||||||
|
|
||||||
|
Transcript excerpt:
|
||||||
|
"""
|
||||||
|
${excerpt}
|
||||||
|
"""`,
|
||||||
maxOutputTokens: 400
|
maxOutputTokens: 400
|
||||||
});
|
});
|
||||||
return response.text?.trim() || null;
|
return response.text?.trim() || null;
|
||||||
@@ -179,23 +163,17 @@ export const youtubeExtractTool = tool({
|
|||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
url: z.string().describe('The YouTube video URL to add to knowledge base'),
|
url: z.string().describe('The YouTube video URL to add to knowledge base'),
|
||||||
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
|
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
|
||||||
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node (locked dimensions first)')
|
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node')
|
||||||
}),
|
}),
|
||||||
execute: async ({ url, title, dimensions }) => {
|
execute: async ({ url, title, dimensions }) => {
|
||||||
console.log('🎯 YouTubeExtract tool called with URL:', url);
|
console.log('🎯 YouTubeExtract tool called with URL:', url);
|
||||||
try {
|
try {
|
||||||
// Validate YouTube URL
|
|
||||||
if (!url.includes('youtube.com') && !url.includes('youtu.be')) {
|
if (!url.includes('youtube.com') && !url.includes('youtu.be')) {
|
||||||
return {
|
return { success: false, error: 'Invalid YouTube URL format', data: null };
|
||||||
success: false,
|
|
||||||
error: 'Invalid YouTube URL format',
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: { success: boolean; source?: string; metadata?: any; error?: string };
|
let result: { success: boolean; source?: string; metadata?: any; error?: string };
|
||||||
|
|
||||||
console.log('📝 Using TypeScript yt-dlp extractor');
|
|
||||||
try {
|
try {
|
||||||
const extractionResult = await extractYouTube(url);
|
const extractionResult = await extractYouTube(url);
|
||||||
result = {
|
result = {
|
||||||
@@ -215,23 +193,13 @@ export const youtubeExtractTool = tool({
|
|||||||
error: extractionResult.error
|
error: extractionResult.error
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
result = {
|
result = { success: false, error: error.message || 'TypeScript extraction failed' };
|
||||||
success: false,
|
|
||||||
error: error.message || 'TypeScript extraction failed'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.success || !result.source) {
|
if (!result.success || !result.source) {
|
||||||
return {
|
return { success: false, error: result.error || 'Failed to extract YouTube content', data: null };
|
||||||
success: false,
|
|
||||||
error: result.error || 'Failed to extract YouTube content',
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🎯 YouTube extraction successful, analyzing with AI...');
|
|
||||||
|
|
||||||
// Step 2: AI Analysis for enhanced metadata
|
|
||||||
const existingDimensions = await fetchExistingDimensions();
|
const existingDimensions = await fetchExistingDimensions();
|
||||||
const aiAnalysis = await analyzeContentWithAI(
|
const aiAnalysis = await analyzeContentWithAI(
|
||||||
result.metadata?.video_title || 'YouTube Video',
|
result.metadata?.video_title || 'YouTube Video',
|
||||||
@@ -240,31 +208,32 @@ export const youtubeExtractTool = tool({
|
|||||||
existingDimensions
|
existingDimensions
|
||||||
);
|
);
|
||||||
|
|
||||||
// Step 3: Create node with extracted content and AI analysis
|
|
||||||
const nodeTitle = title || result.metadata?.video_title || `YouTube Video ${url.split('/').pop()?.split('?')[0]}`;
|
const nodeTitle = title || result.metadata?.video_title || `YouTube Video ${url.split('/').pop()?.split('?')[0]}`;
|
||||||
const transcriptSummary = await summariseTranscript(nodeTitle, result.source);
|
const transcriptSummary = await summariseTranscript(nodeTitle, result.source);
|
||||||
|
|
||||||
const suppliedDimensions = Array.isArray(dimensions) ? dimensions : [];
|
const suppliedDimensions = Array.isArray(dimensions) ? dimensions : [];
|
||||||
let trimmedDimensions = suppliedDimensions
|
const finalDimensions = suppliedDimensions.slice(0, 5).length > 0
|
||||||
.map(dim => (typeof dim === 'string' ? dim.trim() : ''))
|
? suppliedDimensions.slice(0, 5)
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
trimmedDimensions = trimmedDimensions.slice(0, 5);
|
|
||||||
const finalDimensions = trimmedDimensions.length > 0
|
|
||||||
? trimmedDimensions
|
|
||||||
: (aiAnalysis?.dimensions || []).slice(0, 5);
|
: (aiAnalysis?.dimensions || []).slice(0, 5);
|
||||||
|
const nodeDescription = ensureNodeDescription(
|
||||||
|
aiAnalysis?.nodeDescription,
|
||||||
|
transcriptSummary || `YouTube video by ${result.metadata?.channel_name || 'an unknown creator'} about ${nodeTitle}`
|
||||||
|
);
|
||||||
|
|
||||||
const createResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: nodeTitle,
|
title: nodeTitle,
|
||||||
description: aiAnalysis?.nodeDescription,
|
description: nodeDescription,
|
||||||
source: result.source,
|
source: result.source,
|
||||||
link: url,
|
link: url,
|
||||||
dimensions: finalDimensions,
|
dimensions: finalDimensions,
|
||||||
metadata: {
|
metadata: {
|
||||||
source: 'youtube',
|
type: 'youtube',
|
||||||
|
state: 'not_processed',
|
||||||
|
captured_method: 'youtube_extract',
|
||||||
|
captured_by: 'agent',
|
||||||
|
source_metadata: {
|
||||||
video_id: result.metadata?.video_id,
|
video_id: result.metadata?.video_id,
|
||||||
channel_name: result.metadata?.channel_name,
|
channel_name: result.metadata?.channel_name,
|
||||||
channel_url: result.metadata?.channel_url,
|
channel_url: result.metadata?.channel_url,
|
||||||
@@ -277,36 +246,28 @@ export const youtubeExtractTool = tool({
|
|||||||
summary_origin: transcriptSummary ? 'transcript_summary' : 'metadata_description',
|
summary_origin: transcriptSummary ? 'transcript_summary' : 'metadata_description',
|
||||||
refined_at: new Date().toISOString()
|
refined_at: new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const createResult = await createResponse.json();
|
const createResult = await createResponse.json();
|
||||||
|
|
||||||
if (!createResponse.ok) {
|
if (!createResponse.ok) {
|
||||||
return {
|
return { success: false, error: createResult.error || 'Failed to create item', data: null };
|
||||||
success: false,
|
|
||||||
error: createResult.error || 'Failed to create item',
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🎯 YouTubeExtract completed successfully');
|
|
||||||
|
|
||||||
// Use actual assigned dimensions from API response (includes auto-assigned locked + keywords)
|
|
||||||
const actualDimensions: string[] = createResult.data?.dimensions || finalDimensions || [];
|
const actualDimensions: string[] = createResult.data?.dimensions || finalDimensions || [];
|
||||||
const formattedNode = createResult.data?.id
|
const formattedNode = createResult.data?.id
|
||||||
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
|
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
|
||||||
: nodeTitle;
|
: nodeTitle;
|
||||||
const dimsDisplay = actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Added ${formattedNode} with dimensions: ${dimsDisplay}`,
|
message: `Added ${formattedNode} with dimensions: ${actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none'}`,
|
||||||
data: {
|
data: {
|
||||||
nodeId: createResult.data?.id,
|
nodeId: createResult.data?.id,
|
||||||
title: nodeTitle,
|
title: nodeTitle,
|
||||||
contentLength: result.source.length,
|
contentLength: result.source.length,
|
||||||
url: url,
|
url,
|
||||||
dimensions: actualDimensions
|
dimensions: actualDimensions
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+33
-1
@@ -1,3 +1,32 @@
|
|||||||
|
export type NodeMetadataState = 'processed' | 'not_processed';
|
||||||
|
export type NodeCapturedBy = 'human' | 'agent';
|
||||||
|
|
||||||
|
export interface CanonicalNodeMetadata {
|
||||||
|
type?: string;
|
||||||
|
state?: NodeMetadataState;
|
||||||
|
captured_method?: string;
|
||||||
|
captured_by?: NodeCapturedBy;
|
||||||
|
source_metadata?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Context {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
// New Node-based type system replacing rigid Item categorization
|
// New Node-based type system replacing rigid Item categorization
|
||||||
export interface Node {
|
export interface Node {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -10,10 +39,12 @@ export interface Node {
|
|||||||
dimensions: string[]; // Flexible dimensions replacing type + stage + segment + tags
|
dimensions: string[]; // Flexible dimensions replacing type + stage + segment + tags
|
||||||
embedding?: Buffer; // Node-level embedding (BLOB data)
|
embedding?: Buffer; // Node-level embedding (BLOB data)
|
||||||
chunk?: string; // Deprecated legacy field - do not write
|
chunk?: string; // Deprecated legacy field - do not write
|
||||||
metadata?: any; // Flexible metadata storage
|
metadata?: CanonicalNodeMetadata | null; // Flexible metadata storage with canonical contract
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
edge_count?: number; // Derived count of edges, included in some queries
|
edge_count?: number; // Derived count of edges, included in some queries
|
||||||
|
context_id?: number | null;
|
||||||
|
context?: Pick<Context, 'id' | 'name' | 'description' | 'icon'> | null;
|
||||||
|
|
||||||
// Optional embedding fields
|
// Optional embedding fields
|
||||||
embedding_updated_at?: string;
|
embedding_updated_at?: string;
|
||||||
@@ -67,6 +98,7 @@ export interface EdgeContext {
|
|||||||
// New NodeFilters interface replacing rigid ItemFilters
|
// New NodeFilters interface replacing rigid ItemFilters
|
||||||
export interface NodeFilters {
|
export interface NodeFilters {
|
||||||
dimensions?: string[]; // Filter by dimensions (replaces stage/type filtering)
|
dimensions?: string[]; // Filter by dimensions (replaces stage/type filtering)
|
||||||
|
contextId?: number;
|
||||||
search?: string; // Text search in title/content
|
search?: string; // Text search in title/content
|
||||||
searchMode?: 'standard' | 'hybrid'; // standard = FTS/LIKE, hybrid = add node-vector retrieval
|
searchMode?: 'standard' | 'hybrid'; // standard = FTS/LIKE, hybrid = add node-vector retrieval
|
||||||
chunkStatus?: 'not_chunked' | 'chunking' | 'chunked' | 'error';
|
chunkStatus?: 'not_chunked' | 'chunking' | 'chunked' | 'error';
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const LOCAL_HOST_PATTERN = /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0)(:\d+)?(\/.*)?$/i;
|
||||||
|
const BARE_HOST_PATTERN = /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/.*)?$/i;
|
||||||
|
|
||||||
|
export function normalizeNodeLink(input?: string | null): string | null {
|
||||||
|
if (typeof input !== 'string') return null;
|
||||||
|
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
|
||||||
|
? trimmed
|
||||||
|
: BARE_HOST_PATTERN.test(trimmed) || LOCAL_HOST_PATTERN.test(trimmed)
|
||||||
|
? `https://${trimmed}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!candidate) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(candidate);
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user