'use strict'; const { query, transaction, getDb } = require('./sqlite-client'); /** * Get nodes with optional filtering. */ function getNodes(filters = {}) { const { dimensions, search, limit = 100, offset = 0 } = filters; let sql = ` SELECT n.id, n.title, n.description, n.notes, n.link, n.event_date, n.metadata, n.chunk, 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 params = []; // Filter by dimensions if (dimensions && dimensions.length > 0) { sql += ` AND EXISTS ( SELECT 1 FROM node_dimensions nd WHERE nd.node_id = n.id AND nd.dimension IN (${dimensions.map(() => '?').join(',')}) )`; params.push(...dimensions); } // Text search if (search) { sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.notes LIKE ? COLLATE NOCASE)`; params.push(`%${search}%`, `%${search}%`, `%${search}%`); } // Sort by search relevance or updated_at if (search) { sql += ` ORDER BY 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 n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 6 END, CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 4 ELSE 6 END, n.updated_at DESC`; params.push(search, `${search}%`, `%${search}%`, `%${search}%`); } else { sql += ' ORDER BY n.updated_at DESC'; } sql += ` LIMIT ?`; params.push(limit); if (offset > 0) { sql += ` OFFSET ?`; params.push(offset); } const rows = query(sql, params); return rows.map(row => ({ ...row, dimensions: JSON.parse(row.dimensions_json || '[]'), metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null, dimensions_json: undefined })); } /** * Get a single node by ID. */ function getNodeById(id) { const sql = ` SELECT n.id, n.title, n.description, n.notes, n.link, n.event_date, n.metadata, n.chunk, 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 n.id = ? `; const rows = query(sql, [id]); if (rows.length === 0) return null; const row = rows[0]; return { ...row, dimensions: JSON.parse(row.dimensions_json || '[]'), metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null, dimensions_json: undefined }; } /** * Sanitize title — strip extraction artifacts. */ function sanitizeTitle(title) { let clean = title.trim(); if (clean.startsWith('Title: ')) clean = clean.slice(7); if (clean.endsWith(' / X')) clean = clean.slice(0, -4); clean = clean.replace(/\s+/g, ' '); return clean.slice(0, 160); } /** * Create a new node. */ function createNode(nodeData) { const { title: rawTitle, description, notes, link, event_date, dimensions = [], chunk, metadata = {} } = nodeData; const title = sanitizeTitle(rawTitle); const now = new Date().toISOString(); const db = getDb(); // Build chunk fallback from available fields when no explicit chunk provided let chunkToStore = chunk ?? null; if (!chunkToStore || !chunkToStore.trim()) { const fallbackParts = [title, description, notes].filter(Boolean); const fallback = fallbackParts.join('\n\n').trim(); if (fallback) { chunkToStore = fallback; } } const nodeId = transaction(() => { const stmt = db.prepare(` INSERT INTO nodes (title, description, notes, link, event_date, metadata, chunk, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); const result = stmt.run( title, description ?? null, notes ?? null, link ?? null, event_date ?? null, JSON.stringify(metadata), chunkToStore, now, now ); const id = Number(result.lastInsertRowid); // Insert dimensions if (dimensions.length > 0) { const dimStmt = db.prepare( 'INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)' ); for (const dimension of dimensions) { dimStmt.run(id, dimension); } } return id; }); return getNodeById(nodeId); } /** * Update an existing node. * Note: notes is APPENDED by default (MCP tool behavior), not replaced. */ function updateNode(id, updates, options = {}) { const { appendNotes = true } = options; const { title, description, notes, link, event_date, dimensions, chunk, metadata } = updates; const now = new Date().toISOString(); const db = getDb(); // Check node exists const existing = getNodeById(id); if (!existing) { throw new Error(`Node with ID ${id} not found`); } transaction(() => { const setFields = []; const params = []; if (title !== undefined) { setFields.push('title = ?'); params.push(title); } if (description !== undefined) { setFields.push('description = ?'); params.push(description); } if (notes !== undefined) { if (appendNotes && existing.notes) { // Append to existing notes setFields.push('notes = ?'); params.push(existing.notes + '\n\n' + notes); } else { setFields.push('notes = ?'); params.push(notes); } } if (link !== undefined) { setFields.push('link = ?'); params.push(link); } if (event_date !== undefined) { setFields.push('event_date = ?'); params.push(event_date); } if (chunk !== undefined) { setFields.push('chunk = ?'); params.push(chunk); } if (metadata !== undefined) { setFields.push('metadata = ?'); params.push(JSON.stringify(metadata)); } // Always update timestamp setFields.push('updated_at = ?'); params.push(now); params.push(id); if (setFields.length > 1) { const stmt = db.prepare(`UPDATE nodes SET ${setFields.join(', ')} WHERE id = ?`); stmt.run(...params); } // Handle dimensions separately if (Array.isArray(dimensions)) { db.prepare('DELETE FROM node_dimensions WHERE node_id = ?').run(id); const dimStmt = db.prepare('INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)'); for (const dim of dimensions) { dimStmt.run(id, dim); } } }); return getNodeById(id); } /** * Delete a node. */ function deleteNode(id) { const result = query('DELETE FROM nodes WHERE id = ?', [id]); if (result.changes === 0) { throw new Error(`Node with ID ${id} not found`); } return true; } /** * Get node count. */ function getNodeCount() { const rows = query('SELECT COUNT(*) as count FROM nodes'); return Number(rows[0].count); } /** * Get knowledge graph context overview. * Returns stats, hub nodes, dimensions, and recent activity. */ function getContext() { const nodeCount = query('SELECT COUNT(*) as count FROM nodes')[0].count; const edgeCount = query('SELECT COUNT(*) as count FROM edges')[0].count; const dimensionService = require('./dimensionService'); const dimensions = dimensionService.getDimensions(); const recentNodes = query(` SELECT n.id, n.title, n.description, GROUP_CONCAT(nd.dimension) as dimensions FROM nodes n LEFT JOIN node_dimensions nd ON n.id = nd.node_id GROUP BY n.id ORDER BY n.created_at DESC LIMIT 5 `); const hubNodes = query(` SELECT n.id, n.title, n.description, COUNT(e.id) as edge_count FROM nodes n LEFT JOIN edges e ON n.id = e.from_node_id OR n.id = e.to_node_id GROUP BY n.id ORDER BY edge_count DESC LIMIT 5 `); return { stats: { nodeCount, edgeCount, dimensionCount: dimensions.length }, dimensions, recentNodes, hubNodes }; } module.exports = { getNodes, getNodeById, createNode, updateNode, deleteNode, getNodeCount, getContext };