Files
jerome 88228a6104 chore: fork with local patches for Syslog Solution
Patches applied:
- enforceRelayTTL — auto-deletes expired relay nodes (>7d)
- updateNode — tenant enforcement (block updates on NULL-namespace nodes)
- buildCanonicalMetadata — reject nested objects, sanitize values
- createNode — source-based and title-based deduplication
- getContext — NPE-safe count queries, stale node detection
- repair_log integration on updateNode
- bridge-validation.js — auto-fill defaults for tenant/namespace

Origin: github.com/bradwmorris/ra-h_os@70e95e7
2026-06-01 09:37:19 -04:00

733 lines
22 KiB
JavaScript

'use strict';
const { query, transaction, getDb } = require('./sqlite-client');
function sanitizeFtsQuery(input) {
return String(input || '')
.toLowerCase()
.replace(/[^a-z0-9\s]+/g, ' ')
.trim()
.split(/\s+/)
.filter(word => word.length > 0 && !/^(AND|OR|NOT|NEAR)$/i.test(word))
.join(' ');
}
function extractRelaxedSearchTerms(queryText) {
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 = String(queryText || '')
.toLowerCase()
.replace(/[^a-z0-9\s]+/g, ' ')
.split(/\s+/)
.map(term => term.trim())
.filter(Boolean);
const expanded = new Set();
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 normalizeSearchText(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function getQueryTerms(queryText) {
return normalizeSearchText(queryText).split(' ').filter(term => term.length > 0);
}
function countOccurrences(text, term) {
if (!text || !term) return 0;
const matches = text.match(new RegExp(`\\b${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'));
return matches ? matches.length : 0;
}
function orderedTermMatches(text, terms) {
let position = 0;
for (const term of terms) {
const index = text.indexOf(term, position);
if (index === -1) return false;
position = index + term.length;
}
return terms.length > 0;
}
function scoreNodeSearchMatch(node, queryText) {
const normalizedQuery = normalizeSearchText(queryText);
const normalizedTitle = normalizeSearchText(node.title || '');
const normalizedDescription = normalizeSearchText(node.description || '');
const normalizedSource = normalizeSearchText(node.source || '');
const terms = getQueryTerms(queryText);
let score = 0;
if (normalizedTitle === normalizedQuery) score += 2000;
if (normalizedTitle.startsWith(normalizedQuery)) score += 1200;
if (normalizedTitle.includes(normalizedQuery)) score += 700;
if (orderedTermMatches(normalizedTitle, terms)) score += 500;
if (terms.length > 0 && terms.every(term => normalizedTitle.includes(term))) score += 350;
if (normalizedDescription.includes(normalizedQuery)) score += 180;
if (orderedTermMatches(normalizedDescription, terms)) score += 120;
if (normalizedSource.includes(normalizedQuery)) score += 90;
for (const term of terms) {
score += countOccurrences(normalizedTitle, term) * 40;
score += countOccurrences(normalizedDescription, term) * 8;
score += countOccurrences(normalizedSource, term) * 3;
}
if (node.updated_at) {
score += new Date(node.updated_at).getTime() / 1e13;
}
return score;
}
function parseMetadata(metadata) {
if (!metadata) return {};
if (typeof metadata === 'string') {
try {
const parsed = JSON.parse(metadata);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? { ...parsed } : {};
} catch {
return {};
}
}
return metadata && typeof metadata === 'object' && !Array.isArray(metadata) ? { ...metadata } : {};
}
function normalizeString(value) {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function buildCanonicalMetadata({ existing, metadata }) {
const prior = parseMetadata(existing);
const incoming = parseMetadata(metadata);
const sourceMetadata = {
...(prior.source_metadata && typeof prior.source_metadata === 'object' ? prior.source_metadata : {}),
...(incoming.source_metadata && typeof incoming.source_metadata === 'object' ? incoming.source_metadata : {}),
};
// Sanitize a value: reject nested objects/arrays, return trimmed string or undefined
function sanitizeValue(value) {
if (value === undefined || value === null) return undefined;
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (typeof value === 'object') {
// Unwrap nested {value: ...} format from MCP tool calls
if (value.value && typeof value.value === 'string') return value.value.trim();
console.warn(`[buildCanonicalMetadata] WARNING: Nested object/array rejected. Value: ${JSON.stringify(value).substring(0, 200)}`);
return undefined;
}
return undefined;
}
// Build merged metadata from prior and incoming, but only keep string values
const merged = {};
// Start with source_metadata (always an object)
merged.source_metadata = sourceMetadata;
// Copy from prior: only string values
Object.keys(prior).forEach(key => {
if (key === 'source_metadata') return; // handled separately
const val = prior[key];
if (typeof val === 'string' && val.trim().length > 0) {
merged[key] = val;
}
});
// Override with incoming: only string values
Object.keys(incoming).forEach(key => {
if (key === 'source_metadata') return; // handled separately
const sanitized = sanitizeValue(incoming[key]);
const val = sanitized;
if (typeof val === 'string' && val.trim().length > 0) {
merged[key] = val.trim();
}
});
// Ensure canonical defaults
if (!merged.state) merged.state = 'not_processed';
if (!merged.captured_by) merged.captured_by = 'human';
return merged;
}
function mapNodeRow(row) {
return {
...row,
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
};
}
function buildNodeFilterClauses(filters = {}, alias = 'n') {
const clauses = [];
const params = [];
if (filters.createdAfter) {
clauses.push(`${alias}.created_at >= ?`);
params.push(filters.createdAfter);
}
if (filters.createdBefore) {
clauses.push(`${alias}.created_at < ?`);
params.push(filters.createdBefore);
}
if (filters.eventAfter) {
clauses.push(`${alias}.event_date >= ?`);
params.push(filters.eventAfter);
}
if (filters.eventBefore) {
clauses.push(`${alias}.event_date < ?`);
params.push(filters.eventBefore);
}
return { clauses, params };
}
function searchNodes(filters = {}) {
const search = normalizeString(filters.search);
const limit = Math.min(Math.max(filters.limit || 25, 1), 100);
if (!search) return [];
const rowsById = new Map();
for (const row of searchNodesFts(search, filters, Math.max(limit * 2, 25))) {
rowsById.set(row.id, row);
}
for (const row of searchNodesLike(search, filters, Math.max(limit * 2, 25))) {
if (!rowsById.has(row.id)) rowsById.set(row.id, row);
}
for (const row of searchNodesLikeRelaxed(search, filters, Math.max(limit * 2, 25))) {
if (!rowsById.has(row.id)) rowsById.set(row.id, row);
}
return Array.from(rowsById.values())
.map(row => ({ row, score: scoreNodeSearchMatch(row, search) }))
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(entry => mapNodeRow(entry.row));
}
function searchNodesFts(search, filters, limit) {
const db = getDb();
const ftsQuery = sanitizeFtsQuery(search);
if (!ftsQuery) return [];
const ftsExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes_fts'").get();
if (!ftsExists) return [];
const { clauses, params } = buildNodeFilterClauses(filters);
const whereClause = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
try {
return query(`
WITH fts_matches AS (
SELECT rowid, rank
FROM nodes_fts
WHERE nodes_fts MATCH ?
LIMIT ?
)
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at,
fm.rank
FROM fts_matches fm
JOIN nodes n ON n.id = fm.rowid
${whereClause}
ORDER BY fm.rank
LIMIT ?
`, [ftsQuery, Math.max(limit * 2, 50), ...params, limit]);
} catch {
return [];
}
}
function searchNodesLike(search, filters, limit) {
const words = search.split(/\s+/).filter(Boolean);
const { clauses, params } = buildNodeFilterClauses(filters);
let sql = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at
FROM nodes n
WHERE 1=1
`;
const queryParams = [...params];
if (clauses.length > 0) {
sql += ` AND ${clauses.join(' AND ')}`;
}
for (const word of words) {
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
queryParams.push(`%${word}%`, `%${word}%`, `%${word}%`);
}
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,
CASE WHEN n.source LIKE ? COLLATE NOCASE THEN 5 ELSE 6 END,
n.updated_at DESC
LIMIT ?`;
queryParams.push(search, `${search}%`, `%${search}%`, `%${search}%`, `%${search}%`, limit);
return query(sql, queryParams);
}
function searchNodesLikeRelaxed(search, filters, limit) {
const terms = extractRelaxedSearchTerms(search);
if (terms.length === 0) return [];
const { clauses, params } = buildNodeFilterClauses(filters);
let sql = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at
FROM nodes n
WHERE 1=1
`;
const queryParams = [...params];
if (clauses.length > 0) {
sql += ` AND ${clauses.join(' AND ')}`;
}
const termClauses = [];
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}%`);
}
sql += ` AND (${termClauses.join(' OR ')})`;
const scoreClauses = [];
const scoreParams = [];
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}%`);
}
sql += ` 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);
return query(sql, queryParams);
}
/**
* Get nodes with optional filtering.
*/
function getNodes(filters = {}) {
const { search, limit = 100, offset = 0 } = filters;
if (normalizeString(search)) {
return searchNodes(filters);
}
let sql = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at
FROM nodes n
WHERE 1=1
`;
const params = [];
// Text search
if (search) {
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source 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(mapNodeRow);
}
/**
* Get a single node by ID.
*/
function getNodeById(id) {
const sql = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at
FROM nodes n
WHERE n.id = ?
`;
const rows = query(sql, [id]);
if (rows.length === 0) return null;
const row = rows[0];
return mapNodeRow(row);
}
/**
* 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);
}
function getChunkStatusForSource(sourceText) {
return normalizeString(sourceText) ? 'not_chunked' : null;
}
/**
* Create a new node.
*/
/**
* Enforce TTL on relay messages — delete expired ones.
* Relay messages have a TTL of 7 days.
*/
function enforceRelayTTL() {
const db = getDb();
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 7);
const cutoffISO = cutoff.toISOString();
// Find relay messages older than 7 days
const expired = query(`
SELECT id, title, created_at FROM nodes
WHERE metadata LIKE '%relay_message%'
AND created_at < ?
ORDER BY created_at ASC
`, [cutoffISO]);
if (expired.length > 0) {
const ids = expired.map(n => n.id);
const idsStr = ids.join(',');
console.warn(`[enforceRelayTTL] ${expired.length} relay messages expired, deleting (IDs: ${idsStr})`);
db.prepare(`DELETE FROM nodes WHERE id IN (${idsStr})`).run();
db.prepare(`DELETE FROM repair_log WHERE node_id IN (${idsStr})`).run();
db.prepare(`DELETE FROM edges WHERE from_node_id IN (${idsStr}) OR to_node_id IN (${idsStr})`).run();
}
return expired.length;
}
function createNode(nodeData) {
const {
title: rawTitle,
description,
source,
link,
event_date,
metadata = {}
} = nodeData;
const title = sanitizeTitle(rawTitle);
// FIX 2: Deduplication — check if a node with same source or title+description already exists
const existingSource = source || ([title, description].filter(Boolean).join('\n\n').trim() || null);
const existingSourceNormalized = normalizeString(existingSource);
const existingTitleNormalized = normalizeString(title);
// Check for source-based deduplication first
const sourceCheckResults = query(`
SELECT id, title FROM nodes WHERE source = ?
`, [existingSourceNormalized || '']);
if (sourceCheckResults.length > 0) {
console.warn(`[createNode] WARNING: Node with identical source already exists (ID: ${sourceCheckResults[0].id}, Title: ${sourceCheckResults[0].title}). Skipping duplicate creation.`);
return getNodeById(Number(sourceCheckResults[0].id));
}
// Check for title+description deduplication
const dedupCheckResults = query(`
SELECT id, title FROM nodes WHERE title = ?
`, [title]);
if (dedupCheckResults.length > 0) {
console.warn(`[createNode] WARNING: Node with identical title already exists (ID: ${dedupCheckResults[0].id}, Title: ${dedupCheckResults[0].title}). Skipping duplicate creation.`);
return getNodeById(Number(dedupCheckResults[0].id));
}
// Enforce TTL on relay messages before creating new node
enforceRelayTTL();
const canonicalMetadata = buildCanonicalMetadata({ metadata });
const now = new Date().toISOString();
const db = getDb();
const sourceToStore = source ?? ([title, description].filter(Boolean).join('\n\n').trim() || null);
const chunkStatus = getChunkStatusForSource(sourceToStore);
const nodeId = transaction(() => {
const stmt = db.prepare(`
INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
title,
description ?? null,
sourceToStore,
link ?? null,
event_date ?? null,
JSON.stringify(canonicalMetadata),
chunkStatus,
now,
now
);
return Number(result.lastInsertRowid);
});
return getNodeById(nodeId);
}
/**
* Update an existing node.
*/
function updateNode(id, updates, options = {}) {
// Enforce TTL on relay messages before updating any node
enforceRelayTTL();
const { title, description, source, link, event_date, 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. Use rah_search_nodes to find nodes by keyword.`);
}
// === TENANT ENFORCEMENT: require namespace/tenant in metadata ===
// Extract tenant from existing node metadata
let nodeTenant = null;
try {
const meta = typeof existing.metadata === 'string'
? JSON.parse(existing.metadata)
: existing.metadata;
nodeTenant = meta.namespace || null;
} catch (e) {
// metadata parse error — treat as no tenant
}
// Block update if no tenant/namespace
// Bootstrap: allow updates that SET namespace on NULL-namespace nodes
const metaUpdate = metadata || {};
const isSettingNamespace = metaUpdate.namespace && (
typeof metaUpdate.namespace === 'string' ||
(typeof metaUpdate.namespace === 'object' && metaUpdate.namespace.value)
);
if (!nodeTenant && !isSettingNamespace) {
console.warn(`[updateNode] BLOCK: Node #${id} has no tenant/namespace. Refusing update.`);
throw new Error(`Node #${id} has no namespace field in metadata. Update blocked. Set metadata.namespace before updating.`);
}
const mergedMetadata = metadata !== undefined
? buildCanonicalMetadata({ existing: existing.metadata, metadata })
: undefined;
const sourceWasProvided = Object.prototype.hasOwnProperty.call(updates, 'source');
const normalizedSource = sourceWasProvided ? normalizeString(source) : undefined;
transaction(() => {
const setFields = [];
const params = [];
if (title !== undefined) {
setFields.push('title = ?');
params.push(title);
}
if (description !== undefined) {
setFields.push('description = ?');
params.push(description);
}
if (source !== undefined) {
setFields.push('source = ?');
params.push(source);
}
if (link !== undefined) {
setFields.push('link = ?');
params.push(link);
}
if (event_date !== undefined) {
setFields.push('event_date = ?');
params.push(event_date);
}
if (sourceWasProvided) {
setFields.push('chunk_status = ?');
params.push(getChunkStatusForSource(normalizedSource));
}
if (mergedMetadata !== undefined) {
setFields.push('metadata = ?');
params.push(JSON.stringify(mergedMetadata));
}
// 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);
}
});
// === REPAIR LOG: log the update ===
try {
const logStmt = db.prepare(
'INSERT INTO repair_log(node_id, action, old_metadata, new_metadata, timestamp, verified, tenant) VALUES (?, ?, ?, ?, datetime(?), 0, ?)'
);
logStmt.run(
id,
'updateNode',
typeof existing.metadata === 'string' ? existing.metadata : JSON.stringify(existing.metadata ?? null),
mergedMetadata ? JSON.stringify(mergedMetadata) : 'unchanged',
new Date().toISOString().split('T')[0],
nodeTenant || 'unknown'
);
} catch (logErr) {
console.warn(`[updateNode] repair_log write failed: ${logErr.message}`);
}
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. Use rah_search_nodes to find nodes by keyword.`);
}
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, and recent activity.
*/
function getContext() {
const nodeCountRow = query('SELECT COUNT(*) as count FROM nodes');
const nodeCount = (nodeCountRow && nodeCountRow[0] && nodeCountRow[0].count) || 0;
const edgeCountRow = query('SELECT COUNT(*) as count FROM edges');
const edgeCount = (edgeCountRow && edgeCountRow[0] && edgeCountRow[0].count) || 0;
const recentNodes = query(`
SELECT n.id, n.title, n.description
FROM nodes n
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 10
`);
// Get all nodes for bulk operations
const allNodes = query(`
SELECT n.id, n.title, n.description, n.metadata, n.created_at, n.updated_at
FROM nodes n
ORDER BY n.updated_at DESC
`).map(row => ({
id: row.id,
title: row.title,
description: row.description ?? null,
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
created_at: row.created_at,
updated_at: row.updated_at
}));
// Detect stale nodes (no update in 30 days, state is not_processed)
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
const staleNodes = allNodes.filter(n =>
n.metadata?.state === 'not_processed' &&
n.updated_at &&
n.updated_at < thirtyDaysAgo
);
return {
stats: { nodeCount, edgeCount, dimensionCount: 0 },
recentNodes,
hubNodes,
allNodes,
staleNodes
};
}
module.exports = {
getNodes,
searchNodes,
getNodeById,
createNode,
updateNode,
deleteNode,
getNodeCount,
getContext
};