feat: port source-first ingestion to os repo

- make source the canonical field across os routes, tools, ui, and mcp
- align fresh-install schema, search, and embedding flows with source-first ingestion

Generated with Claude Code
This commit is contained in:
“BeeRad”
2026-03-20 12:36:26 +11:00
parent 41f8498d4a
commit 28e570696c
34 changed files with 588 additions and 521 deletions
+2 -4
View File
@@ -57,10 +57,8 @@ export async function POST(request: NextRequest) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
title, title,
// Give the description generator and UI a readable summary plus a short excerpt. description: `PDF document imported from ${file.name}${extraction.metadata.pages} pages of extracted text. Useful as a searchable local source.`,
notes: `Imported PDF document: ${file.name} (${extraction.metadata.pages} pages, ${Math.round(extraction.metadata.text_length / 1000)}k characters).\n\nPreview:\n${extraction.chunk.slice(0, 1200)}`, source: extraction.chunk,
// Full extracted text goes in chunk for universal chunking/embedding
chunk: extraction.chunk,
metadata: { metadata: {
source: 'pdf_upload', source: 'pdf_upload',
original_filename: file.name, original_filename: file.name,
+5 -4
View File
@@ -134,7 +134,7 @@ function createRAHServer(): McpServer {
id: node.id, id: node.id,
title: node.title, title: node.title,
description: node.description ?? null, description: node.description ?? null,
notes: node.notes ?? null, source: node.source ?? node.notes ?? null,
link: node.link ?? null, link: node.link ?? null,
dimensions: node.dimensions || [], dimensions: node.dimensions || [],
metadata: node.metadata || {}, metadata: node.metadata || {},
@@ -263,21 +263,22 @@ function createRAHServer(): McpServer {
description: 'Create a new node in the knowledge graph.', description: 'Create a new node in the knowledge graph.',
inputSchema: { inputSchema: {
title: z.string().min(1).max(160).describe('Node title'), title: z.string().min(1).max(160).describe('Node title'),
content: z.string().max(20000).optional().describe('Node content/notes'), 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'), link: z.string().url().optional().describe('Source URL'),
description: z.string().max(2000).optional().describe('Short description'), description: z.string().max(2000).optional().describe('Short description'),
dimensions: z.array(z.string()).min(1).max(5).describe('Categories/tags (at least 1)'), dimensions: z.array(z.string()).min(1).max(5).describe('Categories/tags (at least 1)'),
metadata: z.record(z.any()).optional().describe('Additional metadata'), metadata: z.record(z.any()).optional().describe('Additional metadata'),
}, },
}, },
async ({ title, content, link, description, dimensions, metadata }) => { async ({ title, content, source, link, description, dimensions, metadata }) => {
// Call the nodes API internally // Call the nodes API internally
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, { const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
title: title.trim(), title: title.trim(),
content: content?.trim(), source: source?.trim() || content?.trim(),
link: link?.trim(), link: link?.trim(),
description: description?.trim(), description: description?.trim(),
dimensions, dimensions,
@@ -85,7 +85,7 @@ export async function POST(
// Generate new description using the description service // Generate new description using the description service
const newDescription = await generateDescription({ const newDescription = await generateDescription({
title: node.title, title: node.title,
notes: node.notes || undefined, notes: node.source || node.description || undefined,
link: node.link || undefined, link: node.link || undefined,
metadata: enrichedMetadata, metadata: enrichedMetadata,
+8 -10
View File
@@ -85,13 +85,15 @@ export async function PUT(
updates.dimensions = normalizeDimensions(body.dimensions, 5); updates.dimensions = normalizeDimensions(body.dimensions, 5);
} }
const incomingChunk = typeof body.chunk === 'string' ? body.chunk : undefined; delete updates.notes;
const incomingNotes = typeof body.notes === 'string' ? body.notes : undefined; delete updates.chunk;
const existingChunk = existingNode.chunk ?? '';
if (incomingChunk !== undefined) { const incomingSource = typeof body.source === 'string' ? body.source : undefined;
const trimmedIncoming = incomingChunk.trim(); const existingSource = existingNode.source ?? '';
const trimmedExisting = existingChunk.trim();
if (incomingSource !== undefined) {
const trimmedIncoming = incomingSource.trim();
const trimmedExisting = existingSource.trim();
if (!trimmedIncoming) { if (!trimmedIncoming) {
updates.chunk_status = null; updates.chunk_status = null;
@@ -101,10 +103,6 @@ export async function PUT(
} else { } else {
delete updates.chunk_status; delete updates.chunk_status;
} }
} else if (!existingChunk.trim() && hasSufficientContent(incomingNotes)) {
updates.chunk = incomingNotes;
updates.chunk_status = 'not_chunked';
shouldQueueEmbed = true;
} }
const node = await nodeService.updateNode(nodeId, updates); const node = await nodeService.updateNode(nodeId, updates);
+7 -21
View File
@@ -92,15 +92,15 @@ export async function POST(request: NextRequest) {
// Sanitize title (strip extraction artifacts) // Sanitize title (strip extraction artifacts)
body.title = sanitizeTitle(body.title); body.title = sanitizeTitle(body.title);
const rawNotes = typeof body.notes === 'string' ? body.notes : null; const rawSource = typeof body.source === 'string' ? body.source.trim() : null;
const rawChunk = typeof body.chunk === 'string' ? body.chunk : null;
const eventDate = typeof body.event_date === 'string' ? body.event_date : null; const eventDate = typeof body.event_date === 'string' ? body.event_date : null;
// Process provided dimensions first (needed for description generation) // Process provided dimensions first (needed for description generation)
const trimmedProvidedDimensions = normalizeDimensions(body.dimensions, 5); const trimmedProvidedDimensions = normalizeDimensions(body.dimensions, 5);
// Use provided description if present, otherwise auto-generate // Use provided description if present, otherwise auto-generate
let nodeDescription: string | undefined = typeof body.description === 'string' && body.description.trim() 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, 280)
: undefined; : undefined;
@@ -108,7 +108,7 @@ export async function POST(request: NextRequest) {
try { try {
nodeDescription = await generateDescription({ nodeDescription = await generateDescription({
title: body.title, title: body.title,
notes: rawNotes || rawChunk?.slice(0, 2000) || undefined, notes: rawSource?.slice(0, 2000) || undefined,
link: body.link || undefined, link: body.link || undefined,
metadata: body.metadata, metadata: body.metadata,
dimensions: trimmedProvidedDimensions dimensions: trimmedProvidedDimensions
@@ -127,7 +127,6 @@ export async function POST(request: NextRequest) {
const descriptionError = validateExplicitDescription(finalDescription); const descriptionError = validateExplicitDescription(finalDescription);
if (descriptionError) { if (descriptionError) {
const isUserSuppliedDescription = typeof body.description === 'string' && body.description.trim().length > 0;
if (isUserSuppliedDescription) { if (isUserSuppliedDescription) {
return NextResponse.json({ return NextResponse.json({
success: false, success: false,
@@ -148,33 +147,20 @@ export async function POST(request: NextRequest) {
// Use only provided dimensions (no auto-assignment) // Use only provided dimensions (no auto-assignment)
const finalDimensions = trimmedProvidedDimensions; const finalDimensions = trimmedProvidedDimensions;
let chunkToStore = rawChunk; const sourceToStore = rawSource || [body.title, nodeDescription].filter(Boolean).join('\n\n').trim() || null;
let chunkStatus: Node['chunk_status']; let chunkStatus: Node['chunk_status'];
if (chunkToStore && chunkToStore.trim().length > 0) { if (sourceToStore && sourceToStore.trim().length > 0) {
chunkStatus = 'not_chunked'; chunkStatus = 'not_chunked';
} else {
// Build chunk from all available notes if not provided
// This ensures every node gets at least one chunk for search
const fallbackContent = [body.title, nodeDescription, rawNotes]
.filter(Boolean)
.join('\n\n')
.trim();
if (fallbackContent) {
chunkToStore = fallbackContent;
chunkStatus = 'not_chunked';
}
} }
const node = await nodeService.createNode({ const node = await nodeService.createNode({
title: body.title, title: body.title,
description: finalDescription, description: finalDescription,
notes: rawNotes ?? undefined, source: sourceToStore ?? undefined,
event_date: eventDate ?? undefined, event_date: eventDate ?? undefined,
link: body.link, link: body.link,
dimensions: finalDimensions, dimensions: finalDimensions,
chunk: chunkToStore ?? undefined,
chunk_status: chunkStatus, chunk_status: chunkStatus,
metadata: body.metadata || {} metadata: body.metadata || {}
}); });
+21 -17
View File
@@ -77,7 +77,8 @@ All data stays on this device.`;
// Tool schemas // Tool schemas
const addNodeInputSchema = { const addNodeInputSchema = {
title: z.string().min(1).max(160).describe('Clear, descriptive title'), title: z.string().min(1).max(160).describe('Clear, descriptive title'),
content: z.string().max(20000).optional().describe('Node content/notes'), 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'), 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().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."'),
dimensions: z.array(z.string()).min(1).max(5).describe('1-5 categories. Call queryDimensions first to use existing ones.'), dimensions: z.array(z.string()).min(1).max(5).describe('1-5 categories. Call queryDimensions first to use existing ones.'),
@@ -354,7 +355,7 @@ async function main() {
// Note: MCP schema uses "content" for external API compat; mapped to "notes" internally // Note: MCP schema uses "content" for external API compat; mapped to "notes" internally
inputSchema: addNodeInputSchema inputSchema: addNodeInputSchema
}, },
async ({ title, content, link, description, dimensions, metadata, chunk }) => { async ({ title, content, source, link, description, dimensions, metadata, chunk }) => {
const normalizedDimensions = sanitizeDimensions(dimensions); const normalizedDimensions = sanitizeDimensions(dimensions);
if (normalizedDimensions.length === 0) { if (normalizedDimensions.length === 0) {
throw new Error('At least one dimension is required.'); throw new Error('At least one dimension is required.');
@@ -366,12 +367,11 @@ async function main() {
const node = nodeService.createNode({ const node = nodeService.createNode({
title: title.trim(), title: title.trim(),
notes: content?.trim(), source: source?.trim() || chunk?.trim() || content?.trim(),
link: link?.trim(), link: link?.trim(),
description: description?.trim(), description: description?.trim(),
dimensions: normalizedDimensions, dimensions: normalizedDimensions,
metadata: metadata || {}, metadata: metadata || {}
chunk: chunk?.trim()
}); });
const summary = `Created node #${node.id}: ${node.title} [${node.dimensions.join(', ')}]`; const summary = `Created node #${node.id}: ${node.title} [${node.dimensions.join(', ')}]`;
@@ -425,7 +425,7 @@ async function main() {
WITH fts_matches AS ( WITH fts_matches AS (
SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT 100 SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT 100
) )
SELECT n.id, n.title, n.description, n.notes, n.link, SELECT n.id, n.title, n.description, n.source, n.link,
n.created_at, n.updated_at, n.event_date, n.created_at, n.updated_at, n.event_date,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
@@ -446,7 +446,7 @@ async function main() {
WITH fts_matches AS ( WITH fts_matches AS (
SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT ? SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT ?
) )
SELECT n.id, n.title, n.description, n.notes, n.link, SELECT n.id, n.title, n.description, n.source, n.link,
n.created_at, n.updated_at, n.event_date, n.created_at, n.updated_at, n.event_date,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
@@ -462,7 +462,7 @@ async function main() {
nodes = rows.map(row => ({ nodes = rows.map(row => ({
id: row.id, id: row.id,
title: row.title, title: row.title,
notes: row.notes ?? null, source: row.source ?? null,
description: row.description ?? null, description: row.description ?? null,
link: row.link ?? null, link: row.link ?? null,
dimensions: JSON.parse(row.dimensions_json || '[]'), dimensions: JSON.parse(row.dimensions_json || '[]'),
@@ -482,7 +482,7 @@ async function main() {
const words = trimmedQuery.split(/\s+/).filter(w => w.length > 0); const words = trimmedQuery.split(/\s+/).filter(w => w.length > 0);
let sql = ` let sql = `
SELECT n.id, n.title, n.description, n.notes, n.link, SELECT n.id, n.title, n.description, n.source, n.link,
n.created_at, n.updated_at, n.event_date, n.created_at, n.updated_at, n.event_date,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
@@ -492,7 +492,7 @@ async function main() {
const params = []; const params = [];
for (const word of words) { for (const word of words) {
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.notes LIKE ? COLLATE NOCASE)`; sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
params.push(`%${word}%`, `%${word}%`, `%${word}%`); params.push(`%${word}%`, `%${word}%`, `%${word}%`);
} }
@@ -518,7 +518,7 @@ async function main() {
nodes = rows.map(row => ({ nodes = rows.map(row => ({
id: row.id, id: row.id,
title: row.title, title: row.title,
notes: row.notes ?? null, source: row.source ?? null,
description: row.description ?? null, description: row.description ?? null,
link: row.link ?? null, link: row.link ?? null,
dimensions: JSON.parse(row.dimensions_json || '[]'), dimensions: JSON.parse(row.dimensions_json || '[]'),
@@ -560,13 +560,13 @@ async function main() {
for (const id of uniqueIds) { for (const id of uniqueIds) {
const node = nodeService.getNodeById(id); const node = nodeService.getNodeById(id);
if (node) { if (node) {
const rawChunk = node.chunk ?? null; const rawChunk = node.source ?? node.chunk ?? null;
const chunkTruncated = rawChunk ? rawChunk.length > CHUNK_LIMIT : false; const chunkTruncated = rawChunk ? rawChunk.length > CHUNK_LIMIT : false;
nodes.push({ nodes.push({
id: node.id, id: node.id,
title: node.title, title: node.title,
notes: node.notes ?? null, source: node.source ?? node.notes ?? null,
description: node.description ?? null, description: node.description ?? null,
link: node.link ?? null, link: node.link ?? null,
chunk: chunkTruncated ? rawChunk.substring(0, CHUNK_LIMIT) : rawChunk, chunk: chunkTruncated ? rawChunk.substring(0, CHUNK_LIMIT) : rawChunk,
@@ -610,14 +610,18 @@ async function main() {
throw new Error(descriptionError); throw new Error(descriptionError);
} }
// Map MCP 'content' field → internal 'notes' field // Map MCP legacy fields to canonical source
const mappedUpdates = { ...updates }; const mappedUpdates = { ...updates };
if (mappedUpdates.content !== undefined) { if (mappedUpdates.content !== undefined) {
mappedUpdates.notes = mappedUpdates.content; mappedUpdates.source = mappedUpdates.content;
delete mappedUpdates.content;
} }
if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) {
mappedUpdates.source = mappedUpdates.chunk;
}
delete mappedUpdates.content;
delete mappedUpdates.chunk;
const node = nodeService.updateNode(id, mappedUpdates, { appendNotes: true }); const node = nodeService.updateNode(id, mappedUpdates, { appendNotes: false });
return { return {
content: [{ type: 'text', text: `Updated node #${id}` }], content: [{ type: 'text', text: `Updated node #${id}` }],
@@ -9,7 +9,7 @@ function getNodes(filters = {}) {
const { dimensions, search, limit = 100, offset = 0 } = filters; const { dimensions, search, limit = 100, offset = 0 } = filters;
let sql = ` let sql = `
SELECT n.id, n.title, n.description, n.notes, n.link, n.event_date, n.metadata, n.chunk, SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk,
n.created_at, n.updated_at, n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
@@ -30,7 +30,7 @@ function getNodes(filters = {}) {
// Text search // Text search
if (search) { if (search) {
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.notes LIKE ? COLLATE NOCASE)`; sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
params.push(`%${search}%`, `%${search}%`, `%${search}%`); params.push(`%${search}%`, `%${search}%`, `%${search}%`);
} }
@@ -70,7 +70,7 @@ function getNodes(filters = {}) {
*/ */
function getNodeById(id) { function getNodeById(id) {
const sql = ` const sql = `
SELECT n.id, n.title, n.description, n.notes, n.link, n.event_date, n.metadata, n.chunk, SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk,
n.created_at, n.updated_at, n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
@@ -108,11 +108,10 @@ function createNode(nodeData) {
const { const {
title: rawTitle, title: rawTitle,
description, description,
notes, source,
link, link,
event_date, event_date,
dimensions = [], dimensions = [],
chunk,
metadata = {} metadata = {}
} = nodeData; } = nodeData;
@@ -121,30 +120,23 @@ function createNode(nodeData) {
const now = new Date().toISOString(); const now = new Date().toISOString();
const db = getDb(); const db = getDb();
// Build chunk fallback from available fields when no explicit chunk provided const sourceToStore = source && source.trim()
let chunkToStore = chunk ?? null; ? source
if (!chunkToStore || !chunkToStore.trim()) { : [title, description].filter(Boolean).join('\n\n').trim() || null;
const fallbackParts = [title, description, notes].filter(Boolean);
const fallback = fallbackParts.join('\n\n').trim();
if (fallback) {
chunkToStore = fallback;
}
}
const nodeId = transaction(() => { const nodeId = transaction(() => {
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO nodes (title, description, notes, link, event_date, metadata, chunk, created_at, updated_at) INSERT INTO nodes (title, description, source, link, event_date, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`); `);
const result = stmt.run( const result = stmt.run(
title, title,
description ?? null, description ?? null,
notes ?? null, sourceToStore,
link ?? null, link ?? null,
event_date ?? null, event_date ?? null,
JSON.stringify(metadata), JSON.stringify(metadata),
chunkToStore,
now, now,
now now
); );
@@ -169,11 +161,11 @@ function createNode(nodeData) {
/** /**
* Update an existing node. * Update an existing node.
* Note: notes is APPENDED by default (MCP tool behavior), not replaced. * Source-first update path.
*/ */
function updateNode(id, updates, options = {}) { function updateNode(id, updates, options = {}) {
const { appendNotes = true } = options; const { appendNotes = true } = options;
const { title, description, notes, link, event_date, dimensions, chunk, metadata } = updates; const { title, description, source, notes, link, event_date, dimensions, chunk, metadata } = updates;
const now = new Date().toISOString(); const now = new Date().toISOString();
const db = getDb(); const db = getDb();
@@ -195,15 +187,15 @@ function updateNode(id, updates, options = {}) {
setFields.push('description = ?'); setFields.push('description = ?');
params.push(description); params.push(description);
} }
if (notes !== undefined) { if (source !== undefined) {
if (appendNotes && existing.notes) { setFields.push('source = ?');
// Append to existing notes params.push(source);
setFields.push('notes = ?'); } else if (notes !== undefined) {
params.push(existing.notes + '\n\n' + notes); const nextSource = appendNotes && existing.source
} else { ? `${existing.source}\n\n${notes}`
setFields.push('notes = ?'); : notes;
params.push(notes); setFields.push('source = ?');
} params.push(nextSource);
} }
if (link !== undefined) { if (link !== undefined) {
setFields.push('link = ?'); setFields.push('link = ?');
@@ -213,8 +205,8 @@ function updateNode(id, updates, options = {}) {
setFields.push('event_date = ?'); setFields.push('event_date = ?');
params.push(event_date); params.push(event_date);
} }
if (chunk !== undefined) { if (chunk !== undefined && source === undefined) {
setFields.push('chunk = ?'); setFields.push('source = ?');
params.push(chunk); params.push(chunk);
} }
if (metadata !== undefined) { if (metadata !== undefined) {
+15 -11
View File
@@ -86,6 +86,7 @@ const sanitizeDimensions = (raw) => {
const addNodeInputSchema = { const addNodeInputSchema = {
title: z.string().min(1).max(160), title: z.string().min(1).max(160),
content: z.string().max(20000).optional(), content: z.string().max(20000).optional(),
source: z.string().max(50000).optional(),
link: z.string().url().optional(), link: z.string().url().optional(),
description: z.string().max(2000).optional(), description: z.string().max(2000).optional(),
dimensions: z.array(z.string()).min(1).max(5), dimensions: z.array(z.string()).min(1).max(5),
@@ -112,7 +113,7 @@ const searchNodesOutputSchema = {
z.object({ z.object({
id: z.number(), id: z.number(),
title: z.string(), title: z.string(),
notes: z.string().nullable(), source: z.string().nullable(),
description: z.string().nullable(), description: z.string().nullable(),
link: z.string().nullable(), link: z.string().nullable(),
dimensions: z.array(z.string()), dimensions: z.array(z.string()),
@@ -151,7 +152,7 @@ const getNodesOutputSchema = {
z.object({ z.object({
id: z.number(), id: z.number(),
title: z.string(), title: z.string(),
notes: z.string().nullable(), source: z.string().nullable(),
link: z.string().nullable(), link: z.string().nullable(),
dimensions: z.array(z.string()), dimensions: z.array(z.string()),
updated_at: z.string() updated_at: z.string()
@@ -349,7 +350,7 @@ mcpServer.registerTool(
inputSchema: addNodeInputSchema, inputSchema: addNodeInputSchema,
outputSchema: addNodeOutputSchema outputSchema: addNodeOutputSchema
}, },
async ({ title, content, link, description, dimensions, metadata, chunk }) => { async ({ title, content, source, link, description, dimensions, metadata, chunk }) => {
const normalizedDimensions = sanitizeDimensions(dimensions); const normalizedDimensions = sanitizeDimensions(dimensions);
if (normalizedDimensions.length === 0) { if (normalizedDimensions.length === 0) {
throw new McpError( throw new McpError(
@@ -360,12 +361,11 @@ mcpServer.registerTool(
const payload = { const payload = {
title: title.trim(), title: title.trim(),
notes: content?.trim() || undefined, source: source?.trim() || chunk?.trim() || content?.trim() || undefined,
link: link?.trim() || undefined, link: link?.trim() || undefined,
description: description?.trim() || undefined, description: description?.trim() || undefined,
dimensions: normalizedDimensions, dimensions: normalizedDimensions,
metadata: metadata || {}, metadata: metadata || {}
chunk: chunk?.trim() || undefined
}; };
const result = await callRaHApi('/api/nodes', { const result = await callRaHApi('/api/nodes', {
@@ -422,7 +422,7 @@ mcpServer.registerTool(
nodes: nodes.map((node) => ({ nodes: nodes.map((node) => ({
id: node.id, id: node.id,
title: node.title, title: node.title,
notes: node.notes ?? null, source: node.source ?? node.notes ?? null,
description: node.description ?? null, description: node.description ?? null,
link: node.link ?? null, link: node.link ?? null,
dimensions: node.dimensions || [], dimensions: node.dimensions || [],
@@ -446,12 +446,16 @@ mcpServer.registerTool(
throw new McpError(ErrorCode.InvalidParams, 'At least one field must be provided in updates.'); throw new McpError(ErrorCode.InvalidParams, 'At least one field must be provided in updates.');
} }
// Map MCP 'content' field → internal 'notes' field // Map MCP legacy fields to canonical source
const mappedUpdates = { ...updates }; const mappedUpdates = { ...updates };
if (mappedUpdates.content !== undefined) { if (mappedUpdates.content !== undefined) {
mappedUpdates.notes = mappedUpdates.content; mappedUpdates.source = mappedUpdates.content;
delete mappedUpdates.content;
} }
if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) {
mappedUpdates.source = mappedUpdates.chunk;
}
delete mappedUpdates.content;
delete mappedUpdates.chunk;
const result = await callRaHApi(`/api/nodes/${id}`, { const result = await callRaHApi(`/api/nodes/${id}`, {
method: 'PUT', method: 'PUT',
@@ -492,7 +496,7 @@ mcpServer.registerTool(
nodes.push({ nodes.push({
id: result.node.id, id: result.node.id,
title: result.node.title, title: result.node.title,
notes: result.node.notes ?? null, source: result.node.source ?? result.node.notes ?? null,
link: result.node.link ?? null, link: result.node.link ?? null,
dimensions: result.node.dimensions || [], dimensions: result.node.dimensions || [],
updated_at: result.node.updated_at updated_at: result.node.updated_at
+13 -9
View File
@@ -35,6 +35,7 @@ const STATUS_PATH = path.join(
const addNodeInputSchema = { const addNodeInputSchema = {
title: z.string().min(1).max(160), title: z.string().min(1).max(160),
content: z.string().max(20000).optional(), content: z.string().max(20000).optional(),
source: z.string().max(50000).optional(),
link: z.string().url().optional(), link: z.string().url().optional(),
description: z.string().max(2000).optional(), description: z.string().max(2000).optional(),
dimensions: z.array(z.string()).min(1).max(5), dimensions: z.array(z.string()).min(1).max(5),
@@ -322,7 +323,7 @@ server.registerTool(
inputSchema: addNodeInputSchema, inputSchema: addNodeInputSchema,
outputSchema: addNodeOutputSchema outputSchema: addNodeOutputSchema
}, },
async ({ title, content, link, description, dimensions, metadata, chunk }) => { async ({ title, content, source, link, description, dimensions, metadata, chunk }) => {
const normalizedDimensions = sanitizeDimensions(dimensions); const normalizedDimensions = sanitizeDimensions(dimensions);
if (normalizedDimensions.length === 0) { if (normalizedDimensions.length === 0) {
throw new Error('At least one dimension/tag is required when creating a node.'); throw new Error('At least one dimension/tag is required when creating a node.');
@@ -330,12 +331,11 @@ server.registerTool(
const payload = { const payload = {
title: title.trim(), title: title.trim(),
notes: content?.trim() || undefined, source: source?.trim() || chunk?.trim() || content?.trim() || undefined,
link: link?.trim() || undefined, link: link?.trim() || undefined,
description: description?.trim() || undefined, description: description?.trim() || undefined,
dimensions: normalizedDimensions, dimensions: normalizedDimensions,
metadata: metadata || {}, metadata: metadata || {}
chunk: chunk?.trim() || undefined
}; };
const result = await callRaHApi('/api/nodes', { const result = await callRaHApi('/api/nodes', {
@@ -393,7 +393,7 @@ server.registerTool(
nodes: nodes.map((node) => ({ nodes: nodes.map((node) => ({
id: node.id, id: node.id,
title: node.title, title: node.title,
notes: node.notes ?? null, source: node.source ?? node.notes ?? null,
description: node.description ?? null, description: node.description ?? null,
link: node.link ?? null, link: node.link ?? null,
dimensions: node.dimensions || [], dimensions: node.dimensions || [],
@@ -417,12 +417,16 @@ server.registerTool(
throw new Error('At least one field must be provided in updates.'); throw new Error('At least one field must be provided in updates.');
} }
// Map MCP 'content' field → internal 'notes' field // Map MCP legacy fields to canonical source
const mappedUpdates = { ...updates }; const mappedUpdates = { ...updates };
if (mappedUpdates.content !== undefined) { if (mappedUpdates.content !== undefined) {
mappedUpdates.notes = mappedUpdates.content; mappedUpdates.source = mappedUpdates.content;
delete mappedUpdates.content;
} }
if (mappedUpdates.chunk !== undefined && mappedUpdates.source === undefined) {
mappedUpdates.source = mappedUpdates.chunk;
}
delete mappedUpdates.content;
delete mappedUpdates.chunk;
const result = await callRaHApi(`/api/nodes/${id}`, { const result = await callRaHApi(`/api/nodes/${id}`, {
method: 'PUT', method: 'PUT',
@@ -463,7 +467,7 @@ server.registerTool(
nodes.push({ nodes.push({
id: result.node.id, id: result.node.id,
title: result.node.title, title: result.node.title,
notes: result.node.notes ?? null, source: result.node.source ?? result.node.notes ?? null,
link: result.node.link ?? null, link: result.node.link ?? null,
dimensions: result.node.dimensions || [], dimensions: result.node.dimensions || [],
updated_at: result.node.updated_at updated_at: result.node.updated_at
+39 -31
View File
@@ -1,5 +1,7 @@
# Database Schema # Database Schema
This page describes the database as it exists on disk, including some legacy tables and columns that remain for compatibility and historical records. The current shipped product uses a single RA-H assistant; old delegation-era fields should not be read as active UX concepts.
## Entity Relationship Diagram ## Entity Relationship Diagram
```mermaid ```mermaid
@@ -8,13 +10,16 @@ erDiagram
nodes ||--o{ edges : "from" nodes ||--o{ edges : "from"
nodes ||--o{ edges : "to" nodes ||--o{ edges : "to"
nodes ||--o{ chunks : "contains" nodes ||--o{ chunks : "contains"
nodes ||--o{ chats : "focused_on"
dimensions ||--o{ node_dimensions : "tagged_with" dimensions ||--o{ node_dimensions : "tagged_with"
chats }o--|| agent_delegations : "belongs_to"
nodes { nodes {
INTEGER id PK INTEGER id PK
TEXT title TEXT title
TEXT content TEXT source
TEXT type TEXT description
TEXT event_date
BLOB embedding BLOB embedding
} }
@@ -29,6 +34,7 @@ erDiagram
dimensions { dimensions {
TEXT name PK TEXT name PK
INTEGER is_priority INTEGER is_priority
TEXT icon
} }
node_dimensions { node_dimensions {
@@ -41,6 +47,19 @@ erDiagram
INTEGER node_id FK INTEGER node_id FK
TEXT text TEXT text
} }
chats {
INTEGER id PK
INTEGER focused_node_id FK
TEXT user_message
TEXT assistant_message
}
agent_delegations {
INTEGER id PK
TEXT task
TEXT status
}
``` ```
--- ---
@@ -80,25 +99,24 @@ Primary knowledge storage. Each row is a discrete knowledge item.
**Columns:** **Columns:**
- `id` (INTEGER PK) - Unique identifier - `id` (INTEGER PK) - Unique identifier
- `title` (TEXT) - Node title - `title` (TEXT) - Node title
- `content` (TEXT) - Full content - `description` (TEXT) - WHAT this is + WHY it matters (primary identity field)
- `type` (TEXT) - Node type (memory, paper, idea, person, etc) - `source` (TEXT) - Canonical source content used for chunking and embedding
- `link` (TEXT) - External source URL (only for source nodes, not derived ideas) - `link` (TEXT) - External source URL (only for source nodes, not derived ideas)
- `description` (TEXT) - Brief summary - `event_date` (TEXT) - When the thing actually happened (vs `created_at` = when it entered the graph)
- `metadata` (TEXT) - JSON metadata - `metadata` (TEXT) - JSON metadata
- `chunk` (TEXT) - Source text for chunking
- `chunk_status` (TEXT) - Chunking status (not_chunked, chunked) - `chunk_status` (TEXT) - Chunking status (not_chunked, chunked)
- `embedding` (BLOB) - Node-level embedding vector - `embedding` (BLOB) - Node-level embedding vector
- `embedding_text` (TEXT) - Text that was embedded - `embedding_text` (TEXT) - Text that was embedded
- `embedding_updated_at` (TEXT) - Embedding timestamp - `embedding_updated_at` (TEXT) - Embedding timestamp
- `is_pinned` (INTEGER) - Legacy pin flag (kept for migration; not surfaced in UI)
- `created_at`, `updated_at` (TEXT) - Timestamps - `created_at`, `updated_at` (TEXT) - Timestamps
**Indexes:** **Temporal dimensions:** Each node has three timestamps:
- `idx_nodes_type` - Fast type filtering - `created_at` - When the node entered the graph (transaction time)
- `idx_nodes_pinned` - Legacy partial index (no longer recreated, safe to drop later) - `updated_at` - When the node was last modified
- `event_date` - When the thing actually happened (valid time)
**FTS:** **FTS:**
- `nodes_fts` - Full-text search on title + content - `nodes_fts` - Full-text search on title + source + description
### edges ### edges
Directed relationships between nodes (knowledge graph). Directed relationships between nodes (knowledge graph).
@@ -117,7 +135,6 @@ Directed relationships between nodes (knowledge graph).
- `created_at` (TEXT) - `created_at` (TEXT)
- `context` (TEXT) — JSON blob (canonical; see `EdgeContext` below) - `context` (TEXT) — JSON blob (canonical; see `EdgeContext` below)
- `explanation` (TEXT) — legacy column (currently not the canonical source of truth) - `explanation` (TEXT) — legacy column (currently not the canonical source of truth)
- `user_feedback` (INTEGER) — user rating (not currently used in core flows)
**Indexes:** **Indexes:**
- `idx_edges_from` — fast “outgoing edges” queries - `idx_edges_from` — fast “outgoing edges” queries
@@ -200,7 +217,8 @@ Master list of categorization tags.
**Columns:** **Columns:**
- `name` (TEXT PK) - Dimension name - `name` (TEXT PK) - Dimension name
- `is_priority` (INTEGER) - Priority dimension flag - `is_priority` (INTEGER) - Legacy compatibility field retained in schema
- `icon` (TEXT) - Icon identifier (persisted in database)
- `updated_at` (TEXT) - `updated_at` (TEXT)
### node_dimensions ### node_dimensions
@@ -218,12 +236,14 @@ Many-to-many junction table (nodes ↔ dimensions).
### chats ### chats
Conversation history with token/cost tracking. Conversation history with token/cost tracking.
The chat schema still carries some legacy multi-agent fields. Current product framing is simpler: one RA-H assistant, with these columns mainly retained for older records, analytics, and backwards compatibility.
**Columns:** **Columns:**
- `id` (INTEGER PK) - `id` (INTEGER PK)
- `chat_type` (TEXT) - Conversation type - `chat_type` (TEXT) - Conversation type
- `helper_name` (TEXT) - Agent key (ra-h, ra-h-easy, mini-rah, wise-rah) - `helper_name` (TEXT) - Legacy runtime label; current app sessions use `ra-h`
- `agent_type` (TEXT) - Role category (orchestrator, executor, planner) - `agent_type` (TEXT) - Legacy role field retained for historical rows/analytics
- `delegation_id` (INTEGER FK) - `delegation_id` (INTEGER FK) - Legacy link to delegation records
- `user_message` (TEXT) - `user_message` (TEXT)
- `assistant_message` (TEXT) - `assistant_message` (TEXT)
- `thread_id` (TEXT) - Conversation thread - `thread_id` (TEXT) - Conversation thread
@@ -235,7 +255,7 @@ Conversation history with token/cost tracking.
- `idx_chats_thread` - Fast thread retrieval - `idx_chats_thread` - Fast thread retrieval
### agent_delegations ### agent_delegations
Task queue for mini-rah workers. Legacy delegation queue from the older multi-agent runtime. Retained so old rows and analytics remain readable; not an active user-facing feature in the current product.
**Columns:** **Columns:**
- `id` (INTEGER PK) - `id` (INTEGER PK)
@@ -248,18 +268,6 @@ Task queue for mini-rah workers.
- `summary` (TEXT) - Result summary - `summary` (TEXT) - Result summary
- `created_at`, `updated_at` (TEXT) - `created_at`, `updated_at` (TEXT)
### chat_memory_state
Checkpoint tracker for memory pipeline.
**Columns:**
- `thread_id` (TEXT PK)
- `helper_name` (TEXT) - Agent that owns the thread
- `last_processed_chat_id` (INTEGER) - Last chat processed
- `last_processed_at` (TEXT)
**Indexes:**
- `idx_chat_memory_thread` - Fast state lookup
### logs ### logs
Activity audit trail (auto-pruned to last 10k). Activity audit trail (auto-pruned to last 10k).
@@ -316,8 +324,8 @@ Nodes with dimensions aggregated as JSON array.
```sql ```sql
SELECT SELECT
n.id, n.title, n.content, n.link, n.metadata, n.chunk, n.id, n.title, n.description, n.source, n.link, n.metadata,
n.created_at, n.updated_at, n.event_date, n.created_at, n.updated_at,
COALESCE(JSON_GROUP_ARRAY(d.dimension), '[]') AS dimensions_json COALESCE(JSON_GROUP_ARRAY(d.dimension), '[]') AS dimensions_json
FROM nodes n FROM nodes n
LEFT JOIN node_dimensions d ON d.node_id = n.id LEFT JOIN node_dimensions d ON d.node_id = n.id
+4 -5
View File
@@ -118,13 +118,12 @@ CREATE TABLE nodes (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
title TEXT, title TEXT,
description TEXT, description TEXT,
notes TEXT, source TEXT,
link TEXT, link TEXT,
event_date TEXT, event_date TEXT,
created_at TEXT, created_at TEXT,
updated_at TEXT, updated_at TEXT,
metadata TEXT, metadata TEXT,
chunk TEXT,
embedding BLOB, embedding BLOB,
embedding_updated_at TEXT, embedding_updated_at TEXT,
embedding_text TEXT, embedding_text TEXT,
@@ -281,9 +280,9 @@ if has_table nodes; then
echo "Adding nodes.metadata" echo "Adding nodes.metadata"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN metadata TEXT;" "$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN metadata TEXT;"
fi fi
if ! has_col nodes chunk; then if ! has_col nodes source; then
echo "Adding nodes.chunk" echo "Adding nodes.source"
"$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN chunk TEXT;" "$SQLITE_BIN" "$DB_PATH" "ALTER TABLE nodes ADD COLUMN source TEXT;"
fi fi
# is_pinned removed in final schema pass # is_pinned removed in final schema pass
fi fi
+132 -139
View File
@@ -9,10 +9,10 @@ import { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter';
import { Node, NodeConnection, Chunk } from '@/types/database'; import { Node, NodeConnection, Chunk } from '@/types/database';
import DimensionTags from './dimensions/DimensionTags'; import DimensionTags from './dimensions/DimensionTags';
import { getNodeIcon } from '@/utils/nodeIcons'; import { getNodeIcon } from '@/utils/nodeIcons';
import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl';
import { useDimensionIcons } from '@/context/DimensionIconsContext'; import { useDimensionIcons } from '@/context/DimensionIconsContext';
import ConfirmDialog from '../common/ConfirmDialog'; import ConfirmDialog from '../common/ConfirmDialog';
import { SourceReader } from './source'; import { SourceReader } from './source';
import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl';
interface NodeSearchResult { interface NodeSearchResult {
@@ -36,8 +36,8 @@ interface FocusPanelProps {
} }
export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeClick, onTabClose, refreshTrigger, onOpenInOtherSlot, hideTabBar, onTextSelect, highlightedPassage }: FocusPanelProps) { export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeClick, onTabClose, refreshTrigger, onOpenInOtherSlot, hideTabBar, onTextSelect, highlightedPassage }: FocusPanelProps) {
const { dimensionIcons } = useDimensionIcons();
const [nodesData, setNodesData] = useState<Record<number, Node>>({}); const [nodesData, setNodesData] = useState<Record<number, Node>>({});
const { dimensionIcons } = useDimensionIcons();
// Context menu state // Context menu state
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: number } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: number } | null>(null);
@@ -97,10 +97,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
const [chunkExpanded, setChunkExpanded] = useState<{ [key: number]: boolean }>({}); const [chunkExpanded, setChunkExpanded] = useState<{ [key: number]: boolean }>({});
// Edges expand/collapse state // Edges expand/collapse state
// edgesExpanded removed — all connections shown in Edges tab const [edgesExpanded, setEdgesExpanded] = useState<{ [key: number]: boolean }>({});
// Connections section collapsed state (default closed)
const [edgeSearchOpen, setEdgeSearchOpen] = useState(false);
// Title expanded state for click-to-expand full title // Title expanded state for click-to-expand full title
const [titleExpanded, setTitleExpanded] = useState<{ [key: number]: boolean }>({}); const [titleExpanded, setTitleExpanded] = useState<{ [key: number]: boolean }>({});
@@ -108,7 +105,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
// Description regeneration state // Description regeneration state
const [regeneratingDescription, setRegeneratingDescription] = useState<number | null>(null); const [regeneratingDescription, setRegeneratingDescription] = useState<number | null>(null);
// Content tab state: 'notes', 'desc', or 'source' // Content tab state: 'desc', 'notes', 'edges', or 'source'
const [activeContentTab, setActiveContentTab] = useState<'notes' | 'desc' | 'edges' | 'source'>('desc'); const [activeContentTab, setActiveContentTab] = useState<'notes' | 'desc' | 'edges' | 'source'>('desc');
// Desc (description) edit mode state // Desc (description) edit mode state
@@ -135,6 +132,9 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
const [loadingChunks, setLoadingChunks] = useState<Set<number>>(new Set()); const [loadingChunks, setLoadingChunks] = useState<Set<number>>(new Set());
const [chunksExpanded, setChunksExpanded] = useState<Record<number, boolean>>({}); const [chunksExpanded, setChunksExpanded] = useState<Record<number, boolean>>({});
// Edge creation search toggle
const [edgeSearchOpen, setEdgeSearchOpen] = useState(false);
// Helper: preview edge type based on heuristics (mirrors backend logic) // Helper: preview edge type based on heuristics (mirrors backend logic)
const previewEdgeType = (explanation: string): { type: string; label: string } | null => { const previewEdgeType = (explanation: string): { type: string; label: string } | null => {
const norm = (explanation || '').trim().toLowerCase(); const norm = (explanation || '').trim().toLowerCase();
@@ -230,13 +230,16 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
setEditingField(null); setEditingField(null);
setEditingValue(''); setEditingValue('');
setEditingNodeId(null); setEditingNodeId(null);
// Also clear notes/desc/source edit modes // Also clear notes/desc/source/edges edit modes
setNotesEditMode(false); setNotesEditMode(false);
setNotesEditValue(''); setNotesEditValue('');
setDescEditMode(false); setDescEditMode(false);
setDescEditValue(''); setDescEditValue('');
setSourceEditMode(false); setSourceEditMode(false);
setSourceEditValue(''); setSourceEditValue('');
setEdgeSearchOpen(false);
setNodeSearchQuery('');
setNodeSearchSuggestions([]);
}, [activeTab]); }, [activeTab]);
const fetchNodeData = async (id: number) => { const fetchNodeData = async (id: number) => {
@@ -356,7 +359,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
const updateData: Record<string, string> = {}; const updateData: Record<string, string> = {};
if (editingField === 'notes') { if (editingField === 'notes') {
updateData.content = editingValue; updateData.notes = editingValue;
} else { } else {
updateData[editingField] = editingValue; updateData[editingField] = editingValue;
} }
@@ -378,7 +381,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
setNodesData(prev => ({ ...prev, [nodeId]: result.node })); setNodesData(prev => ({ ...prev, [nodeId]: result.node }));
} }
// Safety net: ensure edges exist for any tokens present in saved content // Safety net: ensure edges exist for any tokens present in saved notes
if (editingField === 'notes' && typeof editingValue === 'string') { if (editingField === 'notes' && typeof editingValue === 'string') {
try { try {
const tokens = parseNodeMarkers(editingValue); const tokens = parseNodeMarkers(editingValue);
@@ -421,7 +424,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
const response = await fetch(`/api/nodes/${nodeId}`, { const response = await fetch(`/api/nodes/${nodeId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes: contentValue }), body: JSON.stringify({ content: contentValue }),
}); });
if (!response.ok) throw new Error('Failed to save'); if (!response.ok) throw new Error('Failed to save');
const result = await response.json(); const result = await response.json();
@@ -497,16 +500,16 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
setDescEditMode(true); setDescEditMode(true);
}; };
// Sync description to source (chunk) and re-embed // Sync description to source and re-embed
const syncDescToSource = async () => { const syncDescToSource = async () => {
if (!activeTab) return; if (!activeTab) return;
setDescSaving(true); setDescSaving(true);
try { try {
// Save description to chunk field // Save description to source field
const response = await fetch(`/api/nodes/${activeTab}`, { const response = await fetch(`/api/nodes/${activeTab}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chunk: descEditValue }), body: JSON.stringify({ source: descEditValue }),
}); });
if (!response.ok) throw new Error('Failed to sync'); if (!response.ok) throw new Error('Failed to sync');
const result = await response.json(); const result = await response.json();
@@ -533,7 +536,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
} }
}; };
// Save notes (content) with explicit Save button // Save notes-tab content into canonical source
const saveNotes = async () => { const saveNotes = async () => {
if (!activeTab) return; if (!activeTab) return;
setNotesSaving(true); setNotesSaving(true);
@@ -541,7 +544,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
const response = await fetch(`/api/nodes/${activeTab}`, { const response = await fetch(`/api/nodes/${activeTab}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes: notesEditValue }), body: JSON.stringify({ source: notesEditValue }),
}); });
if (!response.ok) throw new Error('Failed to save'); if (!response.ok) throw new Error('Failed to save');
const result = await response.json(); const result = await response.json();
@@ -571,8 +574,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
setNotesEditMode(false); setNotesEditMode(false);
setNotesEditValue(''); setNotesEditValue('');
} catch (e) { } catch (e) {
console.error('Error saving notes:', e); console.error('Error saving source content:', e);
alert('Failed to save notes. Please try again.'); alert('Failed to save content. Please try again.');
} finally { } finally {
setNotesSaving(false); setNotesSaving(false);
} }
@@ -587,11 +590,11 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
// Start editing notes // Start editing notes
const startNotesEdit = () => { const startNotesEdit = () => {
if (!activeTab || !nodesData[activeTab]) return; if (!activeTab || !nodesData[activeTab]) return;
setNotesEditValue(nodesData[activeTab].notes || ''); setNotesEditValue(nodesData[activeTab].source || nodesData[activeTab].notes || '');
setNotesEditMode(true); setNotesEditMode(true);
}; };
// Save source (chunk) with explicit Save button // Save source with explicit Save button
const saveSource = async () => { const saveSource = async () => {
if (!activeTab) return; if (!activeTab) return;
setSourceSaving(true); setSourceSaving(true);
@@ -599,7 +602,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
const response = await fetch(`/api/nodes/${activeTab}`, { const response = await fetch(`/api/nodes/${activeTab}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chunk: sourceEditValue }), body: JSON.stringify({ source: sourceEditValue }),
}); });
if (!response.ok) throw new Error('Failed to save'); if (!response.ok) throw new Error('Failed to save');
const result = await response.json(); const result = await response.json();
@@ -625,7 +628,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
// Start editing source // Start editing source
const startSourceEdit = () => { const startSourceEdit = () => {
if (!activeTab || !nodesData[activeTab]) return; if (!activeTab || !nodesData[activeTab]) return;
setSourceEditValue(nodesData[activeTab].chunk || ''); setSourceEditValue(nodesData[activeTab].source || nodesData[activeTab].chunk || '');
setSourceEditMode(true); setSourceEditMode(true);
}; };
@@ -641,11 +644,11 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
setSyncing(true); setSyncing(true);
setShowSyncConfirm(false); setShowSyncConfirm(false);
try { try {
// First, save notes content to chunk field // First, save notes content to source field
const response = await fetch(`/api/nodes/${activeTab}`, { const response = await fetch(`/api/nodes/${activeTab}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chunk: notesEditValue }), body: JSON.stringify({ source: notesEditValue }),
}); });
if (!response.ok) throw new Error('Failed to sync'); if (!response.ok) throw new Error('Failed to sync');
const result = await response.json(); const result = await response.json();
@@ -997,14 +1000,14 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
const embedContent = async (nodeId: number) => { const embedContent = async (nodeId: number) => {
const node = nodesData[nodeId]; const node = nodesData[nodeId];
const hasNotes = node?.notes?.trim(); const hasNotes = node?.notes?.trim();
const hasChunk = node?.chunk?.trim(); const hasSource = (node?.source || node?.chunk)?.trim();
// If chunk is empty but content exists, auto-populate chunk from content // If source is empty but notes exist, auto-populate source from notes
if (!hasChunk && hasNotes) { if (!hasSource && hasNotes) {
try { try {
const response = await fetch(`/api/nodes/${nodeId}`, { const response = await fetch(`/api/nodes/${nodeId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chunk: hasNotes }) body: JSON.stringify({ source: hasNotes })
}); });
if (response.ok) { if (response.ok) {
@@ -1014,13 +1017,13 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
} }
} }
} catch (error) { } catch (error) {
console.error('Failed to auto-populate chunk for embedding:', error); console.error('Failed to auto-populate source for embedding:', error);
return; return;
} }
} }
// If neither content nor chunk exist, require content // If neither notes nor source exist, require notes
if (!hasNotes && !hasChunk) { if (!hasNotes && !hasSource) {
startEdit('content', ''); startEdit('notes', '');
return; return;
} }
setEmbeddingNode(nodeId); setEmbeddingNode(nodeId);
@@ -1119,7 +1122,6 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
setAddingEdge(null); setAddingEdge(null);
setEdgeExplanation(''); setEdgeExplanation('');
setPendingEdgeTarget(null); setPendingEdgeTarget(null);
setEdgeSearchOpen(false);
} catch (error) { } catch (error) {
console.error('Error creating edge:', error); console.error('Error creating edge:', error);
@@ -1157,7 +1159,6 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
setPendingEdgeTarget(null); setPendingEdgeTarget(null);
setNodeSearchQuery(''); setNodeSearchQuery('');
setNodeSearchSuggestions([]); setNodeSearchSuggestions([]);
setEdgeSearchOpen(false);
} catch (error) { } catch (error) {
console.error('Error creating edge:', error); console.error('Error creating edge:', error);
@@ -1277,63 +1278,55 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
borderRadius: '8px', borderRadius: '8px',
maxHeight: '200px', maxHeight: '200px',
overflowY: 'auto', overflowY: 'auto',
zIndex: 10, zIndex: 100,
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)' boxShadow: '0 8px 24px rgba(0, 0, 0, 0.5)',
}}> }}>
{nodeSearchSuggestions.map((suggestion, index) => ( {nodeSearchSuggestions.map((suggestion, index) => (
<div <div
key={suggestion.id} key={suggestion.id}
onClick={() => handleSelectNodeSuggestion(suggestion)} onClick={() => {
handleSelectNodeSuggestion(suggestion);
setNodeSearchQuery('');
setNodeSearchSuggestions([]);
setEdgeSearchOpen(false);
}}
style={{ style={{
padding: '10px 14px', padding: '8px 12px',
cursor: 'pointer', cursor: 'pointer',
borderBottom: index < nodeSearchSuggestions.length - 1 ? '1px solid var(--rah-bg-active)' : 'none', borderBottom: index < nodeSearchSuggestions.length - 1 ? '1px solid var(--rah-border)' : 'none',
background: index === selectedSearchIndex ? 'var(--rah-bg-active)' : 'transparent', background: index === selectedSearchIndex ? 'var(--rah-bg-active)' : 'transparent',
transition: 'background 100ms ease', transition: 'background 100ms ease',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '10px' gap: '8px',
}}
onMouseEnter={(e) => {
if (index !== selectedSearchIndex) {
e.currentTarget.style.background = 'var(--rah-bg-active)';
}
}}
onMouseLeave={(e) => {
if (index !== selectedSearchIndex) {
e.currentTarget.style.background = 'transparent';
}
}} }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--rah-bg-active)'; }}
onMouseLeave={(e) => { if (index !== selectedSearchIndex) e.currentTarget.style.background = 'transparent'; }}
> >
<span style={{ <span style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '9px', fontSize: '9px',
fontWeight: 600, fontWeight: 600,
color: 'var(--rah-bg-base)', color: 'var(--rah-text-inverse)',
background: '#22c55e', background: 'var(--rah-accent-green)',
padding: '3px 6px', padding: '2px 5px',
borderRadius: '6px', borderRadius: '4px',
minWidth: '24px',
textAlign: 'center',
flexShrink: 0, flexShrink: 0,
fontFamily: "'SF Mono', 'Fira Code', monospace" fontFamily: "'SF Mono', 'Fira Code', monospace",
}}> }}>
{suggestion.id} {suggestion.id}
</span> </span>
<span style={{ <span style={{
fontSize: '13px', fontSize: '12px',
color: 'var(--rah-text-base)', color: 'var(--rah-text-base)',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
flex: 1 flex: 1,
}}> }}>
{suggestion.title} {suggestion.title}
</span> </span>
{index === selectedSearchIndex && ( {index === selectedSearchIndex && (
<span style={{ color: 'var(--rah-text-muted)', fontSize: '13px' }}></span> <span style={{ color: 'var(--rah-text-muted)', fontSize: '12px' }}></span>
)} )}
</div> </div>
))} ))}
@@ -1342,10 +1335,10 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
</div> </div>
)} )}
{/* Connections List */} {/* Connections list */}
<div style={{ flex: 1, overflowY: 'auto' }}> <div style={{ flex: 1, overflowY: 'auto' }}>
{loadingEdges.has(activeTab) ? ( {loadingEdges.has(activeTab) ? (
<div style={{ color: 'var(--rah-text-muted)', fontSize: '12px', fontStyle: 'italic', padding: '16px 0', textAlign: 'center' }}>Loading connections</div> <div style={{ color: 'var(--rah-text-muted)', fontSize: '12px', fontStyle: 'italic', padding: '8px 0' }}>Loading connections</div>
) : (() => { ) : (() => {
const list = edgesData[activeTab] || []; const list = edgesData[activeTab] || [];
if (list.length === 0) { if (list.length === 0) {
@@ -1358,18 +1351,18 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
key={connection.id} key={connection.id}
style={{ style={{
padding: '6px 0', padding: '6px 0',
borderBottom: '1px solid var(--rah-bg-panel)', borderBottom: '1px solid var(--rah-border)',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '2px', gap: '2px',
minWidth: 0, minWidth: 0,
}} }}
> >
{/* Row 1: arrow + ID + icon + title + delete */} {/* Row 1: arrow + ID + title + delete */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}>
<span style={{ <span style={{
fontSize: '11px', fontSize: '11px',
color: connection.edge.from_node_id === activeTab ? '#22c55e' : '#f59e0b', color: connection.edge.from_node_id === activeTab ? 'var(--rah-accent-green)' : '#f59e0b',
fontWeight: 600, fontWeight: 600,
width: '14px', width: '14px',
textAlign: 'center', textAlign: 'center',
@@ -1380,8 +1373,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
<span style={{ <span style={{
fontSize: '9px', fontSize: '9px',
fontWeight: 700, fontWeight: 700,
color: 'var(--rah-bg-base)', color: 'var(--rah-text-inverse)',
background: '#22c55e', background: 'var(--rah-accent-green)',
padding: '1px 5px', padding: '1px 5px',
borderRadius: '4px', borderRadius: '4px',
flexShrink: 0, flexShrink: 0,
@@ -1405,7 +1398,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
minWidth: 0, minWidth: 0,
cursor: 'pointer', cursor: 'pointer',
}} }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#22c55e'; }} onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--rah-accent-green)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-base)'; }} onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-base)'; }}
> >
{connection.connected_node.title} {connection.connected_node.title}
@@ -1414,7 +1407,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
onClick={() => deleteEdge(connection.edge.id)} onClick={() => deleteEdge(connection.edge.id)}
disabled={deletingEdge === connection.edge.id} disabled={deletingEdge === connection.edge.id}
style={{ style={{
color: 'var(--rah-border-stronger)', color: 'var(--rah-text-muted)',
fontSize: '14px', fontSize: '14px',
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
@@ -1424,7 +1417,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
lineHeight: 1, lineHeight: 1,
}} }}
onMouseEnter={(e) => { if (deletingEdge !== connection.edge.id) e.currentTarget.style.color = '#dc2626'; }} onMouseEnter={(e) => { if (deletingEdge !== connection.edge.id) e.currentTarget.style.color = '#dc2626'; }}
onMouseLeave={(e) => { if (deletingEdge !== connection.edge.id) e.currentTarget.style.color = 'var(--rah-border-stronger)'; }} onMouseLeave={(e) => { if (deletingEdge !== connection.edge.id) e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
> >
{deletingEdge === connection.edge.id ? '...' : '×'} {deletingEdge === connection.edge.id ? '...' : '×'}
</button> </button>
@@ -1464,8 +1457,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
disabled={edgeSavingId === connection.edge.id} disabled={edgeSavingId === connection.edge.id}
style={{ style={{
fontSize: '10px', fontSize: '10px',
color: '#000', color: 'var(--rah-text-inverse)',
background: '#22c55e', background: 'var(--rah-accent-green)',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
padding: '3px 8px', padding: '3px 8px',
@@ -1508,9 +1501,9 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
{typeof connection.edge.context?.type === 'string' && ( {typeof connection.edge.context?.type === 'string' && (
<span style={{ <span style={{
fontSize: '9px', fontSize: '9px',
color: 'var(--rah-text-muted)', color: 'var(--rah-text-soft)',
background: 'var(--rah-bg-active)', background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-bg-active)', border: '1px solid var(--rah-border)',
padding: '1px 5px', padding: '1px 5px',
borderRadius: '999px', borderRadius: '999px',
flexShrink: 0, flexShrink: 0,
@@ -1522,7 +1515,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
<span <span
style={{ style={{
fontSize: '11px', fontSize: '11px',
color: connection.edge.context?.explanation ? '#777' : '#444', color: connection.edge.context?.explanation ? 'var(--rah-text-muted)' : 'var(--rah-text-soft)',
fontStyle: connection.edge.context?.explanation ? 'normal' : 'italic', fontStyle: connection.edge.context?.explanation ? 'normal' : 'italic',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
@@ -1594,7 +1587,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
<div style={{ <div style={{
display: 'flex', display: 'flex',
borderBottom: '1px solid var(--rah-border)', borderBottom: '1px solid var(--rah-border)',
background: 'var(--rah-bg-surface)', background: 'var(--rah-bg-base)',
flexShrink: 0, flexShrink: 0,
overflowX: 'auto', overflowX: 'auto',
overflowY: 'hidden' overflowY: 'hidden'
@@ -1631,7 +1624,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
borderRight: '1px solid var(--rah-border)', borderRight: '1px solid var(--rah-border)',
background: isActive ? 'var(--rah-bg-subtle)' : 'var(--rah-bg-surface)', background: isActive ? 'var(--rah-bg-subtle)' : 'var(--rah-bg-base)',
borderBottom: isActive ? '2px solid var(--rah-text-muted)' : 'none', borderBottom: isActive ? '2px solid var(--rah-text-muted)' : 'none',
paddingBottom: isActive ? '0' : '2px', paddingBottom: isActive ? '0' : '2px',
minWidth: '120px', minWidth: '120px',
@@ -1646,7 +1639,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
padding: '8px 12px', padding: '8px 12px',
fontSize: '12px', fontSize: '12px',
fontFamily: 'inherit', fontFamily: 'inherit',
color: isActive ? 'var(--rah-text-active)' : 'var(--rah-text-muted)', color: isActive ? 'var(--rah-text-active)' : 'var(--rah-text-soft)',
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
cursor: 'pointer', cursor: 'pointer',
@@ -1708,7 +1701,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
Loading... Loading...
</div> </div>
) : !currentNode ? ( ) : !currentNode ? (
<div style={{ color: 'var(var(--rah-text-muted))', fontSize: '13px' }}>Node not found.</div> <div style={{ color: 'var(--rah-text-muted)', fontSize: '13px' }}>Node not found.</div>
) : nodesData[activeTab] ? ( ) : nodesData[activeTab] ? (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}> <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* URL Row - Above Title */} {/* URL Row - Above Title */}
@@ -1791,8 +1784,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
return; return;
} }
const link = nodesData[activeTab].link; const link = nodesData[activeTab].link || '';
if (!link || !shouldOpenExternally(link)) { if (!shouldOpenExternally(link)) {
return; return;
} }
@@ -1819,7 +1812,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
<span <span
onClick={() => startEdit('link', '')} onClick={() => startEdit('link', '')}
style={{ style={{
color: 'var(--rah-text-muted)', color: '#555',
fontSize: '11px', fontSize: '11px',
cursor: 'pointer', cursor: 'pointer',
fontStyle: 'italic' fontStyle: 'italic'
@@ -1850,8 +1843,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
}} }}
style={{ style={{
display: 'inline-block', display: 'inline-block',
background: '#22c55e', background: 'var(--rah-accent-green)',
color: 'var(--rah-bg-base)', color: 'var(--rah-text-inverse)',
fontSize: '10px', fontSize: '10px',
fontWeight: 600, fontWeight: 600,
padding: '2px 6px', padding: '2px 6px',
@@ -1864,7 +1857,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
{activeTab} {activeTab}
</span> </span>
{/* Node type icon */} {/* Node icon */}
{nodesData[activeTab] && ( {nodesData[activeTab] && (
<span style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}> <span style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{getNodeIcon(nodesData[activeTab], dimensionIcons, 18)} {getNodeIcon(nodesData[activeTab], dimensionIcons, 18)}
@@ -1938,7 +1931,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
title={titleExpanded[activeTab] ? undefined : (nodesData[activeTab].title || 'Untitled')} title={titleExpanded[activeTab] ? undefined : (nodesData[activeTab].title || 'Untitled')}
> >
{nodesData[activeTab].title || 'Untitled'} {nodesData[activeTab].title || 'Untitled'}
{savingField === 'title' && <span style={{ color: 'var(--rah-text-muted)', fontSize: '10px', marginLeft: '6px' }}>saving...</span>} {savingField === 'title' && <span style={{ color: '#555', fontSize: '10px', marginLeft: '6px' }}>saving...</span>}
</h1> </h1>
)} )}
@@ -1948,14 +1941,14 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '4px', gap: '6px',
padding: '4px 8px', padding: '4px 8px',
fontSize: '10px', fontSize: '10px',
fontWeight: 500, fontWeight: 500,
color: activeContentTab === 'edges' ? '#22c55e' : 'var(--rah-text-muted)', color: activeContentTab === 'edges' ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)',
background: activeContentTab === 'edges' ? '#0f2417' : 'transparent', background: activeContentTab === 'edges' ? '#0f2417' : 'transparent',
border: '1px solid', border: '1px solid',
borderColor: activeContentTab === 'edges' ? '#22c55e' : 'var(--rah-border-strong)', borderColor: activeContentTab === 'edges' ? 'var(--rah-accent-green)' : 'var(--rah-border-strong)',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s', transition: 'all 0.2s',
@@ -1963,7 +1956,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (activeContentTab !== 'edges') { if (activeContentTab !== 'edges') {
e.currentTarget.style.color = '#22c55e'; e.currentTarget.style.color = 'var(--rah-accent-green)';
e.currentTarget.style.borderColor = '#166534'; e.currentTarget.style.borderColor = '#166534';
} }
}} }}
@@ -1983,18 +1976,18 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
style={{ style={{
minWidth: '18px', minWidth: '18px',
height: '18px', height: '18px',
padding: '0 6px',
borderRadius: '999px', borderRadius: '999px',
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: '0 6px', background: activeContentTab === 'edges' ? 'var(--rah-accent-green-soft)' : 'var(--rah-bg-active)',
background: activeContentTab === 'edges' ? 'rgba(34, 197, 94, 0.18)' : 'var(--rah-bg-active)', color: activeContentTab === 'edges' ? 'var(--rah-accent-green)' : 'var(--rah-text-soft)',
color: activeContentTab === 'edges' ? '#a7f3b8' : 'var(--rah-text-muted)',
fontSize: '10px', fontSize: '10px',
lineHeight: 1, lineHeight: 1,
}} }}
> >
{(edgesData[activeTab] || []).length} {edgesData[activeTab]?.length ?? 0}
</span> </span>
</span> </span>
)} )}
@@ -2084,7 +2077,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
borderBottom: '1px solid var(--rah-border)' borderBottom: '1px solid var(--rah-border)'
}}> }}>
<button <button
onClick={() => { setActiveContentTab('desc'); setNotesEditMode(false); setSourceEditMode(false); }} onClick={() => { setActiveContentTab('desc'); setNotesEditMode(false); setSourceEditMode(false); setEdgeSearchOpen(false); }}
style={{ style={{
padding: '8px 16px', padding: '8px 16px',
fontSize: '11px', fontSize: '11px',
@@ -2092,7 +2085,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
color: activeContentTab === 'desc' ? 'var(--rah-text-base)' : 'var(--rah-text-muted)', color: activeContentTab === 'desc' ? 'var(--rah-text-base)' : 'var(--rah-text-muted)',
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
borderBottom: activeContentTab === 'desc' ? '2px solid #22c55e' : '2px solid transparent', borderBottom: activeContentTab === 'desc' ? '2px solid var(--rah-accent-green)' : '2px solid transparent',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.15s', transition: 'all 0.15s',
marginBottom: '-1px' marginBottom: '-1px'
@@ -2101,7 +2094,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
Desc Desc
</button> </button>
<button <button
onClick={() => { setActiveContentTab('notes'); setDescEditMode(false); setSourceEditMode(false); }} onClick={() => { setActiveContentTab('notes'); setDescEditMode(false); setSourceEditMode(false); setEdgeSearchOpen(false); }}
style={{ style={{
padding: '8px 16px', padding: '8px 16px',
fontSize: '11px', fontSize: '11px',
@@ -2109,7 +2102,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
color: activeContentTab === 'notes' ? 'var(--rah-text-base)' : 'var(--rah-text-muted)', color: activeContentTab === 'notes' ? 'var(--rah-text-base)' : 'var(--rah-text-muted)',
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
borderBottom: activeContentTab === 'notes' ? '2px solid #22c55e' : '2px solid transparent', borderBottom: activeContentTab === 'notes' ? '2px solid var(--rah-accent-green)' : '2px solid transparent',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.15s', transition: 'all 0.15s',
marginBottom: '-1px' marginBottom: '-1px'
@@ -2126,13 +2119,13 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
color: activeContentTab === 'edges' ? 'var(--rah-text-base)' : 'var(--rah-text-muted)', color: activeContentTab === 'edges' ? 'var(--rah-text-base)' : 'var(--rah-text-muted)',
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
borderBottom: activeContentTab === 'edges' ? '2px solid #22c55e' : '2px solid transparent', borderBottom: activeContentTab === 'edges' ? '2px solid var(--rah-accent-green)' : '2px solid transparent',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.15s', transition: 'all 0.15s',
marginBottom: '-1px' marginBottom: '-1px'
}} }}
> >
Edges{activeTab && edgesData[activeTab]?.length ? ` (${edgesData[activeTab].length})` : ''} Edges
</button> </button>
<button <button
onClick={() => { setActiveContentTab('source'); setDescEditMode(false); setNotesEditMode(false); setEdgeSearchOpen(false); }} onClick={() => { setActiveContentTab('source'); setDescEditMode(false); setNotesEditMode(false); setEdgeSearchOpen(false); }}
@@ -2143,7 +2136,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
color: activeContentTab === 'source' ? 'var(--rah-text-base)' : 'var(--rah-text-muted)', color: activeContentTab === 'source' ? 'var(--rah-text-base)' : 'var(--rah-text-muted)',
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
borderBottom: activeContentTab === 'source' ? '2px solid #22c55e' : '2px solid transparent', borderBottom: activeContentTab === 'source' ? '2px solid var(--rah-accent-green)' : '2px solid transparent',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.15s', transition: 'all 0.15s',
marginBottom: '-1px' marginBottom: '-1px'
@@ -2164,7 +2157,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
gap: '4px', gap: '4px',
padding: '4px 8px', padding: '4px 8px',
fontSize: '10px', fontSize: '10px',
color: 'var(--rah-text-muted)', color: 'var(--rah-text-soft)',
background: 'transparent', background: 'transparent',
border: '1px solid var(--rah-border-strong)', border: '1px solid var(--rah-border-strong)',
borderRadius: '4px', borderRadius: '4px',
@@ -2183,7 +2176,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
gap: '4px', gap: '4px',
padding: '4px 8px', padding: '4px 8px',
fontSize: '10px', fontSize: '10px',
color: 'var(--rah-text-muted)', color: 'var(--rah-text-soft)',
background: 'transparent', background: 'transparent',
border: '1px solid var(--rah-border-strong)', border: '1px solid var(--rah-border-strong)',
borderRadius: '4px', borderRadius: '4px',
@@ -2208,7 +2201,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
gap: '4px', gap: '4px',
padding: '4px 8px', padding: '4px 8px',
fontSize: '10px', fontSize: '10px',
color: '#22c55e', color: 'var(--rah-accent-green)',
background: 'transparent', background: 'transparent',
border: '1px solid #166534', border: '1px solid #166534',
borderRadius: '4px', borderRadius: '4px',
@@ -2227,7 +2220,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
gap: '4px', gap: '4px',
padding: '4px 8px', padding: '4px 8px',
fontSize: '10px', fontSize: '10px',
color: 'var(--rah-text-muted)', color: 'var(--rah-text-soft)',
background: 'transparent', background: 'transparent',
border: '1px solid var(--rah-border-strong)', border: '1px solid var(--rah-border-strong)',
borderRadius: '4px', borderRadius: '4px',
@@ -2268,8 +2261,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
gap: '4px', gap: '4px',
padding: '4px 8px', padding: '4px 8px',
fontSize: '10px', fontSize: '10px',
color: edgeSearchOpen ? '#000' : '#22c55e', color: edgeSearchOpen ? 'var(--rah-text-inverse)' : 'var(--rah-accent-green)',
background: edgeSearchOpen ? '#22c55e' : 'transparent', background: edgeSearchOpen ? 'var(--rah-accent-green)' : 'transparent',
border: '1px solid #166534', border: '1px solid #166534',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer', cursor: 'pointer',
@@ -2383,7 +2376,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
gap: '4px', gap: '4px',
padding: '6px 12px', padding: '6px 12px',
fontSize: '11px', fontSize: '11px',
color: 'var(--rah-text-muted)', color: 'var(--rah-text-soft)',
background: 'transparent', background: 'transparent',
border: '1px solid var(--rah-border-strong)', border: '1px solid var(--rah-border-strong)',
borderRadius: '4px', borderRadius: '4px',
@@ -2402,8 +2395,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
gap: '4px', gap: '4px',
padding: '6px 12px', padding: '6px 12px',
fontSize: '11px', fontSize: '11px',
color: '#000', color: 'var(--rah-text-inverse)',
background: '#22c55e', background: 'var(--rah-accent-green)',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer', cursor: 'pointer',
@@ -2433,7 +2426,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
<div <div
onClick={startDescEdit} onClick={startDescEdit}
style={{ style={{
color: 'var(--rah-text-muted)', color: '#555',
fontSize: '12px', fontSize: '12px',
fontStyle: 'italic', fontStyle: 'italic',
cursor: 'pointer', cursor: 'pointer',
@@ -2536,7 +2529,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
top: '50px', top: '50px',
left: '12px', left: '12px',
background: 'var(--rah-bg-active)', background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-stronger)', border: '1px solid var(--rah-border-strong)',
borderRadius: '6px', borderRadius: '6px',
zIndex: 1000, zIndex: 1000,
maxHeight: '200px', maxHeight: '200px',
@@ -2566,11 +2559,11 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
fontSize: '13px', fontSize: '13px',
color: 'var(--rah-text-base)', color: 'var(--rah-text-base)',
cursor: 'pointer', cursor: 'pointer',
background: idx === mentionIndex ? 'var(--rah-bg-hover)' : 'transparent', background: idx === mentionIndex ? 'var(--rah-bg-elevated)' : 'transparent',
borderBottom: idx < mentionResults.length - 1 ? '1px solid var(--rah-border-strong)' : 'none' borderBottom: idx < mentionResults.length - 1 ? '1px solid var(--rah-border-strong)' : 'none'
}} }}
> >
<span style={{ color: '#22c55e', marginRight: '8px', fontWeight: 600 }}>{n.id}</span> <span style={{ color: 'var(--rah-accent-green)', marginRight: '8px', fontWeight: 600 }}>{n.id}</span>
<span>{n.title.length > 50 ? n.title.slice(0, 50) + '…' : n.title}</span> <span>{n.title.length > 50 ? n.title.slice(0, 50) + '…' : n.title}</span>
</div> </div>
)) ))
@@ -2621,7 +2614,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
gap: '4px', gap: '4px',
padding: '6px 12px', padding: '6px 12px',
fontSize: '11px', fontSize: '11px',
color: 'var(--rah-text-muted)', color: 'var(--rah-text-soft)',
background: 'transparent', background: 'transparent',
border: '1px solid var(--rah-border-strong)', border: '1px solid var(--rah-border-strong)',
borderRadius: '4px', borderRadius: '4px',
@@ -2640,8 +2633,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
gap: '4px', gap: '4px',
padding: '6px 12px', padding: '6px 12px',
fontSize: '11px', fontSize: '11px',
color: '#000', color: 'var(--rah-text-inverse)',
background: '#22c55e', background: 'var(--rah-accent-green)',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer', cursor: 'pointer',
@@ -2671,7 +2664,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
style={{ style={{
padding: '4px 10px', padding: '4px 10px',
fontSize: '11px', fontSize: '11px',
color: 'var(--rah-text-muted)', color: 'var(--rah-text-soft)',
background: 'transparent', background: 'transparent',
border: '1px solid var(--rah-border-strong)', border: '1px solid var(--rah-border-strong)',
borderRadius: '4px', borderRadius: '4px',
@@ -2685,7 +2678,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
style={{ style={{
padding: '4px 10px', padding: '4px 10px',
fontSize: '11px', fontSize: '11px',
color: '#000', color: 'var(--rah-text-inverse)',
background: '#f59e0b', background: '#f59e0b',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
@@ -2711,7 +2704,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
}} }}
> >
<MarkdownWithNodeTokens <MarkdownWithNodeTokens
content={nodesData[activeTab].notes || ''} content={nodesData[activeTab].notes}
onNodeClick={onNodeClick || onTabSelect} onNodeClick={onNodeClick || onTabSelect}
/> />
</div> </div>
@@ -2719,7 +2712,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
<div <div
onClick={startNotesEdit} onClick={startNotesEdit}
style={{ style={{
color: 'var(--rah-text-muted)', color: '#555',
fontSize: '12px', fontSize: '12px',
fontStyle: 'italic', fontStyle: 'italic',
cursor: 'pointer', cursor: 'pointer',
@@ -2814,7 +2807,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
gap: '4px', gap: '4px',
padding: '6px 12px', padding: '6px 12px',
fontSize: '11px', fontSize: '11px',
color: 'var(--rah-text-muted)', color: 'var(--rah-text-soft)',
background: 'transparent', background: 'transparent',
border: '1px solid var(--rah-border-strong)', border: '1px solid var(--rah-border-strong)',
borderRadius: '4px', borderRadius: '4px',
@@ -2833,8 +2826,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
gap: '4px', gap: '4px',
padding: '6px 12px', padding: '6px 12px',
fontSize: '11px', fontSize: '11px',
color: '#000', color: 'var(--rah-text-inverse)',
background: '#22c55e', background: 'var(--rah-accent-green)',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer', cursor: 'pointer',
@@ -2861,7 +2854,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
display: 'flex', display: 'flex',
background: 'var(--rah-bg-surface)', background: 'var(--rah-bg-surface)',
borderRadius: '4px', borderRadius: '4px',
border: '1px solid var(--rah-bg-active)', border: '1px solid var(--rah-border)',
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<button <button
@@ -2871,7 +2864,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
fontSize: '10px', fontSize: '10px',
fontWeight: 500, fontWeight: 500,
color: sourceReaderMode === 'raw' ? 'var(--rah-text-active)' : 'var(--rah-text-muted)', color: sourceReaderMode === 'raw' ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
background: sourceReaderMode === 'raw' ? 'var(--rah-border-strong)' : 'transparent', background: sourceReaderMode === 'raw' ? 'var(--rah-bg-active)' : 'transparent',
border: 'none', border: 'none',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.15s ease', transition: 'all 0.15s ease',
@@ -2886,7 +2879,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
fontSize: '10px', fontSize: '10px',
fontWeight: 500, fontWeight: 500,
color: sourceReaderMode === 'reader' ? 'var(--rah-text-active)' : 'var(--rah-text-muted)', color: sourceReaderMode === 'reader' ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
background: sourceReaderMode === 'reader' ? 'var(--rah-border-strong)' : 'transparent', background: sourceReaderMode === 'reader' ? 'var(--rah-bg-active)' : 'transparent',
border: 'none', border: 'none',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.15s ease', transition: 'all 0.15s ease',
@@ -2907,17 +2900,17 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
fontSize: '10px', fontSize: '10px',
color: 'var(--rah-text-muted)', color: 'var(--rah-text-muted)',
background: 'transparent', background: 'transparent',
border: '1px solid var(--rah-bg-active)', border: '1px solid var(--rah-border)',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer', cursor: 'pointer',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--rah-text-secondary)'; e.currentTarget.style.color = 'var(--rah-text-soft)';
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)'; e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--rah-text-muted)'; e.currentTarget.style.color = 'var(--rah-text-muted)';
e.currentTarget.style.borderColor = 'var(--rah-bg-active)'; e.currentTarget.style.borderColor = 'var(--rah-border)';
}} }}
> >
<Pencil size={10} /> <Pencil size={10} />
@@ -2927,10 +2920,10 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
{/* Content display */} {/* Content display */}
<div style={{ flex: 1, overflow: 'auto' }}> <div style={{ flex: 1, overflow: 'auto' }}>
{nodesData[activeTab]?.chunk ? ( {(nodesData[activeTab]?.source || nodesData[activeTab]?.chunk) ? (
sourceReaderMode === 'reader' ? ( sourceReaderMode === 'reader' ? (
<SourceReader <SourceReader
content={nodesData[activeTab].chunk} content={nodesData[activeTab].source || nodesData[activeTab].chunk || ''}
onTextSelect={onTextSelect ? (text) => onTextSelect(activeTab, nodesData[activeTab]?.title || 'Untitled', text) : undefined} onTextSelect={onTextSelect ? (text) => onTextSelect(activeTab, nodesData[activeTab]?.title || 'Untitled', text) : undefined}
highlightedText={highlightedPassage?.nodeId === activeTab ? highlightedPassage.selectedText : null} highlightedText={highlightedPassage?.nodeId === activeTab ? highlightedPassage.selectedText : null}
/> />
@@ -2949,7 +2942,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
minHeight: '100%', minHeight: '100%',
}} }}
> >
{nodesData[activeTab].chunk} {nodesData[activeTab].source || nodesData[activeTab].chunk}
</div> </div>
) )
) : ( ) : (
@@ -3036,7 +3029,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
textAlign: 'left', textAlign: 'left',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--rah-border-strong)'; e.currentTarget.style.background = 'var(--rah-bg-elevated)';
e.currentTarget.style.color = 'var(--rah-text-active)'; e.currentTarget.style.color = 'var(--rah-text-active)';
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
@@ -3068,7 +3061,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
textAlign: 'left', textAlign: 'left',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--rah-border-strong)'; e.currentTarget.style.background = 'var(--rah-bg-elevated)';
e.currentTarget.style.color = 'var(--rah-text-active)'; e.currentTarget.style.color = 'var(--rah-text-active)';
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
+1 -1
View File
@@ -172,12 +172,12 @@ export default function ThreePanelLayout() {
id: node.id, id: node.id,
title: node.title, title: node.title,
link: node.link, link: node.link,
source: node.source,
notes: node.notes, notes: node.notes,
dimensions: node.dimensions, dimensions: node.dimensions,
created_at: node.created_at, created_at: node.created_at,
updated_at: node.updated_at, updated_at: node.updated_at,
chunk_status: node.chunk_status, chunk_status: node.chunk_status,
chunk: node.chunk,
metadata: node.metadata, metadata: node.metadata,
})); }));
setOpenTabsData(validNodes); setOpenTabsData(validNodes);
+52 -52
View File
@@ -175,13 +175,13 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
<div style={{ <div style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
background: 'var(var(--rah-bg-surface))', background: 'var(--rah-bg-base)',
border: '1px solid var(--rah-border-strong)', border: '1px solid var(--rah-border)',
borderRadius: '6px', borderRadius: '6px',
padding: '0 8px', padding: '0 8px',
gap: '6px', gap: '6px',
}}> }}>
<Search size={12} style={{ color: 'var(var(--rah-text-muted))', flexShrink: 0 }} /> <Search size={12} style={{ color: 'var(--rah-text-muted)', flexShrink: 0 }} />
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
@@ -190,7 +190,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
style={{ style={{
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
color: 'var(var(--rah-text-active))', color: 'var(--rah-text-active)',
fontSize: '12px', fontSize: '12px',
padding: '5px 0', padding: '5px 0',
outline: 'none', outline: 'none',
@@ -201,7 +201,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
<button <button
type="button" type="button"
onClick={() => { setSearchQuery(''); setActiveSearch(''); }} onClick={() => { setSearchQuery(''); setActiveSearch(''); }}
style={{ background: 'transparent', border: 'none', color: 'var(var(--rah-text-muted))', cursor: 'pointer', padding: 0, display: 'flex' }} style={{ background: 'transparent', border: 'none', color: 'var(--rah-text-muted)', cursor: 'pointer', padding: 0, display: 'flex' }}
> >
<X size={11} /> <X size={11} />
</button> </button>
@@ -236,8 +236,8 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
style={{ style={{
display: 'flex', alignItems: 'center', gap: '4px', display: 'flex', alignItems: 'center', gap: '4px',
padding: '4px 7px', background: 'transparent', padding: '4px 7px', background: 'transparent',
border: '1px solid var(--rah-border-strong)', borderRadius: '5px', border: '1px solid var(--rah-border)', borderRadius: '5px',
color: 'var(var(--rah-text-muted))', fontSize: '11px', cursor: 'pointer', color: 'var(--rah-text-soft)', fontSize: '11px', cursor: 'pointer',
}} }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }} onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }} onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
@@ -249,7 +249,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
{showFilterPicker && ( {showFilterPicker && (
<div style={{ <div style={{
position: 'absolute', top: '100%', left: 0, marginTop: '4px', position: 'absolute', top: '100%', left: 0, marginTop: '4px',
background: 'var(var(--rah-bg-panel))', border: '1px solid var(--rah-border-strong)', borderRadius: '10px', background: 'var(--rah-bg-panel)', border: '1px solid var(--rah-border)', borderRadius: '10px',
padding: '6px', minWidth: '220px', maxHeight: '320px', overflowY: 'auto', padding: '6px', minWidth: '220px', maxHeight: '320px', overflowY: 'auto',
zIndex: 1000, boxShadow: '0 8px 24px rgba(0,0,0,0.4)', zIndex: 1000, boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
}}> }}>
@@ -260,13 +260,13 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
placeholder="Search dimensions..." placeholder="Search dimensions..."
autoFocus autoFocus
style={{ style={{
width: '100%', padding: '7px 10px', background: 'var(var(--rah-bg-surface))', width: '100%', padding: '7px 10px', background: 'var(--rah-bg-base)',
border: '1px solid transparent', borderRadius: '6px', border: '1px solid transparent', borderRadius: '6px',
color: 'var(var(--rah-text-active))', fontSize: '12px', marginBottom: '4px', outline: 'none', color: 'var(--rah-text-active)', fontSize: '12px', marginBottom: '4px', outline: 'none',
}} }}
/> />
{filterPickerDimensions.length === 0 ? ( {filterPickerDimensions.length === 0 ? (
<div style={{ padding: '12px', color: 'var(var(--rah-text-muted))', fontSize: '12px', textAlign: 'center' }}> <div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
No matching dimensions No matching dimensions
</div> </div>
) : ( ) : (
@@ -283,14 +283,14 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
style={{ style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
width: '100%', padding: '7px 10px', background: 'transparent', width: '100%', padding: '7px 10px', background: 'transparent',
border: 'none', borderRadius: '5px', color: 'var(var(--rah-text-secondary))', border: 'none', borderRadius: '5px', color: 'var(--rah-text-secondary)',
fontSize: '12px', cursor: 'pointer', textAlign: 'left', fontSize: '12px', cursor: 'pointer', textAlign: 'left',
}} }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }} onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }} onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
> >
<span>{d.dimension}</span> <span>{d.dimension}</span>
<span style={{ color: 'var(var(--rah-text-muted))', fontSize: '10px', background: 'var(var(--rah-bg-active))', padding: '1px 6px', borderRadius: '10px' }}> <span style={{ color: 'var(--rah-text-muted)', fontSize: '10px', background: 'var(--rah-bg-active)', padding: '1px 6px', borderRadius: '10px' }}>
{d.count} {d.count}
</span> </span>
</button> </button>
@@ -303,9 +303,9 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
{selectedFilters.length > 0 && ( {selectedFilters.length > 0 && (
<button <button
onClick={() => setSelectedFilters([])} onClick={() => setSelectedFilters([])}
style={{ padding: '4px 6px', background: 'transparent', border: 'none', color: 'var(var(--rah-text-muted))', fontSize: '11px', cursor: 'pointer' }} style={{ padding: '4px 6px', background: 'transparent', border: 'none', color: 'var(--rah-text-muted)', fontSize: '11px', cursor: 'pointer' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }} onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }} onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
> >
Clear Clear
</button> </button>
@@ -319,8 +319,8 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
style={{ style={{
display: 'flex', alignItems: 'center', gap: '4px', display: 'flex', alignItems: 'center', gap: '4px',
padding: '4px 7px', background: 'transparent', padding: '4px 7px', background: 'transparent',
border: '1px solid var(--rah-border-strong)', borderRadius: '5px', border: '1px solid var(--rah-border)', borderRadius: '5px',
color: 'var(var(--rah-text-muted))', fontSize: '11px', cursor: 'pointer', whiteSpace: 'nowrap', color: 'var(--rah-text-soft)', fontSize: '11px', cursor: 'pointer', whiteSpace: 'nowrap',
}} }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }} onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }} onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
@@ -333,7 +333,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
{showSortDropdown && ( {showSortDropdown && (
<div style={{ <div style={{
position: 'absolute', top: '100%', right: 0, marginTop: '4px', position: 'absolute', top: '100%', right: 0, marginTop: '4px',
background: 'var(var(--rah-bg-panel))', border: '1px solid var(--rah-border-strong)', borderRadius: '10px', background: 'var(--rah-bg-panel)', border: '1px solid var(--rah-border)', borderRadius: '10px',
padding: '4px', minWidth: '160px', zIndex: 1000, padding: '4px', minWidth: '160px', zIndex: 1000,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)', boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
}}> }}>
@@ -346,13 +346,13 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
width: '100%', padding: '7px 10px', width: '100%', padding: '7px 10px',
background: sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent', background: sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent',
border: 'none', borderRadius: '5px', border: 'none', borderRadius: '5px',
color: sortOrder === key ? '#f0f0f0' : '#999', color: sortOrder === key ? 'var(--rah-text-active)' : 'var(--rah-text-soft)',
fontSize: '12px', cursor: 'pointer', textAlign: 'left', fontSize: '12px', cursor: 'pointer', textAlign: 'left',
}} }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }} onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent'; }} onMouseLeave={(e) => { e.currentTarget.style.background = sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent'; }}
> >
{sortOrder === key && <span style={{ color: '#22c55e', fontSize: '12px' }}></span>} {sortOrder === key && <span style={{ color: 'var(--rah-accent-green)', fontSize: '12px' }}></span>}
{SORT_LABELS[key]} {SORT_LABELS[key]}
</button> </button>
))} ))}
@@ -363,15 +363,15 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
{/* Pagination */} {/* Pagination */}
<div style={{ <div style={{
display: 'flex', alignItems: 'center', gap: '6px', display: 'flex', alignItems: 'center', gap: '6px',
fontSize: '11px', color: 'var(var(--rah-text-muted))', whiteSpace: 'nowrap', fontSize: '11px', color: 'var(--rah-text-muted)', whiteSpace: 'nowrap',
}}> }}>
<span>{total > 0 ? `${startItem}-${endItem} of ${total}` : '0 nodes'}</span> <span>{total > 0 ? `${startItem}-${endItem} of ${total}` : '0 nodes'}</span>
<button <button
onClick={() => setPage(p => Math.max(1, p - 1))} onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1} disabled={page <= 1}
style={{ style={{
background: 'transparent', border: '1px solid var(--rah-border-strong)', borderRadius: '4px', background: 'transparent', border: '1px solid var(--rah-border)', borderRadius: '4px',
color: page <= 1 ? '#333' : '#888', cursor: page <= 1 ? 'default' : 'pointer', color: page <= 1 ? 'var(--rah-text-muted)' : 'var(--rah-text-soft)', cursor: page <= 1 ? 'default' : 'pointer',
padding: '2px 4px', display: 'flex', padding: '2px 4px', display: 'flex',
}} }}
> >
@@ -381,8 +381,8 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
onClick={() => setPage(p => Math.min(totalPages, p + 1))} onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages} disabled={page >= totalPages}
style={{ style={{
background: 'transparent', border: '1px solid var(--rah-border-strong)', borderRadius: '4px', background: 'transparent', border: '1px solid var(--rah-border)', borderRadius: '4px',
color: page >= totalPages ? '#333' : '#888', color: page >= totalPages ? 'var(--rah-text-muted)' : 'var(--rah-text-soft)',
cursor: page >= totalPages ? 'default' : 'pointer', cursor: page >= totalPages ? 'default' : 'pointer',
padding: '2px 4px', display: 'flex', padding: '2px 4px', display: 'flex',
}} }}
@@ -410,9 +410,9 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
{/* Table */} {/* Table */}
<div style={{ flex: 1, overflow: 'auto' }}> <div style={{ flex: 1, overflow: 'auto' }}>
{loading ? ( {loading ? (
<div style={{ padding: '40px', color: 'var(var(--rah-text-muted))', textAlign: 'center', fontSize: '13px' }}>Loading...</div> <div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>Loading...</div>
) : nodes.length === 0 ? ( ) : nodes.length === 0 ? (
<div style={{ padding: '40px', color: 'var(var(--rah-text-muted))', textAlign: 'center', fontSize: '13px' }}> <div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>
{activeSearch || selectedFilters.length > 0 ? 'No nodes match your filters.' : 'No nodes yet.'} {activeSearch || selectedFilters.length > 0 ? 'No nodes match your filters.' : 'No nodes yet.'}
</div> </div>
) : ( ) : (
@@ -450,15 +450,15 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
height: '44px', height: '44px',
cursor: 'pointer', cursor: 'pointer',
background: hoveredRow === node.id background: hoveredRow === node.id
? '#141414' ? 'var(--rah-bg-panel)'
: i % 2 === 0 ? '#080808' : '#0d0d0d', : i % 2 === 0 ? 'var(--rah-bg-base)' : 'var(--rah-bg-subtle)',
transition: 'background 0.1s ease', transition: 'background 0.1s ease',
}} }}
> >
{/* Title */} {/* Title */}
<td style={tdStyle()}> <td style={tdStyle()}>
<div style={truncCell}> <div style={truncCell}>
<span style={{ fontSize: '13px', color: '#e0e0e0', fontWeight: 400 }}> <span style={{ fontSize: '13px', color: 'var(--rah-text-base)', fontWeight: 400 }}>
{node.title || 'Untitled'} {node.title || 'Untitled'}
</span> </span>
</div> </div>
@@ -468,7 +468,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
<td style={tdStyle({ textAlign: 'right' })}> <td style={tdStyle({ textAlign: 'right' })}>
<span style={{ <span style={{
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace', fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
fontSize: '11px', color: 'var(var(--rah-text-muted))', fontSize: '11px', color: 'var(--rah-text-muted)',
}}> }}>
{node.id} {node.id}
</span> </span>
@@ -477,7 +477,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
{/* Description */} {/* Description */}
<td style={tdStyle()}> <td style={tdStyle()}>
<div style={truncCell}> <div style={truncCell}>
<span style={{ fontSize: '11px', color: 'var(var(--rah-text-muted))' }}> <span style={{ fontSize: '11px', color: 'var(--rah-text-soft)' }}>
{node.description || '\u2014'} {node.description || '\u2014'}
</span> </span>
</div> </div>
@@ -486,8 +486,8 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
{/* Notes */} {/* Notes */}
<td style={tdStyle()}> <td style={tdStyle()}>
<div style={truncCell}> <div style={truncCell}>
<span style={{ fontSize: '11px', color: 'var(var(--rah-text-muted))' }}> <span style={{ fontSize: '11px', color: 'var(--rah-text-soft)' }}>
{node.notes ? node.notes.slice(0, 120) : '\u2014'} {(node.source || node.notes) ? (node.source || node.notes || '').slice(0, 120) : '\u2014'}
</span> </span>
</div> </div>
</td> </td>
@@ -503,7 +503,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
</span> </span>
</span> </span>
) : ( ) : (
<span style={{ fontSize: '11px', color: '#333' }}>{'\u2014'}</span> <span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>{'\u2014'}</span>
)} )}
</div> </div>
</td> </td>
@@ -516,49 +516,49 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
{node.dimensions.slice(0, 3).map(d => ( {node.dimensions.slice(0, 3).map(d => (
<span key={d} style={{ <span key={d} style={{
fontSize: '9px', padding: '1px 5px', fontSize: '9px', padding: '1px 5px',
background: '#111914', border: '1px solid #1f2f24', background: 'var(--rah-accent-green-soft)', border: '1px solid var(--rah-accent-green-soft-strong)',
color: '#bbf7d0', borderRadius: '3px', color: 'var(--rah-accent-green)', borderRadius: '3px',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}}> }}>
{d} {d}
</span> </span>
))} ))}
{node.dimensions.length > 3 && ( {node.dimensions.length > 3 && (
<span style={{ fontSize: '9px', color: 'var(var(--rah-text-muted))' }}> <span style={{ fontSize: '9px', color: 'var(--rah-text-muted)' }}>
+{node.dimensions.length - 3} +{node.dimensions.length - 3}
</span> </span>
)} )}
</> </>
) : ( ) : (
<span style={{ fontSize: '10px', color: '#333' }}>{'\u2014'}</span> <span style={{ fontSize: '10px', color: 'var(--rah-text-muted)' }}>{'\u2014'}</span>
)} )}
</div> </div>
</td> </td>
{/* Edges */} {/* Edges */}
<td style={tdStyle({ textAlign: 'right' })}> <td style={tdStyle({ textAlign: 'right' })}>
<span style={{ fontSize: '12px', color: node.edge_count ? '#888' : '#333' }}> <span style={{ fontSize: '12px', color: node.edge_count ? 'var(--rah-text-soft)' : 'var(--rah-text-muted)' }}>
{node.edge_count ?? 0} {node.edge_count ?? 0}
</span> </span>
</td> </td>
{/* Event Date */} {/* Event Date */}
<td style={tdStyle()}> <td style={tdStyle()}>
<span style={{ fontSize: '11px', color: node.event_date ? '#888' : '#333' }}> <span style={{ fontSize: '11px', color: node.event_date ? 'var(--rah-text-soft)' : 'var(--rah-text-muted)' }}>
{formatDate(node.event_date)} {formatDate(node.event_date)}
</span> </span>
</td> </td>
{/* Updated */} {/* Updated */}
<td style={tdStyle()}> <td style={tdStyle()}>
<span style={{ fontSize: '11px', color: 'var(var(--rah-text-muted))' }}> <span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
{formatRelativeTime(node.updated_at)} {formatRelativeTime(node.updated_at)}
</span> </span>
</td> </td>
{/* Created */} {/* Created */}
<td style={tdStyle()}> <td style={tdStyle()}>
<span style={{ fontSize: '11px', color: 'var(var(--rah-text-muted))' }}> <span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
{formatRelativeTime(node.created_at)} {formatRelativeTime(node.created_at)}
</span> </span>
</td> </td>
@@ -566,7 +566,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
{/* Metadata */} {/* Metadata */}
<td style={tdStyle()}> <td style={tdStyle()}>
<div style={truncCell}> <div style={truncCell}>
<span style={{ fontSize: '10px', color: 'var(var(--rah-text-muted))', fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace' }}> <span style={{ fontSize: '10px', color: 'var(--rah-text-muted)', fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace' }}>
{metaStr || '\u2014'} {metaStr || '\u2014'}
</span> </span>
</div> </div>
@@ -575,8 +575,8 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
{/* Chunk */} {/* Chunk */}
<td style={tdStyle()}> <td style={tdStyle()}>
<div style={truncCell}> <div style={truncCell}>
<span style={{ fontSize: '10px', color: 'var(var(--rah-text-muted))' }}> <span style={{ fontSize: '10px', color: 'var(--rah-text-muted)' }}>
{node.chunk ? node.chunk.slice(0, 100) : '\u2014'} {(node.source || node.chunk) ? (node.source || node.chunk || '').slice(0, 100) : '\u2014'}
</span> </span>
</div> </div>
</td> </td>
@@ -585,7 +585,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
<td style={tdStyle()}> <td style={tdStyle()}>
<span style={{ <span style={{
fontSize: '10px', fontSize: '10px',
color: node.chunk_status === 'chunked' ? '#4a9' : node.chunk_status === 'error' ? '#e55' : '#555', color: node.chunk_status === 'chunked' ? 'var(--rah-accent-green)' : node.chunk_status === 'error' ? '#e55' : 'var(--rah-text-muted)',
}}> }}>
{node.chunk_status || '\u2014'} {node.chunk_status || '\u2014'}
</span> </span>
@@ -593,7 +593,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
{/* Embedding Updated */} {/* Embedding Updated */}
<td style={tdStyle()}> <td style={tdStyle()}>
<span style={{ fontSize: '11px', color: 'var(var(--rah-text-muted))' }}> <span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
{node.embedding_updated_at ? formatRelativeTime(node.embedding_updated_at) : '\u2014'} {node.embedding_updated_at ? formatRelativeTime(node.embedding_updated_at) : '\u2014'}
</span> </span>
</td> </td>
@@ -612,15 +612,15 @@ function thStyle(extra: React.CSSProperties = {}): React.CSSProperties {
return { return {
position: 'sticky' as const, position: 'sticky' as const,
top: 0, top: 0,
background: 'var(var(--rah-bg-base))', background: 'var(--rah-bg-base)',
padding: '8px 12px', padding: '8px 12px',
fontSize: '10px', fontSize: '10px',
fontWeight: 500, fontWeight: 500,
color: 'var(var(--rah-text-muted))', color: 'var(--rah-text-muted)',
textAlign: 'left', textAlign: 'left',
letterSpacing: '0.05em', letterSpacing: '0.05em',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
borderBottom: '1px solid rgba(255,255,255,0.06)', borderBottom: '1px solid var(--rah-border)',
zIndex: 1, zIndex: 1,
...extra, ...extra,
}; };
@@ -630,7 +630,7 @@ function tdStyle(extra: React.CSSProperties = {}): React.CSSProperties {
return { return {
padding: '0 12px', padding: '0 12px',
verticalAlign: 'middle', verticalAlign: 'middle',
borderBottom: '1px solid #111', borderBottom: '1px solid var(--rah-border)',
overflow: 'hidden', overflow: 'hidden',
...extra, ...extra,
}; };
+2 -2
View File
@@ -104,7 +104,7 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
</div> </div>
{/* Description or Content Preview */} {/* Description or Content Preview */}
{(node.description || node.notes) && ( {(node.description || node.source || node.notes) && (
<div style={{ <div style={{
flex: 1, flex: 1,
fontSize: '11px', fontSize: '11px',
@@ -116,7 +116,7 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
WebkitBoxOrient: 'vertical', WebkitBoxOrient: 'vertical',
marginBottom: '10px' marginBottom: '10px'
}}> }}>
{node.description || truncateContent(node.notes)} {node.description || truncateContent(node.source || node.notes)}
</div> </div>
)} )}
+2 -2
View File
@@ -105,7 +105,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
</div> </div>
{/* Description or Content Preview */} {/* Description or Content Preview */}
{(node.description || node.notes) && ( {(node.description || node.source || node.notes) && (
<div style={{ <div style={{
fontSize: '12px', fontSize: '12px',
color: '#666', color: '#666',
@@ -116,7 +116,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
WebkitBoxOrient: 'vertical', WebkitBoxOrient: 'vertical',
overflow: 'hidden' overflow: 'hidden'
}}> }}>
{node.description || truncateContent(node.notes)} {node.description || truncateContent(node.source || node.notes)}
</div> </div>
)} )}
+3 -5
View File
@@ -1,9 +1,6 @@
--- ---
name: DB Operations name: DB Operations
description: "Use this for all graph read/write operations with strict data quality standards." description: "Use for graph read, write, connect, classify, or traverse operations with strict data quality standards."
when_to_use: "Any request to read, create, update, connect, classify, or traverse graph data."
when_not_to_use: "Pure conversation with no graph interaction needed."
success_criteria: "Writes are explicit and correct; descriptions are concrete; edges and dimensions are high-signal."
--- ---
# DB Operations # DB Operations
@@ -20,8 +17,9 @@ success_criteria: "Writes are explicit and correct; descriptions are concrete; e
- `title`: clear and specific. - `title`: clear and specific.
- `description`: concrete object-level description, not vague summaries. - `description`: concrete object-level description, not vague summaries.
- `notes/content`: extra context, analysis, supporting detail. - `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). This is what gets chunked and embedded for semantic search.
- `link`: external source URL only. - `link`: external source URL only.
- Derived analysis, briefs, and research notes should be stored in a separate linked node, not appended to the source node.
## Execution Pattern ## Execution Pattern
+5 -5
View File
@@ -236,7 +236,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
const title = deriveFallbackLinkTitle(url); const title = deriveFallbackLinkTitle(url);
const description = const description =
`Link record for this source. RA-H could not correctly process the URL during ingestion because ${message}. Stored so the source is not lost and can be revisited later.`; `Link record for this source. RA-H could not correctly process the URL during ingestion because ${message}. Stored so the source is not lost and can be revisited later.`;
const notes = [ const source = [
`Original URL: ${url}`, `Original URL: ${url}`,
`Ingestion failure: ${message}`, `Ingestion failure: ${message}`,
`Attempted pipeline: ${type}`, `Attempted pipeline: ${type}`,
@@ -248,7 +248,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
body: JSON.stringify({ body: JSON.stringify({
title, title,
description, description,
notes, source,
link: url, link: url,
metadata: { metadata: {
source: 'quick-add-link-fallback', source: 'quick-add-link-fallback',
@@ -292,7 +292,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
const title = deriveNoteTitle(content); const title = deriveNoteTitle(content);
const nodePayload: Record<string, unknown> = { const nodePayload: Record<string, unknown> = {
title, title,
notes: content, source: content,
metadata: { metadata: {
source: 'quick-add-note', source: 'quick-add-note',
refined_at: new Date().toISOString(), refined_at: new Date().toISOString(),
@@ -396,8 +396,8 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
title, title,
notes: content, source: transcript,
chunk: transcript, description: content,
metadata, metadata,
}), }),
}); });
+5 -11
View File
@@ -477,18 +477,14 @@ export class EdgeService {
WHEN e.from_node_id = ? THEN n_to.title WHEN e.from_node_id = ? THEN n_to.title
ELSE n_from.title ELSE n_from.title
END as connected_node_title, END as connected_node_title,
CASE
WHEN e.from_node_id = ? THEN n_to.notes
ELSE n_from.notes
END as connected_node_notes,
CASE CASE
WHEN e.from_node_id = ? THEN n_to.link WHEN e.from_node_id = ? THEN n_to.link
ELSE n_from.link ELSE n_from.link
END as connected_node_link, END as connected_node_link,
CASE CASE
WHEN e.from_node_id = ? THEN n_to.chunk WHEN e.from_node_id = ? THEN n_to.source
ELSE n_from.chunk ELSE n_from.source
END as connected_node_chunk, END as connected_node_source,
CASE CASE
WHEN e.from_node_id = ? THEN n_to.metadata WHEN e.from_node_id = ? THEN n_to.metadata
ELSE n_from.metadata ELSE n_from.metadata
@@ -547,11 +543,10 @@ export class EdgeService {
const connected_node: Node = { const connected_node: Node = {
id: row.connected_node_id, id: row.connected_node_id,
title: row.connected_node_title, title: row.connected_node_title,
notes: row.connected_node_notes,
link: row.connected_node_link, link: row.connected_node_link,
dimensions: row.connected_node_dimensions, dimensions: row.connected_node_dimensions,
embedding: undefined, // Not needed for display embedding: undefined, // Not needed for display
chunk: row.connected_node_chunk, source: row.connected_node_source,
metadata: row.connected_node_metadata, metadata: row.connected_node_metadata,
created_at: row.connected_node_created_at, created_at: row.connected_node_created_at,
updated_at: row.connected_node_updated_at updated_at: row.connected_node_updated_at
@@ -592,11 +587,10 @@ export class EdgeService {
const connected_node: Node = { const connected_node: Node = {
id: row.connected_node_id, id: row.connected_node_id,
title: row.connected_node_title, title: row.connected_node_title,
notes: row.connected_node_notes,
link: row.connected_node_link, link: row.connected_node_link,
dimensions: JSON.parse(row.connected_node_dimensions_json || '[]'), dimensions: JSON.parse(row.connected_node_dimensions_json || '[]'),
embedding: undefined, // Not needed for display embedding: undefined, // Not needed for display
chunk: row.connected_node_chunk, source: row.connected_node_source,
metadata: typeof row.connected_node_metadata === 'string' ? JSON.parse(row.connected_node_metadata) : row.connected_node_metadata, metadata: typeof row.connected_node_metadata === 'string' ? JSON.parse(row.connected_node_metadata) : row.connected_node_metadata,
created_at: row.connected_node_created_at, created_at: row.connected_node_created_at,
updated_at: row.connected_node_updated_at updated_at: row.connected_node_updated_at
+119 -25
View File
@@ -16,6 +16,38 @@ function sanitizeFtsQuery(input: string): string {
.join(' '); .join(' ');
} }
function extractRelaxedSearchTerms(query: string): string[] {
const stopWords = new Set([
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'can', 'do', 'find',
'for', 'from', 'hello', 'i', 'in', 'is', 'it', 'me', 'my', 'of', 'on',
'or', 'recent', 'stuff', 'term', 'that', 'the', 'this', 'to', 'with', 'you'
]);
const rawTerms = query
.toLowerCase()
.replace(/[^a-z0-9\s]+/g, ' ')
.split(/\s+/)
.map(term => term.trim())
.filter(Boolean);
const expanded = new Set<string>();
for (const term of rawTerms) {
if (!stopWords.has(term) && term.length >= 3) {
expanded.add(term);
}
const alphaParts = term.replace(/\d+/g, ' ').split(/\s+/).filter(Boolean);
for (const part of alphaParts) {
if (!stopWords.has(part) && part.length >= 3) {
expanded.add(part);
}
}
}
return Array.from(expanded).slice(0, 8);
}
function reciprocalRankFuse<T extends { id: number }>( function reciprocalRankFuse<T extends { id: number }>(
rankedLists: T[][], rankedLists: T[][],
limit: number, limit: number,
@@ -48,7 +80,7 @@ export class NodeService {
async countNodes(filters: NodeFilters = {}): Promise<number> { async countNodes(filters: NodeFilters = {}): Promise<number> {
const { dimensions, search, dimensionsMatch = 'any', const { dimensions, search, dimensionsMatch = 'any',
createdAfter, createdBefore, eventAfter, eventBefore } = filters; createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus } = filters;
if (search?.trim()) { if (search?.trim()) {
return this.countSearchNodesSQLite(filters); return this.countSearchNodesSQLite(filters);
@@ -78,7 +110,7 @@ export class NodeService {
} }
if (search) { if (search) {
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.notes LIKE ? COLLATE NOCASE)`; query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
params.push(`%${search}%`, `%${search}%`, `%${search}%`); params.push(`%${search}%`, `%${search}%`, `%${search}%`);
} }
@@ -86,6 +118,7 @@ export class NodeService {
if (createdBefore) { query += ` AND n.created_at < ?`; params.push(createdBefore); } if (createdBefore) { query += ` AND n.created_at < ?`; params.push(createdBefore); }
if (eventAfter) { query += ` AND n.event_date >= ?`; params.push(eventAfter); } if (eventAfter) { query += ` AND n.event_date >= ?`; params.push(eventAfter); }
if (eventBefore) { query += ` AND n.event_date < ?`; params.push(eventBefore); } if (eventBefore) { query += ` AND n.event_date < ?`; params.push(eventBefore); }
if (chunkStatus) { query += ` AND n.chunk_status = ?`; params.push(chunkStatus); }
const result = sqlite.query<{ total: number }>(query, params); const result = sqlite.query<{ total: number }>(query, params);
return result.rows[0]?.total ?? 0; return result.rows[0]?.total ?? 0;
@@ -95,7 +128,7 @@ export class NodeService {
private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> { private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> {
const { dimensions, search, limit = 100, offset = 0, sortBy, dimensionsMatch = 'any', const { dimensions, search, limit = 100, offset = 0, sortBy, dimensionsMatch = 'any',
createdAfter, createdBefore, eventAfter, eventBefore } = filters; createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus } = filters;
if (search?.trim()) { if (search?.trim()) {
return this.searchNodesSQLite(filters); return this.searchNodesSQLite(filters);
@@ -105,7 +138,7 @@ export class NodeService {
// Use nodes_v view for array-like dimensions behavior (exclude embedding BLOB for performance) // Use nodes_v view for array-like dimensions behavior (exclude embedding BLOB for performance)
let query = ` let query = `
SELECT n.id, n.title, n.description, n.notes, n.link, n.event_date, n.metadata, n.chunk, SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk,
n.chunk_status, n.embedding_updated_at, n.embedding_text, n.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
@@ -137,9 +170,9 @@ export class NodeService {
} }
} }
// Text search in title, description, and notes (SQLite LIKE with COLLATE NOCASE) // Text search in title, description, and source (SQLite LIKE with COLLATE NOCASE)
if (search) { if (search) {
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.notes LIKE ? COLLATE NOCASE)`; query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
params.push(`%${search}%`, `%${search}%`, `%${search}%`); params.push(`%${search}%`, `%${search}%`, `%${search}%`);
} }
@@ -160,23 +193,27 @@ export class NodeService {
query += ` AND n.event_date < ?`; query += ` AND n.event_date < ?`;
params.push(eventBefore); params.push(eventBefore);
} }
if (chunkStatus) {
query += ` AND n.chunk_status = ?`;
params.push(chunkStatus);
}
// Sorting logic // Sorting logic
if (search) { if (search) {
// For search queries, prioritize by relevance: exact title → starts with → contains in title → description → notes // For search queries, prioritize by relevance: exact title → starts with → contains in title → description → source
query += ` ORDER BY query += ` ORDER BY
CASE WHEN LOWER(n.title) = LOWER(?) THEN 1 ELSE 6 END, CASE WHEN LOWER(n.title) = LOWER(?) THEN 1 ELSE 6 END,
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 6 END, CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 6 END,
CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 6 END, CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 6 END,
CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 4 ELSE 6 END, CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 4 ELSE 6 END,
CASE WHEN n.notes LIKE ? COLLATE NOCASE THEN 5 ELSE 6 END, CASE WHEN n.source LIKE ? COLLATE NOCASE THEN 5 ELSE 6 END,
n.updated_at DESC`; n.updated_at DESC`;
params.push( params.push(
search, // Exact match (case-insensitive) search, // Exact match (case-insensitive)
`${search}%`, // Starts with search term `${search}%`, // Starts with search term
`%${search}%`, // Contains in title `%${search}%`, // Contains in title
`%${search}%`, // Contains in description `%${search}%`, // Contains in description
`%${search}%` // Contains in notes `%${search}%` // Contains in source
); );
} else if (sortBy === 'edges') { } else if (sortBy === 'edges') {
// Sort by edge count (most connected first) // Sort by edge count (most connected first)
@@ -215,7 +252,7 @@ export class NodeService {
private async getNodeByIdSQLite(id: number): Promise<Node | null> { private async getNodeByIdSQLite(id: number): Promise<Node | null> {
const sqlite = getSQLiteClient(); const sqlite = getSQLiteClient();
const query = ` const query = `
SELECT n.id, n.title, n.description, n.notes, n.link, n.event_date, n.metadata, n.chunk, SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk,
n.chunk_status, n.embedding_updated_at, n.embedding_text, n.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
@@ -241,11 +278,10 @@ export class NodeService {
const { const {
title, title,
description, description,
notes, source,
link, link,
event_date, event_date,
dimensions = [], dimensions = [],
chunk,
chunk_status, chunk_status,
metadata = {} metadata = {}
} = nodeData; } = nodeData;
@@ -255,16 +291,15 @@ export class NodeService {
const nodeId = sqlite.transaction(() => { const nodeId = sqlite.transaction(() => {
// Insert node using prepare/run for lastInsertRowid access // Insert node using prepare/run for lastInsertRowid access
const nodeResult = sqlite.prepare(` const nodeResult = sqlite.prepare(`
INSERT INTO nodes (title, description, notes, link, event_date, metadata, chunk, chunk_status, created_at, updated_at) INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
title, title,
description ?? null, description ?? null,
notes ?? null, source ?? null,
link ?? null, link ?? null,
event_date ?? null, event_date ?? null,
JSON.stringify(metadata), JSON.stringify(metadata),
chunk ?? null,
chunk_status ?? null, chunk_status ?? null,
now, now,
now now
@@ -308,7 +343,7 @@ export class NodeService {
// PostgreSQL path removed in SQLite-only consolidation // PostgreSQL path removed in SQLite-only consolidation
private async updateNodeSQLite(id: number, updates: Partial<Node>): Promise<Node> { private async updateNodeSQLite(id: number, updates: Partial<Node>): Promise<Node> {
const { title, description, notes, link, event_date, dimensions, chunk, metadata } = updates; const { title, description, source, link, event_date, dimensions, metadata } = updates;
const now = new Date().toISOString(); const now = new Date().toISOString();
const sqlite = getSQLiteClient(); const sqlite = getSQLiteClient();
@@ -327,10 +362,9 @@ export class NodeService {
if (title !== undefined) { setFields.push('title = ?'); params.push(title); } if (title !== undefined) { setFields.push('title = ?'); params.push(title); }
if (description !== undefined) { setFields.push('description = ?'); params.push(description); } if (description !== undefined) { setFields.push('description = ?'); params.push(description); }
if (notes !== undefined) { setFields.push('notes = ?'); params.push(notes); } if (source !== undefined) { setFields.push('source = ?'); params.push(source); }
if (link !== undefined) { setFields.push('link = ?'); params.push(link); } if (link !== undefined) { setFields.push('link = ?'); params.push(link); }
if (event_date !== undefined) { setFields.push('event_date = ?'); params.push(event_date); } if (event_date !== undefined) { setFields.push('event_date = ?'); params.push(event_date); }
if (chunk !== undefined) { setFields.push('chunk = ?'); params.push(chunk); }
if (Object.prototype.hasOwnProperty.call(updates, 'chunk_status')) { if (Object.prototype.hasOwnProperty.call(updates, 'chunk_status')) {
setFields.push('chunk_status = ?'); setFields.push('chunk_status = ?');
params.push(updates.chunk_status ?? null); params.push(updates.chunk_status ?? null);
@@ -469,6 +503,10 @@ export class NodeService {
rankedRows = this.searchNodesLike(sqlite, search, filters, searchLimit); rankedRows = this.searchNodesLike(sqlite, search, filters, searchLimit);
} }
if (rankedRows.length === 0) {
rankedRows = this.searchNodesLikeRelaxed(sqlite, search, filters, searchLimit);
}
if ((filters.searchMode ?? 'standard') === 'hybrid') { if ((filters.searchMode ?? 'standard') === 'hybrid') {
const vectorRows = await this.searchNodesVector(sqlite, search, filters, searchLimit); const vectorRows = await this.searchNodesVector(sqlite, search, filters, searchLimit);
if (vectorRows.length > 0) { if (vectorRows.length > 0) {
@@ -518,7 +556,7 @@ export class NodeService {
} }
for (const word of words) { for (const word of words) {
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.notes LIKE ? COLLATE NOCASE)`; query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
queryParams.push(`%${word}%`, `%${word}%`, `%${word}%`); queryParams.push(`%${word}%`, `%${word}%`, `%${word}%`);
} }
@@ -551,7 +589,7 @@ export class NodeService {
WHERE nodes_fts MATCH ? WHERE nodes_fts MATCH ?
LIMIT ? LIMIT ?
) )
SELECT n.id, n.title, n.description, n.notes, n.link, n.event_date, n.metadata, n.chunk, SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk,
n.chunk_status, n.embedding_updated_at, n.embedding_text, n.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
@@ -580,7 +618,7 @@ export class NodeService {
const words = search.split(/\s+/).filter(Boolean); const words = search.split(/\s+/).filter(Boolean);
const { clauses, params } = this.buildNodeFilterClauses(filters); const { clauses, params } = this.buildNodeFilterClauses(filters);
let query = ` let query = `
SELECT n.id, n.title, n.description, n.notes, n.link, n.event_date, n.metadata, n.chunk, SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk,
n.chunk_status, n.embedding_updated_at, n.embedding_text, n.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
@@ -595,7 +633,7 @@ export class NodeService {
} }
for (const word of words) { for (const word of words) {
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.notes LIKE ? COLLATE NOCASE)`; query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
queryParams.push(`%${word}%`, `%${word}%`, `%${word}%`); queryParams.push(`%${word}%`, `%${word}%`, `%${word}%`);
} }
@@ -604,7 +642,7 @@ export class NodeService {
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 6 END, CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 6 END,
CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 6 END, CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 6 END,
CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 4 ELSE 6 END, CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 4 ELSE 6 END,
CASE WHEN n.notes LIKE ? COLLATE NOCASE THEN 5 ELSE 6 END, CASE WHEN n.source LIKE ? COLLATE NOCASE THEN 5 ELSE 6 END,
n.updated_at DESC n.updated_at DESC
LIMIT ?`; LIMIT ?`;
@@ -614,6 +652,62 @@ export class NodeService {
return result.rows; return result.rows;
} }
private searchNodesLikeRelaxed(
sqlite: ReturnType<typeof getSQLiteClient>,
search: string,
filters: NodeFilters,
limit: number,
): NodeSearchRow[] {
const terms = extractRelaxedSearchTerms(search);
if (terms.length === 0) return [];
const { clauses, params } = this.buildNodeFilterClauses(filters);
let query = `
SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk,
n.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
FROM nodes n
WHERE 1=1
`;
const queryParams = [...params];
if (clauses.length > 0) {
query += ` AND ${clauses.join(' AND ')}`;
}
const termClauses: string[] = [];
for (const term of terms) {
termClauses.push(`n.title LIKE ? COLLATE NOCASE`);
termClauses.push(`n.description LIKE ? COLLATE NOCASE`);
termClauses.push(`n.source LIKE ? COLLATE NOCASE`);
queryParams.push(`%${term}%`, `%${term}%`, `%${term}%`);
}
query += ` AND (${termClauses.join(' OR ')})`;
const scoreClauses: string[] = [];
const scoreParams: string[] = [];
for (const term of terms) {
scoreClauses.push(`CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 0 END`);
scoreClauses.push(`CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 2 ELSE 0 END`);
scoreClauses.push(`CASE WHEN n.source LIKE ? COLLATE NOCASE THEN 1 ELSE 0 END`);
scoreParams.push(`%${term}%`, `%${term}%`, `%${term}%`);
}
query += ` ORDER BY
(${scoreClauses.join(' + ')}) DESC,
CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 0 ELSE 1 END,
n.updated_at DESC
LIMIT ?`;
queryParams.push(...scoreParams, `%${search}%`, limit);
const result = sqlite.query<NodeSearchRow>(query, queryParams);
return result.rows;
}
private async searchNodesVector( private async searchNodesVector(
sqlite: ReturnType<typeof getSQLiteClient>, sqlite: ReturnType<typeof getSQLiteClient>,
search: string, search: string,
@@ -643,7 +737,7 @@ export class NodeService {
ORDER BY distance ORDER BY distance
LIMIT ? LIMIT ?
) )
SELECT n.id, n.title, n.description, n.notes, n.link, n.event_date, n.metadata, n.chunk, SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk,
n.chunk_status, n.embedding_updated_at, n.embedding_text, n.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
+55 -6
View File
@@ -616,13 +616,60 @@ class SQLiteClient {
// 10) Final schema pass migrations (content→notes, event_date, dimensions.icon, drop dead columns) // 10) Final schema pass migrations (content→notes, event_date, dimensions.icon, drop dead columns)
try { try {
const nodeCols2 = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>; let nodeCols2 = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
const nodeColNames = nodeCols2.map(c => c.name); let nodeColNames = nodeCols2.map(c => c.name);
// Rename content → notes (additive first) // Rename content → notes (additive first)
if (nodeColNames.includes('content') && !nodeColNames.includes('notes')) { if (nodeColNames.includes('content') && !nodeColNames.includes('notes')) {
console.log('Migrating nodes.content → nodes.notes...'); console.log('Migrating nodes.content → nodes.notes...');
this.db.exec('ALTER TABLE nodes RENAME COLUMN content TO notes;'); this.db.exec('ALTER TABLE nodes RENAME COLUMN content TO notes;');
nodeCols2 = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
nodeColNames = nodeCols2.map(c => c.name);
}
if (!nodeColNames.includes('source')) {
console.log('Adding nodes.source column...');
this.db.exec('ALTER TABLE nodes ADD COLUMN source TEXT;');
nodeCols2 = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
nodeColNames = nodeCols2.map(c => c.name);
}
if (nodeColNames.includes('source')) {
this.db.exec(`
UPDATE nodes
SET source = chunk
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
AND chunk IS NOT NULL
AND LENGTH(TRIM(chunk)) > 0;
`);
this.db.exec(`
UPDATE nodes
SET source = notes,
chunk_status = 'not_chunked'
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
AND notes IS NOT NULL
AND LENGTH(TRIM(notes)) > 0;
`);
this.db.exec(`
UPDATE nodes
SET source = title || CASE
WHEN description IS NOT NULL AND LENGTH(TRIM(description)) > 0
THEN char(10) || char(10) || description
ELSE ''
END,
chunk_status = 'not_chunked'
WHERE source IS NULL OR LENGTH(TRIM(source)) = 0;
`);
this.db.exec(`
UPDATE nodes
SET chunk_status = 'not_chunked'
WHERE source IS NOT NULL
AND LENGTH(TRIM(source)) > 0
AND (chunk_status IS NULL OR chunk_status != 'chunked');
`);
} }
// Add event_date // Add event_date
@@ -657,8 +704,10 @@ class SQLiteClient {
if (edgeCols.some(c => c.name === 'user_feedback')) { if (edgeCols.some(c => c.name === 'user_feedback')) {
try { this.db.exec('ALTER TABLE edges DROP COLUMN user_feedback;'); } catch {} try { this.db.exec('ALTER TABLE edges DROP COLUMN user_feedback;'); } catch {}
} }
// edges.explanation (top-level column added alongside context JSON)
if (!edgeCols.some(c => c.name === 'explanation')) { if (!edgeCols.some(c => c.name === 'explanation')) {
this.db.exec('ALTER TABLE edges ADD COLUMN explanation TEXT;'); this.db.exec('ALTER TABLE edges ADD COLUMN explanation TEXT;');
// Backfill from context JSON where available
try { try {
this.db.exec(` this.db.exec(`
UPDATE edges SET explanation = json_extract(context, '$.explanation') UPDATE edges SET explanation = json_extract(context, '$.explanation')
@@ -667,13 +716,13 @@ class SQLiteClient {
} catch {} } catch {}
} }
// Recreate nodes_fts to index title + description + notes // Recreate nodes_fts to index title + source + description
try { try {
const ftsCheck = this.db.prepare("SELECT sql FROM sqlite_master WHERE name='nodes_fts'").get() as { sql?: string } | undefined; const ftsCheck = this.db.prepare("SELECT sql FROM sqlite_master WHERE name='nodes_fts'").get() as { sql?: string } | undefined;
if (ftsCheck?.sql && (!ftsCheck.sql.includes('description') || ftsCheck.sql.includes('content'))) { const needsRebuild = !ftsCheck?.sql || !ftsCheck.sql.includes('source') || ftsCheck.sql.includes('notes') || ftsCheck.sql.includes('content');
if (needsRebuild) {
this.db.exec('DROP TABLE IF EXISTS nodes_fts;'); this.db.exec('DROP TABLE IF EXISTS nodes_fts;');
this.db.exec("CREATE VIRTUAL TABLE nodes_fts USING fts5(title, description, notes, content='nodes', content_rowid='id');"); this.db.exec("CREATE VIRTUAL TABLE nodes_fts USING fts5(title, source, description, content='nodes', content_rowid='id');");
// Rebuild FTS index
this.db.exec("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');"); this.db.exec("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');");
} }
} catch (ftsErr) { } catch (ftsErr) {
+10 -13
View File
@@ -16,12 +16,15 @@ export class AutoEmbedQueue {
private readonly lastRunAt = new Map<number, number>(); private readonly lastRunAt = new Map<number, number>();
private readonly maxConcurrent = 1; private readonly maxConcurrent = 1;
private readonly cooldownMs = DEFAULT_COOLDOWN_MS; private readonly cooldownMs = DEFAULT_COOLDOWN_MS;
private readonly embeddingsDisabled = process.env.DISABLE_EMBEDDINGS === 'true';
async recoverStuckNodes(): Promise<void> {
const stuckNodes = await nodeService.getNodes({ chunkStatus: 'not_chunked', limit: 1000 });
for (const node of stuckNodes) {
this.enqueue(node.id, { reason: 'startup_recovery' });
}
}
enqueue(nodeId: number, task: Omit<AutoEmbedTask, 'nodeId'> = {}): boolean { enqueue(nodeId: number, task: Omit<AutoEmbedTask, 'nodeId'> = {}): boolean {
if (this.embeddingsDisabled && !task.force) {
return false;
}
const existing = this.pendingTasks.get(nodeId); const existing = this.pendingTasks.get(nodeId);
if (!existing) { if (!existing) {
this.pendingTasks.set(nodeId, { nodeId, ...task }); this.pendingTasks.set(nodeId, { nodeId, ...task });
@@ -77,21 +80,12 @@ export class AutoEmbedQueue {
} }
private async executeTask(task: AutoEmbedTask) { private async executeTask(task: AutoEmbedTask) {
if (this.embeddingsDisabled && !task.force) {
return;
}
const node = await nodeService.getNodeById(task.nodeId); const node = await nodeService.getNodeById(task.nodeId);
if (!node) { if (!node) {
console.warn('[AutoEmbedQueue] Node missing, skipping', task.nodeId); console.warn('[AutoEmbedQueue] Node missing, skipping', task.nodeId);
return; return;
} }
const chunkText = node.chunk?.trim();
if (!chunkText) {
console.warn('[AutoEmbedQueue] Node has no chunk content, skipping', task.nodeId);
return;
}
if (!task.force && node.chunk_status === 'chunked') { if (!task.force && node.chunk_status === 'chunked') {
return; return;
} }
@@ -117,4 +111,7 @@ declare global {
export const autoEmbedQueue = globalThis.autoEmbedQueue ?? new AutoEmbedQueue(); export const autoEmbedQueue = globalThis.autoEmbedQueue ?? new AutoEmbedQueue();
if (!globalThis.autoEmbedQueue) { if (!globalThis.autoEmbedQueue) {
globalThis.autoEmbedQueue = autoEmbedQueue; globalThis.autoEmbedQueue = autoEmbedQueue;
autoEmbedQueue.recoverStuckNodes().catch(error => {
console.error('[AutoEmbedQueue] Startup recovery failed', error);
});
} }
+2 -2
View File
@@ -103,10 +103,10 @@ export async function embedNodeContent(nodeId: number): Promise<EmbeddingPipelin
}; };
} }
if (!node.chunk || !node.chunk.trim()) { if (!node.source || !node.source.trim()) {
results.chunk_embeddings = { results.chunk_embeddings = {
status: 'skipped', status: 'skipped',
message: 'No chunk content to embed', message: 'No source content to embed',
chunks_created: 0 chunks_created: 0
}; };
} else { } else {
+9 -7
View File
@@ -16,6 +16,7 @@ import {
interface NodeRecord { interface NodeRecord {
id: number; id: number;
title: string; title: string;
source: string | null;
notes: string | null; notes: string | null;
description: string | null; description: string | null;
dimensions_json: string; dimensions_json: string;
@@ -58,7 +59,7 @@ export class NodeEmbedder {
const prompt = `Analyze this content and provide 2-3 key insights or themes in a concise paragraph (max 100 words): const prompt = `Analyze this content and provide 2-3 key insights or themes in a concise paragraph (max 100 words):
Title: ${node.title} Title: ${node.title}
Content: ${node.notes || 'No content'} Source: ${node.source || node.notes || 'No source'}
Dimensions: ${dimensionsText} Dimensions: ${dimensionsText}
Focus on the main concepts, key relationships, and practical implications.`; Focus on the main concepts, key relationships, and practical implications.`;
@@ -106,13 +107,14 @@ Focus on the main concepts, key relationships, and practical implications.`;
// Create base embedding text // Create base embedding text
let embeddingText = formatEmbeddingText( let embeddingText = formatEmbeddingText(
node.title, node.title,
node.notes || '', node.source || node.notes || '',
dimensions, dimensions,
node.description node.description
); );
// Add AI analysis if content exists // Add AI analysis if source exists
if (node.notes && node.notes.trim().length > 0) { const sourceText = node.source || node.notes || '';
if (sourceText.trim().length > 0) {
const analysis = await this.analyzeNodeWithAI(node); const analysis = await this.analyzeNodeWithAI(node);
if (analysis) { if (analysis) {
embeddingText += `\n\nAI Analysis: ${analysis}`; embeddingText += `\n\nAI Analysis: ${analysis}`;
@@ -177,7 +179,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
if (nodeId) { if (nodeId) {
// Single node // Single node
query = ` query = `
SELECT n.id, n.title, n.content, n.description, SELECT n.id, n.title, n.source, n.notes, n.description,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json, FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
n.embedding, n.embedding_updated_at n.embedding, n.embedding_updated_at
@@ -188,7 +190,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
} else if (forceReEmbed) { } else if (forceReEmbed) {
// All nodes // All nodes
query = ` query = `
SELECT n.id, n.title, n.content, n.description, SELECT n.id, n.title, n.source, n.notes, n.description,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json, FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
n.embedding, n.embedding_updated_at n.embedding, n.embedding_updated_at
@@ -198,7 +200,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
} else { } else {
// Only nodes without embeddings // Only nodes without embeddings
query = ` query = `
SELECT n.id, n.title, n.content, n.description, SELECT n.id, n.title, n.source, n.notes, n.description,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json, FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
n.embedding, n.embedding_updated_at n.embedding, n.embedding_updated_at
+6 -6
View File
@@ -1,6 +1,6 @@
/** /**
* Universal chunking and embedding service for RA-H knowledge management system * Universal chunking and embedding service for RA-H knowledge management system
* Takes a node_id, reads chunk content from nodes table, chunks it, and stores in chunks table * Takes a node_id, reads source content from nodes table, chunks it, and stores in chunks table
*/ */
import OpenAI from 'openai'; import OpenAI from 'openai';
@@ -14,7 +14,7 @@ import {
interface Node { interface Node {
id: number; id: number;
title: string; title: string;
chunk: string | null; source: string | null;
chunk_status?: string | null; chunk_status?: string | null;
} }
@@ -157,7 +157,7 @@ export class UniversalEmbedder {
// Get node data // Get node data
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
SELECT id, title, chunk, chunk_status SELECT id, title, source, chunk_status
FROM nodes FROM nodes
WHERE id = ? WHERE id = ?
`); `);
@@ -168,8 +168,8 @@ export class UniversalEmbedder {
throw new Error(`Node ${nodeId} not found`); throw new Error(`Node ${nodeId} not found`);
} }
if (!node.chunk || node.chunk.trim().length === 0) { if (!node.source || node.source.trim().length === 0) {
console.log(`Node ${nodeId} has no chunk content to process`); console.log(`Node ${nodeId} has no source content to process`);
return { chunks: 0 }; return { chunks: 0 };
} }
@@ -179,7 +179,7 @@ export class UniversalEmbedder {
this.deleteExistingChunks(nodeId); this.deleteExistingChunks(nodeId);
// Split text into chunks // Split text into chunks
const chunks = await this.textSplitter.splitText(node.chunk); const chunks = await this.textSplitter.splitText(node.source);
if (verbose) { if (verbose) {
console.log(`Split into ${chunks.length} chunks`); console.log(`Split into ${chunks.length} chunks`);
+1 -2
View File
@@ -8,8 +8,8 @@ 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. Description is REQUIRED and must be explicit about what the thing is (podcast, chat summary, idea, etc).',
inputSchema: z.object({ inputSchema: z.object({
title: z.string().describe('The title of the node'), title: z.string().describe('The title of the node'),
notes: z.string().optional().describe('User notes, analysis, or thoughts about this node'),
description: z.string().max(280).describe('REQUIRED. Explicitly state WHAT this is (e.g. podcast episode, conversation summary, user insight) + WHY it matters for context grounding.'), description: z.string().max(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.'),
link: z.string().optional().describe('A URL link to the source'), link: z.string().optional().describe('A URL link to the source'),
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'), event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
dimensions: z dimensions: z
@@ -17,7 +17,6 @@ export const createNodeTool = tool({
.max(5) .max(5)
.optional() .optional()
.describe('Optional dimension tags to apply to this node (0-5 items).'), .describe('Optional dimension tags to apply to this node (0-5 items).'),
chunk: z.string().optional().describe('Raw content for later processing - CRITICAL for extracted content from URLs'),
metadata: z.record(z.any()).optional().describe('Additional metadata like source info, extraction details, etc.') metadata: z.record(z.any()).optional().describe('Additional metadata like source info, extraction details, etc.')
}), }),
execute: async (params) => { execute: async (params) => {
+7 -5
View File
@@ -6,9 +6,9 @@ export const getNodesByIdTool = tool({
description: 'Load full node records by IDs', description: 'Load full node records by IDs',
inputSchema: z.object({ inputSchema: z.object({
nodeIds: z.array(z.number().int().positive()).min(1).max(10).describe('List of node IDs to load'), nodeIds: z.array(z.number().int().positive()).min(1).max(10).describe('List of node IDs to load'),
includeNotesPreview: z.boolean().default(true).describe('Whether to return a trimmed content preview for each node'), includeSourcePreview: z.boolean().default(true).describe('Whether to return a trimmed source preview for each node'),
}), }),
execute: async ({ nodeIds, includeNotesPreview }) => { execute: async ({ nodeIds, includeSourcePreview }) => {
const uniqueIds = Array.from(new Set(nodeIds.filter(id => Number.isFinite(id) && id > 0))); const uniqueIds = Array.from(new Set(nodeIds.filter(id => Number.isFinite(id) && id > 0)));
if (uniqueIds.length === 0) { if (uniqueIds.length === 0) {
return { return {
@@ -23,8 +23,8 @@ export const getNodesByIdTool = tool({
try { try {
const node = await nodeService.getNodeById(id); const node = await nodeService.getNodeById(id);
if (!node) return null; if (!node) return null;
const preview = includeNotesPreview const preview = includeSourcePreview
? (node.notes || node.description || '') ? (node.source || node.description || '')
.split(/\s+/) .split(/\s+/)
.slice(0, 80) .slice(0, 80)
.join(' ') .join(' ')
@@ -35,10 +35,12 @@ export const getNodesByIdTool = tool({
id: node.id, id: node.id,
title: node.title, title: node.title,
link: node.link, link: node.link,
event_date: node.event_date ?? null,
dimensions: node.dimensions || [], dimensions: node.dimensions || [],
chunk_status: node.chunk_status || 'unknown', chunk_status: node.chunk_status || 'unknown',
created_at: node.created_at,
updated_at: node.updated_at, updated_at: node.updated_at,
notes_preview: preview || null, source_preview: preview || null,
metadata: node.metadata ?? null, metadata: node.metadata ?? null,
}; };
} catch (error) { } catch (error) {
+5 -4
View File
@@ -48,11 +48,12 @@ export const queryDimensionNodesTool = tool({
event_date: node.event_date ?? null, event_date: node.event_date ?? null,
}; };
if (includeContent && node.notes) { if (includeContent && (node.source || node.notes)) {
const previewSource = node.source || node.notes || '';
// Truncate to ~100 chars // Truncate to ~100 chars
formatted.notesPreview = node.notes.length > 100 formatted.sourcePreview = previewSource.length > 100
? node.notes.substring(0, 100) + '...' ? previewSource.substring(0, 100) + '...'
: node.notes; : previewSource;
} }
return formatted; return formatted;
+5 -5
View File
@@ -44,7 +44,7 @@ function scoreNodeForSearch(node: Node, searchTerm: string): number {
const normalizedSearch = searchTerm.toLowerCase(); const normalizedSearch = searchTerm.toLowerCase();
const title = (node.title || '').toLowerCase(); const title = (node.title || '').toLowerCase();
const description = (node.description || '').toLowerCase(); const description = (node.description || '').toLowerCase();
const notes = (node.notes || '').toLowerCase(); const source = (node.source || '').toLowerCase();
const terms = extractSearchTerms(searchTerm); const terms = extractSearchTerms(searchTerm);
let score = 0; let score = 0;
@@ -52,23 +52,23 @@ function scoreNodeForSearch(node: Node, searchTerm: string): number {
if (title === normalizedSearch) score += 100; if (title === normalizedSearch) score += 100;
if (title.includes(normalizedSearch)) score += 40; if (title.includes(normalizedSearch)) score += 40;
if (description.includes(normalizedSearch)) score += 20; if (description.includes(normalizedSearch)) score += 20;
if (notes.includes(normalizedSearch)) score += 10; if (source.includes(normalizedSearch)) score += 10;
for (const term of terms) { for (const term of terms) {
if (title.includes(term)) score += 8; if (title.includes(term)) score += 8;
if (description.includes(term)) score += 3; if (description.includes(term)) score += 3;
if (notes.includes(term)) score += 2; if (source.includes(term)) score += 2;
} }
return score; return score;
} }
export const queryNodesTool = tool({ export const queryNodesTool = tool({
description: 'Search nodes across title, description, and notes. For free-text lookups, search the graph broadly and prioritize title/description matches. Do not use dimensions to constrain keyword search unless the user is explicitly asking about a known dimension.', description: 'Search nodes across title, description, and source. For free-text lookups, search the graph broadly and prioritize title/description matches. Do not use dimensions to constrain keyword search unless the user is explicitly asking about a known dimension.',
inputSchema: z.object({ inputSchema: z.object({
filters: z.object({ filters: z.object({
dimensions: z.array(z.string()).describe('Filter by dimensions (e.g., ["research", "ai", "technology"]). Replaces old type/stage filtering.').optional(), dimensions: z.array(z.string()).describe('Filter by dimensions (e.g., ["research", "ai", "technology"]). Replaces old type/stage filtering.').optional(),
search: z.string().describe('Search term to match against node title, description, or notes').optional(), search: z.string().describe('Search term to match against node title, description, or source').optional(),
limit: z.number().min(1).max(50).default(10).describe('Maximum number of results to return'), limit: z.number().min(1).max(50).default(10).describe('Maximum number of results to return'),
createdAfter: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'), createdAfter: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'),
createdBefore: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'), createdBefore: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'),
+2 -53
View File
@@ -9,14 +9,13 @@ export const updateNodeTool = tool({
id: z.number().describe('The ID of the node to update'), id: z.number().describe('The ID of the node to update'),
updates: z.object({ updates: z.object({
title: z.string().optional().describe('New title'), title: z.string().optional().describe('New title'),
notes: z.string().optional().describe('User notes/analysis to append. USE THIS for workflow outputs, briefs, research notes, etc.'),
description: z.string().max(280).describe('REQUIRED on every update. Explicitly state WHAT this is + WHY it matters. No "discusses/explores".'), description: z.string().max(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.'),
link: z.string().optional().describe('New link'), link: z.string().optional().describe('New link'),
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'), event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
dimensions: z.array(z.string()).optional().describe('New dimension tags - completely replaces existing dimensions'), dimensions: z.array(z.string()).optional().describe('New dimension tags - completely replaces existing dimensions'),
chunk: z.string().optional().describe('DO NOT USE - raw source text that triggers re-embedding. Only for source corrections.'),
metadata: z.record(z.any()).optional().describe('New metadata - completely replaces existing metadata') metadata: z.record(z.any()).optional().describe('New metadata - completely replaces existing metadata')
}).describe('Object containing the fields to update. For adding notes/analysis, always use "notes" field.') }).describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.')
}), }),
execute: async ({ id, updates }) => { execute: async ({ id, updates }) => {
try { try {
@@ -44,56 +43,6 @@ export const updateNodeTool = tool({
}; };
} }
// FORCE APPEND for notes field - fetch existing and append new notes
if (updates.notes) {
const fetchResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`);
if (fetchResponse.ok) {
const { node } = await fetchResponse.json();
const existingNotes = (node?.notes || '').trim();
const newNotes = updates.notes.trim();
// Skip if new notes are identical to existing (model sent duplicate)
if (existingNotes === newNotes) {
console.log(`[updateNode] ERROR - new notes identical to existing (${existingNotes.length} chars). Model should NOT call updateNode again.`);
return {
success: false,
error: 'Notes already up to date - do not call updateNode again. Move to next step.',
data: null
};
}
// Detect if adding a section that already exists (e.g., ## Integration Analysis)
const newSectionMatch = newNotes.match(/^##\s+(.+)$/m);
if (newSectionMatch && existingNotes) {
const sectionHeader = newSectionMatch[0]; // e.g., "## Integration Analysis"
if (existingNotes.includes(sectionHeader)) {
console.log(`[updateNode] ERROR - Section "${sectionHeader}" already exists in node`);
return {
success: false,
error: `Section "${sectionHeader}" already exists in this node. Cannot append duplicate section.`,
data: null
};
}
}
// Detect if model included existing notes + new notes
if (existingNotes && newNotes.startsWith(existingNotes)) {
// Extract only the new part
const actualNewNotes = newNotes.substring(existingNotes.length).trim();
console.log(`[updateNode] Model included existing notes - extracting new part only (${actualNewNotes.length} chars)`);
const separator = existingNotes.endsWith('\n\n') ? '' : '\n\n';
updates.notes = `${existingNotes}${separator}${actualNewNotes}`;
} else if (existingNotes) {
// Normal append
const separator = existingNotes.endsWith('\n\n') ? '' : '\n\n';
updates.notes = `${existingNotes}${separator}${newNotes}`;
console.log(`[updateNode] Appended notes: ${existingNotes.length} + ${newNotes.length} = ${updates.notes.length} chars`);
} else {
console.log(`[updateNode] No existing notes, using new notes as-is (${newNotes.length} chars)`);
}
}
}
if (Array.isArray(updates.dimensions)) { if (Array.isArray(updates.dimensions)) {
updates.dimensions = normalizeDimensions(updates.dimensions, 5); updates.dimensions = normalizeDimensions(updates.dimensions, 5);
} }
+8 -10
View File
@@ -69,7 +69,7 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
} }
export const paperExtractTool = tool({ export const paperExtractTool = tool({
description: 'Extract a PDF or research paper into a node with summary, metadata, and full-text chunk', description: 'Extract a PDF or research paper into a node with summary, metadata, and full-text source',
inputSchema: z.object({ inputSchema: z.object({
url: z.string().describe('The PDF URL to add to inbox'), url: z.string().describe('The PDF URL to add to inbox'),
title: z.string().optional().describe('Custom title (auto-generated if not provided)'), title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
@@ -95,14 +95,13 @@ export const paperExtractTool = tool({
}; };
} }
let result: { success: boolean; notes?: string; chunk?: string; metadata?: any; error?: string }; let result: { success: boolean; source?: string; metadata?: any; error?: string };
try { try {
const extractionResult = await extractPaper(url); const extractionResult = await extractPaper(url);
result = { result = {
success: true, success: true,
notes: extractionResult.content, source: extractionResult.chunk || extractionResult.content,
chunk: extractionResult.chunk,
metadata: { metadata: {
title: extractionResult.metadata.title, title: extractionResult.metadata.title,
pages: extractionResult.metadata.pages, pages: extractionResult.metadata.pages,
@@ -119,7 +118,7 @@ export const paperExtractTool = tool({
}; };
} }
if (!result.success || (!result.notes && !result.chunk)) { if (!result.success || !result.source) {
return { return {
success: false, success: false,
error: result.error || 'Failed to extract PDF content', error: result.error || 'Failed to extract PDF content',
@@ -132,7 +131,7 @@ export const paperExtractTool = tool({
// Step 2: AI Analysis for enhanced metadata // Step 2: AI Analysis for enhanced metadata
const aiAnalysis = await analyzeContentWithAI( const aiAnalysis = await analyzeContentWithAI(
result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`, result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`,
result.notes?.substring(0, 2000) || 'PDF document content', result.source.substring(0, 2000) || 'PDF document content',
'pdf' 'pdf'
); );
@@ -153,17 +152,16 @@ export const paperExtractTool = tool({
body: JSON.stringify({ body: JSON.stringify({
title: nodeTitle, title: nodeTitle,
description: aiAnalysis?.nodeDescription, description: aiAnalysis?.nodeDescription,
notes: enhancedDescription, source: result.source,
link: url, link: url,
dimensions: trimmedDimensions, dimensions: trimmedDimensions,
chunk: result.chunk || result.notes,
metadata: { metadata: {
source: 'pdf', source: 'pdf',
hostname: new URL(url).hostname, hostname: new URL(url).hostname,
author: result.metadata?.author || result.metadata?.info?.Author, author: result.metadata?.author || result.metadata?.info?.Author,
pages: result.metadata?.pages, pages: result.metadata?.pages,
file_size: result.metadata?.file_size, file_size: result.metadata?.file_size,
content_length: (result.chunk || result.notes)?.length, content_length: result.source.length,
extraction_method: result.metadata?.extraction_method || 'python_pdfplumber', extraction_method: result.metadata?.extraction_method || 'python_pdfplumber',
ai_analysis: aiAnalysis?.reasoning, ai_analysis: aiAnalysis?.reasoning,
enhanced_description: enhancedDescription, enhanced_description: enhancedDescription,
@@ -197,7 +195,7 @@ export const paperExtractTool = tool({
data: { data: {
nodeId: createResult.data?.id, nodeId: createResult.data?.id,
title: nodeTitle, title: nodeTitle,
contentLength: (result.chunk || result.notes || '').length, contentLength: result.source.length,
url: url, url: url,
dimensions: actualDimensions dimensions: actualDimensions
} }
+8 -10
View File
@@ -149,7 +149,7 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
} }
export const websiteExtractTool = tool({ export const websiteExtractTool = tool({
description: 'Extract website content and metadata into a node with summary, tags, and raw chunk', description: 'Extract website content and metadata into a node with summary, tags, and raw source text',
inputSchema: z.object({ inputSchema: z.object({
url: z.string().describe('The website URL to add to knowledge base'), url: z.string().describe('The website URL to add to knowledge base'),
title: z.string().optional().describe('Custom title (auto-generated if not provided)'), title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
@@ -166,14 +166,13 @@ export const websiteExtractTool = tool({
}; };
} }
let result: { success: boolean; notes?: string; chunk?: string; metadata?: any; error?: string }; let result: { success: boolean; source?: string; metadata?: any; error?: string };
try { try {
const extractionResult = await extractWebsite(url); const extractionResult = await extractWebsite(url);
result = { result = {
success: true, success: true,
notes: extractionResult.content, source: extractionResult.chunk || extractionResult.content,
chunk: extractionResult.chunk,
metadata: { metadata: {
title: extractionResult.metadata.title, title: extractionResult.metadata.title,
author: extractionResult.metadata.author, author: extractionResult.metadata.author,
@@ -191,7 +190,7 @@ export const websiteExtractTool = tool({
}; };
} }
if (!result.success || (!result.notes && !result.chunk)) { if (!result.success || !result.source) {
return { return {
success: false, success: false,
error: result.error || 'Failed to extract website content', error: result.error || 'Failed to extract website content',
@@ -206,7 +205,7 @@ export const websiteExtractTool = tool({
const contentType = inferWebsiteContentType(url); const contentType = inferWebsiteContentType(url);
const aiAnalysis = await analyzeContentWithAI( const aiAnalysis = await analyzeContentWithAI(
result.metadata?.title || `Website: ${new URL(url).hostname}`, result.metadata?.title || `Website: ${new URL(url).hostname}`,
result.notes?.substring(0, 2000) || 'Website content', result.source.substring(0, 2000) || 'Website content',
contentType, contentType,
existingDimensions existingDimensions
); );
@@ -231,17 +230,16 @@ export const websiteExtractTool = tool({
body: JSON.stringify({ body: JSON.stringify({
title: nodeTitle, title: nodeTitle,
description: aiAnalysis?.nodeDescription, description: aiAnalysis?.nodeDescription,
notes: enhancedDescription, source: result.source,
link: url, link: url,
event_date: result.metadata?.published_date || result.metadata?.date || null, event_date: result.metadata?.published_date || result.metadata?.date || null,
dimensions: finalDimensions, dimensions: finalDimensions,
chunk: result.chunk || result.notes,
metadata: { metadata: {
source: contentType, source: contentType,
hostname: new URL(url).hostname, hostname: new URL(url).hostname,
author: result.metadata?.author, author: result.metadata?.author,
published_date: result.metadata?.published_date || result.metadata?.date, published_date: result.metadata?.published_date || result.metadata?.date,
content_length: (result.chunk || result.notes)?.length, content_length: result.source.length,
extraction_method: result.metadata?.extraction_method || 'python_beautifulsoup', extraction_method: result.metadata?.extraction_method || 'python_beautifulsoup',
ai_analysis: aiAnalysis?.reasoning, ai_analysis: aiAnalysis?.reasoning,
enhanced_description: enhancedDescription, enhanced_description: enhancedDescription,
@@ -275,7 +273,7 @@ export const websiteExtractTool = tool({
data: { data: {
nodeId: createResult.data?.id, nodeId: createResult.data?.id,
title: nodeTitle, title: nodeTitle,
contentLength: (result.chunk || result.notes || '').length, contentLength: result.source.length,
url: url, url: url,
dimensions: actualDimensions dimensions: actualDimensions
} }
+6 -9
View File
@@ -193,15 +193,14 @@ export const youtubeExtractTool = tool({
}; };
} }
let result: { success: boolean; notes?: string; chunk?: string; metadata?: any; error?: string }; let result: { success: boolean; source?: string; metadata?: any; error?: string };
console.log('📝 Using TypeScript yt-dlp extractor'); console.log('📝 Using TypeScript yt-dlp extractor');
try { try {
const extractionResult = await extractYouTube(url); const extractionResult = await extractYouTube(url);
result = { result = {
success: extractionResult.success, success: extractionResult.success,
notes: extractionResult.content, source: extractionResult.chunk || extractionResult.content,
chunk: extractionResult.chunk,
metadata: { metadata: {
video_title: extractionResult.metadata.video_title, video_title: extractionResult.metadata.video_title,
channel_name: extractionResult.metadata.channel_name, channel_name: extractionResult.metadata.channel_name,
@@ -222,7 +221,7 @@ export const youtubeExtractTool = tool({
}; };
} }
if (!result.success || (!result.notes && !result.chunk)) { if (!result.success || !result.source) {
return { return {
success: false, success: false,
error: result.error || 'Failed to extract YouTube content', error: result.error || 'Failed to extract YouTube content',
@@ -243,8 +242,7 @@ export const youtubeExtractTool = tool({
// Step 3: Create node with extracted content and AI analysis // Step 3: Create node with extracted content and AI analysis
const nodeTitle = title || result.metadata?.video_title || `YouTube Video ${url.split('/').pop()?.split('?')[0]}`; const nodeTitle = title || result.metadata?.video_title || `YouTube Video ${url.split('/').pop()?.split('?')[0]}`;
const transcriptSummary = await summariseTranscript(nodeTitle, result.chunk || result.notes || ''); const transcriptSummary = await summariseTranscript(nodeTitle, result.source);
const nodeNotes = transcriptSummary || aiAnalysis?.enhancedDescription || `YouTube video by ${result.metadata?.channel_name || 'Unknown Channel'}`;
const suppliedDimensions = Array.isArray(dimensions) ? dimensions : []; const suppliedDimensions = Array.isArray(dimensions) ? dimensions : [];
let trimmedDimensions = suppliedDimensions let trimmedDimensions = suppliedDimensions
@@ -262,10 +260,9 @@ export const youtubeExtractTool = tool({
body: JSON.stringify({ body: JSON.stringify({
title: nodeTitle, title: nodeTitle,
description: aiAnalysis?.nodeDescription, description: aiAnalysis?.nodeDescription,
notes: nodeNotes, source: result.source,
link: url, link: url,
dimensions: finalDimensions, dimensions: finalDimensions,
chunk: result.chunk || result.notes,
metadata: { metadata: {
source: 'youtube', source: 'youtube',
video_id: result.metadata?.video_id, video_id: result.metadata?.video_id,
@@ -308,7 +305,7 @@ export const youtubeExtractTool = tool({
data: { data: {
nodeId: createResult.data?.id, nodeId: createResult.data?.id,
title: nodeTitle, title: nodeTitle,
contentLength: (result.chunk || result.notes || '').length, contentLength: result.source.length,
url: url, url: url,
dimensions: actualDimensions dimensions: actualDimensions
} }
+4 -2
View File
@@ -3,12 +3,13 @@ export interface Node {
id: number; id: number;
title: string; title: string;
description?: string; description?: string;
notes?: string; // User's notes/thoughts about this node source?: string; // Canonical embeddable content
notes?: string; // Deprecated legacy field - do not write
link?: string; link?: string;
event_date?: string | null; // When the thing actually happened (ISO 8601) event_date?: string | null; // When the thing actually happened (ISO 8601)
dimensions: string[]; // Flexible dimensions replacing type + stage + segment + tags dimensions: string[]; // Flexible dimensions replacing type + stage + segment + tags
embedding?: Buffer; // Node-level embedding (BLOB data) embedding?: Buffer; // Node-level embedding (BLOB data)
chunk?: string; chunk?: string; // Deprecated legacy field - do not write
metadata?: any; // Flexible metadata storage metadata?: any; // Flexible metadata storage
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -68,6 +69,7 @@ export interface NodeFilters {
dimensions?: string[]; // Filter by dimensions (replaces stage/type filtering) dimensions?: string[]; // Filter by dimensions (replaces stage/type filtering)
search?: string; // Text search in title/content search?: string; // Text search in title/content
searchMode?: 'standard' | 'hybrid'; // standard = FTS/LIKE, hybrid = add node-vector retrieval searchMode?: 'standard' | 'hybrid'; // standard = FTS/LIKE, hybrid = add node-vector retrieval
chunkStatus?: 'not_chunked' | 'chunking' | 'chunked' | 'error';
limit?: number; limit?: number;
offset?: number; offset?: number;
sortBy?: 'updated' | 'edges' | 'created' | 'event_date'; // Sort by updated_at, edge count, created_at, or event_date sortBy?: 'updated' | 'edges' | 'created' | 'event_date'; // Sort by updated_at, edge count, created_at, or event_date