feat: port contexts layer and MCP parity

This commit is contained in:
“BeeRad”
2026-04-10 19:41:26 +10:00
parent a8c5506fb7
commit 35f9ecf89c
62 changed files with 4206 additions and 1224 deletions
+3 -2
View File
@@ -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:
| 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 |
| `createNode` | Create a new node |
| `getNodesById` | Fetch nodes by ID |
+22
View File
@@ -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 });
}
}
+26
View File
@@ -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 });
}
}
+47
View File
@@ -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 });
}
}
+38 -8
View File
@@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { nodeService } from '@/services/database';
import { contextService, nodeService } from '@/services/database';
import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue';
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 { normalizeNodeLink } from '@/utils/nodeLink';
import { mergeNodeMetadata } from '@/services/nodes/metadata';
export const runtime = 'nodejs';
@@ -73,13 +75,23 @@ export async function PUT(
const updates: Record<string, unknown> = { ...body };
let shouldQueueEmbed = false;
if (typeof body.description === 'string') {
const descriptionError = validateExplicitDescription(body.description);
if (descriptionError) {
console.warn(
`[DescriptionQuality] User-updated description failed validation for node ${nodeId}: ${descriptionError}`
);
if (typeof body.link === 'string') {
const trimmedLink = body.link.trim();
const normalizedLink = normalizeNodeLink(trimmedLink);
if (trimmedLink && !normalizedLink) {
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)) {
@@ -96,6 +108,24 @@ export async function PUT(
delete updates.notes;
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 existingSource = existingNode.source ?? '';
+68 -24
View File
@@ -1,11 +1,14 @@
import { NextRequest, NextResponse } from 'next/server';
import { nodeService } from '@/services/database';
import { contextService, nodeService } from '@/services/database';
import { Node, NodeFilters } from '@/types/database';
import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue';
import { generateDescription } from '@/services/database/descriptionService';
import { scheduleAutoEdgeCreation } from '@/services/agents/autoEdge';
import { normalizeDimensions, validateExplicitDescription } from '@/services/database/quality';
import { coerceDescriptionForStorage, normalizeDimensions } from '@/services/database/quality';
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';
@@ -19,6 +22,14 @@ export async function GET(request: NextRequest) {
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)
const dimensionsParam = searchParams.get('dimensions');
if (dimensionsParam) {
@@ -94,6 +105,14 @@ export async function POST(request: NextRequest) {
body.title = sanitizeTitle(body.title);
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;
// 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
const isUserSuppliedDescription = typeof body.description === 'string' && body.description.trim().length > 0;
let nodeDescription: string | undefined = isUserSuppliedDescription
? body.description.trim().slice(0, 280)
? body.description.trim().slice(0, 500)
: undefined;
if (!nodeDescription) {
@@ -117,7 +136,7 @@ export async function POST(request: NextRequest) {
nodeDescription = await generateDescription({
title: body.title,
source: rawSource?.slice(0, 2000) || undefined,
link: body.link || undefined,
link: normalizedLink || undefined,
metadata: body.metadata,
dimensions: trimmedProvidedDimensions
});
@@ -128,28 +147,16 @@ export async function POST(request: NextRequest) {
// Final safety net — never store null/empty description
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 descriptionError = validateExplicitDescription(finalDescription);
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);
}
const finalDescription = coerceDescriptionForStorage({
title: body.title,
description: nodeDescription
});
// Monitor description quality
if (WEAK_PATTERNS.test(finalDescription)) {
if (WEAK_PATTERNS.test(nodeDescription ?? finalDescription)) {
console.warn(`[DescriptionQuality] Weak description for node "${body.title}": "${finalDescription}"`);
}
@@ -162,15 +169,52 @@ export async function POST(request: NextRequest) {
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({
title: body.title,
description: finalDescription,
source: sourceToStore ?? undefined,
event_date: eventDate ?? undefined,
link: body.link,
link: normalizedLink ?? undefined,
dimensions: finalDimensions,
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) {
+3 -2
View File
@@ -41,7 +41,7 @@ Restart Claude. Done.
## What to Expect
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
- **Read skills for complex tasks** — skills are editable and shared across internal + external agents
- **Search before creating** to avoid duplicates
@@ -50,9 +50,10 @@ Once connected, Claude will:
| 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 |
| `queryNodes` | Search nodes by keyword |
| `queryContexts` | List or inspect contexts |
| `getNodesById` | Load nodes by ID (includes chunk + metadata) |
| `updateNode` | Update an existing node |
| `createEdge` | Create connection between nodes |
+117 -43
View File
@@ -31,13 +31,14 @@ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio
const { initDatabase, getDatabasePath, closeDatabase, getDb, query } = require('./services/sqlite-client');
const nodeService = require('./services/nodeService');
const edgeService = require('./services/edgeService');
const contextService = require('./services/contextService');
const dimensionService = require('./services/dimensionService');
const skillService = require('./services/skillService');
// Server info
const serverInfo = {
name: 'ra-h-standalone',
version: '1.8.0'
version: '1.10.1'
};
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.
## 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.
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.
Do not invent new dimensions from node titles, concepts, or phrasing.
Only call createDimension when the user explicitly instructs you to create a new dimension.
Use contexts as the primary scope layer. Query contexts before assigning when needed.
## Available skills
${skillIndex}
@@ -83,15 +85,18 @@ const addNodeInputSchema = {
content: z.string().max(20000).optional().describe('Legacy content field; mapped to source'),
source: z.string().max(50000).optional().describe('Full source text'),
link: z.string().url().optional().describe('Source URL'),
description: z.string().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.'),
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')
};
const searchNodesInputSchema = {
query: z.string().min(1).max(400).describe('Search query'),
limit: z.number().min(1).max(25).optional().describe('Max results (default 10)'),
contextId: z.number().int().positive().optional().describe('Optional primary context filter.'),
dimensions: z.array(z.string()).max(5).optional().describe('Filter by dimensions'),
created_after: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'),
created_before: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'),
@@ -107,9 +112,11 @@ const updateNodeInputSchema = {
id: z.number().int().positive().describe('Node ID'),
updates: z.object({
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'),
source: z.string().optional().describe('Canonical source content for embedding'),
link: z.string().optional().describe('New link'),
context_id: z.number().int().positive().nullable().optional().describe('Primary context ID. Omit to preserve existing context; use null to clear it.'),
dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'),
metadata: z.record(z.any()).optional().describe('New metadata')
}).describe('Fields to update')
@@ -131,6 +138,14 @@ const queryEdgesInputSchema = {
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 createDimensionInputSchema = {
@@ -192,26 +207,6 @@ function sanitizeDimensions(raw) {
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
function sanitizeFtsQuery(input) {
return input
@@ -319,7 +314,7 @@ async function main() {
'getContext',
{
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: {}
},
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 {
content: [{ type: 'text', text: summary }],
structuredContent: context
@@ -354,24 +349,28 @@ async function main() {
'createNode',
{
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
},
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);
if (normalizedDimensions.length === 0) {
throw new Error('At least one dimension is required.');
}
const descriptionError = validateExplicitDescription(description);
if (descriptionError) {
throw new Error(descriptionError);
let resolvedContextId;
try {
resolvedContextId = contextService.resolveContextId({ context_id, context_name });
} catch (error) {
log('Warning: invalid explicit context input on createNode; falling back to automatic inference:', error.message);
resolvedContextId = undefined;
}
const node = nodeService.createNode({
title: title.trim(),
source: source?.trim() || chunk?.trim() || content?.trim(),
link: link?.trim(),
description: description?.trim(),
description: typeof description === 'string' ? description.trim() : description,
context_id: resolvedContextId,
dimensions: normalizedDimensions,
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.',
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 safeLimit = Math.min(Math.max(limit, 1), 25);
const trimmedQuery = searchQuery.trim();
const fts = checkFtsAvailability();
if (contextId) {
const nodes = nodeService.getNodes({
search: trimmedQuery,
limit: safeLimit,
contextId,
dimensions: normalizedDimensions,
});
const summary = nodes.length === 0
? 'No matching RA-H nodes found in that context.'
: `Found ${nodes.length} node(s) in that context.`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: {
count: nodes.length,
nodes: nodes.map((node) => ({
id: node.id,
title: node.title,
source: node.source ?? null,
description: node.description ?? null,
link: node.link ?? null,
dimensions: node.dimensions || [],
updated_at: node.updated_at,
})),
},
};
}
// Build temporal filter clauses
const temporalClauses = [];
const temporalParams = [];
@@ -596,21 +624,13 @@ async function main() {
'updateNode',
{
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
},
async ({ id, updates }) => {
if (!updates || Object.keys(updates).length === 0) {
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
const mappedUpdates = { ...updates };
if (mappedUpdates.content !== undefined) {
@@ -621,6 +641,11 @@ async function main() {
}
delete mappedUpdates.content;
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);
@@ -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(
'createDimension',
{
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@ra-h/mcp-server",
"version": "1.8.0",
"version": "1.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ra-h/mcp-server",
"version": "1.8.0",
"version": "1.10.1",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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.",
"main": "index.js",
"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';
const { query, transaction, getDb } = require('./sqlite-client');
const contextService = require('./contextService');
function normalizeDimensionName(value) {
return String(value || '').trim().replace(/\s+/g, ' ');
}
function getUnknownDimensions(dimensions) {
if (!Array.isArray(dimensions) || dimensions.length === 0) return [];
const normalized = dimensions
.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) {
if (values.length === 1) {
return `Unknown dimension: "${values[0]}". Create it first or use an existing dimension.`;
function parseMetadata(metadata) {
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 } : {};
}
return `Unknown dimensions: ${values.map(value => `"${value}"`).join(', ')}. Create them first or use existing dimensions.`;
function normalizeString(value) {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function buildCanonicalMetadata({ existing, metadata }) {
const prior = parseMetadata(existing);
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;
}
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.
*/
function getNodes(filters = {}) {
const { dimensions, search, limit = 100, offset = 0 } = filters;
const { dimensions, search, limit = 100, offset = 0, contextId } = filters;
let sql = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at,
n.created_at, n.updated_at, n.context_id,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
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
LEFT JOIN contexts c ON c.id = n.context_id
WHERE 1=1
`;
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)`;
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
if (contextId !== undefined) {
sql += ' AND n.context_id = ?';
params.push(contextId);
}
// Sort by search relevance or updated_at
if (search) {
@@ -85,12 +125,7 @@ function getNodes(filters = {}) {
const rows = query(sql, params);
return rows.map(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
}));
return rows.map(mapNodeRow);
}
/**
@@ -99,10 +134,15 @@ function getNodes(filters = {}) {
function getNodeById(id) {
const sql = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at,
n.created_at, n.updated_at, n.context_id,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
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
LEFT JOIN contexts c ON c.id = n.context_id
WHERE n.id = ?
`;
@@ -110,12 +150,7 @@ function getNodeById(id) {
if (rows.length === 0) return null;
const row = rows[0];
return {
...row,
dimensions: JSON.parse(row.dimensions_json || '[]'),
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
dimensions_json: undefined
};
return mapNodeRow(row);
}
/**
@@ -129,6 +164,134 @@ function sanitizeTitle(title) {
return clean.slice(0, 160);
}
const STOP_WORDS = new Set([
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'i',
'in', 'is', 'it', 'its', 'of', 'on', 'or', 'that', 'the', 'their', 'this',
'to', 'was', 'with', 'you', 'your'
]);
function normalizeText(value) {
if (typeof value !== 'string') return '';
return value.toLowerCase().replace(/[^a-z0-9\s]+/g, ' ').replace(/\s+/g, ' ').trim();
}
function tokenize(value) {
return normalizeText(value)
.split(' ')
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !STOP_WORDS.has(token));
}
function uniqueTokens(values) {
return [...new Set(values.flatMap((value) => tokenize(value || '')))];
}
function safeStringify(value) {
try {
return JSON.stringify(value ?? {});
} catch {
return '';
}
}
function fetchContextCandidates() {
return query(`
WITH context_counts AS (
SELECT c.id, c.name, c.description, COUNT(n.id) AS count
FROM contexts c
LEFT JOIN nodes n ON n.context_id = c.id
GROUP BY c.id
),
ranked_anchors AS (
SELECT
c.id AS context_id,
n.title AS anchor_title,
n.description AS anchor_description,
ROW_NUMBER() OVER (
PARTITION BY c.id
ORDER BY COUNT(e.id) DESC, n.updated_at DESC, n.id ASC
) AS anchor_rank
FROM contexts c
LEFT JOIN nodes n ON n.context_id = c.id
LEFT JOIN edges e ON (e.from_node_id = n.id OR e.to_node_id = n.id)
GROUP BY c.id, n.id
)
SELECT
cc.id,
cc.name,
cc.description,
cc.count,
ra.anchor_title,
ra.anchor_description
FROM context_counts cc
LEFT JOIN ranked_anchors ra
ON ra.context_id = cc.id
AND ra.anchor_rank = 1
ORDER BY cc.name COLLATE NOCASE ASC
`).map((row) => ({
id: Number(row.id),
name: row.name,
description: row.description ?? null,
count: Number(row.count ?? 0),
anchor_title: row.anchor_title ?? null,
anchor_description: row.anchor_description ?? null,
}));
}
function scoreContextCandidate(candidate, input) {
const titleText = normalizeText(input.title || '');
const descriptionText = normalizeText(input.description || '');
const sourceText = normalizeText(String(input.source || '').slice(0, 4000));
const metadataText = normalizeText(safeStringify(input.metadata));
const dimensionTokens = uniqueTokens(input.dimensions || []);
const contextName = normalizeText(candidate.name);
const contextNameTokens = tokenize(candidate.name);
const contextDescriptorTokens = uniqueTokens([
candidate.description,
candidate.anchor_title,
candidate.anchor_description,
]);
let score = 0;
if (contextName && (titleText.includes(contextName) || descriptionText.includes(contextName))) score += 80;
if (contextName && sourceText.includes(contextName)) score += 40;
for (const token of contextNameTokens) {
if (dimensionTokens.includes(token)) score += 30;
if (titleText.includes(token)) score += 16;
if (descriptionText.includes(token)) score += 12;
if (sourceText.includes(token)) score += 6;
if (metadataText.includes(token)) score += 4;
}
for (const token of contextDescriptorTokens) {
if (dimensionTokens.includes(token)) score += 8;
if (titleText.includes(token)) score += 4;
if (descriptionText.includes(token)) score += 3;
if (sourceText.includes(token)) score += 2;
}
return score;
}
function inferBestContextIdForNode(input) {
const contexts = fetchContextCandidates();
if (contexts.length === 0) return null;
const ranked = contexts
.map((context) => ({ context, score: scoreContextCandidate(context, input) }))
.sort((a, b) => b.score - a.score || (b.context.count - a.context.count) || a.context.id - b.context.id);
const best = ranked[0];
if (!best) return null;
if (best.score > 0) return best.context.id;
const research = contexts.find((context) => context.name.trim().toLowerCase() === 'research');
if (research) return research.id;
return best.context.id;
}
/**
* Create a new node.
*/
@@ -140,26 +303,25 @@ function createNode(nodeData) {
link,
event_date,
dimensions = [],
metadata = {}
metadata = {},
context_id
} = nodeData;
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 db = getDb();
const sourceToStore = source && source.trim()
? source
: [title, description].filter(Boolean).join('\n\n').trim() || null;
const sourceToStore = source ?? ([title, description].filter(Boolean).join('\n\n').trim() || null);
const effectiveContextId = context_id == null
? inferBestContextIdForNode({ title, description, source: sourceToStore, dimensions, metadata: canonicalMetadata })
: context_id;
const nodeId = transaction(() => {
const stmt = db.prepare(`
INSERT INTO nodes (title, description, source, link, event_date, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO nodes (title, description, source, link, event_date, metadata, context_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
@@ -168,7 +330,8 @@ function createNode(nodeData) {
sourceToStore,
link ?? null,
event_date ?? null,
JSON.stringify(metadata),
JSON.stringify(canonicalMetadata),
effectiveContextId ?? null,
now,
now
);
@@ -193,7 +356,6 @@ function createNode(nodeData) {
/**
* Update an existing node.
* Source-first update path.
*/
function updateNode(id, updates, options = {}) {
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.`);
}
if (Array.isArray(dimensions)) {
const unknownDimensions = getUnknownDimensions(dimensions);
if (unknownDimensions.length > 0) {
throw new Error(formatUnknownDimensionsError(unknownDimensions));
}
}
const mergedMetadata = metadata !== undefined
? buildCanonicalMetadata({ existing: existing.metadata, metadata })
: undefined;
transaction(() => {
const setFields = [];
@@ -237,9 +396,13 @@ function updateNode(id, updates, options = {}) {
setFields.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 = ?');
params.push(JSON.stringify(metadata));
params.push(JSON.stringify(mergedMetadata));
}
// Always update timestamp
@@ -286,7 +449,7 @@ function getNodeCount() {
/**
* Get knowledge graph context overview.
* Returns stats, hub nodes, dimensions, and recent activity.
* Returns stats, contexts, hub nodes, dimensions, and recent activity.
*/
function getContext() {
const nodeCount = query('SELECT COUNT(*) as count FROM nodes')[0].count;
@@ -315,7 +478,8 @@ function getContext() {
`);
return {
stats: { nodeCount, edgeCount, dimensionCount: dimensions.length },
stats: { nodeCount, edgeCount, dimensionCount: dimensions.length, contextCount: contextService.listContexts().length },
contexts: contextService.listContexts(),
dimensions,
recentNodes,
hubNodes
@@ -26,6 +26,7 @@ const SEEDED_SKILL_IDS = new Set([
'persona',
'calibration',
'connect',
'node-context-enrichment',
]);
const DEPRECATED_SKILL_IDS = new Set([
@@ -59,7 +59,9 @@ function initDatabase() {
embedding BLOB,
embedding_updated_at 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 (
@@ -93,6 +95,15 @@ function initDatabase() {
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS contexts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
icon TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Seed default dimensions
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('research', 1);
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('ideas', 1);
@@ -143,6 +154,42 @@ function initDatabase() {
AND LENGTH(TRIM(chunk)) > 0;
`);
}
if (!nodeCols.includes('context_id')) {
db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;');
}
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);
if (!edgeCols.includes('explanation')) {
+3 -4
View File
@@ -1,9 +1,6 @@
---
name: Audit
description: "Run a structured audit of 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."
description: "Use for structured review, QA, cleanup, or governance checks across graph quality, skill quality, and operational consistency."
---
# Audit
@@ -27,3 +24,5 @@ success_criteria: "Findings are prioritized, concrete, and tied to actionable fi
- Prefer specific evidence over generic commentary.
- Propose the smallest high-leverage fixes first.
- 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."
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."
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
@@ -14,7 +14,7 @@ Re-anchor the graph to the user's current state.
## 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.
3. Identify stale nodes and missing nodes.
4. Propose precise updates: update existing vs create new.
@@ -1,9 +1,6 @@
---
name: DB Operations
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
@@ -11,7 +8,7 @@ success_criteria: "Writes are explicit and correct; descriptions are concrete; e
## Core Rules
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).
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.
@@ -19,9 +16,10 @@ success_criteria: "Writes are explicit and correct; descriptions are concrete; e
## Write Quality Contract
- `title`: clear and specific.
- `description`: concrete object-level description, not vague summaries.
- `notes/content`: extra context, analysis, supporting detail.
- `description`: natural prose, not labels. It should still make what / why / status clear when possible.
- `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). This is what gets chunked and embedded for semantic search. For user-authored ideas or dictated notes, preserve the user's original wording with minimal cleanup.
- `link`: external source URL only.
- `metadata`: prefer canonical keys `type`, `state`, `captured_method`, `captured_by`, and `source_metadata`.
## Execution Pattern
@@ -29,9 +27,11 @@ success_criteria: "Writes are explicit and correct; descriptions are concrete; e
2. Decide: create vs update vs connect.
3. Execute minimum required writes.
4. Verify result reflects user intent exactly.
5. If description framing was materially inferred, complete the write first and then invite one concise user feedback pass instead of blocking creation.
## Do Not
- Create duplicate nodes when an update is correct.
- Write vague descriptions ("discusses", "explores", "is about").
- Replace a user's raw idea/source with a thin summary.
- Create weak or directionless edges.
@@ -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."
when_to_use: "New user setup or major reset of account context."
when_not_to_use: "User asks for a narrow tactical operation only."
success_criteria: "User has an initial 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
@@ -22,13 +22,14 @@ Understand the user deeply enough to bootstrap a useful externalized context gra
## Graph Bootstrap
1. Propose 3-6 hub nodes with clear rationale.
2. Propose starter dimensions that reflect real domains.
3. Create initial edges between hubs and active projects.
4. Confirm with user before writing.
1. Propose 3-6 primary contexts with clear rationale.
2. Identify one strong anchor candidate per context.
3. Propose starter dimensions as secondary metadata and filters.
4. Create initial edges between anchor nodes and active project nodes.
5. Confirm with user before writing.
## Output
- Initial hub map
- Initial context map
- Suggested first write actions
- Suggested weekly maintenance rhythm
+103 -34
View File
@@ -44,8 +44,9 @@ let logger = (message) => console.log(`[mcp] ${message}`);
const instructions = [
'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
'Core concepts: nodes (knowledge units), edges (connections with explanations), dimensions (categories).',
'Always call rah_get_context first to orient yourself — it returns hub nodes, dimensions, stats, and available guides.',
'Core concepts: contexts (primary scopes), nodes (knowledge units), edges (connections with explanations), and dimensions (secondary metadata and filters).',
'Always call rah_get_context first to orient yourself — it returns contexts, hub nodes, dimensions, stats, and available guides.',
'Use contexts as the primary scope layer. Use rah_query_contexts before assigning or filtering by context when needed.',
'When assigning dimensions, use only existing dimensions returned by rah_get_context or rah_query_dimensions.',
'Do not invent new dimensions from node titles, concepts, or phrasing.',
'Only call rah_create_dimension when the user explicitly instructs you to create a new dimension.',
@@ -91,9 +92,11 @@ const addNodeInputSchema = {
content: z.string().max(20000).optional(),
source: z.string().max(50000).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),
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()
};
@@ -107,7 +110,16 @@ const addNodeOutputSchema = {
const searchNodesInputSchema = {
query: z.string().min(1).max(400),
limit: z.number().min(1).max(25).optional(),
dimensions: z.array(z.string()).max(5).optional()
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 = {
@@ -130,11 +142,13 @@ const updateNodeInputSchema = {
id: z.number().int().positive().describe('The ID of the node to update'),
updates: z.object({
title: z.string().optional().describe('New title'),
description: z.string().optional().describe('New description (overwrites existing)'),
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'),
context_id: z.number().int().positive().nullable().optional().describe('Primary context ID. Omit to preserve existing context; use null to clear.'),
dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'),
metadata: z.record(z.any()).optional().describe('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')
};
@@ -269,8 +283,7 @@ const extractUrlInputSchema = {
const extractUrlOutputSchema = {
success: z.boolean(),
title: z.string(),
content: z.string(),
chunk: z.string(),
source: z.string(),
metadata: z.record(z.any())
};
@@ -283,7 +296,7 @@ const extractYoutubeOutputSchema = {
success: z.boolean(),
title: z.string(),
channel: z.string(),
transcript: z.string(),
source: z.string(),
metadata: z.record(z.any())
};
@@ -295,8 +308,7 @@ const extractPdfInputSchema = {
const extractPdfOutputSchema = {
success: z.boolean(),
title: z.string(),
content: z.string(),
chunk: z.string(),
source: z.string(),
metadata: z.record(z.any())
};
@@ -349,11 +361,11 @@ mcpServer.registerTool(
'rah_add_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,
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);
if (normalizedDimensions.length === 0) {
throw new McpError(
@@ -364,9 +376,11 @@ mcpServer.registerTool(
const payload = {
title: title.trim(),
source: source?.trim() || chunk?.trim() || content?.trim() || undefined,
source: source?.trim() || content?.trim() || chunk?.trim() || undefined,
link: link?.trim() || undefined,
description: description?.trim() || undefined,
context_id: context_id === null ? null : context_id,
context_name: context_name?.trim() || undefined,
dimensions: normalizedDimensions,
metadata: metadata || {}
};
@@ -399,7 +413,7 @@ mcpServer.registerTool(
inputSchema: searchNodesInputSchema,
outputSchema: searchNodesOutputSchema
},
async ({ query, limit = 10, dimensions }) => {
async ({ query, limit = 10, dimensions, contextId }) => {
const params = new URLSearchParams();
params.set('search', query.trim());
params.set('limit', String(Math.min(Math.max(limit, 1), 25)));
@@ -408,6 +422,9 @@ mcpServer.registerTool(
if (dimensionList.length > 0) {
params.set('dimensions', dimensionList.join(','));
}
if (contextId) {
params.set('contextId', String(contextId));
}
const result = await callRaHApi(`/api/nodes?${params.toString()}`, {
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(
'rah_update_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,
outputSchema: updateNodeOutputSchema
},
@@ -449,15 +514,15 @@ mcpServer.registerTool(
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 };
if (mappedUpdates.content !== undefined) {
mappedUpdates.source = mappedUpdates.content;
}
if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) {
mappedUpdates.source = mappedUpdates.chunk;
}
delete mappedUpdates.content;
if (mappedUpdates.content !== undefined) {
mappedUpdates.source = mappedUpdates.content;
delete mappedUpdates.content;
}
delete mappedUpdates.chunk;
const result = await callRaHApi(`/api/nodes/${id}`, {
@@ -622,7 +687,7 @@ mcpServer.registerTool(
'rah_create_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,
outputSchema: createDimensionOutputSchema
},
@@ -760,8 +825,7 @@ mcpServer.registerTool(
structuredContent: {
success: true,
title: result.title || 'Untitled',
content: result.content || '',
chunk: result.chunk || '',
source: result.source || '',
metadata: result.metadata || {}
}
};
@@ -789,7 +853,7 @@ mcpServer.registerTool(
success: true,
title: result.title || 'Untitled',
channel: result.channel || 'Unknown',
transcript: result.transcript || '',
source: result.source || '',
metadata: result.metadata || {}
}
};
@@ -816,8 +880,7 @@ mcpServer.registerTool(
structuredContent: {
success: true,
title: result.title || 'Untitled PDF',
content: result.content || '',
chunk: result.chunk || '',
source: result.source || '',
metadata: result.metadata || {}
}
};
@@ -829,11 +892,12 @@ mcpServer.registerTool(
'rah_get_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: {},
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() })),
contexts: z.array(z.object({ id: z.number(), name: z.string(), description: z.string().nullable(), icon: z.string().nullable().optional(), count: z.number() })).optional(),
dimensions: z.array(z.object({ name: z.string(), nodeCount: z.number(), description: z.string().nullable() })),
guides: z.array(z.string())
}
@@ -849,18 +913,23 @@ mcpServer.registerTool(
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 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 {
const countResult = await callRaHApi('/api/nodes?limit=1', { method: 'GET' });
if (countResult.total !== undefined) stats.nodeCount = countResult.total;
} catch { /* use defaults */ }
return {
content: [{ type: 'text', text: `Knowledge graph: ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.` }],
structuredContent: { stats, hubNodes, dimensions, guides }
content: [{ type: 'text', text: `Knowledge graph: ${stats.contextCount} contexts, ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.` }],
structuredContent: { stats, hubNodes, contexts, dimensions, guides }
};
}
);
+119 -36
View File
@@ -11,8 +11,9 @@ const packageJson = require('../../package.json');
const instructions = [
'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
'Core concepts: nodes (knowledge units), edges (connections with explanations), dimensions (categories).',
'Always call rah_get_context first to orient yourself — it returns hub nodes, dimensions, stats, and available guides.',
'Core concepts: contexts (primary scopes), nodes (knowledge units), edges (connections with explanations), and dimensions (secondary metadata and filters).',
'Always call rah_get_context first to orient yourself — it returns contexts, hub nodes, dimensions, stats, and available guides.',
'Use contexts as the primary scope layer. Use rah_query_contexts before assigning or filtering by context when needed.',
'When assigning dimensions, use only existing dimensions returned by rah_get_context or rah_query_dimensions.',
'Do not invent new dimensions from node titles, concepts, or phrasing.',
'Only call rah_create_dimension when the user explicitly instructs you to create a new dimension.',
@@ -40,9 +41,11 @@ const addNodeInputSchema = {
content: z.string().max(20000).optional(),
source: z.string().max(50000).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),
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()
};
@@ -56,7 +59,16 @@ const addNodeOutputSchema = {
const searchNodesInputSchema = {
query: z.string().min(1).max(400),
limit: z.number().min(1).max(25).optional(),
dimensions: z.array(z.string()).max(5).optional()
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 = {
@@ -65,7 +77,7 @@ const searchNodesOutputSchema = {
z.object({
id: z.number(),
title: z.string(),
content: z.string().nullable(),
source: z.string().nullable(),
description: z.string().nullable(),
link: z.string().nullable(),
dimensions: z.array(z.string()),
@@ -79,10 +91,13 @@ const updateNodeInputSchema = {
id: z.number().int().positive().describe('The ID of the node to update'),
updates: z.object({
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'),
context_id: z.number().int().positive().nullable().optional().describe('Primary context ID. Omit to preserve existing context; use null to clear.'),
dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'),
metadata: z.record(z.any()).optional().describe('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')
};
@@ -103,7 +118,7 @@ const getNodesOutputSchema = {
z.object({
id: z.number(),
title: z.string(),
content: z.string().nullable(),
source: z.string().nullable(),
link: z.string().nullable(),
dimensions: z.array(z.string()),
updated_at: z.string()
@@ -217,8 +232,7 @@ const extractUrlInputSchema = {
const extractUrlOutputSchema = {
success: z.boolean(),
title: z.string(),
content: z.string(),
chunk: z.string(),
source: z.string(),
metadata: z.record(z.any())
};
@@ -231,7 +245,7 @@ const extractYoutubeOutputSchema = {
success: z.boolean(),
title: z.string(),
channel: z.string(),
transcript: z.string(),
source: z.string(),
metadata: z.record(z.any())
};
@@ -243,8 +257,7 @@ const extractPdfInputSchema = {
const extractPdfOutputSchema = {
success: z.boolean(),
title: z.string(),
content: z.string(),
chunk: z.string(),
source: z.string(),
metadata: z.record(z.any())
};
@@ -322,11 +335,11 @@ server.registerTool(
'rah_add_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,
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);
if (normalizedDimensions.length === 0) {
throw new Error('At least one dimension/tag is required when creating a node.');
@@ -334,9 +347,11 @@ server.registerTool(
const payload = {
title: title.trim(),
source: source?.trim() || chunk?.trim() || content?.trim() || undefined,
source: source?.trim() || content?.trim() || chunk?.trim() || undefined,
link: link?.trim() || undefined,
description: description?.trim() || undefined,
context_id: context_id === null ? null : context_id,
context_name: context_name?.trim() || undefined,
dimensions: normalizedDimensions,
metadata: metadata || {}
};
@@ -369,7 +384,7 @@ server.registerTool(
inputSchema: searchNodesInputSchema,
outputSchema: searchNodesOutputSchema
},
async ({ query, limit = 10, dimensions }) => {
async ({ query, limit = 10, dimensions, contextId }) => {
const params = new URLSearchParams();
params.set('search', query.trim());
params.set('limit', String(Math.min(Math.max(limit, 1), 25)));
@@ -378,6 +393,9 @@ server.registerTool(
if (dimensionList.length > 0) {
params.set('dimensions', dimensionList.join(','));
}
if (contextId) {
params.set('contextId', String(contextId));
}
const result = await callRaHApi(`/api/nodes?${params.toString()}`, {
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(
'rah_update_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,
outputSchema: updateNodeOutputSchema
},
@@ -420,15 +486,15 @@ server.registerTool(
throw new Error('At least one field must be provided in updates.');
}
// Map MCP legacy fields to canonical source
// Backward compatibility: map legacy content/chunk → source
const mappedUpdates = { ...updates };
if (mappedUpdates.content !== undefined) {
mappedUpdates.source = mappedUpdates.content;
}
if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) {
mappedUpdates.source = mappedUpdates.chunk;
}
delete mappedUpdates.content;
if (mappedUpdates.content !== undefined) {
mappedUpdates.source = mappedUpdates.content;
delete mappedUpdates.content;
}
delete mappedUpdates.chunk;
const result = await callRaHApi(`/api/nodes/${id}`, {
@@ -593,7 +659,7 @@ server.registerTool(
'rah_create_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,
outputSchema: createDimensionOutputSchema
},
@@ -731,8 +797,7 @@ server.registerTool(
structuredContent: {
success: true,
title: result.title || 'Untitled',
content: result.content || '',
chunk: result.chunk || '',
source: result.source || '',
metadata: result.metadata || {}
}
};
@@ -760,7 +825,7 @@ server.registerTool(
success: true,
title: result.title || 'Untitled',
channel: result.channel || 'Unknown',
transcript: result.transcript || '',
source: result.source || '',
metadata: result.metadata || {}
}
};
@@ -787,8 +852,7 @@ server.registerTool(
structuredContent: {
success: true,
title: result.title || 'Untitled PDF',
content: result.content || '',
chunk: result.chunk || '',
source: result.source || '',
metadata: result.metadata || {}
}
};
@@ -797,10 +861,11 @@ server.registerTool(
// rah_get_context — orientation tool for external agents
const getContextOutputSchema = {
schema: z.object({
stats: z.object({
nodeCount: z.number(),
edgeCount: z.number(),
dimensionCount: z.number()
dimensionCount: z.number(),
contextCount: z.number().optional()
}),
hubNodes: z.array(z.object({
id: z.number(),
@@ -808,6 +873,13 @@ const getContextOutputSchema = {
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(),
@@ -820,7 +892,7 @@ server.registerTool(
'rah_get_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: {},
outputSchema: getContextOutputSchema
},
@@ -842,6 +914,15 @@ server.registerTool(
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
const guideResult = await callRaHApi('/api/guides', { method: 'GET' });
const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : [];
@@ -851,7 +932,8 @@ server.registerTool(
const stats = {
nodeCount: nodeCount ?? hubNodes.reduce((_, n) => 0, 0),
edgeCount: 0,
dimensionCount: dimensions.length
dimensionCount: dimensions.length,
contextCount: contexts.length
};
// Try to get actual counts from a stats endpoint or compute
@@ -862,13 +944,14 @@ server.registerTool(
}
} 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 {
content: [{ type: 'text', text: summary }],
structuredContent: {
schema: stats,
stats,
hubNodes,
contexts,
dimensions,
guides
}
+1 -1
View File
@@ -14,7 +14,7 @@ RA-OS exposes these core standalone MCP tools:
| 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 |
| `getNodesById` | Fetch full nodes by ID |
| `createNode` | Create a node |
+3 -2
View File
@@ -76,7 +76,8 @@ If you want real-time UI updates when nodes are created:
| 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) |
| `queryNodes` | Search existing nodes by keyword |
| `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:
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
3. **Read skills for complex tasks** — skills provide reusable procedural instructions for graph operations and workflows
4. **Search before creating** to avoid duplicates
File diff suppressed because it is too large Load Diff
+80
View File
@@ -8,6 +8,8 @@ import {
LayoutList,
Map,
Folder,
ChevronDown,
ChevronRight,
Table2,
BookOpen,
Settings,
@@ -18,6 +20,7 @@ import {
} from 'lucide-react';
import type { PaneType, NavigablePaneType } from '../panes/types';
import type { Theme } from '@/hooks/useTheme';
import type { ContextSummary } from '@/types/database';
interface LeftToolbarProps {
onSearchClick: () => void;
@@ -31,6 +34,8 @@ interface LeftToolbarProps {
activeTabType: PaneType | null;
theme: Theme;
onThemeToggle: () => void;
contexts?: ContextSummary[];
onContextQuickSelect?: (contextId: number, contextName: string) => void;
}
const NAV_WIDTH_COLLAPSED = 50;
@@ -113,7 +118,11 @@ export default function LeftToolbar({
activeTabType,
theme,
onThemeToggle,
contexts = [],
onContextQuickSelect,
}: LeftToolbarProps) {
const [contextsExpanded, setContextsExpanded] = useState(false);
return (
<div
style={{
@@ -148,6 +157,77 @@ export default function LeftToolbar({
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', minHeight: 0 }}>
<div style={{ borderTop: '1px solid var(--rah-border)', paddingTop: '14px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '6px' }}>
<div style={{ display: 'flex', 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) => (
<NavButton
key={`${item.paneType}-${item.label}`}
+78 -6
View File
@@ -4,7 +4,7 @@ import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { PanelLeftOpen, GripVertical, X } from 'lucide-react';
import SettingsModal, { SettingsTab } from '../settings/SettingsModal';
import SearchModal from '../nodes/SearchModal';
import { Node } from '@/types/database';
import { ContextSummary, Node } from '@/types/database';
import { DatabaseEvent } from '@/services/events';
import { usePersistentState } from '@/hooks/usePersistentState';
import { useTheme } from '@/hooks/useTheme';
@@ -12,7 +12,7 @@ import { useTheme } from '@/hooks/useTheme';
import LeftToolbar from './LeftToolbar';
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 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_C_WEIGHT_KEY = 'ui.panelC.weight.v1';
const LEFT_NAV_EXPANDED_KEY = 'ui.leftNavExpanded';
const ACTIVE_CONTEXT_KEY = 'ui.focus.activeContextId';
const ACTIVE_DIMENSION_KEY = 'ui.focus.activeDimension';
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 {
if (!raw) return null;
@@ -84,7 +85,14 @@ export default function ThreePanelLayout() {
const [nodesPanelRefresh, setNodesPanelRefresh] = useState(0);
const [focusPanelRefresh, setFocusPanelRefresh] = 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 [browseContextFilters, setBrowseContextFilters] = useState<Record<SlotId, number | null>>({
A: null,
B: null,
C: null,
});
const [browseDimensionFilters, setBrowseDimensionFilters] = useState<Record<SlotId, string | null>>({
A: null,
B: null,
@@ -104,6 +112,20 @@ export default function ThreePanelLayout() {
setSlotC((prev) => normalizeSlotState(prev));
}, [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 => {
switch (slot) {
case 'A':
@@ -549,6 +571,17 @@ export default function ThreePanelLayout() {
openNodeFromSlot(nodeId, 'A');
}, [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) => {
setBrowseDimensionFilters((prev) => ({ ...prev, [slot]: dimensionName }));
setActiveDimension(dimensionName);
@@ -565,7 +598,7 @@ export default function ThreePanelLayout() {
const response = await fetch('/api/quick-add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input, mode, description }),
body: JSON.stringify({ input, mode, description, contextId: activeContextId }),
});
if (!response.ok) {
@@ -591,7 +624,7 @@ export default function ThreePanelLayout() {
} catch (error) {
console.error('[ThreePanelLayout] Quick Add error:', error);
}
}, [openPaneSingleton]);
}, [activeContextId, openPaneSingleton]);
const handleCloseSlotA = useCallback(() => {
setSlotA(null);
@@ -619,11 +652,20 @@ export default function ThreePanelLayout() {
setActivePane(slot);
}
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':
openNodeFromSlot(action.nodeId, slot);
break;
}
}, [openNodeFromSlot, setSingletonPaneInSlot]);
}, [handleContextSelect, openNodeFromSlot, setActiveDimension, setSingletonPaneInSlot]);
const handleSearchNodeSelect = useCallback((nodeId: number) => {
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':
return (
<DimensionsPane
@@ -802,7 +856,16 @@ export default function ThreePanelLayout() {
refreshToken={nodesPanelRefresh}
pendingNodes={pendingNodes}
onDismissPending={(id) => setPendingNodes(prev => prev.filter(p => p.id !== id))}
externalContextFilterId={browseContextFilters[slot]}
externalDimensionFilter={browseDimensionFilters[slot]}
onContextFilterSelect={(contextId) => {
setBrowseContextFilters((prev) => ({ ...prev, [slot]: contextId }));
setActiveContextId(contextId);
}}
onClearExternalContextFilter={() => {
setBrowseContextFilters((prev) => ({ ...prev, [slot]: null }));
setActiveContextId(null);
}}
onClearExternalDimensionFilter={() => {
setBrowseDimensionFilters((prev) => ({ ...prev, [slot]: null }));
setActiveDimension(null);
@@ -985,6 +1048,15 @@ export default function ThreePanelLayout() {
onRefreshClick={handleRefreshAll}
theme={theme}
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
+124
View File
@@ -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)',
};
+76 -78
View File
@@ -17,6 +17,7 @@ export default function NodePane({
onPaneAction,
onCollapse,
onSwapPanes,
tabBar,
openTabs,
activeTab,
onTabSelect,
@@ -92,76 +93,76 @@ export default function NodePane({
background: 'transparent',
overflow: 'hidden',
}}>
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes}>
{/* Tabs rendered inline */}
{openTabs.length === 0 ? (
<span style={{ fontSize: '12px', color: 'var(--rah-text-muted)' }}>No tabs open</span>
) : (
openTabs.map((tabId) => {
const title = nodeTitles[tabId] || 'Loading...';
const isActiveTab = activeTab === tabId;
return (
<div
key={tabId}
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'copyMove';
e.dataTransfer.setData('application/x-rah-tab', JSON.stringify({ id: tabId, title, sourceSlot: slot }));
e.dataTransfer.setData('text/plain', `[NODE:${tabId}:"${title}"]`);
}}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: isActiveTab ? 'var(--rah-bg-active)' : 'transparent',
borderRadius: '4px',
cursor: 'grab',
flexShrink: 0,
}}
>
<button
onClick={() => onTabSelect(tabId)}
style={{
fontSize: '11px',
color: isActiveTab ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: 0,
whiteSpace: 'nowrap',
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar ? tabBar : (
/* Legacy node tabs (fallback when no tabBar prop) */
openTabs.length === 0 ? (
<span style={{ fontSize: '12px', color: '#666' }}>No tabs open</span>
) : (
openTabs.map((tabId) => {
const title = nodeTitles[tabId] || 'Loading...';
const isActiveTab = activeTab === tabId;
return (
<div
key={tabId}
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'copyMove';
e.dataTransfer.setData('application/x-rah-tab', JSON.stringify({ id: tabId, title, sourceSlot: slot }));
e.dataTransfer.setData('text/plain', `[NODE:${tabId}:"${title}"]`);
}}
>
{truncateTitle(title)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tabId);
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
}}
style={{
fontSize: '12px',
color: 'var(--rah-text-muted)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '0 2px',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: isActiveTab ? '#1f1f1f' : 'transparent',
borderRadius: '4px',
cursor: 'grab',
flexShrink: 0,
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--rah-text-active)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
>
×
</button>
</div>
);
})
)}
</PaneHeader>
<button
onClick={() => onTabSelect(tabId)}
style={{
fontSize: '11px',
color: isActiveTab ? '#fff' : '#888',
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: 0,
whiteSpace: 'nowrap',
}}
>
{truncateTitle(title)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tabId);
}}
style={{
fontSize: '12px',
color: '#666',
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '0 2px',
lineHeight: 1,
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
>
×
</button>
</div>
);
})
)
)} />
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<FocusPanel
@@ -171,11 +172,8 @@ export default function NodePane({
onNodeClick={onNodeClick}
onTabClose={onTabClose}
refreshTrigger={refreshTrigger}
onReorderTabs={onReorderTabs}
onOpenInOtherSlot={onOpenInOtherSlot}
onTextSelect={onTextSelect}
highlightedPassage={highlightedPassage}
hideTabBar
/>
</div>
@@ -186,8 +184,8 @@ export default function NodePane({
position: 'fixed',
top: contextMenu.y,
left: contextMenu.x,
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
background: '#1a1a1a',
border: '1px solid #2a2a2a',
borderRadius: '6px',
padding: '4px',
zIndex: 9999,
@@ -211,18 +209,18 @@ export default function NodePane({
background: 'transparent',
border: 'none',
borderRadius: '4px',
color: 'var(--rah-text-secondary)',
color: '#ccc',
fontSize: '12px',
cursor: 'pointer',
textAlign: 'left',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--rah-bg-active)';
e.currentTarget.style.color = 'var(--rah-text-active)';
e.currentTarget.style.background = '#2a2a2a';
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--rah-text-secondary)';
e.currentTarget.style.color = '#ccc';
}}
>
<span style={{ fontSize: '14px' }}></span>
@@ -243,18 +241,18 @@ export default function NodePane({
background: 'transparent',
border: 'none',
borderRadius: '4px',
color: 'var(--rah-text-secondary)',
color: '#ccc',
fontSize: '12px',
cursor: 'pointer',
textAlign: 'left',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--rah-bg-active)';
e.currentTarget.style.color = 'var(--rah-text-active)';
e.currentTarget.style.background = '#2a2a2a';
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--rah-text-secondary)';
e.currentTarget.style.color = '#ccc';
}}
>
<span style={{ fontSize: '14px' }}>×</span>
+9
View File
@@ -12,7 +12,10 @@ export interface ViewsPaneProps extends BasePaneProps {
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;
}
@@ -28,7 +31,10 @@ export default function ViewsPane({
refreshToken,
pendingNodes,
onDismissPending,
externalContextFilterId,
externalDimensionFilter,
onContextFilterSelect,
onClearExternalContextFilter,
onClearExternalDimensionFilter,
}: ViewsPaneProps) {
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
@@ -58,7 +64,10 @@ export default function ViewsPane({
refreshToken={refreshToken}
pendingNodes={pendingNodes}
onDismissPending={onDismissPending}
externalContextFilterId={externalContextFilterId}
externalDimensionFilter={externalDimensionFilter}
onContextFilterSelect={onContextFilterSelect}
onClearExternalContextFilter={onClearExternalContextFilter}
onClearExternalDimensionFilter={onClearExternalDimensionFilter}
toolbarHost={toolbarHost}
/>
+1
View File
@@ -1,4 +1,5 @@
export { default as NodePane } from './NodePane';
export { default as ContextsPane } from './ContextsPane';
export { default as DimensionsPane } from './DimensionsPane';
export { default as MapPane } from './MapPane';
export { default as ViewsPane } from './ViewsPane';
+10 -1
View File
@@ -18,7 +18,7 @@ export type AgentDelegation = {
};
// 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
export interface SlotState {
@@ -34,6 +34,7 @@ export interface SlotState {
// Actions panes can emit to the layout
export type PaneAction =
| { type: 'open-node'; nodeId: number; targetSlot?: SlotId }
| { type: 'open-context'; contextId: number | null; contextName?: string | null; targetSlot?: SlotId }
| { type: 'open-dimension'; dimension: string; targetSlot?: SlotId }
| { type: 'switch-pane-type'; paneType: PaneType }
| { type: 'close-pane' };
@@ -90,10 +91,17 @@ export interface ViewsPaneProps extends BasePaneProps {
onNodeClick: (nodeId: number) => void;
onNodeOpenInOtherPane?: (nodeId: number) => void;
refreshToken?: number;
externalContextFilterId?: number | null;
externalDimensionFilter?: string | null;
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
onClearExternalContextFilter?: () => void;
onClearExternalDimensionFilter?: () => void;
}
export interface ContextsPaneProps extends BasePaneProps {
onContextSelect?: (contextId: number | null, contextName?: string | null) => void;
}
// TablePane specific props
export interface TablePaneProps extends BasePaneProps {
onNodeClick: (nodeId: number) => void;
@@ -113,6 +121,7 @@ export interface PaneHeaderProps {
// Labels for pane types
export const PANE_LABELS: Record<PaneType, string> = {
node: 'Nodes',
contexts: 'Contexts',
dimensions: 'Dimensions',
map: 'Map',
views: 'Feed',
+1 -1
View File
@@ -73,7 +73,7 @@ export default function ContextViewer() {
return (
<div style={containerStyle}>
<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>
{/* Toggle */}
+21 -5
View File
@@ -3,6 +3,7 @@
import { Node } from '@/types/database';
import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import { getNodeProcessedState } from '@/services/nodes/metadata';
interface GridViewProps {
nodes: Node[];
@@ -43,7 +44,11 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: '12px'
}}>
{nodes.map(node => (
{nodes.map(node => {
const processedState = getNodeProcessedState(node.metadata);
const isProcessed = processedState === 'processed';
return (
<button
key={node.id}
onClick={() => onNodeClick(node.id)}
@@ -51,13 +56,14 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
display: 'flex',
flexDirection: 'column',
padding: '16px',
background: '#0a0a0a',
border: '1px solid #1a1a1a',
background: isProcessed ? 'rgba(34, 58, 42, 0.45)' : '#0a0a0a',
border: `1px solid ${isProcessed ? 'rgba(74, 222, 128, 0.28)' : '#1a1a1a'}`,
borderRadius: '8px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.2s',
minHeight: '140px'
minHeight: '140px',
opacity: isProcessed ? 0.84 : 1
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#111';
@@ -103,6 +109,15 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
</div>
</div>
<div style={{
marginBottom: '10px',
fontSize: '10px',
color: isProcessed ? '#86efac' : '#888',
textTransform: 'lowercase'
}}>
{processedState}
</div>
{/* Description or Content Preview */}
{(node.description || node.source) && (
<div style={{
@@ -154,7 +169,8 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
</div>
)}
</button>
))}
);
})}
</div>
</div>
);
+22 -5
View File
@@ -5,6 +5,7 @@ import { Node } from '@/types/database';
import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import { formatRelativeDate } from '@/utils/formatDate';
import { getNodeProcessedState } from '@/services/nodes/metadata';
interface ListViewProps {
nodes: Node[];
@@ -48,7 +49,11 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
overflowY: 'auto',
padding: '8px'
}}>
{nodes.map(node => (
{nodes.map(node => {
const processedState = getNodeProcessedState(node.metadata);
const isProcessed = processedState === 'processed';
return (
<button
key={node.id}
onClick={() => onNodeClick(node.id)}
@@ -59,13 +64,14 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
gap: '12px',
padding: '12px',
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)',
borderLeft: '2px solid var(--rah-border-stronger)',
borderLeft: `2px solid ${isProcessed ? 'var(--rah-accent-green, #4ade80)' : 'var(--rah-border-stronger)'}`,
borderRadius: '6px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.15s ease'
transition: 'all 0.15s ease',
opacity: isProcessed ? 0.82 : 1
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--rah-bg-surface)';
@@ -132,6 +138,16 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
gap: '12px',
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 */}
{node.dimensions && node.dimensions.length > 0 && (
<div style={{
@@ -186,7 +202,8 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
</div>
</div>
</button>
))}
);
})}
</div>
);
}
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -1,9 +1,6 @@
---
name: Audit
description: "Run a structured audit of 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."
description: "Use for structured review, QA, cleanup, or governance checks across graph quality, skill quality, and operational consistency."
---
# Audit
@@ -27,3 +24,5 @@ success_criteria: "Findings are prioritized, concrete, and tied to actionable fi
- Prefer specific evidence over generic commentary.
- Propose the smallest high-leverage fixes first.
- 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.
+2 -2
View File
@@ -3,7 +3,7 @@ name: Calibration
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_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
@@ -14,7 +14,7 @@ Re-anchor the graph to the user's current state.
## 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.
3. Identify stale nodes and missing nodes.
4. Propose precise updates: update existing vs create new.
+6 -3
View File
@@ -8,7 +8,7 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
## Core Rules
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).
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.
@@ -16,9 +16,10 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
## Write Quality Contract
- `title`: clear and specific.
- `description`: concrete object-level description, not vague summaries.
- `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). This is what gets chunked and embedded for semantic search.
- `description`: natural prose, not labels. It should still make what / why / status clear when possible.
- `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). This is what gets chunked and embedded for semantic search. For user-authored ideas or dictated notes, preserve the user's original wording with minimal cleanup.
- `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.
## Execution Pattern
@@ -27,9 +28,11 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
2. Decide: create vs update vs connect.
3. Execute minimum required writes.
4. Verify result reflects user intent exactly.
5. If description framing was materially inferred, complete the write first and then invite one concise user feedback pass instead of blocking creation.
## Do Not
- Create duplicate nodes when an update is correct.
- Write vague descriptions ("discusses", "explores", "is about").
- Replace a user's raw idea/source with a thin summary.
- Create weak or directionless edges.
@@ -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.
+7 -6
View File
@@ -3,7 +3,7 @@ name: Onboarding
description: "Onboard a new user by mapping goals, projects, worldview, and preferences into a strong initial graph."
when_to_use: "New user setup or major reset of account context."
when_not_to_use: "User asks for a narrow tactical operation only."
success_criteria: "User has an initial 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
@@ -22,13 +22,14 @@ Understand the user deeply enough to bootstrap a useful externalized context gra
## Graph Bootstrap
1. Propose 3-6 hub nodes with clear rationale.
2. Propose starter dimensions that reflect real domains.
3. Create initial edges between hubs and active projects.
4. Confirm with user before writing.
1. Propose 3-6 primary contexts with clear rationale.
2. Identify one strong anchor candidate per context.
3. Propose starter dimensions as secondary metadata and filters.
4. Create initial edges between anchor nodes and active project nodes.
5. Confirm with user before writing.
## Output
- Initial hub map
- Initial context map
- Suggested first write actions
- Suggested weekly maintenance rhythm
+45 -40
View File
@@ -5,6 +5,7 @@ import { paperExtractTool } from '@/tools/other/paperExtract';
import { formatNodeForChat } from '@/tools/infrastructure/nodeFormatter';
import { summarizeTranscript } from './transcriptSummarizer';
import { eventBroadcaster } from '@/services/events';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
export type QuickAddMode = 'link' | 'text';
@@ -242,7 +243,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
`Attempted pipeline: ${type}`,
].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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -251,11 +252,16 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
source,
link: url,
metadata: {
source: 'quick-add-link-fallback',
attempted_pipeline: type,
extraction_failed: true,
extraction_error: message,
refined_at: new Date().toISOString(),
type: 'website',
state: 'not_processed',
captured_method: 'quick_add_link_fallback',
captured_by: 'human',
source_metadata: {
attempted_pipeline: type,
extraction_failed: true,
extraction_error: message,
refined_at: new Date().toISOString(),
},
},
}),
});
@@ -294,8 +300,13 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
title,
source: content,
metadata: {
source: 'quick-add-note',
refined_at: new Date().toISOString(),
type: 'note',
state: 'not_processed',
captured_method: 'quick_add_note',
captured_by: 'human',
source_metadata: {
refined_at: new Date().toISOString(),
},
},
};
@@ -303,7 +314,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nodePayload),
@@ -356,48 +367,42 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
? ['Where things felt stuck:', ...stickingPoints.map((item) => `- ${item}`)].join('\n')
: 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 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 = {
source: 'quick-add-chat',
summary_subject: summaryResult.subject,
summary_intent: summaryResult.intent,
summary_progress: summaryResult.progress,
highlights: summaryResult.highlights ?? [],
open_questions: summaryResult.openQuestions ?? [],
participants: summaryResult.participants ?? [],
sticking_points: summaryResult.stickingPoints ?? [],
transcript_length_chars: transcript.length,
transcript_length_words: wordCount,
transcript_truncated_for_summary: summaryResult.truncated ?? false,
summary_generated_at: new Date().toISOString(),
type: 'chat',
state: 'not_processed',
captured_method: 'quick_add_chat',
captured_by: 'human',
source_metadata: {
summary_subject: summaryResult.subject,
summary_intent: summaryResult.intent,
summary_progress: summaryResult.progress,
highlights: summaryResult.highlights ?? [],
open_questions: summaryResult.openQuestions ?? [],
participants: summaryResult.participants ?? [],
sticking_points: summaryResult.stickingPoints ?? [],
transcript_length_chars: transcript.length,
transcript_length_words: wordCount,
transcript_truncated_for_summary: summaryResult.truncated ?? false,
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
source: transcript,
description: content,
description: nodeDescription,
metadata,
}),
});
+199 -38
View File
@@ -1,72 +1,233 @@
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { getAutoContextSettings } from '@/services/settings/autoContextSettings';
interface AutoContextRow {
id: number;
title: string | null;
updated_at: string;
edge_count: number | null;
}
export interface AutoContextSummary {
id: number;
title: string;
edgeCount: number;
description: 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[] {
const db = getSQLiteClient();
const rows = db
.query<AutoContextRow>(
`
SELECT n.id,
n.title,
n.updated_at,
COUNT(DISTINCT e.id) AS edge_count
FROM nodes n
LEFT JOIN edges e
ON (e.from_node_id = n.id OR e.to_node_id = n.id)
WHERE 1=1
GROUP BY n.id
ORDER BY edge_count DESC, n.updated_at DESC
LIMIT ?
`,
[limit]
)
.rows;
const rows = db.query<{
id: number;
title: string | null;
description: string | null;
updated_at: string;
edge_count: number | null;
}>(
`
SELECT n.id,
n.title,
n.description,
n.updated_at,
COUNT(DISTINCT e.id) AS edge_count
FROM nodes n
LEFT JOIN edges e
ON (e.from_node_id = n.id OR e.to_node_id = n.id)
GROUP BY n.id
ORDER BY edge_count DESC, n.updated_at DESC, n.id ASC
LIMIT ?
`,
[limit]
).rows;
return rows.map((row) => ({
id: row.id,
title: row.title || 'Untitled node',
description: row.description || '',
updatedAt: row.updated_at,
edgeCount: Number(row.edge_count ?? 0),
}));
}
export function getAutoContextSummaries(limit = 10): AutoContextSummary[] {
const settings = getAutoContextSettings();
if (!settings.autoContextEnabled) {
return [];
}
export function getHubNodes(limit = 5): AutoContextSummary[] {
return fetchAutoContextRows(limit);
}
export function buildAutoContextBlock(limit = 10): string | null {
const summaries = getAutoContextSummaries(limit);
export function getContextSummaries(limit = 12): PromptContextSummary[] {
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) {
return null;
}
const lines: string[] = [
'=== BACKGROUND CONTEXT ===',
'Top 10 most-connected nodes (important knowledge hubs). Use queryNodes/getNodesById if relevant.',
'Global Hub Diagnostics',
'These are secondary graph diagnostics only. Do not use them as the primary scope layer when contexts are available.',
'',
];
for (const summary of summaries) {
lines.push(`[NODE:${summary.id}:"${summary.title}"] (edges: ${summary.edgeCount})`);
}
summaries.forEach((summary, i) => {
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');
}
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;
}
+168
View File
@@ -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;
}
+225
View File
@@ -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();
+47 -76
View File
@@ -1,12 +1,13 @@
import { openai as openaiProvider } from '@ai-sdk/openai';
import { generateText } from 'ai';
import { hasValidOpenAiKey } from '../storage/apiKeys';
import type { CanonicalNodeMetadata } from '@/types/database';
export interface DescriptionInput {
title: string;
source?: string;
link?: string;
metadata?: {
metadata?: CanonicalNodeMetadata & {
source?: string;
channel_name?: string;
author?: string;
@@ -18,52 +19,12 @@ export interface DescriptionInput {
dimensions?: string[];
}
// Re-export for backwards compatibility — canonical source is ../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> {
// Fast path: skip AI if no valid API key
if (!hasValidOpenAiKey()) {
console.log(`[DescriptionService] No valid OpenAI key, using fallback for: "${input.title}"`);
return generateFallbackDescription(input);
}
// 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);
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);
}
try {
@@ -78,31 +39,38 @@ export async function generateDescription(input: DescriptionInput): Promise<stri
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) {
console.error('[DescriptionService] Error generating description:', error);
// Return a fallback description
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);
}
}
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() : '';
// 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 =
input.metadata?.author?.trim() ||
input.metadata?.channel_name?.trim() ||
(typeof sourceMetadata?.author === 'string' ? sourceMetadata.author.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 = input.metadata?.site_name?.trim() || '';
const publisherHint =
(typeof sourceMetadata?.site_name === 'string' ? sourceMetadata.site_name.trim() : '') ||
(typeof input.metadata?.site_name === 'string' ? input.metadata.site_name.trim() : '') ||
'';
const likelyExternal =
Boolean(url) ||
@@ -123,38 +91,41 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
if (input.link) lines.push(`URL: ${input.link}`);
if (input.dimensions?.length) lines.push(`Dimensions: ${input.dimensions.join(', ')}`);
if (input.metadata?.channel_name) lines.push(`Channel: ${input.metadata.channel_name}`);
if (input.metadata?.author) lines.push(`Author: ${input.metadata.author}`);
if (input.metadata?.site_name) lines.push(`Site: ${input.metadata.site_name}`);
if (input.metadata?.source) lines.push(`Source type: ${input.metadata.source}`);
if (input.metadata?.original_filename) lines.push(`Original filename: ${input.metadata.original_filename}`);
if (typeof input.metadata?.pages === 'number') lines.push(`Pages: ${input.metadata.pages}`);
if (typeof input.metadata?.text_length === 'number') lines.push(`Text length: ${input.metadata.text_length}`);
if (sourceMetadata?.channel_name || input.metadata?.channel_name) lines.push(`Channel: ${sourceMetadata?.channel_name || input.metadata?.channel_name}`);
if (sourceMetadata?.author || input.metadata?.author) lines.push(`Author: ${sourceMetadata?.author || input.metadata?.author}`);
if (sourceMetadata?.site_name || input.metadata?.site_name) lines.push(`Site: ${sourceMetadata?.site_name || input.metadata?.site_name}`);
if (sourceType) lines.push(`Source type: ${sourceType}`);
if (sourceMetadata?.original_filename || input.metadata?.original_filename) lines.push(`Original filename: ${sourceMetadata?.original_filename || input.metadata?.original_filename}`);
if (typeof sourceMetadata?.pages === 'number' || typeof input.metadata?.pages === 'number') lines.push(`Pages: ${sourceMetadata?.pages || input.metadata?.pages}`);
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 (publisherHint) lines.push(`Publisher hint: ${publisherHint}`);
lines.push(`Likely user-authored: ${likelyUserAuthored ? 'yes' : 'no'}`);
const contentPreview = input.source?.slice(0, 800) || '';
if (contentPreview) lines.push(`Source excerpt: ${contentPreview}${input.source && input.source.length > 800 ? '...' : ''}`);
const sourcePreview = input.source?.slice(0, 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:
1) Name the format only if the context clearly supports it: "Podcast episode where…", "Blog post arguing…", "Personal note capturing…", "Research paper showing…", "Resume/CV for…", "Document likely containing…", "Idea that…"
2) Name people by role — channel/host is the creator, title figures are guests/subjects. Use the Creator hint if available.
3) State the actual claim, finding, or insight from the content — not a vague summary of the topic.
4) End with why it's interesting or important — one concrete phrase.
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.
6) Do NOT start with "Your note —" or "This note —". Use a concrete opener tied to the actual artifact.
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.
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) If workflow status is unknown, say that naturally, for example by noting it has not been reviewed yet.
6) Do NOT use labels or headings like "WHAT:", "WHY:", or "STATUS:".
7) ABSOLUTELY FORBIDDEN — these words and phrases will be rejected: "discusses", "explores", "examines", "talks about", "is about", "delves into", "emphasizing the need for", "insightful for understanding", "relevant to", "important for", "useful for understanding". State things directly instead.
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: "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: "Personal note capturing a recurring pattern: morning optimism reverses to evening pessimism. Indicates a belief-level swing worth tracking."
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."
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: "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. 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."
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."
@@ -170,7 +141,7 @@ function sanitizeDescription(rawText: string, input: DescriptionInput): string {
.replace(/^["']|["']$/g, '');
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(
@@ -178,7 +149,7 @@ function sanitizeDescription(rawText: string, input: DescriptionInput): string {
'Personal note capturing '
);
return noGenericPrefix.slice(0, 280);
return noGenericPrefix.slice(0, 500);
}
export const descriptionService = {
+1
View File
@@ -3,6 +3,7 @@ export { nodeService, NodeService } from './nodes';
export { chunkService, ChunkService } from './chunks';
export { edgeService, EdgeService } from './edges';
export { dimensionService, DimensionService } from './dimensionService';
export { contextService, ContextService } from './contextService';
// export { HelperService } from './helpers'; // Removed - migrated to JSON-based service
// Types
+43 -13
View File
@@ -3,8 +3,9 @@ import { Node, NodeFilters } from '@/types/database';
import { eventBroadcaster } from '../events';
import { EmbeddingService } from '@/services/embeddings';
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 };
function sanitizeFtsQuery(input: string): string {
@@ -81,7 +82,7 @@ export class NodeService {
async countNodes(filters: NodeFilters = {}): Promise<number> {
const { dimensions, search, dimensionsMatch = 'any',
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus } = filters;
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus, contextId } = filters;
if (search?.trim()) {
return this.countSearchNodesSQLite(filters);
@@ -120,6 +121,7 @@ export class NodeService {
if (eventAfter) { query += ` AND n.event_date >= ?`; params.push(eventAfter); }
if (eventBefore) { query += ` AND n.event_date < ?`; params.push(eventBefore); }
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);
return result.rows[0]?.total ?? 0;
@@ -129,7 +131,7 @@ export class NodeService {
private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> {
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()) {
return this.searchNodesSQLite(filters);
@@ -141,11 +143,16 @@ export class NodeService {
let query = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at,
n.created_at, n.updated_at, n.context_id,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
END as context_json,
(SELECT COUNT(*) FROM edges WHERE from_node_id = n.id OR to_node_id = n.id) as edge_count
FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE 1=1
`;
const params: any[] = [];
@@ -198,6 +205,10 @@ export class NodeService {
query += ` AND n.chunk_status = ?`;
params.push(chunkStatus);
}
if (contextId !== undefined) {
query += ` AND n.context_id = ?`;
params.push(contextId);
}
// Sorting logic
if (search) {
@@ -255,10 +266,15 @@ export class NodeService {
const query = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at,
n.created_at, n.updated_at, n.context_id,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
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
LEFT JOIN contexts c ON c.id = n.context_id
WHERE n.id = ?
`;
const result = sqlite.query<NodeRow>(query, [id]);
@@ -284,24 +300,27 @@ export class NodeService {
event_date,
dimensions = [],
chunk_status,
metadata = {}
metadata = {},
context_id,
} = nodeData;
const canonicalMetadata = buildCanonicalNodeMetadata({ metadata });
const now = new Date().toISOString();
const sqlite = getSQLiteClient();
const nodeId = sqlite.transaction(() => {
// Insert node using prepare/run for lastInsertRowid access
const nodeResult = sqlite.prepare(`
INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, context_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
title,
description ?? null,
source ?? null,
link ?? null,
event_date ?? null,
JSON.stringify(metadata),
JSON.stringify(canonicalMetadata),
chunk_status ?? null,
context_id ?? null,
now,
now
);
@@ -349,13 +368,17 @@ export class NodeService {
const sqlite = getSQLiteClient();
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];
if (!existingRow) {
throw new Error(`Node with ID ${id} not found`);
}
const mergedMetadata = metadata !== undefined
? mergeNodeMetadata(existingRow.metadata, metadata)
: undefined;
sqlite.transaction(() => {
// Update node columns (only update provided fields)
const setFields: string[] = [];
@@ -366,13 +389,17 @@ export class NodeService {
if (source !== undefined) { setFields.push('source = ?'); params.push(source); }
if (link !== undefined) { setFields.push('link = ?'); params.push(link); }
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')) {
setFields.push('chunk_status = ?');
params.push(updates.chunk_status ?? null);
}
if (metadata !== undefined) {
if (mergedMetadata !== undefined) {
setFields.push('metadata = ?');
params.push(JSON.stringify(metadata));
params.push(JSON.stringify(mergedMetadata));
}
// Always update timestamp
@@ -445,6 +472,7 @@ export class NodeService {
...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,
};
}
@@ -456,6 +484,7 @@ export class NodeService {
createdBefore,
eventAfter,
eventBefore,
contextId,
} = filters;
const clauses: string[] = [];
@@ -483,6 +512,7 @@ export class NodeService {
if (createdBefore) { clauses.push(`${alias}.created_at < ?`); params.push(createdBefore); }
if (eventAfter) { clauses.push(`${alias}.event_date >= ?`); params.push(eventAfter); }
if (eventBefore) { clauses.push(`${alias}.event_date < ?`); params.push(eventBefore); }
if (contextId !== undefined) { clauses.push(`${alias}.context_id = ?`); params.push(contextId); }
return { clauses, params };
}
+33
View File
@@ -1,6 +1,8 @@
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 UNCERTAINTY_PATTERNS = /\b(likely|probably|possibly|appears to be|seems to be|unclear|uncertain)\b/i;
const WHY_PATTERNS = /(why added:|added (?:after|as|for|because|to)|follow-?on|queued for|saved for|relevant because|connected to|not inferred|belongs in the graph because|belongs here because|captures .* idea|ties directly into|ties into)/i;
const STATUS_PATTERNS = /(status:|queued|not yet reviewed|in progress|processed|reviewed|saved for later|to review|to read|to watch|to listen|draft|not yet published|unpublished)/i;
const GENERIC_EDGE_PATTERNS = /^(related|related to|connected|connected to|association|associated with)$/i;
export function normalizeDimensionName(value: string): string {
@@ -29,6 +31,9 @@ export function normalizeDimensions(values: unknown, max = 5): string[] {
export function validateExplicitDescription(description: string): string | null {
const text = description.trim();
if (text.length > 500) {
return 'Description must be 500 characters or less.';
}
if (text.length < 24) {
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)) {
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;
}
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 {
const text = explanation.trim();
if (text.length < 8) {
+58
View File
@@ -69,6 +69,7 @@ class SQLiteClient {
// Ensure logging schema (rename memory->logs if needed, create triggers/views)
this.ensureLoggingAndMemorySchema();
this.ensureContextsSchema();
}
console.log('SQLite client initialized successfully');
@@ -219,6 +220,11 @@ class SQLiteClient {
return;
}
try {
const hasReadyLogs = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='logs'").get();
if (hasReadyLogs) {
return;
}
// 1) If logs table missing but legacy memory table exists, migrate
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();
@@ -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 {
if (this.readOnly || !this.vectorCapability.available) {
return;
+100
View File
@@ -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';
}
+1
View File
@@ -38,6 +38,7 @@ const SEEDED_SKILL_IDS = new Set([
'persona',
'calibration',
'connect',
'node-context-enrichment',
]);
const DEPRECATED_SKILL_IDS = new Set([
+65 -31
View File
@@ -2,42 +2,87 @@ import { tool } from 'ai';
import { z } from 'zod';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
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({
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({
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.'),
source: z.string().optional().describe('Raw content for embedding: transcript, article text, book passages, or user thoughts. If omitted, falls back to title + description.'),
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('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'),
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Use when the node clearly belongs to a known context.'),
context_name: z.string().optional().describe('Optional convenience context name. Resolved to a stable context_id before persistence.'),
dimensions: z
.array(z.string())
.max(5)
.optional()
.describe('Optional 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.')
.describe('Optional secondary dimension tags to apply to this node (0-5 items).'),
metadata: z.record(z.any()).optional().describe('Optional node metadata. Use canonical keys when known: type, state, captured_method, captured_by, and source_metadata. Source-specific facts belong inside source_metadata.')
}),
execute: async (params) => {
execute: async (params, context) => {
console.log('🎯 CreateNode tool called with params:', JSON.stringify(params, null, 2));
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 canonicalSource = inferSourceFromContext(params, context);
// Call the nodes API endpoint
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...params, dimensions: trimmedDimensions })
body: JSON.stringify({ ...params, source: canonicalSource, dimensions: trimmedDimensions })
});
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 {
success: true,
data: {
...result.data,
formatted_display: formattedDisplay
},
message: `Created node ${formattedDisplay} with dimensions: ${result.data.dimensions ? result.data.dimensions.join(', ') : 'none'}`
data: result.data,
message: `Created: ${formatNodeForChat(result.data)}`
};
} catch (error) {
return {
@@ -75,5 +110,4 @@ export const createNodeTool = tool({
}
});
// Legacy export for backwards compatibility
export const createItemTool = createNodeTool;
+90
View File
@@ -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,
},
};
}
},
});
+3
View File
@@ -7,6 +7,7 @@ import { scoreNodeSearchMatch } from '@/services/database/searchRanking';
type QueryNodeFilters = {
dimensions?: string[];
contextId?: number;
search?: string;
limit?: number;
createdAfter?: string;
@@ -20,6 +21,7 @@ export const queryNodesTool = tool({
inputSchema: z.object({
filters: z.object({
dimensions: z.array(z.string()).describe('Filter by dimensions (e.g., ["research", "ai", "technology"]). Replaces old type/stage filtering.').optional(),
contextId: z.number().int().positive().describe('Optional primary context filter.').optional(),
search: z.string().describe('Search term to match against node title, description, or source').optional(),
limit: z.number().min(1).max(50).default(10).describe('Maximum number of results to return'),
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({
limit,
dimensions: queryFilters.dimensions,
contextId: queryFilters.contextId,
search: queryFilters.search,
searchMode: searchTerm ? 'hybrid' : 'standard',
createdAfter: queryFilters.createdAfter,
+8 -25
View File
@@ -1,20 +1,21 @@
import { tool } from 'ai';
import { z } from 'zod';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { normalizeDimensions, validateExplicitDescription } from '@/services/database/quality';
import { normalizeDimensions } from '@/services/database/quality';
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({
id: z.number().describe('The ID of the node to update'),
updates: z.object({
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".'),
source: z.string().optional().describe('Canonical source content for embedding. Use this only to set or correct the raw source text.'),
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 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'),
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'),
metadata: z.record(z.any()).optional().describe('New metadata - completely replaces existing metadata')
context_id: z.number().int().positive().nullable().optional().describe('Primary context ID. Omit to preserve the existing context. Use null only to clear it intentionally.'),
dimensions: z.array(z.string()).optional().describe('New secondary dimension tags - completely replaces existing dimensions'),
metadata: z.record(z.any()).optional().describe('Metadata patch. It now merges with existing metadata instead of replacing the full blob. Use canonical keys: type, state, captured_method, captured_by, source_metadata.')
}).describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.')
}),
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)) {
updates.dimensions = normalizeDimensions(updates.dimensions, 5);
}
// Call the nodes API endpoint
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -74,10 +58,9 @@ export const updateNodeTool = tool({
success: false,
error: error instanceof Error ? error.message : 'Failed to update node',
data: null
};
};
}
}
});
// Legacy export for backwards compatibility
export const updateItemTool = updateNodeTool;
+1
View File
@@ -40,6 +40,7 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
queryDimensions: 'core',
getDimension: 'core',
queryDimensionNodes: 'core',
queryContexts: 'core',
searchContentEmbeddings: 'core',
// Orchestration: Web search and reasoning
+2
View File
@@ -14,6 +14,7 @@ import { deleteDimensionTool } from '../database/deleteDimension';
import { queryDimensionsTool } from '../database/queryDimensions';
import { getDimensionTool } from '../database/getDimension';
import { queryDimensionNodesTool } from '../database/queryDimensionNodes';
import { queryContextsTool } from '../database/queryContexts';
import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings';
import { webSearchTool } from '../other/webSearch';
import { thinkTool } from '../other/think';
@@ -32,6 +33,7 @@ const CORE_TOOLS: Record<string, any> = {
queryDimensions: queryDimensionsTool,
getDimension: getDimensionTool,
queryDimensionNodes: queryDimensionNodesTool,
queryContexts: queryContextsTool,
searchContentEmbeddings: searchContentEmbeddingsTool,
};
+54 -93
View File
@@ -3,9 +3,20 @@ import { z } from 'zod';
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
import { extractPaper } from '@/services/typescript/extractors/paper';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
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) {
try {
const prompt = `Analyze this ${contentType} content and provide classification.
@@ -13,56 +24,36 @@ async function analyzeContentWithAI(title: string, description: string, contentT
Title: "${title}"
Description: "${description}"
CRITICAL — nodeDescription rules (max 280 chars):
1. Say WHAT this literally is: "Paper by…", "Research from…", "Preprint introducing…"
2. Name the authors if known from the metadata.
3. State the actual finding, method, or contribution — not "a study on X" but what they actually found or built.
4. End with why it matters — one concrete phrase about impact or implication.
5. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for". State things directly.
CRITICAL — nodeDescription rules (max 500 chars):
1. Write natural prose.
2. Make clear what this literally is.
3. State the actual finding, method, or contribution.
4. Make clear why it belongs in the graph. If unclear, say so naturally.
5. Make the workflow status clear.
Examples:
- 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):
Respond with ONLY valid JSON:
{
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
"nodeDescription": "<your 280-char description following the rules above>",
"tags": ["relevant", "semantic", "tags"],
"reasoning": "Brief explanation of classification choices"
"enhancedDescription": "A comprehensive summary.",
"nodeDescription": "<your natural description>",
"reasoning": "Brief explanation"
}`;
const response = await generateText({
model: openai('gpt-4o-mini'),
prompt,
maxOutputTokens: 800
});
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 content = (response.text || '{}').replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const result = JSON.parse(content);
return {
enhancedDescription: result.enhancedDescription || description,
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 280) : undefined,
tags: Array.isArray(result.tags) ? result.tags : [],
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 500) : undefined,
reasoning: result.reasoning || 'AI analysis completed'
};
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
console.warn('Paper analysis fallback (using default description):', message);
console.warn('Paper analysis fallback (using default description):', error);
return {
enhancedDescription: description,
nodeDescription: undefined,
tags: [],
reasoning: 'Fallback description used'
};
}
@@ -73,30 +64,19 @@ export const paperExtractTool = tool({
inputSchema: z.object({
url: z.string().describe('The PDF URL to add to inbox'),
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 }) => {
try {
// Validate PDF URL
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return {
success: false,
error: 'Invalid URL format - must start with http:// or https://',
data: null
};
return { 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')) {
return {
success: false,
error: 'URL does not appear to point to a PDF file',
data: null
};
return { 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 };
try {
const extractionResult = await extractPaper(url);
result = {
@@ -112,91 +92,72 @@ export const paperExtractTool = tool({
}
};
} catch (error: any) {
result = {
success: false,
error: error.message || 'TypeScript extraction failed'
};
result = { success: false, error: error.message || 'TypeScript extraction failed' };
}
if (!result.success || !result.source) {
return {
success: false,
error: result.error || 'Failed to extract PDF content',
data: null
};
return { 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(
result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`,
result.source.substring(0, 2000) || 'PDF document content',
'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 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 : [];
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`, {
const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: nodeTitle,
description: aiAnalysis?.nodeDescription,
description: nodeDescription,
source: result.source,
link: url,
dimensions: trimmedDimensions,
metadata: {
source: 'pdf',
hostname: new URL(url).hostname,
author: result.metadata?.author || result.metadata?.info?.Author,
pages: result.metadata?.pages,
file_size: result.metadata?.file_size,
content_length: result.source.length,
extraction_method: result.metadata?.extraction_method || 'python_pdfplumber',
ai_analysis: aiAnalysis?.reasoning,
enhanced_description: enhancedDescription,
refined_at: new Date().toISOString()
type: 'pdf',
state: 'not_processed',
captured_method: 'paper_extract',
captured_by: 'agent',
source_metadata: {
hostname: new URL(url).hostname,
author: result.metadata?.author || result.metadata?.info?.Author,
pages: result.metadata?.pages,
content_length: result.source.length,
extraction_method: result.metadata?.extraction_method || 'typescript',
ai_analysis: aiAnalysis?.reasoning,
enhanced_description: aiAnalysis?.enhancedDescription,
refined_at: new Date().toISOString()
}
}
})
});
const createResult = await createResponse.json();
if (!createResponse.ok) {
return {
success: false,
error: createResult.error || 'Failed to create node',
data: null
};
return { 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 formattedNode = createResult.data?.id
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
: nodeTitle;
const dimsDisplay = actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none';
return {
success: true,
message: `Added ${formattedNode} with dimensions: ${dimsDisplay}`,
message: `Added ${formattedNode} with dimensions: ${actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none'}`,
data: {
nodeId: createResult.data?.id,
title: nodeTitle,
contentLength: result.source.length,
url: url,
url,
dimensions: actualDimensions
}
};
+62 -122
View File
@@ -3,13 +3,25 @@ import { z } from 'zod';
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
import { extractWebsite } from '@/services/typescript/extractors/website';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import { validateExplicitDescription } from '@/services/database/quality';
interface ExistingDimension {
name: string;
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' {
try {
const hostname = new URL(url).hostname.toLowerCase();
@@ -23,12 +35,10 @@ function inferWebsiteContentType(url: string): 'website' | 'tweet' {
async function fetchExistingDimensions(): Promise<ExistingDimension[]> {
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 [];
const result = await response.json();
if (!Array.isArray(result.data)) return [];
return result.data
.map((dimension: { dimension?: unknown; description?: unknown }) => ({
name: typeof dimension.dimension === 'string' ? dimension.dimension.trim() : '',
@@ -41,17 +51,11 @@ async function fetchExistingDimensions(): Promise<ExistingDimension[]> {
}
}
function selectExistingDimensions(
selected: unknown,
existingDimensions: ExistingDimension[],
max = 5
): string[] {
function selectExistingDimensions(selected: unknown, existingDimensions: ExistingDimension[], max = 5): string[] {
if (!Array.isArray(selected) || existingDimensions.length === 0) return [];
const byLowerName = new Map(existingDimensions.map((dimension) => [dimension.name.toLowerCase(), dimension.name]));
const normalized: string[] = [];
const seen = new Set<string>();
for (const value of selected) {
if (typeof value !== 'string') continue;
const matched = byLowerName.get(value.trim().toLowerCase());
@@ -62,89 +66,53 @@ function selectExistingDimensions(
normalized.push(matched);
if (normalized.length >= max) break;
}
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 {
const availableDimensionsBlock = existingDimensions.length > 0
? existingDimensions
.map((dimension) => `- ${dimension.name}${dimension.description ? `: ${dimension.description}` : ''}`)
.join('\n')
? existingDimensions.map((dimension) => `- ${dimension.name}${dimension.description ? `: ${dimension.description}` : ''}`).join('\n')
: '- No existing dimensions available. Return an empty dimensions array.';
const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}"
Description: "${description}"
CRITICAL — nodeDescription rules (max 280 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…"
2. Name the author/site if known from the metadata.
3. State the actual claim or thesis — don't paraphrase into vague abstractions.
4. End with why it's interesting or important — one concrete phrase.
5. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for". State things directly.
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.
CRITICAL — nodeDescription rules (max 500 chars):
1. Write natural prose, not labels or a checklist.
2. Make clear what this literally is using explicit entity words only.
3. Name the author/site if known from the metadata.
4. State the actual claim or thesis.
5. Make clear why it belongs in the graph. If that remains unclear, say so naturally.
6. Make workflow status clear.
Available dimensions:
${availableDimensionsBlock}
Examples:
- 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):
Respond with ONLY valid JSON:
{
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
"nodeDescription": "<your 280-char description following the rules above>",
"dimensions": ["existing-dimension-1", "existing-dimension-2"],
"reasoning": "Brief explanation of classification choices"
"enhancedDescription": "A comprehensive summary.",
"nodeDescription": "<your natural description>",
"dimensions": ["existing-dimension-1"],
"reasoning": "Brief explanation"
}`;
const response = await generateText({
model: openai('gpt-4o-mini'),
prompt,
maxOutputTokens: 800
});
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 content = (response.text || '{}').replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const result = JSON.parse(content);
return {
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),
reasoning: result.reasoning || 'AI analysis completed'
};
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
console.warn('Website analysis fallback (using default description):', message);
return {
enhancedDescription: description,
nodeDescription: undefined,
dimensions: [],
reasoning: 'Fallback description used'
};
console.warn('Website analysis fallback (using default description):', error);
return { enhancedDescription: description, nodeDescription: undefined, dimensions: [], reasoning: 'Fallback description used' };
}
}
@@ -153,21 +121,15 @@ export const websiteExtractTool = tool({
inputSchema: z.object({
url: z.string().describe('The website URL to add to knowledge base'),
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 }) => {
try {
// Validate URL format
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return {
success: false,
error: 'Invalid URL format - must start with http:// or https://',
data: null
};
return { success: false, error: 'Invalid URL format - must start with http:// or https://', data: null };
}
let result: { success: boolean; source?: string; metadata?: any; error?: string };
try {
const extractionResult = await extractWebsite(url);
result = {
@@ -184,97 +146,75 @@ export const websiteExtractTool = tool({
}
};
} catch (error: any) {
result = {
success: false,
error: error.message || 'TypeScript extraction failed'
};
result = { success: false, error: error.message || 'TypeScript extraction failed' };
}
if (!result.success || !result.source) {
return {
success: false,
error: result.error || 'Failed to extract website content',
data: null
};
return { 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 contentType = inferWebsiteContentType(url);
const aiAnalysis = await analyzeContentWithAI(
result.metadata?.title || `Website: ${new URL(url).hostname}`,
result.source.substring(0, 2000) || 'Website content',
contentType,
inferWebsiteContentType(url),
existingDimensions
);
// Step 3: Create node with extracted content and AI analysis
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 : [];
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`, {
const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: nodeTitle,
description: aiAnalysis?.nodeDescription,
description: nodeDescription,
source: result.source,
link: url,
event_date: result.metadata?.published_date || result.metadata?.date || null,
dimensions: finalDimensions,
metadata: {
source: contentType,
hostname: new URL(url).hostname,
author: result.metadata?.author,
published_date: result.metadata?.published_date || result.metadata?.date,
content_length: result.source.length,
extraction_method: result.metadata?.extraction_method || 'python_beautifulsoup',
ai_analysis: aiAnalysis?.reasoning,
enhanced_description: enhancedDescription,
refined_at: new Date().toISOString()
type: inferWebsiteContentType(url),
state: 'not_processed',
captured_method: 'website_extract',
captured_by: 'agent',
source_metadata: {
hostname: new URL(url).hostname,
author: result.metadata?.author,
site_name: result.metadata?.site_name,
date: result.metadata?.date,
og_image: result.metadata?.og_image,
extraction_method: result.metadata?.extraction_method,
ai_analysis: aiAnalysis?.reasoning,
refined_at: new Date().toISOString()
}
}
})
});
const createResult = await createResponse.json();
if (!createResponse.ok) {
return {
success: false,
error: createResult.error || 'Failed to create node',
data: null
};
return { 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 formattedNode = createResult.data?.id
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
: nodeTitle;
const dimsDisplay = actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none';
return {
success: true,
message: `Added ${formattedNode} with dimensions: ${dimsDisplay}`,
message: `Added ${formattedNode} with dimensions: ${actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none'}`,
data: {
nodeId: createResult.data?.id,
title: nodeTitle,
contentLength: result.source.length,
url: url,
url,
dimensions: actualDimensions
}
};
+82 -121
View File
@@ -3,16 +3,33 @@ import { z } from 'zod';
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
import { extractYouTube } from '@/services/typescript/extractors/youtube';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import { validateExplicitDescription } from '@/services/database/quality';
interface ExistingDimension {
name: string;
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[]> {
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 [];
const result = await response.json();
@@ -30,11 +47,7 @@ async function fetchExistingDimensions(): Promise<ExistingDimension[]> {
}
}
function selectExistingDimensions(
selected: unknown,
existingDimensions: ExistingDimension[],
max = 5
): string[] {
function selectExistingDimensions(selected: unknown, existingDimensions: ExistingDimension[], max = 5): string[] {
if (!Array.isArray(selected) || existingDimensions.length === 0) return [];
const byLowerName = new Map(existingDimensions.map((dimension) => [dimension.name.toLowerCase(), dimension.name]));
@@ -55,55 +68,39 @@ function selectExistingDimensions(
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 {
const availableDimensionsBlock = existingDimensions.length > 0
? existingDimensions
.map((dimension) => `- ${dimension.name}${dimension.description ? `: ${dimension.description}` : ''}`)
.join('\n')
? existingDimensions.map((dimension) => `- ${dimension.name}${dimension.description ? `: ${dimension.description}` : ''}`).join('\n')
: '- No existing dimensions available. Return an empty dimensions array.';
const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}"
Description: "${description}"
CRITICAL — nodeDescription rules (max 280 chars):
1. Say WHAT this literally is: "Podcast episode where…", "Talk by…", "Interview with…", "Video essay on…"
2. Name people by their role: the channel/host is the creator, anyone in the title is likely the guest or subject.
3. State the actual claim or thesis from the title — don't paraphrase into vague abstractions.
4. End with why it's interesting or important — one concrete phrase.
5. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for". State things directly.
CRITICAL — nodeDescription rules (max 500 chars):
1. Write natural prose, not labels or a checklist.
2. Make clear what this literally is: "Podcast episode where…", "Talk by…", "Interview with…", "Video essay on…"
3. Name people by their role: the channel/host is the creator, anyone in the title is likely the guest or subject.
4. State the actual claim or thesis from the title.
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.
Do NOT invent new dimension names.
Pick only dimensions that genuinely fit this content.
If nothing fits, return an empty array.
Available dimensions:
${availableDimensionsBlock}
Examples:
- 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):
Respond with ONLY valid JSON:
{
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
"nodeDescription": "<your 280-char description following the rules above>",
"dimensions": ["existing-dimension-1", "existing-dimension-2"],
"reasoning": "Brief explanation of classification choices"
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars).",
"nodeDescription": "<your natural description>",
"dimensions": ["existing-dimension-1"],
"reasoning": "Brief explanation"
}`;
const response = await generateText({
@@ -112,22 +109,17 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
maxOutputTokens: 800
});
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 content = (response.text || '{}').replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const result = JSON.parse(content);
return {
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),
reasoning: result.reasoning || 'AI analysis completed'
};
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
console.warn('YouTube analysis fallback (using default description):', message);
console.warn('YouTube analysis fallback (using default description):', error);
return {
enhancedDescription: description,
nodeDescription: undefined,
@@ -142,29 +134,21 @@ async function summariseTranscript(title: string, transcript: string): Promise<s
return null;
}
// Limit transcript length to keep token costs manageable
const MAX_CHARS = 16000;
let excerpt = 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}
"""
`;
const excerpt = transcript.trim().length > 16000
? `${transcript.trim().slice(0, 8000)}\n[...]\n${transcript.trim().slice(-8000)}`
: transcript.trim();
try {
const response = await generateText({
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
});
return response.text?.trim() || null;
@@ -179,23 +163,17 @@ export const youtubeExtractTool = tool({
inputSchema: z.object({
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)'),
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 }) => {
console.log('🎯 YouTubeExtract tool called with URL:', url);
try {
// Validate YouTube URL
if (!url.includes('youtube.com') && !url.includes('youtu.be')) {
return {
success: false,
error: 'Invalid YouTube URL format',
data: null
};
return { success: false, error: 'Invalid YouTube URL format', data: null };
}
let result: { success: boolean; source?: string; metadata?: any; error?: string };
console.log('📝 Using TypeScript yt-dlp extractor');
try {
const extractionResult = await extractYouTube(url);
result = {
@@ -215,23 +193,13 @@ export const youtubeExtractTool = tool({
error: extractionResult.error
};
} catch (error: any) {
result = {
success: false,
error: error.message || 'TypeScript extraction failed'
};
result = { success: false, error: error.message || 'TypeScript extraction failed' };
}
if (!result.success || !result.source) {
return {
success: false,
error: result.error || 'Failed to extract YouTube content',
data: null
};
return { 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 aiAnalysis = await analyzeContentWithAI(
result.metadata?.video_title || 'YouTube Video',
@@ -240,73 +208,66 @@ export const youtubeExtractTool = tool({
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 transcriptSummary = await summariseTranscript(nodeTitle, result.source);
const suppliedDimensions = Array.isArray(dimensions) ? dimensions : [];
let trimmedDimensions = suppliedDimensions
.map(dim => (typeof dim === 'string' ? dim.trim() : ''))
.filter(Boolean);
trimmedDimensions = trimmedDimensions.slice(0, 5);
const finalDimensions = trimmedDimensions.length > 0
? trimmedDimensions
const finalDimensions = suppliedDimensions.slice(0, 5).length > 0
? suppliedDimensions.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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: nodeTitle,
description: aiAnalysis?.nodeDescription,
description: nodeDescription,
source: result.source,
link: url,
dimensions: finalDimensions,
metadata: {
source: 'youtube',
video_id: result.metadata?.video_id,
channel_name: result.metadata?.channel_name,
channel_url: result.metadata?.channel_url,
thumbnail_url: result.metadata?.thumbnail_url,
transcript_length: result.metadata?.transcript_length,
total_segments: result.metadata?.total_segments,
language: result.metadata?.language,
extraction_method: result.metadata?.extraction_method,
ai_analysis: aiAnalysis?.reasoning,
summary_origin: transcriptSummary ? 'transcript_summary' : 'metadata_description',
refined_at: new Date().toISOString()
type: 'youtube',
state: 'not_processed',
captured_method: 'youtube_extract',
captured_by: 'agent',
source_metadata: {
video_id: result.metadata?.video_id,
channel_name: result.metadata?.channel_name,
channel_url: result.metadata?.channel_url,
thumbnail_url: result.metadata?.thumbnail_url,
transcript_length: result.metadata?.transcript_length,
total_segments: result.metadata?.total_segments,
language: result.metadata?.language,
extraction_method: result.metadata?.extraction_method,
ai_analysis: aiAnalysis?.reasoning,
summary_origin: transcriptSummary ? 'transcript_summary' : 'metadata_description',
refined_at: new Date().toISOString()
}
}
})
});
const createResult = await createResponse.json();
if (!createResponse.ok) {
return {
success: false,
error: createResult.error || 'Failed to create item',
data: null
};
return { 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 formattedNode = createResult.data?.id
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
: nodeTitle;
const dimsDisplay = actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none';
return {
success: true,
message: `Added ${formattedNode} with dimensions: ${dimsDisplay}`,
message: `Added ${formattedNode} with dimensions: ${actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none'}`,
data: {
nodeId: createResult.data?.id,
title: nodeTitle,
contentLength: result.source.length,
url: url,
url,
dimensions: actualDimensions
}
};
+33 -1
View File
@@ -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
export interface Node {
id: number;
@@ -10,10 +39,12 @@ export interface Node {
dimensions: string[]; // Flexible dimensions replacing type + stage + segment + tags
embedding?: Buffer; // Node-level embedding (BLOB data)
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;
updated_at: string;
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
embedding_updated_at?: string;
@@ -67,6 +98,7 @@ export interface EdgeContext {
// New NodeFilters interface replacing rigid ItemFilters
export interface NodeFilters {
dimensions?: string[]; // Filter by dimensions (replaces stage/type filtering)
contextId?: number;
search?: string; // Text search in title/content
searchMode?: 'standard' | 'hybrid'; // standard = FTS/LIKE, hybrid = add node-vector retrieval
chunkStatus?: 'not_chunked' | 'chunking' | 'chunked' | 'error';
+27
View File
@@ -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;
}
}